diff --git a/.dockerignore b/.dockerignore index 1dabeb7..35814a3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -41,6 +41,10 @@ dist/ .vscode/ .idea/ +# Exclude compiled TypeScript output (will be regenerated in Docker build) +code/web/static/js/ +node_modules/ + # Exclude OS files .DS_Store Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc2fb7..9ad7bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,55 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Added +- **Testing Standards Documentation**: Standards guide and base classes for new tests + - `docs/web_backend/testing.md` — patterns for route, service, validation, and error handler tests + - `code/tests/base_test_cases.py` — `RouteTestCase`, `ServiceTestCase`, `ErrorHandlerTestCase`, `ValidationTestMixin` + - Covers naming conventions, fixture setup, coverage targets, and what not to test +- **Error Handling Integration**: Custom exceptions now wired into the web layer + - `DeckBuilderError` handler in `app.py` — typed exceptions get correct HTTP status (not always 500) + - `deck_builder_error_response()` utility: JSON responses for API, HTML fragments for HTMX + - Status code mapping for 50+ exception classes (400/401/404/503/500) + - Web-specific exceptions: `SessionExpiredError` (401), `BuildNotFoundError` (404), `FeatureDisabledError` (404) + - `partner_suggestions.py` converted from raw `HTTPException` to typed exceptions + - Fixed pre-existing bug: `CommanderValidationError.__init__` now accepts optional `code` kwarg + - Error handling guide: `docs/web_backend/error_handling.md` +- **Backend Standardization Framework**: Improved code organization and maintainability + - Response builder utilities for consistent HTTP responses + - Telemetry decorators for route access tracking and error logging + - Route pattern documentation defining standards for all routes + - Split monolithic build route handler into focused, maintainable modules + - Step-based wizard routes consolidated into dedicated module + - New build flow and quick build automation extracted into focused module + - Alternative card suggestions extracted to standalone module + - Compliance/enforcement and card replacement extracted to focused module + - Foundation for integrating custom exceptions into web layer +- **Service Layer Architecture**: Base classes, interfaces, and registry for service standardization + - `BaseService`, `StateService`, `DataService`, `CachedService` abstract base classes + - Service protocols/interfaces for type-safe dependency injection + - `ServiceRegistry` for singleton/factory/lazy service patterns + - `SessionManager` refactored from global dict to thread-safe `StateService` +- **Validation Framework**: Centralized Pydantic models and validators + - Pydantic models for all key request types (`BuildRequest`, `CommanderSearchRequest`, etc.) + - `CardNameValidator` with normalization for diacritics, punctuation, multi-face cards + - `ThemeValidator`, `PowerBracketValidator`, `ColorIdentityValidator` + - `ValidationMessages` class for consistent user-facing error messages + +### Fixed +- **Image Cache Status UI**: Setup page status stuck on "Checking…" + - Stale `.download_status.json` from a failed run caused indefinite spinner + - Added error state handling in JS to show "Last download failed" with message + - Status endpoint now auto-cleans stale file after download completion/failure + - Last download result persisted to `.last_download_result.json` across restarts + - Card count now shown correctly (was double-counting by summing both size variants) + - Shows "+N new cards" from last download run +- **Scryfall Bulk Data API**: HTTP 400 error when triggering image download + - Scryfall now requires `Accept: application/json` on API endpoints + - Fixed `ScryfallBulkDataClient._make_request()` to include the header + +### Removed +- **Permalink Feature**: Removed permalink generation and restoration functionality + - Deemed unnecessary for single-session deck building workflow + - Users can still export decks (CSV/TXT/JSON) or use headless configs for automation - **Template Validation Tests**: Comprehensive test suite for HTML/Jinja2 templates - Validates Jinja2 syntax across all templates - Checks HTML structure (balanced tags, unique IDs, proper attributes) @@ -92,6 +141,15 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Optimized linting rules for development workflow ### Fixed +- **Deck Summary Display**: Fixed issue where deck summary cards would not display correctly in manual builds + - Card images and names now appear properly in both List and Thumbnails views + - Commander card displayed correctly in Step 5 sidebar + - Summary data now properly persists across wizard stages +- **Multi-Copy Package Detection**: Fixed bug preventing multi-copy suggestions from appearing in New Deck wizard + - Corrected key mismatch between archetype definitions ('tagsAny') and detection code ('tags_any') + - Multi-copy panel now properly displays when commander and theme tags match supported archetypes (e.g., Hare Apparent for Rabbit Kindred + Tokens Matter) + - Updated panel background color to match theme (now uses CSS variable instead of hardcoded value) + - Affects all 12 multi-copy archetypes (Hare Apparent, Slime Against Humanity, Dragon's Approach, etc.) - **Card Data Auto-Refresh**: Fixed stale data issue when new sets are released - Auto-refresh now deletes cached raw parquet file before downloading fresh data - Ensures new sets are included instead of reprocessing old cached data diff --git a/Dockerfile b/Dockerfile index 1f76105..b7a22a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,8 +40,9 @@ COPY mypy.ini . # Tailwind source is already in code/web/static/tailwind.css from COPY code/ # TypeScript sources are in code/web/static/ts/ from COPY code/ -# Force fresh CSS build by removing any copied styles.css +# Force fresh builds by removing any compiled artifacts RUN rm -f ./code/web/static/styles.css +RUN rm -rf ./code/web/static/js/*.js ./code/web/static/js/*.js.map # Build CSS and TypeScript RUN npm run build diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index b04af1e..c942c2f 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -3,9 +3,34 @@ ## [Unreleased] ### Summary -Web UI improvements with Tailwind CSS migration, TypeScript conversion, component library, template validation tests, enhanced code quality tools, and optional card image caching for faster performance and better maintainability. +Backend standardization infrastructure (M1-M5 complete): response builders, telemetry, service layer, validation framework, error handling integration, and testing standards. Web UI improvements with Tailwind CSS migration, TypeScript conversion, component library, template validation tests, enhanced code quality tools, and optional card image caching. Bug fixes for image cache UI, Scryfall API compatibility, and container startup errors. ### Added +- **Testing Standards Documentation**: Developer guide and base classes for writing new tests + - `docs/web_backend/testing.md` covers route tests, service tests, HTMX patterns, naming conventions, and coverage targets + - `code/tests/base_test_cases.py` provides `RouteTestCase`, `ServiceTestCase`, `ErrorHandlerTestCase`, `ValidationTestMixin` +- **Error Handling Integration**: Custom exceptions now wired into the web layer + - Typed domain exceptions get correct HTTP status codes (not always 500) + - HTMX requests receive HTML error fragments; API requests receive JSON + - New web-specific exceptions: `SessionExpiredError`, `BuildNotFoundError`, `FeatureDisabledError` + - Error handling guide at `docs/web_backend/error_handling.md` +- **Backend Standardization Framework**: Improved code organization and maintainability + - Response builder utilities for standardized HTTP/JSON/HTMX responses + - Telemetry decorators for automatic route tracking and error logging + - Route pattern documentation with examples and migration guide + - Modular route organization with focused, maintainable modules + - Step-based wizard routes consolidated into dedicated module + - New build flow and quick build automation extracted into focused module + - Alternative card suggestions extracted to standalone module + - Compliance/enforcement and card replacement extracted to focused module + - Foundation for integrating custom exception hierarchy + - Benefits: Easier to maintain, extend, and test backend code + +### Removed +- **Permalink Feature**: Removed permalink generation and restoration functionality + - Deemed unnecessary for single-session deck building workflow + - Simplified UI by removing "Copy Permalink" and "Open Permalink" buttons + - Users can still export decks (CSV/TXT/JSON) or use headless JSON configs for automation - **Template Validation Tests**: Comprehensive test suite ensuring HTML/template quality - Validates Jinja2 syntax and structure - Checks for common HTML issues (duplicate IDs, balanced tags) @@ -89,6 +114,20 @@ Web UI improvements with Tailwind CSS migration, TypeScript conversion, componen _None_ ### Fixed +- **Image Cache Status UI**: Setup page status stuck on "Checking…" after a failed download + - Error state now shown correctly with failure message + - Card count display fixed (was double-counting by summing both size variants) + - Last download run stats ("+N new cards") persist across container restarts +- **Scryfall Bulk Data API**: HTTP 400 error fixed by adding required `Accept: application/json` header +- **Deck Summary Display**: Fixed issue where deck summary cards would not display correctly in manual builds + - Card images and names now appear properly in both List and Thumbnails views + - Commander card displayed correctly in Step 5 sidebar + - Summary data now properly persists across wizard stages +- **Multi-Copy Package Detection**: Fixed multi-copy suggestions not appearing in New Deck wizard + - Multi-copy panel now properly displays when commander and theme tags match supported archetypes + - Example: Hare Apparent now appears when building with Rabbit Kindred + Tokens Matter themes + - Panel styling now matches current theme (dark/light mode support) + - Affects all 12 multi-copy archetypes in the system - **Card Data Auto-Refresh**: Fixed stale data issue when new sets are released - Auto-refresh now deletes cached raw parquet file before downloading fresh data - Ensures new sets are included instead of reprocessing old cached data diff --git a/code/deck_builder/builder_utils.py b/code/deck_builder/builder_utils.py index a47101e..095d218 100644 --- a/code/deck_builder/builder_utils.py +++ b/code/deck_builder/builder_utils.py @@ -1034,7 +1034,7 @@ def detect_viable_multi_copy_archetypes(builder) -> list[dict]: continue # Tag triggers trig = meta.get('triggers', {}) or {} - any_tags = _normalize_tags_list(trig.get('tags_any', []) or []) + any_tags = _normalize_tags_list(trig.get('tagsAny', []) or []) all_tags = _normalize_tags_list(trig.get('tags_all', []) or []) score = 0 reasons: list[str] = [] diff --git a/code/exceptions.py b/code/exceptions.py index 5518ccb..3b7fad2 100644 --- a/code/exceptions.py +++ b/code/exceptions.py @@ -496,14 +496,15 @@ class CommanderValidationError(DeckBuilderError): missing required fields, or contains inconsistent information. """ - def __init__(self, message: str, details: dict | None = None): + def __init__(self, message: str, details: dict | None = None, *, code: str = "CMD_VALID"): """Initialize commander validation error. Args: message: Description of the validation failure details: Additional context about the error + code: Error code (overridable by subclasses) """ - super().__init__(message, code="CMD_VALID", details=details) + super().__init__(message, code=code, details=details) class CommanderTypeError(CommanderValidationError): """Raised when commander type validation fails. @@ -1393,4 +1394,30 @@ class ThemePoolError(DeckBuilderError): message, code="THEME_POOL_ERR", details=details - ) \ No newline at end of file + ) + + +# --- Web layer exceptions --- + +class SessionExpiredError(DeckBuilderError): + """Raised when a required session is missing or has expired.""" + + def __init__(self, sid: str | None = None, details: dict | None = None): + message = "Session has expired or could not be found" + super().__init__(message, code="SESSION_EXPIRED", details=details or {"sid": sid}) + + +class BuildNotFoundError(DeckBuilderError): + """Raised when a requested build result is not found in the session.""" + + def __init__(self, sid: str | None = None, details: dict | None = None): + message = "Build result not found; please start a new build" + super().__init__(message, code="BUILD_NOT_FOUND", details=details or {"sid": sid}) + + +class FeatureDisabledError(DeckBuilderError): + """Raised when a feature is accessed but has been disabled via environment config.""" + + def __init__(self, feature: str, details: dict | None = None): + message = f"Feature '{feature}' is currently disabled" + super().__init__(message, code="FEATURE_DISABLED", details=details or {"feature": feature}) \ No newline at end of file diff --git a/code/file_setup/image_cache.py b/code/file_setup/image_cache.py index 08a7c22..45a4c23 100644 --- a/code/file_setup/image_cache.py +++ b/code/file_setup/image_cache.py @@ -87,10 +87,42 @@ class ImageCache: self.bulk_data_path = Path(bulk_data_path) self.client = ScryfallBulkDataClient() self._last_download_time: float = 0.0 + + # In-memory index of available images (avoids repeated filesystem checks) + # Key: (size, sanitized_filename), Value: True if exists + self._image_index: dict[tuple[str, str], bool] = {} + self._index_built = False def is_enabled(self) -> bool: """Check if image caching is enabled via environment variable.""" return os.getenv("CACHE_CARD_IMAGES", "0") == "1" + + def _build_image_index(self) -> None: + """ + Build in-memory index of cached images to avoid repeated filesystem checks. + This dramatically improves performance by eliminating stat() calls for every image. + """ + if self._index_built or not self.is_enabled(): + return + + logger.info("Building image cache index...") + start_time = time.time() + + for size in IMAGE_SIZES: + size_dir = self.base_dir / size + if not size_dir.exists(): + continue + + # Scan directory for .jpg files + for image_file in size_dir.glob("*.jpg"): + # Store just the filename without extension + filename = image_file.stem + self._image_index[(size, filename)] = True + + elapsed = time.time() - start_time + total_images = len(self._image_index) + logger.info(f"Image index built: {total_images} images indexed in {elapsed:.3f}s") + self._index_built = True def get_image_path(self, card_name: str, size: str = "normal") -> Optional[Path]: """ @@ -105,12 +137,17 @@ class ImageCache: """ if not self.is_enabled(): return None + + # Build index on first access (lazy initialization) + if not self._index_built: + self._build_image_index() safe_name = sanitize_filename(card_name) - image_path = self.base_dir / size / f"{safe_name}.jpg" - - if image_path.exists(): - return image_path + + # Check in-memory index first (fast) + if (size, safe_name) in self._image_index: + return self.base_dir / size / f"{safe_name}.jpg" + return None def get_image_url(self, card_name: str, size: str = "normal") -> str: diff --git a/code/file_setup/scryfall_bulk_data.py b/code/file_setup/scryfall_bulk_data.py index fd41d90..ae52dbc 100644 --- a/code/file_setup/scryfall_bulk_data.py +++ b/code/file_setup/scryfall_bulk_data.py @@ -58,6 +58,7 @@ class ScryfallBulkDataClient: try: req = Request(url) req.add_header("User-Agent", "MTG-Deckbuilder/3.0 (Image Cache)") + req.add_header("Accept", "application/json") with urlopen(req, timeout=30) as response: import json return json.loads(response.read().decode("utf-8")) diff --git a/code/tests/base_test_cases.py b/code/tests/base_test_cases.py new file mode 100644 index 0000000..737beab --- /dev/null +++ b/code/tests/base_test_cases.py @@ -0,0 +1,224 @@ +"""Base test case classes for the MTG Python Deckbuilder web layer. + +Provides reusable base classes and mixins that reduce boilerplate in route, +service, and validation tests. Import what you need — don't inherit everything. + +Usage: + from code.tests.base_test_cases import RouteTestCase, ServiceTestCase + + class TestMyRoute(RouteTestCase): + def test_something(self): + resp = self.client.get("/my-route") + self.assert_ok(resp) +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + + +# --------------------------------------------------------------------------- +# Route test base +# --------------------------------------------------------------------------- + +class RouteTestCase: + """Base class for route integration tests. + + Provides a shared TestClient and assertion helpers. Subclasses can override + `app_fixture` to use a different FastAPI app (e.g., a minimal test app). + + Example: + class TestBuildWizard(RouteTestCase): + def test_step1_renders(self): + resp = self.get("/build/step1") + self.assert_ok(resp) + assert "Commander" in resp.text + """ + + @pytest.fixture(autouse=True) + def setup_client(self, monkeypatch): + """Create a TestClient for the full app. Override to customise.""" + from code.web.app import app + with TestClient(app) as c: + self.client = c + yield + + # --- Shorthand request helpers --- + + def get(self, path: str, *, headers: dict | None = None, cookies: dict | None = None, **params) -> Any: + return self.client.get(path, headers=headers or {}, cookies=cookies or {}, params=params or {}) + + def post(self, path: str, data: dict | None = None, *, json: dict | None = None, + headers: dict | None = None, cookies: dict | None = None) -> Any: + return self.client.post(path, data=data, json=json, headers=headers or {}, cookies=cookies or {}) + + def htmx_get(self, path: str, *, cookies: dict | None = None, **params) -> Any: + """GET with HX-Request header set (simulates HTMX fetch).""" + return self.client.get(path, headers={"HX-Request": "true"}, cookies=cookies or {}, params=params or {}) + + def htmx_post(self, path: str, data: dict | None = None, *, cookies: dict | None = None) -> Any: + """POST with HX-Request header set (simulates HTMX form submission).""" + return self.client.post(path, data=data, headers={"HX-Request": "true"}, cookies=cookies or {}) + + # --- Assertion helpers --- + + def assert_ok(self, resp, *, contains: str | None = None) -> None: + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text[:200]}" + if contains: + assert contains in resp.text, f"Expected {contains!r} in response" + + def assert_status(self, resp, status: int) -> None: + assert resp.status_code == status, f"Expected {status}, got {resp.status_code}: {resp.text[:200]}" + + def assert_json_error(self, resp, *, status: int, error_type: str | None = None) -> dict: + """Assert a standardized error JSON response (M4 format).""" + assert resp.status_code == status + data = resp.json() + assert data.get("error") is True, f"Expected error=True in: {data}" + assert "request_id" in data + if error_type: + assert data.get("error_type") == error_type, f"Expected error_type={error_type!r}, got {data.get('error_type')!r}" + return data + + def assert_redirect(self, resp, *, to: str | None = None) -> None: + assert resp.status_code in (301, 302, 303, 307, 308), f"Expected redirect, got {resp.status_code}" + if to: + assert to in resp.headers.get("location", "") + + def with_session(self, commander: str = "Atraxa, Praetors' Voice", **extra) -> tuple[str, dict]: + """Create a session with basic commander state. Returns (sid, session_dict).""" + from code.web.services.tasks import get_session, new_sid + sid = new_sid() + sess = get_session(sid) + sess["commander"] = {"name": commander, "ok": True} + sess.update(extra) + return sid, sess + + +# --------------------------------------------------------------------------- +# Error handler test base +# --------------------------------------------------------------------------- + +class ErrorHandlerTestCase: + """Base for tests targeting the DeckBuilderError → HTTP response pipeline. + + Spins up a minimal FastAPI app with only the error handler — no routes from + the real app, so tests are fast and isolated. + + Example: + class TestMyErrors(ErrorHandlerTestCase): + def test_custom_error(self): + from code.exceptions import ThemeSelectionError + self._register_raiser("/raise", ThemeSelectionError("bad theme")) + resp = self.error_client.get("/raise") + self.assert_json_error(resp, status=400, error_type="ThemeSelectionError") + """ + + @pytest.fixture(autouse=True) + def setup_error_app(self): + from fastapi import FastAPI, Request + from code.exceptions import DeckBuilderError + from code.web.utils.responses import deck_builder_error_response + + self._mini_app = FastAPI() + self._raisers: dict[str, Exception] = {} + + app = self._mini_app + + @app.exception_handler(Exception) + async def handler(request: Request, exc: Exception): + if isinstance(exc, DeckBuilderError): + return deck_builder_error_response(request, exc) + from fastapi.responses import JSONResponse + return JSONResponse({"error": "unhandled", "detail": str(exc)}, status_code=500) + + with TestClient(app, raise_server_exceptions=False) as c: + self.error_client = c + yield + + def _register_raiser(self, path: str, exc: Exception) -> None: + """Add a GET endpoint that raises `exc` when called.""" + from fastapi import Request + + @self._mini_app.get(path) + async def _raiser(request: Request): + raise exc + + # Rebuild the client after adding the route + with TestClient(self._mini_app, raise_server_exceptions=False) as c: + self.error_client = c + + def assert_json_error(self, resp, *, status: int, error_type: str | None = None) -> dict: + assert resp.status_code == status, f"Expected {status}, got {resp.status_code}: {resp.text[:200]}" + data = resp.json() + assert data.get("error") is True + if error_type: + assert data.get("error_type") == error_type + return data + + +# --------------------------------------------------------------------------- +# Service test base +# --------------------------------------------------------------------------- + +class ServiceTestCase: + """Base class for service unit tests. + + Provides helpers for creating mock dependencies and asserting common + service behaviors. No TestClient — services are tested directly. + + Example: + class TestSessionManager(ServiceTestCase): + def test_new_session_is_empty(self): + from code.web.services.tasks import SessionManager + mgr = SessionManager() + sess = mgr.get("new-key") + assert sess == {} + """ + + def make_mock(self, **attrs) -> MagicMock: + """Create a MagicMock with the given attributes pre-set.""" + m = MagicMock() + for k, v in attrs.items(): + setattr(m, k, v) + return m + + def assert_raises(self, exc_type, fn, *args, **kwargs): + """Assert that fn(*args, **kwargs) raises exc_type.""" + with pytest.raises(exc_type): + fn(*args, **kwargs) + + +# --------------------------------------------------------------------------- +# Validation test mixin +# --------------------------------------------------------------------------- + +class ValidationTestMixin: + """Mixin for Pydantic model validation tests. + + Example: + class TestBuildRequest(ValidationTestMixin): + MODEL = BuildRequest + + def test_commander_required(self): + self.assert_validation_error(commander="") + """ + + MODEL = None # Set in subclass + + def build(self, **kwargs) -> Any: + """Instantiate MODEL with kwargs. Raises ValidationError on invalid input.""" + assert self.MODEL is not None, "Set MODEL in your test class" + return self.MODEL(**kwargs) + + def assert_validation_error(self, **kwargs) -> None: + """Assert that MODEL(**kwargs) raises a Pydantic ValidationError.""" + from pydantic import ValidationError + assert self.MODEL is not None + with pytest.raises(ValidationError): + self.MODEL(**kwargs) diff --git a/code/tests/test_error_handling.py b/code/tests/test_error_handling.py new file mode 100644 index 0000000..21c7255 --- /dev/null +++ b/code/tests/test_error_handling.py @@ -0,0 +1,252 @@ +"""Tests for M4 error handling integration. + +Covers: +- DeckBuilderError → HTTP response conversion +- HTMX vs JSON response detection +- Status code mapping for exception hierarchy +- app.py DeckBuilderError exception handler +- Web-specific exceptions (SessionExpiredError, BuildNotFoundError, FeatureDisabledError) +""" +from __future__ import annotations + +import pytest +from unittest.mock import MagicMock +from fastapi.testclient import TestClient +from fastapi import FastAPI, Request + +from code.exceptions import ( + DeckBuilderError, + CommanderValidationError, + CommanderTypeError, + ThemeSelectionError, + SessionExpiredError, + BuildNotFoundError, + FeatureDisabledError, + CSVFileNotFoundError, + PriceAPIError, +) +from code.web.utils.responses import ( + deck_error_to_status, + deck_builder_error_response, + is_htmx_request, +) + + +# --------------------------------------------------------------------------- +# Unit tests: exception → status mapping +# --------------------------------------------------------------------------- + +class TestDeckErrorToStatus: + def test_commander_validation_400(self): + assert deck_error_to_status(CommanderValidationError("bad")) == 400 + + def test_commander_type_400(self): + assert deck_error_to_status(CommanderTypeError("bad type")) == 400 + + def test_theme_selection_400(self): + assert deck_error_to_status(ThemeSelectionError("bad theme")) == 400 + + def test_session_expired_401(self): + assert deck_error_to_status(SessionExpiredError()) == 401 + + def test_build_not_found_404(self): + assert deck_error_to_status(BuildNotFoundError()) == 404 + + def test_feature_disabled_404(self): + assert deck_error_to_status(FeatureDisabledError("test_feature")) == 404 + + def test_csv_file_not_found_503(self): + assert deck_error_to_status(CSVFileNotFoundError("cards.csv")) == 503 + + def test_price_api_error_503(self): + assert deck_error_to_status(PriceAPIError("http://x", 500)) == 503 + + def test_base_deck_builder_error_500(self): + assert deck_error_to_status(DeckBuilderError("generic")) == 500 + + def test_subclass_not_in_map_falls_back_via_mro(self): + # CommanderTypeError is a subclass of CommanderValidationError + exc = CommanderTypeError("oops") + assert deck_error_to_status(exc) == 400 + + +# --------------------------------------------------------------------------- +# Unit tests: HTMX detection +# --------------------------------------------------------------------------- + +class TestIsHtmxRequest: + def _make_request(self, hx_header: str | None = None) -> Request: + scope = { + "type": "http", + "method": "GET", + "path": "/test", + "query_string": b"", + "headers": [], + } + if hx_header is not None: + scope["headers"] = [(b"hx-request", hx_header.encode())] + return Request(scope) + + def test_htmx_true_header(self): + req = self._make_request("true") + assert is_htmx_request(req) is True + + def test_no_htmx_header(self): + req = self._make_request() + assert is_htmx_request(req) is False + + def test_htmx_false_header(self): + req = self._make_request("false") + assert is_htmx_request(req) is False + + +# --------------------------------------------------------------------------- +# Unit tests: deck_builder_error_response +# --------------------------------------------------------------------------- + +class TestDeckBuilderErrorResponse: + def _make_request(self, htmx: bool = False) -> Request: + headers = [] + if htmx: + headers.append((b"hx-request", b"true")) + scope = { + "type": "http", + "method": "GET", + "path": "/build/new", + "query_string": b"", + "headers": headers, + } + req = Request(scope) + req.state.request_id = "test-rid-123" + return req + + def test_json_response_structure(self): + from fastapi.responses import JSONResponse + req = self._make_request(htmx=False) + exc = CommanderValidationError("Invalid commander") + resp = deck_builder_error_response(req, exc) + assert isinstance(resp, JSONResponse) + assert resp.status_code == 400 + import json + body = json.loads(resp.body) + assert body["error"] is True + assert body["status"] == 400 + assert body["error_type"] == "CommanderValidationError" + assert body["message"] == "Invalid commander" + assert body["request_id"] == "test-rid-123" + assert "timestamp" in body + assert "path" in body + + def test_htmx_response_is_html(self): + from fastapi.responses import HTMLResponse + req = self._make_request(htmx=True) + exc = ThemeSelectionError("Invalid theme") + resp = deck_builder_error_response(req, exc) + assert isinstance(resp, HTMLResponse) + assert resp.status_code == 400 + assert "Invalid theme" in resp.body.decode() + assert resp.headers.get("X-Request-ID") == "test-rid-123" + + def test_request_id_in_response_header(self): + req = self._make_request(htmx=False) + exc = BuildNotFoundError() + resp = deck_builder_error_response(req, exc) + assert resp.headers.get("X-Request-ID") == "test-rid-123" + + +# --------------------------------------------------------------------------- +# Integration tests: app exception handler +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def test_app(): + """Minimal FastAPI app that raises DeckBuilderErrors for testing.""" + app = FastAPI() + + @app.get("/raise/commander") + async def raise_commander(request: Request): + raise CommanderValidationError("Commander 'Foo' not found") + + @app.get("/raise/session") + async def raise_session(request: Request): + raise SessionExpiredError(sid="abc123") + + @app.get("/raise/feature") + async def raise_feature(request: Request): + raise FeatureDisabledError("partner_suggestions") + + @app.get("/raise/generic") + async def raise_generic(request: Request): + raise DeckBuilderError("Something went wrong") + + # Wire the same handler as app.py + from code.exceptions import DeckBuilderError as DBE + from code.web.utils.responses import deck_builder_error_response + + @app.exception_handler(Exception) + async def handler(request: Request, exc: Exception): + if isinstance(exc, DBE): + return deck_builder_error_response(request, exc) + from fastapi.responses import JSONResponse + return JSONResponse({"error": "unhandled"}, status_code=500) + + return app + + +@pytest.fixture(scope="module") +def client(test_app): + return TestClient(test_app, raise_server_exceptions=False) + + +class TestAppExceptionHandler: + def test_commander_validation_returns_400(self, client): + resp = client.get("/raise/commander") + assert resp.status_code == 400 + data = resp.json() + assert data["error"] is True + assert data["error_type"] == "CommanderValidationError" + assert "Commander 'Foo' not found" in data["message"] + + def test_session_expired_returns_401(self, client): + resp = client.get("/raise/session") + assert resp.status_code == 401 + data = resp.json() + assert data["error_type"] == "SessionExpiredError" + + def test_feature_disabled_returns_404(self, client): + resp = client.get("/raise/feature") + assert resp.status_code == 404 + data = resp.json() + assert data["error_type"] == "FeatureDisabledError" + + def test_generic_deck_builder_error_returns_500(self, client): + resp = client.get("/raise/generic") + assert resp.status_code == 500 + data = resp.json() + assert data["error_type"] == "DeckBuilderError" + + def test_htmx_commander_error_returns_html(self, client): + resp = client.get("/raise/commander", headers={"HX-Request": "true"}) + assert resp.status_code == 400 + assert "text/html" in resp.headers.get("content-type", "") + assert "Commander 'Foo' not found" in resp.text + + +# --------------------------------------------------------------------------- +# Web-specific exception constructors +# --------------------------------------------------------------------------- + +class TestWebExceptions: + def test_session_expired_has_code(self): + exc = SessionExpiredError(sid="xyz") + assert exc.code == "SESSION_EXPIRED" + assert "xyz" in str(exc.details) + + def test_build_not_found_has_code(self): + exc = BuildNotFoundError(sid="abc") + assert exc.code == "BUILD_NOT_FOUND" + + def test_feature_disabled_has_feature_name(self): + exc = FeatureDisabledError("partner_suggestions") + assert exc.code == "FEATURE_DISABLED" + assert "partner_suggestions" in exc.message diff --git a/code/tests/test_partner_suggestions_api.py b/code/tests/test_partner_suggestions_api.py index 5180329..8a17e3a 100644 --- a/code/tests/test_partner_suggestions_api.py +++ b/code/tests/test_partner_suggestions_api.py @@ -96,7 +96,6 @@ def _write_dataset(path: Path) -> Path: def _fresh_client(tmp_path: Path) -> tuple[TestClient, Path]: dataset_path = _write_dataset(tmp_path / "partner_synergy.json") os.environ["ENABLE_PARTNER_MECHANICS"] = "1" - os.environ["ENABLE_PARTNER_SUGGESTIONS"] = "1" for module_name in ( "code.web.app", "code.web.routes.partner_suggestions", @@ -177,7 +176,6 @@ def test_partner_suggestions_api_returns_ranked_candidates(tmp_path: Path) -> No except Exception: pass os.environ.pop("ENABLE_PARTNER_MECHANICS", None) - os.environ.pop("ENABLE_PARTNER_SUGGESTIONS", None) for module_name in ( "code.web.app", "code.web.routes.partner_suggestions", @@ -245,7 +243,6 @@ def test_partner_suggestions_api_refresh_flag(monkeypatch) -> None: from code.web.services.partner_suggestions import PartnerSuggestionResult monkeypatch.setattr(route, "ENABLE_PARTNER_MECHANICS", True) - monkeypatch.setattr(route, "ENABLE_PARTNER_SUGGESTIONS", True) captured: dict[str, bool] = {"refresh": False} diff --git a/code/tests/test_permalinks_and_locks.py b/code/tests/test_permalinks_and_locks.py deleted file mode 100644 index f43488f..0000000 --- a/code/tests/test_permalinks_and_locks.py +++ /dev/null @@ -1,44 +0,0 @@ -import base64 -import json -from starlette.testclient import TestClient - - -def test_permalink_includes_locks_and_restores_notice(monkeypatch): - # Lazy import to ensure fresh app state - import importlib - app_module = importlib.import_module('code.web.app') - client = TestClient(app_module.app) - - # Seed a session with a commander and locks by calling /build and directly touching session via cookie path - # Start a session - r = client.get('/build') - assert r.status_code == 200 - - # Now set some session state by invoking endpoints that mutate session - # Simulate selecting commander and a lock - # Use /build/from to load a permalink-like payload directly - payload = { - "commander": "Atraxa, Praetors' Voice", - "tags": ["proliferate"], - "bracket": 3, - "ideals": {"ramp": 10, "lands": 36, "basic_lands": 18, "creatures": 28, "removal": 10, "wipes": 3, "card_advantage": 8, "protection": 4}, - "tag_mode": "AND", - "flags": {"owned_only": False, "prefer_owned": False}, - "locks": ["Swords to Plowshares", "Sol Ring"], - } - raw = json.dumps(payload, separators=(",", ":")).encode('utf-8') - token = base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=') - r2 = client.get(f'/build/from?state={token}') - assert r2.status_code == 200 - # Step 4 should contain the locks restored chip - body = r2.text - assert 'locks restored' in body.lower() - - # Ask the server for a permalink now and ensure locks are present - r3 = client.get('/build/permalink') - assert r3.status_code == 200 - data = r3.json() - # Prefer decoded state when token not provided - state = data.get('state') or {} - assert 'locks' in state - assert set([s.lower() for s in state.get('locks', [])]) == {"swords to plowshares", "sol ring"} diff --git a/code/tests/test_service_layer.py b/code/tests/test_service_layer.py new file mode 100644 index 0000000..b70312b --- /dev/null +++ b/code/tests/test_service_layer.py @@ -0,0 +1,407 @@ +"""Tests for service layer base classes, interfaces, and registry.""" +from __future__ import annotations + +import pytest +import time +from typing import Dict, Any + +from code.web.services.base import ( + BaseService, + StateService, + DataService, + CachedService, + ServiceError, + ValidationError, + NotFoundError, +) +from code.web.services.registry import ServiceRegistry, get_registry, reset_registry +from code.web.services.tasks import SessionManager + + +class TestBaseService: + """Test BaseService abstract base class.""" + + def test_validation_helper(self): + """Test _validate helper method.""" + service = BaseService() + + # Should not raise on True + service._validate(True, "Should not raise") + + # Should raise on False + with pytest.raises(ValidationError, match="Should raise"): + service._validate(False, "Should raise") + + +class MockStateService(StateService): + """Mock state service for testing.""" + + def _initialize_state(self, key: str) -> Dict[str, Any]: + return {"created": time.time(), "data": f"init-{key}"} + + def _should_cleanup(self, key: str, state: Dict[str, Any]) -> bool: + # Cleanup if "expired" flag is set + return state.get("expired", False) + + +class TestStateService: + """Test StateService base class.""" + + def test_get_state_creates_new(self): + """Test that get_state creates new state.""" + service = MockStateService() + state = service.get_state("test-key") + + assert "created" in state + assert state["data"] == "init-test-key" + + def test_get_state_returns_existing(self): + """Test that get_state returns existing state.""" + service = MockStateService() + + state1 = service.get_state("test-key") + state1["custom"] = "value" + + state2 = service.get_state("test-key") + assert state2 is state1 + assert state2["custom"] == "value" + + def test_set_and_get_value(self): + """Test setting and getting state values.""" + service = MockStateService() + + service.set_state_value("key1", "field1", "value1") + assert service.get_state_value("key1", "field1") == "value1" + assert service.get_state_value("key1", "missing", "default") == "default" + + def test_cleanup_state(self): + """Test cleanup of expired state.""" + service = MockStateService() + + # Create some state + service.get_state("keep1") + service.get_state("keep2") + service.get_state("expire1") + service.get_state("expire2") + + # Mark some as expired + service.set_state_value("expire1", "expired", True) + service.set_state_value("expire2", "expired", True) + + # Cleanup + removed = service.cleanup_state() + assert removed == 2 + + # Verify expired are gone + state = service._state + assert "keep1" in state + assert "keep2" in state + assert "expire1" not in state + assert "expire2" not in state + + +class MockDataService(DataService[Dict[str, Any]]): + """Mock data service for testing.""" + + def __init__(self, data: Dict[str, Any]): + super().__init__() + self._mock_data = data + + def _load_data(self) -> Dict[str, Any]: + return self._mock_data.copy() + + +class TestDataService: + """Test DataService base class.""" + + def test_lazy_loading(self): + """Test that data is loaded lazily.""" + service = MockDataService({"key": "value"}) + + assert not service.is_loaded() + data = service.get_data() + assert service.is_loaded() + assert data["key"] == "value" + + def test_cached_loading(self): + """Test that data is cached after first load.""" + service = MockDataService({"key": "value"}) + + data1 = service.get_data() + data1["modified"] = True + + data2 = service.get_data() + assert data2 is data1 + assert data2["modified"] + + def test_force_reload(self): + """Test force reload of data.""" + service = MockDataService({"key": "value"}) + + data1 = service.get_data() + data1["modified"] = True + + data2 = service.get_data(force_reload=True) + assert data2 is not data1 + assert "modified" not in data2 + + +class MockCachedService(CachedService[str, int]): + """Mock cached service for testing.""" + + def __init__(self, ttl_seconds: int | None = None, max_size: int | None = None): + super().__init__(ttl_seconds=ttl_seconds, max_size=max_size) + self.compute_count = 0 + + def _compute_value(self, key: str) -> int: + self.compute_count += 1 + return len(key) + + +class TestCachedService: + """Test CachedService base class.""" + + def test_cache_hit(self): + """Test that values are cached.""" + service = MockCachedService() + + value1 = service.get("hello") + assert value1 == 5 + assert service.compute_count == 1 + + value2 = service.get("hello") + assert value2 == 5 + assert service.compute_count == 1 # Should not recompute + + def test_cache_miss(self): + """Test cache miss computes new value.""" + service = MockCachedService() + + value1 = service.get("hello") + value2 = service.get("world") + + assert value1 == 5 + assert value2 == 5 + assert service.compute_count == 2 + + def test_ttl_expiration(self): + """Test TTL-based expiration.""" + service = MockCachedService(ttl_seconds=1) + + value1 = service.get("hello") + assert service.compute_count == 1 + + # Should hit cache immediately + value2 = service.get("hello") + assert service.compute_count == 1 + + # Wait for expiration + time.sleep(1.1) + + value3 = service.get("hello") + assert service.compute_count == 2 # Should recompute + + def test_max_size_limit(self): + """Test cache size limit.""" + service = MockCachedService(max_size=2) + + service.get("key1") + service.get("key2") + service.get("key3") # Should evict oldest (key1) + + # key1 should be evicted + assert len(service._cache) == 2 + assert "key1" not in service._cache + assert "key2" in service._cache + assert "key3" in service._cache + + def test_invalidate_single(self): + """Test invalidating single cache entry.""" + service = MockCachedService() + + service.get("key1") + service.get("key2") + + service.invalidate("key1") + + assert "key1" not in service._cache + assert "key2" in service._cache + + def test_invalidate_all(self): + """Test invalidating entire cache.""" + service = MockCachedService() + + service.get("key1") + service.get("key2") + + service.invalidate() + + assert len(service._cache) == 0 + + +class MockService: + """Mock service for registry testing.""" + + def __init__(self, value: str): + self.value = value + + +class TestServiceRegistry: + """Test ServiceRegistry for dependency injection.""" + + def test_register_and_get_singleton(self): + """Test registering and retrieving singleton.""" + registry = ServiceRegistry() + instance = MockService("test") + + registry.register_singleton(MockService, instance) + retrieved = registry.get(MockService) + + assert retrieved is instance + assert retrieved.value == "test" + + def test_register_and_get_factory(self): + """Test registering and retrieving from factory.""" + registry = ServiceRegistry() + + registry.register_factory(MockService, lambda: MockService("factory")) + + instance1 = registry.get(MockService) + instance2 = registry.get(MockService) + + assert instance1 is not instance2 # Factory creates new instances + assert instance1.value == "factory" + assert instance2.value == "factory" + + def test_lazy_singleton(self): + """Test lazy-initialized singleton.""" + registry = ServiceRegistry() + call_count = {"count": 0} + + def factory(): + call_count["count"] += 1 + return MockService("lazy") + + registry.register_lazy_singleton(MockService, factory) + + instance1 = registry.get(MockService) + assert call_count["count"] == 1 + + instance2 = registry.get(MockService) + assert call_count["count"] == 1 # Should not call factory again + assert instance1 is instance2 + + def test_duplicate_registration_error(self): + """Test error on duplicate registration.""" + registry = ServiceRegistry() + registry.register_singleton(MockService, MockService("first")) + + with pytest.raises(ValueError, match="already registered"): + registry.register_singleton(MockService, MockService("second")) + + def test_get_unregistered_error(self): + """Test error on getting unregistered service.""" + registry = ServiceRegistry() + + with pytest.raises(KeyError, match="not registered"): + registry.get(MockService) + + def test_try_get(self): + """Test try_get returns None for unregistered.""" + registry = ServiceRegistry() + + result = registry.try_get(MockService) + assert result is None + + registry.register_singleton(MockService, MockService("test")) + result = registry.try_get(MockService) + assert result is not None + + def test_is_registered(self): + """Test checking if service is registered.""" + registry = ServiceRegistry() + + assert not registry.is_registered(MockService) + + registry.register_singleton(MockService, MockService("test")) + assert registry.is_registered(MockService) + + def test_unregister(self): + """Test unregistering a service.""" + registry = ServiceRegistry() + registry.register_singleton(MockService, MockService("test")) + + assert registry.is_registered(MockService) + registry.unregister(MockService) + assert not registry.is_registered(MockService) + + def test_clear(self): + """Test clearing all services.""" + registry = ServiceRegistry() + registry.register_singleton(MockService, MockService("test")) + + registry.clear() + assert not registry.is_registered(MockService) + + +class TestSessionManager: + """Test SessionManager refactored service.""" + + def test_new_session_id(self): + """Test creating new session IDs.""" + manager = SessionManager() + + sid1 = manager.new_session_id() + sid2 = manager.new_session_id() + + assert isinstance(sid1, str) + assert isinstance(sid2, str) + assert sid1 != sid2 + assert len(sid1) == 32 # UUID hex is 32 chars + + def test_get_session_creates_new(self): + """Test get_session with None creates new.""" + manager = SessionManager() + + session = manager.get_session(None) + assert "created" in session + assert "updated" in session + + def test_get_session_returns_existing(self): + """Test get_session returns existing session.""" + manager = SessionManager() + + sid = manager.new_session_id() + session1 = manager.get_session(sid) + session1["custom"] = "data" + + session2 = manager.get_session(sid) + assert session2 is session1 + assert session2["custom"] == "data" + + def test_set_and_get_value(self): + """Test setting and getting session values.""" + manager = SessionManager() + sid = manager.new_session_id() + + manager.set_value(sid, "key1", "value1") + assert manager.get_value(sid, "key1") == "value1" + assert manager.get_value(sid, "missing", "default") == "default" + + def test_cleanup_expired_sessions(self): + """Test cleanup of expired sessions.""" + manager = SessionManager(ttl_seconds=1) + + sid1 = manager.new_session_id() + sid2 = manager.new_session_id() + + manager.get_session(sid1) + time.sleep(1.1) # Let sid1 expire + manager.get_session(sid2) # sid2 is fresh + + removed = manager.cleanup_state() + assert removed == 1 + + # sid1 should be gone, sid2 should exist + assert sid1 not in manager._state + assert sid2 in manager._state diff --git a/code/tests/test_validation.py b/code/tests/test_validation.py new file mode 100644 index 0000000..bf23ec4 --- /dev/null +++ b/code/tests/test_validation.py @@ -0,0 +1,340 @@ +"""Tests for validation framework (models, validators, card names).""" +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from code.web.validation.models import ( + BuildRequest, + CommanderSearchRequest, + ThemeValidationRequest, + OwnedCardsImportRequest, + BatchBuildRequest, + CardReplacementRequest, + PowerBracket, + OwnedMode, + CommanderPartnerType, +) +from code.web.validation.card_names import CardNameValidator +from code.web.validation.validators import ( + ThemeValidator, + PowerBracketValidator, + ColorIdentityValidator, +) +from code.web.validation.messages import ValidationMessages, MSG + + +class TestBuildRequest: + """Test BuildRequest Pydantic model.""" + + def test_minimal_valid_request(self): + """Test minimal valid build request.""" + req = BuildRequest(commander="Atraxa, Praetors' Voice") + + assert req.commander == "Atraxa, Praetors' Voice" + assert req.themes == [] + assert req.power_bracket == PowerBracket.BRACKET_2 + assert req.owned_mode == OwnedMode.OFF + + def test_full_valid_request(self): + """Test fully populated build request.""" + req = BuildRequest( + commander="Kess, Dissident Mage", + themes=["Spellslinger", "Graveyard"], + power_bracket=PowerBracket.BRACKET_3, + owned_mode=OwnedMode.PREFER, + must_include=["Counterspell", "Lightning Bolt"], + must_exclude=["Armageddon"] + ) + + assert req.commander == "Kess, Dissident Mage" + assert len(req.themes) == 2 + assert req.power_bracket == PowerBracket.BRACKET_3 + assert len(req.must_include) == 2 + + def test_commander_whitespace_stripped(self): + """Test commander name whitespace is stripped.""" + req = BuildRequest(commander=" Atraxa ") + assert req.commander == "Atraxa" + + def test_commander_empty_fails(self): + """Test empty commander name fails validation.""" + with pytest.raises(ValidationError): + BuildRequest(commander="") + + with pytest.raises(ValidationError): + BuildRequest(commander=" ") + + def test_themes_deduplicated(self): + """Test themes are deduplicated case-insensitively.""" + req = BuildRequest( + commander="Test", + themes=["Spellslinger", "spellslinger", "SPELLSLINGER", "Tokens"] + ) + + assert len(req.themes) == 2 + assert "Spellslinger" in req.themes + assert "Tokens" in req.themes + + def test_partner_validation_requires_name(self): + """Test partner mode requires partner name.""" + with pytest.raises(ValidationError, match="Partner mode requires partner_name"): + BuildRequest( + commander="Kydele, Chosen of Kruphix", + partner_mode=CommanderPartnerType.PARTNER + ) + + def test_partner_valid_with_name(self): + """Test partner mode valid with name.""" + req = BuildRequest( + commander="Kydele, Chosen of Kruphix", + partner_mode=CommanderPartnerType.PARTNER, + partner_name="Thrasios, Triton Hero" + ) + + assert req.partner_mode == CommanderPartnerType.PARTNER + assert req.partner_name == "Thrasios, Triton Hero" + + def test_background_requires_name(self): + """Test background mode requires background name.""" + with pytest.raises(ValidationError, match="Background mode requires background_name"): + BuildRequest( + commander="Erinis, Gloom Stalker", + partner_mode=CommanderPartnerType.BACKGROUND + ) + + def test_custom_theme_requires_both(self): + """Test custom theme requires both name and tags.""" + with pytest.raises(ValidationError, match="Custom theme requires both name and tags"): + BuildRequest( + commander="Test", + custom_theme_name="My Theme" + ) + + with pytest.raises(ValidationError, match="Custom theme tags require theme name"): + BuildRequest( + commander="Test", + custom_theme_tags=["Tag1", "Tag2"] + ) + + +class TestCommanderSearchRequest: + """Test CommanderSearchRequest model.""" + + def test_valid_search(self): + """Test valid search request.""" + req = CommanderSearchRequest(query="Atraxa") + assert req.query == "Atraxa" + assert req.limit == 10 + + def test_custom_limit(self): + """Test custom limit.""" + req = CommanderSearchRequest(query="Test", limit=25) + assert req.limit == 25 + + def test_empty_query_fails(self): + """Test empty query fails.""" + with pytest.raises(ValidationError): + CommanderSearchRequest(query="") + + def test_limit_bounds(self): + """Test limit must be within bounds.""" + with pytest.raises(ValidationError): + CommanderSearchRequest(query="Test", limit=0) + + with pytest.raises(ValidationError): + CommanderSearchRequest(query="Test", limit=101) + + +class TestCardNameValidator: + """Test card name validation and normalization.""" + + def test_normalize_lowercase(self): + """Test normalization converts to lowercase.""" + assert CardNameValidator.normalize("Atraxa, Praetors' Voice") == "atraxa, praetors' voice" + + def test_normalize_removes_diacritics(self): + """Test normalization removes diacritics.""" + assert CardNameValidator.normalize("Dánitha Capashen") == "danitha capashen" + assert CardNameValidator.normalize("Gisela, the Broken Blade") == "gisela, the broken blade" + + def test_normalize_standardizes_apostrophes(self): + """Test normalization standardizes apostrophes.""" + assert CardNameValidator.normalize("Atraxa, Praetors' Voice") == CardNameValidator.normalize("Atraxa, Praetors' Voice") + assert CardNameValidator.normalize("Atraxa, Praetors` Voice") == CardNameValidator.normalize("Atraxa, Praetors' Voice") + + def test_normalize_collapses_whitespace(self): + """Test normalization collapses whitespace.""" + assert CardNameValidator.normalize("Test Card") == "test card" + assert CardNameValidator.normalize(" Test ") == "test" + + def test_validator_caches_normalization(self): + """Test validator caches normalized lookups.""" + validator = CardNameValidator() + validator._card_names = {"Atraxa, Praetors' Voice"} + validator._normalized_map = { + "atraxa, praetors' voice": "Atraxa, Praetors' Voice" + } + validator._loaded = True + + # Should find exact match + assert validator.is_valid("Atraxa, Praetors' Voice") + + +class TestThemeValidator: + """Test theme validation.""" + + def test_validate_themes_separates_valid_invalid(self): + """Test validation separates valid from invalid themes.""" + validator = ThemeValidator() + validator._themes = {"Spellslinger", "spellslinger", "Tokens", "tokens"} + validator._loaded = True + + valid, invalid = validator.validate_themes(["Spellslinger", "Invalid", "Tokens"]) + + assert "Spellslinger" in valid + assert "Tokens" in valid + assert "Invalid" in invalid + + +class TestPowerBracketValidator: + """Test power bracket validation.""" + + def test_valid_brackets(self): + """Test valid bracket values (1-4).""" + assert PowerBracketValidator.is_valid_bracket(1) + assert PowerBracketValidator.is_valid_bracket(2) + assert PowerBracketValidator.is_valid_bracket(3) + assert PowerBracketValidator.is_valid_bracket(4) + + def test_invalid_brackets(self): + """Test invalid bracket values.""" + assert not PowerBracketValidator.is_valid_bracket(0) + assert not PowerBracketValidator.is_valid_bracket(5) + assert not PowerBracketValidator.is_valid_bracket(-1) + + +class TestColorIdentityValidator: + """Test color identity validation.""" + + def test_parse_comma_separated(self): + """Test parsing comma-separated colors.""" + colors = ColorIdentityValidator.parse_colors("W,U,B") + assert colors == {"W", "U", "B"} + + def test_parse_concatenated(self): + """Test parsing concatenated colors.""" + colors = ColorIdentityValidator.parse_colors("WUB") + assert colors == {"W", "U", "B"} + + def test_parse_empty(self): + """Test parsing empty string.""" + colors = ColorIdentityValidator.parse_colors("") + assert colors == set() + + def test_colorless_subset_any(self): + """Test colorless cards valid in any deck.""" + validator = ColorIdentityValidator() + assert validator.is_subset({"C"}, {"W", "U"}) + assert validator.is_subset(set(), {"R", "G"}) + + def test_subset_validation(self): + """Test subset validation.""" + validator = ColorIdentityValidator() + + # Valid: card colors subset of commander + assert validator.is_subset({"W", "U"}, {"W", "U", "B"}) + + # Invalid: card has colors not in commander + assert not validator.is_subset({"W", "U", "B"}, {"W", "U"}) + + +class TestValidationMessages: + """Test validation message formatting.""" + + def test_format_commander_invalid(self): + """Test commander invalid message formatting.""" + msg = MSG.format_commander_invalid("Test Commander") + assert "Test Commander" in msg + assert "not found" in msg + + def test_format_themes_invalid(self): + """Test multiple invalid themes formatting.""" + msg = MSG.format_themes_invalid(["Theme1", "Theme2"]) + assert "Theme1" in msg + assert "Theme2" in msg + + def test_format_bracket_exceeded(self): + """Test bracket exceeded message formatting.""" + msg = MSG.format_bracket_exceeded("Mana Crypt", 4, 2) + assert "Mana Crypt" in msg + assert "4" in msg + assert "2" in msg + + def test_format_color_mismatch(self): + """Test color mismatch message formatting.""" + msg = MSG.format_color_mismatch("Card", "WUB", "WU") + assert "Card" in msg + assert "WUB" in msg + assert "WU" in msg + + +class TestBatchBuildRequest: + """Test batch build request validation.""" + + def test_valid_batch(self): + """Test valid batch request.""" + base = BuildRequest(commander="Test") + req = BatchBuildRequest(base_config=base, count=5) + + assert req.count == 5 + assert req.base_config.commander == "Test" + + def test_count_limit(self): + """Test batch count limit.""" + base = BuildRequest(commander="Test") + + with pytest.raises(ValidationError): + BatchBuildRequest(base_config=base, count=11) + + +class TestCardReplacementRequest: + """Test card replacement request validation.""" + + def test_valid_replacement(self): + """Test valid replacement request.""" + req = CardReplacementRequest(card_name="Sol Ring", reason="Too powerful") + + assert req.card_name == "Sol Ring" + assert req.reason == "Too powerful" + + def test_whitespace_stripped(self): + """Test whitespace is stripped.""" + req = CardReplacementRequest(card_name=" Sol Ring ") + assert req.card_name == "Sol Ring" + + def test_empty_name_fails(self): + """Test empty card name fails.""" + with pytest.raises(ValidationError): + CardReplacementRequest(card_name="") + + +class TestOwnedCardsImportRequest: + """Test owned cards import validation.""" + + def test_valid_import(self): + """Test valid import request.""" + req = OwnedCardsImportRequest(format_type="csv", content="Name\nSol Ring\n") + + assert req.format_type == "csv" + assert "Sol Ring" in req.content + + def test_invalid_format(self): + """Test invalid format fails.""" + with pytest.raises(ValidationError): + OwnedCardsImportRequest(format_type="invalid", content="test") + + def test_empty_content_fails(self): + """Test empty content fails.""" + with pytest.raises(ValidationError): + OwnedCardsImportRequest(format_type="csv", content="") diff --git a/code/web/app.py b/code/web/app.py index 77f4f7c..ce511cb 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -22,6 +22,8 @@ from .services.combo_utils import detect_all as _detect_all from .services.theme_catalog_loader import prewarm_common_filters, load_index from .services.commander_catalog_loader import load_commander_catalog from .services.tasks import get_session, new_sid, set_session_value +from code.exceptions import DeckBuilderError +from .utils.responses import deck_builder_error_response # Logger for app-level logging logger = logging.getLogger(__name__) @@ -166,6 +168,7 @@ 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) +CACHE_CARD_IMAGES = _as_bool(os.getenv("CACHE_CARD_IMAGES"), 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) @@ -173,8 +176,7 @@ ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True) SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False) ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True) WEB_IDEALS_UI = os.getenv("WEB_IDEALS_UI", "slider").strip().lower() # 'input' or 'slider' -ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_MECHANICS"), True) -ENABLE_PARTNER_SUGGESTIONS = _as_bool(os.getenv("ENABLE_PARTNER_SUGGESTIONS"), True) +ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_GESTIONS"), True) ENABLE_BATCH_BUILD = _as_bool(os.getenv("ENABLE_BATCH_BUILD"), True) RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), True) # initial snapshot (legacy) RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), True) @@ -310,13 +312,12 @@ templates.env.globals.update({ "enable_presets": ENABLE_PRESETS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_partner_mechanics": ENABLE_PARTNER_MECHANICS, - "enable_partner_suggestions": ENABLE_PARTNER_SUGGESTIONS, "allow_must_haves": ALLOW_MUST_HAVES, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "default_theme": DEFAULT_THEME, "random_modes": RANDOM_MODES, "random_ui": RANDOM_UI, - "random_max_attempts": RANDOM_MAX_ATTEMPTS, + "card_images_cached": CACHE_CARD_IMAGES, "random_timeout_ms": RANDOM_TIMEOUT_MS, "random_reroll_throttle_ms": int(RANDOM_REROLL_THROTTLE_MS), "theme_picker_diagnostics": THEME_PICKER_DIAGNOSTICS, @@ -2256,6 +2257,15 @@ async def setup_status(): # Routers from .routes import build as build_routes # noqa: E402 +from .routes import build_validation as build_validation_routes # noqa: E402 +from .routes import build_multicopy as build_multicopy_routes # noqa: E402 +from .routes import build_include_exclude as build_include_exclude_routes # noqa: E402 +from .routes import build_themes as build_themes_routes # noqa: E402 +from .routes import build_partners as build_partners_routes # noqa: E402 +from .routes import build_wizard as build_wizard_routes # noqa: E402 +from .routes import build_newflow as build_newflow_routes # noqa: E402 +from .routes import build_alternatives as build_alternatives_routes # noqa: E402 +from .routes import build_compliance as build_compliance_routes # noqa: E402 from .routes import configs as config_routes # noqa: E402 from .routes import decks as decks_routes # noqa: E402 from .routes import setup as setup_routes # noqa: E402 @@ -2269,6 +2279,15 @@ from .routes import card_browser as card_browser_routes # noqa: E402 from .routes import compare as compare_routes # noqa: E402 from .routes import api as api_routes # noqa: E402 app.include_router(build_routes.router) +app.include_router(build_validation_routes.router, prefix="/build") +app.include_router(build_multicopy_routes.router, prefix="/build") +app.include_router(build_include_exclude_routes.router, prefix="/build") +app.include_router(build_themes_routes.router, prefix="/build") +app.include_router(build_partners_routes.router, prefix="/build") +app.include_router(build_wizard_routes.router, prefix="/build") +app.include_router(build_newflow_routes.router, prefix="/build") +app.include_router(build_alternatives_routes.router) +app.include_router(build_compliance_routes.router) app.include_router(config_routes.router) app.include_router(decks_routes.router) app.include_router(setup_routes.router) @@ -2284,7 +2303,7 @@ app.include_router(api_routes.router) # Warm validation cache early to reduce first-call latency in tests and dev try: - build_routes.warm_validation_name_cache() + build_validation_routes.warm_validation_name_cache() except Exception: pass @@ -2386,6 +2405,13 @@ async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPE @app.exception_handler(Exception) async def unhandled_exception_handler(request: Request, exc: Exception): + # Handle DeckBuilderError subclasses with structured responses before falling to 500 + if isinstance(exc, DeckBuilderError): + rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex + logging.getLogger("web").warning( + f"DeckBuilderError [rid={rid}] {type(exc).__name__} {request.method} {request.url.path}: {exc}" + ) + return deck_builder_error_response(request, exc) rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex logging.getLogger("web").error( f"Unhandled exception [rid={rid}] {request.method} {request.url.path}", exc_info=True diff --git a/code/web/decorators/__init__.py b/code/web/decorators/__init__.py new file mode 100644 index 0000000..d532692 --- /dev/null +++ b/code/web/decorators/__init__.py @@ -0,0 +1 @@ +"""Decorators for route handlers.""" diff --git a/code/web/decorators/telemetry.py b/code/web/decorators/telemetry.py new file mode 100644 index 0000000..6c791bf --- /dev/null +++ b/code/web/decorators/telemetry.py @@ -0,0 +1,97 @@ +"""Telemetry decorators for route handlers. + +Provides decorators to automatically track route access, build times, and other metrics. +""" +from functools import wraps +from typing import Callable, Any +import time +from code.logging_util import get_logger + +LOGGER = get_logger(__name__) + + +def track_route_access(event_name: str): + """Decorator to track route access with telemetry. + + Args: + event_name: Name of the telemetry event to log + + Example: + @router.get("/build/new") + @track_route_access("build_start") + async def start_build(request: Request): + ... + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args, **kwargs): + start_time = time.time() + try: + result = await func(*args, **kwargs) + elapsed_ms = int((time.time() - start_time) * 1000) + LOGGER.debug(f"Route {event_name} completed in {elapsed_ms}ms") + return result + except Exception as e: + elapsed_ms = int((time.time() - start_time) * 1000) + LOGGER.error(f"Route {event_name} failed after {elapsed_ms}ms: {e}") + raise + return wrapper + return decorator + + +def track_build_time(operation: str): + """Decorator to track deck building operation timing. + + Args: + operation: Description of the build operation + + Example: + @track_build_time("commander_selection") + async def select_commander(request: Request): + ... + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args, **kwargs): + start_time = time.time() + result = await func(*args, **kwargs) + elapsed_ms = int((time.time() - start_time) * 1000) + LOGGER.info(f"Build operation '{operation}' took {elapsed_ms}ms") + return result + return wrapper + return decorator + + +def log_route_errors(route_name: str): + """Decorator to log route errors with context. + + Args: + route_name: Name of the route for error context + + Example: + @router.post("/build/create") + @log_route_errors("build_create") + async def create_deck(request: Request): + ... + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as e: + # Extract request if available + request = None + for arg in args: + if hasattr(arg, "url") and hasattr(arg, "state"): + request = arg + break + + request_id = getattr(request.state, "request_id", "unknown") if request else "unknown" + LOGGER.error( + f"Error in route '{route_name}' [request_id={request_id}]: {e}", + exc_info=True + ) + raise + return wrapper + return decorator diff --git a/code/web/middleware/__init__.py b/code/web/middleware/__init__.py new file mode 100644 index 0000000..0d4eaf1 --- /dev/null +++ b/code/web/middleware/__init__.py @@ -0,0 +1 @@ +"""Middleware modules for the web application.""" diff --git a/code/web/routes/api.py b/code/web/routes/api.py index 157344b..6ba3342 100644 --- a/code/web/routes/api.py +++ b/code/web/routes/api.py @@ -31,18 +31,46 @@ async def get_download_status(): import json status_file = Path("card_files/images/.download_status.json") + last_result_file = Path("card_files/images/.last_download_result.json") if not status_file.exists(): - # Check cache statistics if no download in progress + # No active download - return cache stats plus last download result if available stats = _image_cache.cache_statistics() + last_download = None + if last_result_file.exists(): + try: + with last_result_file.open('r', encoding='utf-8') as f: + last_download = json.load(f) + except Exception: + pass return JSONResponse({ "running": False, + "last_download": last_download, "stats": stats }) try: with status_file.open('r', encoding='utf-8') as f: status = json.load(f) + + # If download is complete (or errored), persist result, clean up status file + if not status.get("running", False): + try: + with last_result_file.open('w', encoding='utf-8') as f: + json.dump(status, f) + except Exception: + pass + try: + status_file.unlink() + except Exception: + pass + cache_stats = _image_cache.cache_statistics() + return JSONResponse({ + "running": False, + "last_download": status, + "stats": cache_stats + }) + return JSONResponse(status) except Exception as e: logger.warning(f"Could not read status file: {e}") @@ -136,7 +164,7 @@ async def get_card_image(size: str, card_name: str, face: str = Query(default="f image_path = _image_cache.get_image_path(card_name, size) if image_path and image_path.exists(): - logger.info(f"Serving cached image: {card_name} ({size}, {face})") + logger.debug(f"Serving cached image: {card_name} ({size}, {face})") return FileResponse( image_path, media_type="image/jpeg", diff --git a/code/web/routes/build.py b/code/web/routes/build.py index c9c9090..1e7d729 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -1,146 +1,14 @@ from __future__ import annotations -from fastapi import APIRouter, Request, Form, Query, BackgroundTasks -from fastapi.responses import HTMLResponse, JSONResponse -from typing import Any, Dict, Iterable +from fastapi import APIRouter, Request, Query +from fastapi.responses import HTMLResponse +from typing import Any import json -from ..app import ( - ALLOW_MUST_HAVES, - ENABLE_CUSTOM_THEMES, - SHOW_MUST_HAVE_BUTTONS, - USER_THEME_LIMIT, - DEFAULT_THEME_MATCH_MODE, - _sanitize_theme, - ENABLE_PARTNER_MECHANICS, - ENABLE_PARTNER_SUGGESTIONS, - WEB_IDEALS_UI, - ENABLE_BATCH_BUILD, -) -from ..services.build_utils import ( - step5_base_ctx, - step5_ctx_from_result, - step5_error_ctx, - step5_empty_ctx, - start_ctx_from_session, - owned_set as owned_set_helper, - builder_present_names, - builder_display_map, - commander_hover_context, -) -from ..app import templates -from deck_builder import builder_constants as bc -from ..services import orchestrator as orch -from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale -from ..services.build_utils import owned_names as owned_names_helper -from ..services.tasks import get_session, new_sid +from urllib.parse import urlparse from html import escape as _esc -from deck_builder.builder import DeckBuilder -from deck_builder import builder_utils as bu -from ..services.combo_utils import detect_all as _detect_all -from ..services import custom_theme_manager as theme_mgr -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, - log_partner_suggestion_selected, - log_include_exclude_toggle, -) -from ..services.partner_suggestions import get_partner_suggestions -from urllib.parse import urlparse, quote_plus -from commander_exclusions import lookup_commander_detail -from ..services.commander_catalog_loader import ( - load_commander_catalog, - find_commander_record, - CommanderRecord, - normalized_restricted_labels, - shared_restricted_partner_label, -) -from deck_builder.background_loader import load_background_cards -from deck_builder.partner_selection import apply_partner_inputs -from exceptions import CommanderPartnerError -from code.logging_util import get_logger - -LOGGER = get_logger(__name__) - -# Cache for available card names used by validation endpoints -_AVAILABLE_CARDS_CACHE: set[str] | None = None -_AVAILABLE_CARDS_NORM_SET: set[str] | None = None -_AVAILABLE_CARDS_NORM_MAP: dict[str, str] | None = None - -def _available_cards() -> set[str]: - """Fast load of available card names using the csv module (no pandas). - - Reads only once and caches results in memory. - """ - global _AVAILABLE_CARDS_CACHE - if _AVAILABLE_CARDS_CACHE is not None: - return _AVAILABLE_CARDS_CACHE - try: - import csv - path = f"{_csv_dir()}/cards.csv" - with open(path, 'r', encoding='utf-8', newline='') as f: - reader = csv.DictReader(f) - fields = reader.fieldnames or [] - name_col = None - for col in ['name', 'Name', 'card_name', 'CardName']: - if col in fields: - name_col = col - break - if name_col is None and fields: - # Heuristic: pick first field containing 'name' - for col in fields: - if 'name' in col.lower(): - name_col = col - break - if name_col is None: - raise ValueError(f"No name-like column found in {path}: {fields}") - names: set[str] = set() - for row in reader: - try: - v = row.get(name_col) - if v: - names.add(str(v)) - except Exception: - continue - _AVAILABLE_CARDS_CACHE = names - return _AVAILABLE_CARDS_CACHE - except Exception: - _AVAILABLE_CARDS_CACHE = set() - return _AVAILABLE_CARDS_CACHE - -def _available_cards_normalized() -> tuple[set[str], dict[str, str]]: - """Return cached normalized card names and mapping to originals.""" - global _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP - if _AVAILABLE_CARDS_NORM_SET is not None and _AVAILABLE_CARDS_NORM_MAP is not None: - return _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP - # Build from available cards set - names = _available_cards() - try: - from deck_builder.include_exclude_utils import normalize_punctuation - except Exception: - # Fallback: identity normalization - def normalize_punctuation(x: str) -> str: - return str(x).strip().casefold() - norm_map: dict[str, str] = {} - for name in names: - try: - n = normalize_punctuation(name) - if n not in norm_map: - norm_map[n] = name - except Exception: - continue - _AVAILABLE_CARDS_NORM_MAP = norm_map - _AVAILABLE_CARDS_NORM_SET = set(norm_map.keys()) - return _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP - -def warm_validation_name_cache() -> None: - """Pre-populate the available-cards caches to avoid first-call latency.""" - try: - _ = _available_cards() - _ = _available_cards_normalized() - except Exception: - # Best-effort warmup; proceed silently on failure - pass +from ..app import templates +from ..services.tasks import get_session, new_sid +from ..services.telemetry import log_commander_create_deck def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None: @@ -179,40 +47,6 @@ def _step5_summary_placeholder_html(token: int, *, message: str | None = None) - ) -def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]: - includes = list(sess.get("include_cards") or []) - excludes = list(sess.get("exclude_cards") or []) - state = { - "includes": includes, - "excludes": excludes, - "enforcement_mode": (sess.get("enforcement_mode") or "warn"), - "allow_illegal": bool(sess.get("allow_illegal")), - "fuzzy_matching": bool(sess.get("fuzzy_matching", True)), - } - return state, includes, excludes - - -def _render_include_exclude_summary( - request: Request, - sess: dict, - sid: str, - *, - state: dict[str, Any] | None = None, - includes: list[str] | None = None, - excludes: list[str] | None = None, -) -> HTMLResponse: - ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False) - if state is None or includes is None or excludes is None: - state, includes, excludes = _must_have_state(sess) - ctx["must_have_state"] = state - ctx["summary"] = sess.get("step5_summary") if sess.get("step5_summary_ready") else None - ctx["include_cards"] = includes - ctx["exclude_cards"] = excludes - response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx) - response.set_cookie("sid", sid, httponly=True, samesite="lax") - return response - - def _current_builder_summary(sess: dict) -> Any | None: try: ctx = sess.get("build_ctx") or {} @@ -221,882 +55,19 @@ def _current_builder_summary(sess: dict) -> Any | None: return None summary_fn = getattr(builder, "build_deck_summary", None) if callable(summary_fn): - return summary_fn() + summary_data = summary_fn() + # Also save to session for consistency + if summary_data: + sess["summary"] = summary_data + return summary_data except Exception: return None return None -_COLOR_NAME_MAP = { - "W": "White", - "U": "Blue", - "B": "Black", - "R": "Red", - "G": "Green", - "C": "Colorless", -} -_WUBRG_ORDER = ("W", "U", "B", "R", "G", "C") -_PARTNER_MODE_LABELS = { - "partner": "Partner", - "partner_restricted": "Partner (Restricted)", - "partner_with": "Partner With", - "background": "Choose a Background", - "doctor_companion": "Doctor & Companion", -} - - -def _color_code(identity: Iterable[str]) -> str: - colors = [str(c).strip().upper() for c in identity if str(c).strip()] - if not colors: - return "C" - ordered: list[str] = [c for c in _WUBRG_ORDER if c in colors] - for color in colors: - if color not in ordered: - ordered.append(color) - return "".join(ordered) or "C" - - -def _format_color_label(identity: Iterable[str]) -> str: - code = _color_code(identity) - if code == "C": - return "Colorless (C)" - names = [_COLOR_NAME_MAP.get(ch, ch) for ch in code] - return " / ".join(names) + f" ({code})" - - -def _partner_mode_label(mode: str | None) -> str: - if not mode: - return "Partner Mechanics" - return _PARTNER_MODE_LABELS.get(mode, mode.title()) - - -def _scryfall_image_url(card_name: str, version: str = "normal") -> str | None: - name = str(card_name or "").strip() - if not name: - return None - return f"https://api.scryfall.com/cards/named?fuzzy={quote_plus(name)}&format=image&version={version}" - - -def _scryfall_page_url(card_name: str) -> str | None: - name = str(card_name or "").strip() - if not name: - return None - return f"https://scryfall.com/search?q={quote_plus(name)}" - - -def _secondary_role_label(mode: str | None, secondary_name: str | None) -> str | None: - if not mode: - return None - mode_lower = mode.lower() - if mode_lower == "background": - return "Background" - if mode_lower == "partner_with": - return "Partner With" - if mode_lower == "doctor_companion": - record = find_commander_record(secondary_name or "") if secondary_name else None - if record and getattr(record, "is_doctor", False): - return "Doctor" - if record and getattr(record, "is_doctors_companion", False): - return "Doctor's Companion" - return "Doctor pairing" - return "Partner commander" - - -def _combined_to_payload(combined: Any) -> dict[str, Any]: - color_identity = tuple(getattr(combined, "color_identity", ()) or ()) - warnings = list(getattr(combined, "warnings", []) or []) - mode_obj = getattr(combined, "partner_mode", None) - mode_value = getattr(mode_obj, "value", None) if mode_obj is not None else None - secondary = getattr(combined, "secondary_name", None) - secondary_image = _scryfall_image_url(secondary) - secondary_url = _scryfall_page_url(secondary) - secondary_role = _secondary_role_label(mode_value, secondary) - return { - "primary_name": getattr(combined, "primary_name", None), - "secondary_name": secondary, - "partner_mode": mode_value, - "partner_mode_label": _partner_mode_label(mode_value), - "color_identity": list(color_identity), - "color_code": _color_code(color_identity), - "color_label": _format_color_label(color_identity), - "theme_tags": list(getattr(combined, "theme_tags", []) or []), - "warnings": warnings, - "secondary_image_url": secondary_image, - "secondary_scryfall_url": secondary_url, - "secondary_role_label": secondary_role, - } - - -def _build_partner_options(primary: CommanderRecord | None) -> tuple[list[dict[str, Any]], str | None]: - if not ENABLE_PARTNER_MECHANICS: - return [], None - try: - catalog = load_commander_catalog() - except Exception: - return [], None - - if primary is None: - return [], None - - primary_name = primary.display_name.casefold() - primary_partner_targets = {target.casefold() for target in (primary.partner_with or ())} - primary_is_partner = bool(primary.is_partner or primary_partner_targets) - primary_restricted_labels = normalized_restricted_labels(primary) - primary_is_doctor = bool(primary.is_doctor) - primary_is_companion = bool(primary.is_doctors_companion) - - variant: str | None = None - if primary_is_doctor or primary_is_companion: - variant = "doctor_companion" - elif primary_is_partner: - variant = "partner" - - options: list[dict[str, Any]] = [] - if variant is None: - return [], None - - for record in catalog.entries: - if record.display_name.casefold() == primary_name: - continue - - pairing_mode: str | None = None - role_label: str | None = None - restriction_label: str | None = None - record_name_cf = record.display_name.casefold() - is_direct_pair = bool(primary_partner_targets and record_name_cf in primary_partner_targets) - - if variant == "doctor_companion": - if is_direct_pair: - pairing_mode = "partner_with" - role_label = "Partner With" - elif primary_is_doctor and record.is_doctors_companion: - pairing_mode = "doctor_companion" - role_label = "Doctor's Companion" - elif primary_is_companion and record.is_doctor: - pairing_mode = "doctor_companion" - role_label = "Doctor" - else: - if not record.is_partner or record.is_background: - continue - if primary_partner_targets: - if not is_direct_pair: - continue - pairing_mode = "partner_with" - role_label = "Partner With" - elif primary_restricted_labels: - restriction = shared_restricted_partner_label(primary, record) - if not restriction: - continue - pairing_mode = "partner_restricted" - restriction_label = restriction - else: - if record.partner_with: - continue - if not getattr(record, "has_plain_partner", False): - continue - if record.is_doctors_companion: - continue - pairing_mode = "partner" - - if not pairing_mode: - continue - - options.append( - { - "name": record.display_name, - "color_code": _color_code(record.color_identity), - "color_label": _format_color_label(record.color_identity), - "partner_with": list(record.partner_with or ()), - "pairing_mode": pairing_mode, - "role_label": role_label, - "restriction_label": restriction_label, - "mode_label": _partner_mode_label(pairing_mode), - "image_url": _scryfall_image_url(record.display_name), - "scryfall_url": _scryfall_page_url(record.display_name), - } - ) - - options.sort(key=lambda item: item["name"].casefold()) - return options, variant - - -def _build_background_options() -> list[dict[str, Any]]: - if not ENABLE_PARTNER_MECHANICS: - return [] - - options: list[dict[str, Any]] = [] - try: - catalog = load_background_cards() - except FileNotFoundError as exc: - LOGGER.warning("background_cards_missing fallback_to_commander_catalog", extra={"error": str(exc)}) - catalog = None - except Exception as exc: # pragma: no cover - unexpected loader failure - LOGGER.warning("background_cards_failed fallback_to_commander_catalog", exc_info=exc) - catalog = None - - if catalog and getattr(catalog, "entries", None): - seen: set[str] = set() - for card in catalog.entries: - name_key = card.display_name.casefold() - if name_key in seen: - continue - seen.add(name_key) - options.append( - { - "name": card.display_name, - "color_code": _color_code(card.color_identity), - "color_label": _format_color_label(card.color_identity), - "image_url": _scryfall_image_url(card.display_name), - "scryfall_url": _scryfall_page_url(card.display_name), - "role_label": "Background", - } - ) - if options: - options.sort(key=lambda item: item["name"].casefold()) - return options - - fallback_options = _background_options_from_commander_catalog() - if fallback_options: - return fallback_options - return options - - -def _background_options_from_commander_catalog() -> list[dict[str, Any]]: - try: - catalog = load_commander_catalog() - except Exception as exc: # pragma: no cover - catalog load issues handled elsewhere - LOGGER.warning("commander_catalog_background_fallback_failed", exc_info=exc) - return [] - - seen: set[str] = set() - options: list[dict[str, Any]] = [] - for record in getattr(catalog, "entries", ()): - if not getattr(record, "is_background", False): - continue - name = getattr(record, "display_name", None) - if not name: - continue - key = str(name).casefold() - if key in seen: - continue - seen.add(key) - color_identity = getattr(record, "color_identity", tuple()) - options.append( - { - "name": name, - "color_code": _color_code(color_identity), - "color_label": _format_color_label(color_identity), - "image_url": _scryfall_image_url(name), - "scryfall_url": _scryfall_page_url(name), - "role_label": "Background", - } - ) - - options.sort(key=lambda item: item["name"].casefold()) - return options - - -def _partner_ui_context( - commander_name: str, - *, - partner_enabled: bool, - secondary_selection: str | None, - background_selection: str | None, - combined_preview: dict[str, Any] | None, - warnings: Iterable[str] | None, - partner_error: str | None, - auto_note: str | None, - auto_assigned: bool | None = None, - auto_prefill_allowed: bool = True, -) -> dict[str, Any]: - record = find_commander_record(commander_name) - partner_options, partner_variant = _build_partner_options(record) - supports_backgrounds = bool(record.supports_backgrounds) if record else False - background_options = _build_background_options() if supports_backgrounds else [] - - selected_secondary = (secondary_selection or "").strip() - selected_background = (background_selection or "").strip() - warnings_list = list(warnings or []) - preview_payload: dict[str, Any] | None = combined_preview if isinstance(combined_preview, dict) else None - preview_error: str | None = None - - auto_prefill_applied = False - auto_default_name: str | None = None - auto_note_value = auto_note - - if ( - ENABLE_PARTNER_MECHANICS - and partner_variant == "partner" - and record - and record.partner_with - and not selected_secondary - and not selected_background - and auto_prefill_allowed - ): - target_names = [name.strip() for name in record.partner_with if str(name).strip()] - for target in target_names: - for option in partner_options: - if option["name"].casefold() == target.casefold(): - selected_secondary = option["name"] - auto_default_name = option["name"] - auto_prefill_applied = True - if not auto_note_value: - auto_note_value = f"Automatically paired with {option['name']} (Partner With)." - break - if auto_prefill_applied: - break - - partner_active = bool((selected_secondary or selected_background) and ENABLE_PARTNER_MECHANICS) - partner_capable = bool(ENABLE_PARTNER_MECHANICS and (partner_options or background_options)) - - placeholder = "Select a partner" - select_label = "Partner commander" - role_hint: str | None = None - if partner_variant == "doctor_companion" and record: - has_partner_with_option = any(option.get("pairing_mode") == "partner_with" for option in partner_options) - if record.is_doctor: - if has_partner_with_option: - placeholder = "Select a companion or Partner With match" - select_label = "Companion or Partner" - role_hint = "Choose a Doctor's Companion or Partner With match for this Doctor." - else: - placeholder = "Select a companion" - select_label = "Companion" - role_hint = "Choose a Doctor's Companion to pair with this Doctor." - elif record.is_doctors_companion: - if has_partner_with_option: - placeholder = "Select a Doctor or Partner With match" - select_label = "Doctor or Partner" - role_hint = "Choose a Doctor or Partner With pairing for this companion." - else: - placeholder = "Select a Doctor" - select_label = "Doctor partner" - role_hint = "Choose a Doctor to accompany this companion." - - suggestions_enabled = bool(ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS) - suggestions_visible: list[dict[str, Any]] = [] - suggestions_hidden: list[dict[str, Any]] = [] - suggestions_total = 0 - suggestions_metadata: dict[str, Any] = {} - suggestions_error: str | None = None - suggestions_loaded = False - - if suggestions_enabled and record: - try: - suggestion_result = get_partner_suggestions(record.display_name) - except Exception as exc: # pragma: no cover - defensive logging - LOGGER.warning("partner suggestions failed", exc_info=exc) - suggestion_result = None - if suggestion_result is None: - suggestions_error = "Partner suggestions dataset is unavailable." - else: - suggestions_loaded = True - partner_names = [opt.get("name") for opt in (partner_options or []) if opt.get("name")] - background_names = [opt.get("name") for opt in (background_options or []) if opt.get("name")] - try: - visible, hidden = suggestion_result.flatten(partner_names, background_names, visible_limit=3) - except Exception as exc: # pragma: no cover - defensive - LOGGER.warning("partner suggestions flatten failed", exc_info=exc) - visible = [] - hidden = [] - suggestions_visible = visible - suggestions_hidden = hidden - suggestions_total = suggestion_result.total - if isinstance(suggestion_result.metadata, dict): - suggestions_metadata = dict(suggestion_result.metadata) - - context = { - "partner_feature_available": ENABLE_PARTNER_MECHANICS, - "partner_capable": partner_capable, - "partner_enabled": partner_active, - "selected_secondary_commander": selected_secondary, - "selected_background": selected_background if supports_backgrounds else "", - "partner_options": partner_options if partner_options else [], - "background_options": background_options if background_options else [], - "primary_partner_with": list(record.partner_with) if record else [], - "primary_supports_backgrounds": supports_backgrounds, - "primary_is_partner": bool(record.is_partner) if record else False, - "primary_commander_display": record.display_name if record else commander_name, - "partner_preview": preview_payload, - "partner_warnings": warnings_list, - "partner_error": partner_error, - "partner_auto_note": auto_note_value, - "partner_auto_assigned": bool(auto_prefill_applied or auto_assigned), - "partner_auto_default": auto_default_name, - "partner_select_variant": partner_variant, - "partner_select_label": select_label, - "partner_select_placeholder": placeholder, - "partner_role_hint": role_hint, - "partner_suggestions_enabled": suggestions_enabled, - "partner_suggestions": suggestions_visible, - "partner_suggestions_hidden": suggestions_hidden, - "partner_suggestions_total": suggestions_total, - "partner_suggestions_metadata": suggestions_metadata, - "partner_suggestions_loaded": suggestions_loaded, - "partner_suggestions_error": suggestions_error, - "partner_suggestions_available": bool(suggestions_visible or suggestions_hidden), - "partner_suggestions_has_hidden": bool(suggestions_hidden), - "partner_suggestions_endpoint": "/api/partner/suggestions", - } - context["has_partner_options"] = bool(partner_options) - context["has_background_options"] = bool(background_options) - context["partner_hidden_value"] = "1" if partner_capable else "0" - context["partner_auto_opt_out"] = not bool(auto_prefill_allowed) - context["partner_prefill_available"] = bool(partner_variant == "partner" and partner_options) - - if preview_payload is None and ENABLE_PARTNER_MECHANICS and (selected_secondary or selected_background): - try: - builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) - combined_obj = apply_partner_inputs( - builder, - primary_name=commander_name, - secondary_name=selected_secondary or None, - background_name=selected_background or None, - feature_enabled=True, - ) - except CommanderPartnerError as exc: - preview_error = str(exc) or "Invalid partner selection." - except Exception as exc: - preview_error = f"Partner preview failed: {exc}" - else: - if combined_obj is not None: - preview_payload = _combined_to_payload(combined_obj) - if combined_obj.warnings: - for warn in combined_obj.warnings: - if warn not in warnings_list: - warnings_list.append(warn) - if preview_payload: - context["partner_preview"] = preview_payload - preview_tags = preview_payload.get("theme_tags") - if preview_tags: - context["partner_theme_tags"] = list(preview_tags) - if preview_error and not partner_error: - context["partner_error"] = preview_error - partner_error = preview_error - context["partner_warnings"] = warnings_list - return context - - -def _resolve_partner_selection( - commander_name: str, - *, - feature_enabled: bool, - partner_enabled: bool, - secondary_candidate: str | None, - background_candidate: str | None, - auto_opt_out: bool = False, - selection_source: str | None = None, -) -> tuple[ - str | None, - dict[str, Any] | None, - list[str], - str | None, - str | None, - str | None, - str | None, - bool, -]: - if not (feature_enabled and ENABLE_PARTNER_MECHANICS): - return None, None, [], None, None, None, None, False - - secondary = (secondary_candidate or "").strip() - background = (background_candidate or "").strip() - auto_note: str | None = None - auto_assigned = False - selection_source_clean = (selection_source or "").strip().lower() or None - - record = find_commander_record(commander_name) - partner_options, partner_variant = _build_partner_options(record) - supports_backgrounds = bool(record and record.supports_backgrounds) - background_options = _build_background_options() if supports_backgrounds else [] - - if not partner_enabled and not secondary and not background: - return None, None, [], None, None, None, None, False - - if not supports_backgrounds: - background = "" - if not partner_options: - secondary = "" - - if secondary and background: - return "Provide either a secondary commander or a background, not both.", None, [], auto_note, secondary, background, None, False - - option_lookup = {opt["name"].casefold(): opt for opt in partner_options} - if secondary: - key = secondary.casefold() - if key not in option_lookup: - return "Selected partner is not valid for this commander.", None, [], auto_note, secondary, background or None, None, False - - if background: - normalized_backgrounds = {opt["name"].casefold() for opt in background_options} - if background.casefold() not in normalized_backgrounds: - return "Selected background is not available.", None, [], auto_note, secondary or None, background, None, False - - if not secondary and not background and not auto_opt_out and partner_variant == "partner" and record and record.partner_with: - target_names = [name.strip() for name in record.partner_with if str(name).strip()] - for target in target_names: - opt = option_lookup.get(target.casefold()) - if opt: - secondary = opt["name"] - auto_note = f"Automatically paired with {secondary} (Partner With)." - auto_assigned = True - break - - if not secondary and not background: - return None, None, [], auto_note, None, None, None, auto_assigned - - builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) - try: - combined = apply_partner_inputs( - builder, - primary_name=commander_name, - secondary_name=secondary or None, - background_name=background or None, - feature_enabled=True, - selection_source=selection_source_clean, - ) - except CommanderPartnerError as exc: - message = str(exc) or "Invalid partner selection." - return message, None, [], auto_note, secondary or None, background or None, None, auto_assigned - except Exception as exc: - return f"Partner selection failed: {exc}", None, [], auto_note, secondary or None, background or None, None, auto_assigned - - if combined is None: - return "Unable to resolve partner selection.", None, [], auto_note, secondary or None, background or None, None, auto_assigned - - payload = _combined_to_payload(combined) - warnings = payload.get("warnings", []) or [] - mode = payload.get("partner_mode") - if mode == "background": - resolved_background = payload.get("secondary_name") - return None, payload, warnings, auto_note, None, resolved_background, mode, auto_assigned - return None, payload, warnings, auto_note, payload.get("secondary_name"), None, mode, auto_assigned - - router = APIRouter(prefix="/build") -@router.post("/must-haves/toggle", response_class=HTMLResponse) -async def toggle_must_haves( - request: Request, - card_name: str = Form(...), - list_type: str = Form(...), - enabled: str = Form("1"), -): - if not ALLOW_MUST_HAVES: - return JSONResponse({"error": "Must-have lists are disabled"}, status_code=403) - - name = str(card_name or "").strip() - if not name: - return JSONResponse({"error": "Card name is required"}, status_code=400) - - list_key = str(list_type or "").strip().lower() - if list_key not in {"include", "exclude"}: - return JSONResponse({"error": "Unsupported toggle type"}, status_code=400) - - enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"} - - sid = request.cookies.get("sid") or request.headers.get("X-Session-ID") - if not sid: - sid = new_sid() - sess = get_session(sid) - - includes = list(sess.get("include_cards") or []) - excludes = list(sess.get("exclude_cards") or []) - include_lookup = {str(v).strip().lower(): str(v) for v in includes if str(v).strip()} - exclude_lookup = {str(v).strip().lower(): str(v) for v in excludes if str(v).strip()} - key = name.lower() - display_name = include_lookup.get(key) or exclude_lookup.get(key) or name - - changed = False - include_limit = 10 - exclude_limit = 15 - - def _remove_casefold(items: list[str], item_key: str) -> list[str]: - return [c for c in items if str(c).strip().lower() != item_key] - - if list_key == "include": - if enabled_flag: - if key not in include_lookup: - if len(include_lookup) >= include_limit: - return JSONResponse({"error": f"Include limit reached ({include_limit})."}, status_code=400) - includes.append(name) - include_lookup[key] = name - changed = True - if key in exclude_lookup: - excludes = _remove_casefold(excludes, key) - exclude_lookup.pop(key, None) - changed = True - else: - if key in include_lookup: - includes = _remove_casefold(includes, key) - include_lookup.pop(key, None) - changed = True - else: # exclude - if enabled_flag: - if key not in exclude_lookup: - if len(exclude_lookup) >= exclude_limit: - return JSONResponse({"error": f"Exclude limit reached ({exclude_limit})."}, status_code=400) - excludes.append(name) - exclude_lookup[key] = name - changed = True - if key in include_lookup: - includes = _remove_casefold(includes, key) - include_lookup.pop(key, None) - changed = True - else: - if key in exclude_lookup: - excludes = _remove_casefold(excludes, key) - exclude_lookup.pop(key, None) - changed = True - - if changed: - sess["include_cards"] = includes - sess["exclude_cards"] = excludes - if "include_exclude_diagnostics" in sess: - try: - del sess["include_exclude_diagnostics"] - except Exception: - pass - - response = _render_include_exclude_summary(request, sess, sid) - - try: - log_include_exclude_toggle( - request, - card_name=display_name, - action=list_key, - enabled=enabled_flag, - include_count=len(includes), - exclude_count=len(excludes), - ) - except Exception: - pass - - trigger_payload = { - "card": display_name, - "list": list_key, - "enabled": enabled_flag, - "include_count": len(includes), - "exclude_count": len(excludes), - } - try: - _merge_hx_trigger(response, {"must-haves:toggle": trigger_payload}) - except Exception: - pass - return response - -# Alternatives cache moved to services/alts_utils - - -@router.post("/partner/preview", response_class=JSONResponse) -async def build_partner_preview( - request: Request, - commander: str = Form(...), - partner_enabled: str | None = Form(None), - secondary_commander: str | None = Form(None), - background: str | None = Form(None), - partner_auto_opt_out: str | None = Form(None), - scope: str | None = Form(None), - selection_source: str | None = Form(None), -) -> JSONResponse: - partner_feature_enabled = ENABLE_PARTNER_MECHANICS - raw_partner_enabled = (partner_enabled or "").strip().lower() - partner_flag = partner_feature_enabled and raw_partner_enabled in {"1", "true", "on", "yes"} - auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"} - selection_source_value = (selection_source or "").strip().lower() or None - - try: - ( - partner_error, - combined_payload, - partner_warnings, - partner_auto_note, - resolved_secondary, - resolved_background, - partner_mode, - partner_auto_assigned_flag, - ) = _resolve_partner_selection( - commander, - feature_enabled=partner_feature_enabled, - partner_enabled=partner_flag, - secondary_candidate=secondary_commander, - background_candidate=background, - auto_opt_out=auto_opt_out_flag, - selection_source=selection_source_value, - ) - except Exception as exc: # pragma: no cover - defensive - return JSONResponse( - { - "ok": False, - "error": f"Partner preview failed: {exc}", - "scope": scope or "", - } - ) - - partner_ctx = _partner_ui_context( - commander, - partner_enabled=partner_flag, - secondary_selection=resolved_secondary or secondary_commander, - background_selection=resolved_background or background, - combined_preview=combined_payload, - warnings=partner_warnings, - partner_error=partner_error, - auto_note=partner_auto_note, - auto_assigned=partner_auto_assigned_flag, - auto_prefill_allowed=not auto_opt_out_flag, - ) - - preview_payload = partner_ctx.get("partner_preview") - theme_tags = partner_ctx.get("partner_theme_tags") or [] - warnings_list = partner_ctx.get("partner_warnings") or partner_warnings or [] - - response = { - "ok": True, - "scope": scope or "", - "preview": preview_payload, - "theme_tags": theme_tags, - "warnings": warnings_list, - "auto_note": partner_auto_note, - "resolved_secondary": resolved_secondary, - "resolved_background": resolved_background, - "partner_mode": partner_mode, - "auto_assigned": bool(partner_auto_assigned_flag), - } - if partner_error: - response["error"] = partner_error - try: - log_partner_suggestion_selected( - request, - commander=commander, - scope=scope, - partner_enabled=partner_flag, - auto_opt_out=auto_opt_out_flag, - auto_assigned=bool(partner_auto_assigned_flag), - selection_source=selection_source_value, - secondary_candidate=secondary_commander, - background_candidate=background, - resolved_secondary=resolved_secondary, - resolved_background=resolved_background, - partner_mode=partner_mode, - has_preview=bool(preview_payload), - warnings=warnings_list, - error=response.get("error"), - ) - except Exception: # pragma: no cover - telemetry should not break responses - pass - return JSONResponse(response) - - -def _custom_theme_context( - request: Request, - sess: dict, - *, - message: str | None = None, - level: str = "info", -) -> dict[str, Any]: - """Assemble the Additional Themes section context for the modal.""" - - if not ENABLE_CUSTOM_THEMES: - return { - "request": request, - "theme_state": None, - "theme_message": message, - "theme_message_level": level, - "theme_limit": USER_THEME_LIMIT, - "enable_custom_themes": False, - } - theme_mgr.set_limit(sess, USER_THEME_LIMIT) - state = theme_mgr.get_view_state(sess, default_mode=DEFAULT_THEME_MATCH_MODE) - return { - "request": request, - "theme_state": state, - "theme_message": message, - "theme_message_level": level, - "theme_limit": USER_THEME_LIMIT, - "enable_custom_themes": ENABLE_CUSTOM_THEMES, - } - - -_INVALID_THEME_MESSAGE = ( - "Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores." -) - - -def _rebuild_ctx_with_multicopy(sess: dict) -> None: - """Rebuild the staged context so Multi-Copy runs first, avoiding overfill. - - This ensures the added cards are accounted for before lands and later phases, - which keeps totals near targets and shows the multi-copy additions ahead of basics. - """ - try: - if not sess or not sess.get("commander"): - return - # Build fresh ctx with the same options, threading multi_copy explicitly - opts = orch.bracket_options() - default_bracket = (opts[0]["level"] if opts else 1) - bracket_val = sess.get("bracket") - try: - safe_bracket = int(bracket_val) if bracket_val is not None else default_bracket - except Exception: - safe_bracket = int(default_bracket) - ideals_val = sess.get("ideals") or orch.ideal_defaults() - use_owned = bool(sess.get("use_owned_only")) - prefer = bool(sess.get("prefer_owned")) - owned_names = owned_names_helper() if (use_owned or prefer) else None - locks = list(sess.get("locks", [])) - sess["build_ctx"] = orch.start_build_ctx( - commander=sess.get("commander"), - tags=sess.get("tags", []), - bracket=safe_bracket, - ideals=ideals_val, - tag_mode=sess.get("tag_mode", "AND"), - use_owned_only=use_owned, - prefer_owned=prefer, - owned_names=owned_names, - locks=locks, - custom_export_base=sess.get("custom_export_base"), - multi_copy=sess.get("multi_copy"), - prefer_combos=bool(sess.get("prefer_combos")), - combo_target_count=int(sess.get("combo_target_count", 2)), - combo_balance=str(sess.get("combo_balance", "mix")), - swap_mdfc_basics=bool(sess.get("swap_mdfc_basics")), - ) - except Exception: - # If rebuild fails (e.g., commander not found in test), fall back to injecting - # a minimal Multi-Copy stage on the existing builder so the UI can render additions. - try: - ctx = sess.get("build_ctx") - if not isinstance(ctx, dict): - return - b = ctx.get("builder") - if b is None: - return - # Thread selection onto the builder; runner will be resilient without full DFs - try: - setattr(b, "_web_multi_copy", sess.get("multi_copy") or None) - except Exception: - pass - # Ensure minimal structures exist - try: - if not isinstance(getattr(b, "card_library", None), dict): - b.card_library = {} - except Exception: - pass - try: - if not isinstance(getattr(b, "ideal_counts", None), dict): - b.ideal_counts = {} - except Exception: - pass - # Inject a single Multi-Copy stage - ctx["stages"] = [{"key": "multi_copy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}] - ctx["idx"] = 0 - ctx["last_visible_idx"] = 0 - except Exception: - # Leave existing context untouched on unexpected failure - pass - - @router.get("/", response_class=HTMLResponse) async def build_index(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() @@ -1176,2815 +147,6 @@ async def build_index_alias(request: Request) -> HTMLResponse: return await build_index(request) -@router.get("/multicopy/check", response_class=HTMLResponse) -async def multicopy_check(request: Request) -> HTMLResponse: - """If current commander/tags suggest a multi-copy archetype, render a choose-one modal. - - Returns empty content when not applicable to avoid flashing a modal unnecessarily. - """ - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - commander = str(sess.get("commander") or "").strip() - tags = list(sess.get("tags") or []) - if not commander: - return HTMLResponse("") - # Avoid re-prompting repeatedly for the same selection context - key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()])) - seen = set(sess.get("mc_seen_keys", []) or []) - if key in seen: - return HTMLResponse("") - # Build a light DeckBuilder seeded with commander + tags (no heavy data load required) - try: - tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) - df = tmp.load_commander_data() - row = df[df["name"].astype(str) == commander] - if row.empty: - return HTMLResponse("") - tmp._apply_commander_selection(row.iloc[0]) - tmp.selected_tags = list(tags or []) - try: - tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None - tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None - tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None - except Exception: - pass - # Establish color identity from the selected commander - try: - tmp.determine_color_identity() - except Exception: - pass - # Detect viable archetypes - results = bu.detect_viable_multi_copy_archetypes(tmp) or [] - if not results: - # Remember this key to avoid re-checking until tags/commander change - try: - seen.add(key) - sess["mc_seen_keys"] = list(seen) - except Exception: - pass - return HTMLResponse("") - # Render modal template with top N (cap small for UX) - items = results[:5] - ctx = { - "request": request, - "items": items, - "commander": commander, - "tags": tags, - } - return templates.TemplateResponse("build/_multi_copy_modal.html", ctx) - except Exception: - return HTMLResponse("") - - -@router.post("/multicopy/save", response_class=HTMLResponse) -async def multicopy_save( - request: Request, - choice_id: str = Form(None), - count: int = Form(None), - thrumming: str | None = Form(None), - skip: str | None = Form(None), -) -> HTMLResponse: - """Persist user selection (or skip) for multi-copy archetype in session and close modal. - - Returns a tiny confirmation chip via OOB swap (optional) and removes the modal. - """ - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - commander = str(sess.get("commander") or "").strip() - tags = list(sess.get("tags") or []) - key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()])) - # Update seen set to avoid re-prompt next load - seen = set(sess.get("mc_seen_keys", []) or []) - seen.add(key) - sess["mc_seen_keys"] = list(seen) - # Handle skip explicitly - if skip and str(skip).strip() in ("1","true","on","yes"): - # Clear any prior choice for this run - try: - if sess.get("multi_copy"): - del sess["multi_copy"] - if sess.get("mc_applied_key"): - del sess["mc_applied_key"] - except Exception: - pass - # Return nothing (modal will be removed client-side) - # Also emit an OOB chip indicating skip - chip = ( - '
' - 'Dismissed multi-copy suggestions' - '
' - ) - return HTMLResponse(chip) - # Persist selection when provided - payload = None - try: - meta = bc.MULTI_COPY_ARCHETYPES.get(str(choice_id), {}) - name = meta.get("name") or str(choice_id) - printed_cap = meta.get("printed_cap") - # Coerce count with bounds: default -> rec_window[0], cap by printed_cap when present - if count is None: - count = int(meta.get("default_count", 25)) - try: - count = int(count) - except Exception: - count = int(meta.get("default_count", 25)) - if isinstance(printed_cap, int) and printed_cap > 0: - count = max(1, min(printed_cap, count)) - payload = { - "id": str(choice_id), - "name": name, - "count": int(count), - "thrumming": True if (thrumming and str(thrumming).strip() in ("1","true","on","yes")) else False, - } - sess["multi_copy"] = payload - # Mark as not yet applied so the next build start/continue can account for it once - try: - if sess.get("mc_applied_key"): - del sess["mc_applied_key"] - except Exception: - pass - # If there's an active build context, rebuild it so Multi-Copy runs first - if sess.get("build_ctx"): - _rebuild_ctx_with_multicopy(sess) - except Exception: - payload = None - # Return OOB chip summarizing the selection - if payload: - chip = ( - '
' - f'Selected multi-copy: ' - f"{_esc(payload.get('name',''))} x{int(payload.get('count',0))}" - f"{' + Thrumming Stone' if payload.get('thrumming') else ''}" - '
' - ) - else: - chip = ( - '
' - 'Saved' - '
' - ) - return HTMLResponse(chip) - - - - -# Unified "New Deck" modal (steps 1–3 condensed) -@router.get("/new", response_class=HTMLResponse) -async def build_new_modal(request: Request) -> HTMLResponse: - """Return the New Deck modal content (for an overlay).""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - # Clear build context to allow skip controls to work - # (Otherwise toggle endpoint thinks build is in progress) - if "build_ctx" in sess: - try: - del sess["build_ctx"] - except Exception: - pass - - # M2: Clear all skip preferences for true "New Deck" - skip_keys = [ - "skip_lands", "skip_to_misc", "skip_basics", "skip_staples", - "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes", - "skip_all_creatures", - "skip_creature_primary", "skip_creature_secondary", "skip_creature_fill", - "skip_all_spells", - "skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", - "skip_protection", "skip_spell_fill", - "skip_post_adjust" - ] - for key in skip_keys: - sess.pop(key, None) - - # M2: Check if this is a quick-build scenario (from commander browser) - # Use the quick_build flag set by /build route when ?commander= param present - is_quick_build = sess.pop("quick_build", False) # Pop to consume the flag - - # M2: Clear commander and form selections for fresh start (unless quick build) - if not is_quick_build: - commander_keys = [ - "commander", "partner", "background", "commander_mode", - "themes", "bracket" - ] - for key in commander_keys: - sess.pop(key, None) - - theme_context = _custom_theme_context(request, sess) - ctx = { - "request": request, - "brackets": orch.bracket_options(), - "labels": orch.ideal_labels(), - "defaults": orch.ideal_defaults(), - "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag - "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, - "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, - "ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider' - "form": { - "commander": sess.get("commander", ""), # Pre-fill for quick-build - "prefer_combos": bool(sess.get("prefer_combos")), - "combo_count": sess.get("combo_target_count"), - "combo_balance": sess.get("combo_balance"), - "enable_multicopy": bool(sess.get("multi_copy")), - "use_owned_only": bool(sess.get("use_owned_only")), - "prefer_owned": bool(sess.get("prefer_owned")), - "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), - # Add ideal values from session (will be None on first load, triggering defaults) - "ramp": sess.get("ideals", {}).get("ramp"), - "lands": sess.get("ideals", {}).get("lands"), - "basic_lands": sess.get("ideals", {}).get("basic_lands"), - "creatures": sess.get("ideals", {}).get("creatures"), - "removal": sess.get("ideals", {}).get("removal"), - "wipes": sess.get("ideals", {}).get("wipes"), - "card_advantage": sess.get("ideals", {}).get("card_advantage"), - "protection": sess.get("ideals", {}).get("protection"), - }, - "tag_slot_html": None, - } - for key, value in theme_context.items(): - if key == "request": - continue - ctx[key] = value - resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.get("/new/candidates", response_class=HTMLResponse) -async def build_new_candidates(request: Request, commander: str = Query("")) -> HTMLResponse: - """Return a small list of commander candidates for the modal live search.""" - q = (commander or "").strip() - items = orch.commander_candidates(q, limit=8) if q else [] - candidates: list[dict[str, Any]] = [] - for name, score, colors in items: - detail = lookup_commander_detail(name) - preferred = name - warning = None - if detail: - eligible_raw = detail.get("eligible_faces") - eligible = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else [] - norm_name = str(name).strip().casefold() - eligible_norms = [face.casefold() for face in eligible] - if eligible and norm_name not in eligible_norms: - preferred = eligible[0] - primary = str(detail.get("primary_face") or detail.get("name") or name).strip() - if len(eligible) == 1: - warning = ( - f"Use the back face '{preferred}' when building. Front face '{primary}' can't lead a deck." - ) - else: - faces = ", ".join(f"'{face}'" for face in eligible) - warning = ( - f"This commander only works from specific faces: {faces}." - ) - candidates.append( - { - "display": name, - "value": preferred, - "score": score, - "colors": colors, - "warning": warning, - } - ) - ctx = {"request": request, "query": q, "candidates": candidates} - return templates.TemplateResponse("build/_new_deck_candidates.html", ctx) - - -@router.get("/new/inspect", response_class=HTMLResponse) -async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLResponse: - """When a candidate is chosen in the modal, show the commander preview and tag chips (OOB updates).""" - info = orch.commander_select(name) - if not info.get("ok"): - return HTMLResponse(f'
Commander not found: {name}
') - tags = orch.tags_for_commander(info["name"]) or [] - recommended = orch.recommended_tags_for_commander(info["name"]) if tags else [] - recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {} - exclusion_detail = lookup_commander_detail(info["name"]) - # Render tags slot content and OOB commander preview simultaneously - # Game Changer flag for this commander (affects bracket UI in modal via tags partial consumer) - is_gc = False - try: - is_gc = bool(info["name"] in getattr(bc, 'GAME_CHANGERS', [])) - except Exception: - is_gc = False - ctx = { - "request": request, - "commander": {"name": info["name"], "exclusion": exclusion_detail}, - "tags": tags, - "recommended": recommended, - "recommended_reasons": recommended_reasons, - "gc_commander": is_gc, - "brackets": orch.bracket_options(), - } - ctx.update( - _partner_ui_context( - info["name"], - partner_enabled=False, - secondary_selection=None, - background_selection=None, - combined_preview=None, - warnings=None, - partner_error=None, - auto_note=None, - ) - ) - partner_tags = ctx.get("partner_theme_tags") or [] - if partner_tags: - merged_tags: list[str] = [] - seen: set[str] = set() - for source in (partner_tags, tags): - for tag in source: - token = str(tag).strip() - if not token: - continue - key = token.casefold() - if key in seen: - continue - seen.add(key) - merged_tags.append(token) - ctx["tags"] = merged_tags - - # Deduplicate recommended: remove any that are already in partner_tags - partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} - existing_recommended = ctx.get("recommended") or [] - deduplicated_recommended = [ - tag for tag in existing_recommended - if str(tag).strip().casefold() not in partner_tags_lower - ] - ctx["recommended"] = deduplicated_recommended - - reason_map = dict(ctx.get("recommended_reasons") or {}) - for tag in partner_tags: - if tag not in reason_map: - reason_map[tag] = "Synergizes with partner pairing" - ctx["recommended_reasons"] = reason_map - return templates.TemplateResponse("build/_new_deck_tags.html", ctx) - - -@router.get("/new/multicopy", response_class=HTMLResponse) -async def build_new_multicopy( - request: Request, - commander: str = Query(""), - primary_tag: str | None = Query(None), - secondary_tag: str | None = Query(None), - tertiary_tag: str | None = Query(None), - tag_mode: str | None = Query("AND"), -) -> HTMLResponse: - """Return multi-copy suggestions for the New Deck modal based on commander + selected tags. - - This does not mutate the session; it simply renders a form snippet that posts with the main modal. - """ - name = (commander or "").strip() - if not name: - return HTMLResponse("") - try: - tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) - df = tmp.load_commander_data() - row = df[df["name"].astype(str) == name] - if row.empty: - return HTMLResponse("") - tmp._apply_commander_selection(row.iloc[0]) - tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] - tmp.selected_tags = list(tags or []) - try: - tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None - tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None - tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None - except Exception: - pass - try: - tmp.determine_color_identity() - except Exception: - pass - results = bu.detect_viable_multi_copy_archetypes(tmp) or [] - # For the New Deck modal, only show suggestions where the matched tags intersect - # the explicitly selected tags (ignore commander-default themes). - sel_tags = {str(t).strip().lower() for t in (tags or []) if str(t).strip()} - def _matched_reason_tags(item: dict) -> set[str]: - out = set() - try: - for r in item.get('reasons', []) or []: - if not isinstance(r, str): - continue - rl = r.strip().lower() - if rl.startswith('tags:'): - body = rl.split('tags:', 1)[1].strip() - parts = [p.strip() for p in body.split(',') if p.strip()] - out.update(parts) - except Exception: - return set() - return out - if sel_tags: - results = [it for it in results if (_matched_reason_tags(it) & sel_tags)] - else: - # If no selected tags, do not show any multi-copy suggestions in the modal - results = [] - if not results: - return HTMLResponse("") - items = results[:5] - ctx = {"request": request, "items": items} - return templates.TemplateResponse("build/_new_deck_multicopy.html", ctx) - except Exception: - return HTMLResponse("") - - -@router.post("/themes/add", response_class=HTMLResponse) -async def build_theme_add(request: Request, theme: str = Form("")) -> HTMLResponse: - if not ENABLE_CUSTOM_THEMES: - return HTMLResponse("", status_code=204) - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - trimmed = theme.strip() - sanitized = _sanitize_theme(trimmed) if trimmed else "" - if trimmed and not sanitized: - ctx = _custom_theme_context(request, sess, message=_INVALID_THEME_MESSAGE, level="error") - else: - value = sanitized if sanitized is not None else trimmed - _, message, level = theme_mgr.add_theme( - sess, - value, - commander_tags=list(sess.get("tags", [])), - mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE), - limit=USER_THEME_LIMIT, - ) - ctx = _custom_theme_context(request, sess, message=message, level=level) - resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.post("/themes/remove", response_class=HTMLResponse) -async def build_theme_remove(request: Request, theme: str = Form("")) -> HTMLResponse: - if not ENABLE_CUSTOM_THEMES: - return HTMLResponse("", status_code=204) - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - value = _sanitize_theme(theme) or theme - _, message, level = theme_mgr.remove_theme( - sess, - value, - commander_tags=list(sess.get("tags", [])), - mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE), - ) - ctx = _custom_theme_context(request, sess, message=message, level=level) - resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.post("/themes/choose", response_class=HTMLResponse) -async def build_theme_choose( - request: Request, - original: str = Form(""), - choice: str = Form(""), -) -> HTMLResponse: - if not ENABLE_CUSTOM_THEMES: - return HTMLResponse("", status_code=204) - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - selection = _sanitize_theme(choice) or choice - _, message, level = theme_mgr.choose_suggestion( - sess, - original, - selection, - commander_tags=list(sess.get("tags", [])), - mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE), - ) - ctx = _custom_theme_context(request, sess, message=message, level=level) - resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.post("/themes/mode", response_class=HTMLResponse) -async def build_theme_mode(request: Request, mode: str = Form("permissive")) -> HTMLResponse: - if not ENABLE_CUSTOM_THEMES: - return HTMLResponse("", status_code=204) - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - _, message, level = theme_mgr.set_mode( - sess, - mode, - commander_tags=list(sess.get("tags", [])), - ) - ctx = _custom_theme_context(request, sess, message=message, level=level) - resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.post("/new/toggle-skip", response_class=JSONResponse) -async def build_new_toggle_skip( - request: Request, - skip_key: str = Form(...), - enabled: str = Form(...), -) -> JSONResponse: - """Toggle a skip configuration flag (wizard-only, before build starts). - - Enforces mutual exclusivity: - - skip_lands and skip_to_misc are mutually exclusive with individual land flags - - Individual land flags are mutually exclusive with each other - """ - sid = request.cookies.get("sid") or request.headers.get("X-Session-ID") - if not sid: - return JSONResponse({"error": "No session ID"}, status_code=400) - - sess = get_session(sid) - - # Wizard-only: reject if build has started - if "build_ctx" in sess: - return JSONResponse({"error": "Cannot modify skip settings after build has started"}, status_code=400) - - # Validate skip_key - valid_keys = { - "skip_lands", "skip_to_misc", "skip_basics", "skip_staples", - "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes", - "skip_all_creatures", - "skip_creature_primary", "skip_creature_secondary", "skip_creature_fill", - "skip_all_spells", - "skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", - "skip_protection", "skip_spell_fill", - "skip_post_adjust" - } - - if skip_key not in valid_keys: - return JSONResponse({"error": f"Invalid skip key: {skip_key}"}, status_code=400) - - # Parse enabled flag - enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"} - - # Mutual exclusivity rules - land_group_flags = {"skip_lands", "skip_to_misc"} - individual_land_flags = {"skip_basics", "skip_staples", "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes"} - creature_specific_flags = {"skip_creature_primary", "skip_creature_secondary", "skip_creature_fill"} - spell_specific_flags = {"skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", "skip_protection", "skip_spell_fill"} - - # If enabling a flag, check for conflicts - if enabled_flag: - # Rule 1: skip_lands/skip_to_misc disables all individual land flags - if skip_key in land_group_flags: - for key in individual_land_flags: - sess[key] = False - - # Rule 2: Individual land flags disable skip_lands/skip_to_misc - elif skip_key in individual_land_flags: - for key in land_group_flags: - sess[key] = False - - # Rule 3: skip_all_creatures disables specific creature flags - elif skip_key == "skip_all_creatures": - for key in creature_specific_flags: - sess[key] = False - - # Rule 4: Specific creature flags disable skip_all_creatures - elif skip_key in creature_specific_flags: - sess["skip_all_creatures"] = False - - # Rule 5: skip_all_spells disables specific spell flags - elif skip_key == "skip_all_spells": - for key in spell_specific_flags: - sess[key] = False - - # Rule 6: Specific spell flags disable skip_all_spells - elif skip_key in spell_specific_flags: - sess["skip_all_spells"] = False - - # Set the requested flag - sess[skip_key] = enabled_flag - - # Auto-enable skip_post_adjust when any other skip is enabled - if enabled_flag and skip_key != "skip_post_adjust": - sess["skip_post_adjust"] = True - - # Auto-disable skip_post_adjust when all other skips are disabled - if not enabled_flag: - any_other_skip = any( - sess.get(k, False) for k in valid_keys - if k != "skip_post_adjust" and k != skip_key - ) - if not any_other_skip: - sess["skip_post_adjust"] = False - - return JSONResponse({ - "success": True, - "skip_key": skip_key, - "enabled": enabled_flag, - "skip_post_adjust": bool(sess.get("skip_post_adjust", False)) - }) - - -def _get_descriptive_stage_label(stage: Dict[str, Any], ctx: Dict[str, Any]) -> str: - """Generate a more descriptive label for Quick Build progress display.""" - key = stage.get("key", "") - base_label = stage.get("label", "") - - # Land stages - show what type of lands - land_types = { - "land1": "Basics", - "land2": "Staples", - "land3": "Fetches", - "land4": "Duals", - "land5": "Triomes", - "land6": "Kindred", - "land7": "Misc Utility", - "land8": "Final Lands" - } - if key in land_types: - return f"Lands: {land_types[key]}" - - # Creature stages - show associated theme - if "creatures" in key: - tags = ctx.get("tags", []) - if key == "creatures_all_theme": - if tags: - all_tags = " + ".join(tags[:3]) # Show up to 3 tags - return f"Creatures: All Themes ({all_tags})" - return "Creatures: All Themes" - elif key == "creatures_primary" and len(tags) >= 1: - return f"Creatures: {tags[0]}" - elif key == "creatures_secondary" and len(tags) >= 2: - return f"Creatures: {tags[1]}" - elif key == "creatures_tertiary" and len(tags) >= 3: - return f"Creatures: {tags[2]}" - # Let creatures_fill use default "Creatures: Fill" label - - # Theme spell fill stage - adds any card type (artifacts, enchantments, instants, etc.) that fits theme - if key == "spells_fill": - return "Theme Spell Fill" - - # Default: return original label - return base_label - - -def _run_quick_build_stages(sid: str): - """Background task: Run all stages for Quick Build and update progress in session.""" - import logging - logger = logging.getLogger(__name__) - - logger.info(f"[Quick Build] Starting background task for sid={sid}") - - sess = get_session(sid) - logger.info(f"[Quick Build] Retrieved session: {sess is not None}") - - ctx = sess.get("build_ctx") - if not ctx: - logger.error(f"[Quick Build] No build_ctx found in session") - sess["quick_build_progress"] = { - "running": False, - "current_stage": "Error: No build context", - "completed_stages": [] - } - return - - logger.info(f"[Quick Build] build_ctx found with {len(ctx.get('stages', []))} stages") - - # CRITICAL: Inject session reference into context so skip config can be read - ctx["session"] = sess - logger.info("[Quick Build] Injected session reference into context") - - stages = ctx.get("stages", []) - res = None - - # Initialize progress tracking - sess["quick_build_progress"] = { - "running": True, - "current_stage": "Starting build..." - } - - try: - logger.info("[Quick Build] Starting stage loop") - - # Track which phase we're in for simplified progress display - current_phase = None - - while True: - current_idx = ctx.get("idx", 0) - if current_idx >= len(stages): - logger.info(f"[Quick Build] Reached end of stages (idx={current_idx})") - break - - current_stage = stages[current_idx] - stage_key = current_stage.get("key", "") - logger.info(f"[Quick Build] Stage {current_idx} key: {stage_key}") - - # Determine simplified phase label - if stage_key.startswith("creatures"): - new_phase = "Adding Creatures" - elif stage_key.startswith("spells") or stage_key in ["spells_ramp", "spells_removal", "spells_wipes", "spells_card_advantage", "spells_protection", "spells_fill"]: - new_phase = "Adding Spells" - elif stage_key.startswith("land"): - new_phase = "Adding Lands" - elif stage_key in ["post_spell_land_adjust", "reporting"]: - new_phase = "Doing Some Final Touches" - else: - new_phase = "Building Deck" - - # Only update progress if phase changed - if new_phase != current_phase: - current_phase = new_phase - sess["quick_build_progress"]["current_stage"] = current_phase - logger.info(f"[Quick Build] Phase: {current_phase}") - - # Run stage with show_skipped=False - res = orch.run_stage(ctx, rerun=False, show_skipped=False) - logger.info(f"[Quick Build] Stage {stage_key} completed, done={res.get('done')}") - - # Handle Multi-Copy package marking - try: - if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): - mc = sess.get("multi_copy") - sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" - except Exception: - pass - - # Check if build is done (reporting stage marks done=True) - if res.get("done"): - break - - # run_stage() advances ctx["idx"] internally when stage completes successfully - # If stage is gated, it also advances the index, so we just continue the loop - - # Show summary generation message (stay here for a moment) - sess["quick_build_progress"]["current_stage"] = "Generating Summary" - import time - time.sleep(2) # Pause briefly so user sees this stage - - # Store final result for polling endpoint - sess["last_result"] = res or {} - sess["last_step"] = 5 - - # Small delay to show finishing message - import time - time.sleep(1.5) - - except Exception as e: - # Store error state - logger.exception(f"[Quick Build] Error during stage execution: {e}") - sess["quick_build_progress"]["current_stage"] = f"Error: {str(e)}" - finally: - # Mark build as complete - logger.info("[Quick Build] Background task completed") - sess["quick_build_progress"]["running"] = False - sess["quick_build_progress"]["current_stage"] = "Complete" - - -@router.post("/new", response_class=HTMLResponse) -async def build_new_submit( - request: Request, - background_tasks: BackgroundTasks, - name: str = Form("") , - commander: str = Form(...), - primary_tag: str | None = Form(None), - secondary_tag: str | None = Form(None), - tertiary_tag: str | None = Form(None), - tag_mode: str | None = Form("AND"), - partner_enabled: str | None = Form(None), - secondary_commander: str | None = Form(None), - background: str | None = Form(None), - partner_auto_opt_out: str | None = Form(None), - partner_selection_source: str | None = Form(None), - bracket: int = Form(...), - ramp: int = Form(None), - lands: int = Form(None), - basic_lands: int = Form(None), - creatures: int = Form(None), - removal: int = Form(None), - wipes: int = Form(None), - card_advantage: int = Form(None), - protection: int = Form(None), - prefer_combos: bool = Form(False), - combo_count: int | None = Form(None), - combo_balance: str | None = Form(None), - enable_multicopy: bool = Form(False), - use_owned_only: bool = Form(False), - prefer_owned: bool = Form(False), - swap_mdfc_basics: bool = Form(False), - # Integrated Multi-Copy (optional) - multi_choice_id: str | None = Form(None), - multi_count: int | None = Form(None), - multi_thrumming: str | None = Form(None), - # Must-haves/excludes (optional) - include_cards: str = Form(""), - exclude_cards: str = Form(""), - enforcement_mode: str = Form("warn"), - allow_illegal: bool = Form(False), - fuzzy_matching: bool = Form(True), - # Build count for multi-build - build_count: int = Form(1), - # Quick Build flag - quick_build: str | None = Form(None), -) -> HTMLResponse: - """Handle New Deck modal submit and immediately start the build (skip separate review page).""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - partner_feature_enabled = ENABLE_PARTNER_MECHANICS - raw_partner_flag = (partner_enabled or "").strip().lower() - partner_checkbox = partner_feature_enabled and raw_partner_flag in {"1", "true", "on", "yes"} - initial_secondary = (secondary_commander or "").strip() - initial_background = (background or "").strip() - auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"} - partner_form_state: dict[str, Any] = { - "partner_enabled": bool(partner_checkbox), - "secondary_commander": initial_secondary, - "background": initial_background, - "partner_mode": None, - "partner_auto_note": None, - "partner_warnings": [], - "combined_preview": None, - "partner_auto_assigned": False, - } - - def _form_state(commander_value: str) -> dict[str, Any]: - return { - "name": name, - "commander": commander_value, - "primary_tag": primary_tag or "", - "secondary_tag": secondary_tag or "", - "tertiary_tag": tertiary_tag or "", - "tag_mode": tag_mode or "AND", - "bracket": bracket, - "combo_count": combo_count, - "combo_balance": (combo_balance or "mix"), - "prefer_combos": bool(prefer_combos), - "enable_multicopy": bool(enable_multicopy), - "use_owned_only": bool(use_owned_only), - "prefer_owned": bool(prefer_owned), - "swap_mdfc_basics": bool(swap_mdfc_basics), - "include_cards": include_cards or "", - "exclude_cards": exclude_cards or "", - "enforcement_mode": enforcement_mode or "warn", - "allow_illegal": bool(allow_illegal), - "fuzzy_matching": bool(fuzzy_matching), - "partner_enabled": partner_form_state["partner_enabled"], - "secondary_commander": partner_form_state["secondary_commander"], - "background": partner_form_state["background"], - } - - commander_detail = lookup_commander_detail(commander) - if commander_detail: - eligible_raw = commander_detail.get("eligible_faces") - eligible_faces = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else [] - if eligible_faces: - norm_input = str(commander).strip().casefold() - eligible_norms = [face.casefold() for face in eligible_faces] - if norm_input not in eligible_norms: - suggested = eligible_faces[0] - primary_face = str(commander_detail.get("primary_face") or commander_detail.get("name") or commander).strip() - faces_str = ", ".join(f"'{face}'" for face in eligible_faces) - error_msg = ( - f"'{primary_face or commander}' can't lead a deck. Use {faces_str} as the commander instead. " - "We've updated the commander field for you." - ) - ctx = { - "request": request, - "error": error_msg, - "brackets": orch.bracket_options(), - "labels": orch.ideal_labels(), - "defaults": orch.ideal_defaults(), - "allow_must_haves": ALLOW_MUST_HAVES, - "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, - "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, - "form": _form_state(suggested), - "tag_slot_html": None, - } - theme_ctx = _custom_theme_context(request, sess, message=error_msg, level="error") - for key, value in theme_ctx.items(): - if key == "request": - continue - ctx[key] = value - resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - # Normalize and validate commander selection (best-effort via orchestrator) - sel = orch.commander_select(commander) - if not sel.get("ok"): - # Re-render modal with error - ctx = { - "request": request, - "error": sel.get("error", "Commander not found"), - "brackets": orch.bracket_options(), - "labels": orch.ideal_labels(), - "defaults": orch.ideal_defaults(), - "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag - "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, - "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, - "form": _form_state(commander), - "tag_slot_html": None, - } - theme_ctx = _custom_theme_context(request, sess, message=ctx["error"], level="error") - for key, value in theme_ctx.items(): - if key == "request": - continue - ctx[key] = value - resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - primary_commander_name = sel.get("name") or commander - # Enforce GC bracket restriction before saving session (silently coerce to 3) - try: - is_gc = bool(primary_commander_name in getattr(bc, 'GAME_CHANGERS', [])) - except Exception: - is_gc = False - if is_gc: - try: - if int(bracket) < 3: - bracket = 3 - except Exception: - bracket = 3 - # Save to session - sess["commander"] = primary_commander_name - ( - partner_error, - combined_payload, - partner_warnings, - partner_auto_note, - resolved_secondary, - resolved_background, - partner_mode, - partner_auto_assigned_flag, - ) = _resolve_partner_selection( - primary_commander_name, - feature_enabled=partner_feature_enabled, - partner_enabled=partner_checkbox, - secondary_candidate=secondary_commander, - background_candidate=background, - auto_opt_out=auto_opt_out_flag, - selection_source=partner_selection_source, - ) - - partner_form_state["partner_mode"] = partner_mode - partner_form_state["partner_auto_note"] = partner_auto_note - partner_form_state["partner_warnings"] = partner_warnings - partner_form_state["combined_preview"] = combined_payload - if resolved_secondary: - partner_form_state["secondary_commander"] = resolved_secondary - if resolved_background: - partner_form_state["background"] = resolved_background - partner_form_state["partner_auto_assigned"] = bool(partner_auto_assigned_flag) - - combined_theme_pool: list[str] = [] - if isinstance(combined_payload, dict): - raw_tags = combined_payload.get("theme_tags") or [] - for tag in raw_tags: - token = str(tag).strip() - if not token: - continue - if token not in combined_theme_pool: - combined_theme_pool.append(token) - - if partner_error: - available_tags = orch.tags_for_commander(primary_commander_name) - recommended_tags = orch.recommended_tags_for_commander(primary_commander_name) - recommended_reasons = orch.recommended_tag_reasons_for_commander(primary_commander_name) - inspect_ctx: dict[str, Any] = { - "request": request, - "commander": {"name": primary_commander_name, "exclusion": lookup_commander_detail(primary_commander_name)}, - "tags": available_tags, - "recommended": recommended_tags, - "recommended_reasons": recommended_reasons, - "gc_commander": is_gc, - "brackets": orch.bracket_options(), - } - inspect_ctx.update( - _partner_ui_context( - primary_commander_name, - partner_enabled=partner_checkbox, - secondary_selection=partner_form_state["secondary_commander"] or None, - background_selection=partner_form_state["background"] or None, - combined_preview=combined_payload, - warnings=partner_warnings, - partner_error=partner_error, - auto_note=partner_auto_note, - auto_assigned=partner_form_state["partner_auto_assigned"], - auto_prefill_allowed=not auto_opt_out_flag, - ) - ) - partner_tags = inspect_ctx.pop("partner_theme_tags", None) - if partner_tags: - inspect_ctx["tags"] = partner_tags - tag_slot_html = templates.get_template("build/_new_deck_tags.html").render(inspect_ctx) - ctx = { - "request": request, - "error": partner_error, - "brackets": orch.bracket_options(), - "labels": orch.ideal_labels(), - "defaults": orch.ideal_defaults(), - "allow_must_haves": ALLOW_MUST_HAVES, - "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, - "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, - "form": _form_state(primary_commander_name), - "tag_slot_html": tag_slot_html, - } - theme_ctx = _custom_theme_context(request, sess, message=partner_error, level="error") - for key, value in theme_ctx.items(): - if key == "request": - continue - ctx[key] = value - resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - if partner_checkbox and combined_payload: - sess["partner_enabled"] = True - if resolved_secondary: - sess["secondary_commander"] = resolved_secondary - else: - sess.pop("secondary_commander", None) - if resolved_background: - sess["background"] = resolved_background - else: - sess.pop("background", None) - if partner_mode: - sess["partner_mode"] = partner_mode - else: - sess.pop("partner_mode", None) - sess["combined_commander"] = combined_payload - sess["partner_warnings"] = partner_warnings - if partner_auto_note: - sess["partner_auto_note"] = partner_auto_note - else: - sess.pop("partner_auto_note", None) - sess["partner_auto_assigned"] = bool(partner_auto_assigned_flag) - sess["partner_auto_opt_out"] = bool(auto_opt_out_flag) - else: - sess["partner_enabled"] = False - for key in [ - "secondary_commander", - "background", - "partner_mode", - "partner_warnings", - "combined_commander", - "partner_auto_note", - ]: - try: - sess.pop(key) - except KeyError: - pass - for key in ["partner_auto_assigned", "partner_auto_opt_out"]: - try: - sess.pop(key) - except KeyError: - pass - - # 1) Start from explicitly selected tags (order preserved) - tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] - user_explicit = bool(tags) # whether the user set any theme in the form - # 2) Consider user-added supplemental themes from the Additional Themes UI - additional_from_session = [] - try: - # custom_theme_manager stores resolved list here on add/resolve; present before submit - additional_from_session = [ - str(x) for x in (sess.get("additional_themes") or []) if isinstance(x, str) and x.strip() - ] - except Exception: - additional_from_session = [] - # 3) If no explicit themes were selected, prefer additional themes as primary/secondary/tertiary - if not user_explicit and additional_from_session: - # Cap to three and preserve order - tags = list(additional_from_session[:3]) - # 4) If user selected some themes, fill remaining slots with additional themes (deduping) - elif user_explicit and additional_from_session: - seen = {str(t).strip().casefold() for t in tags} - for name in additional_from_session: - key = name.strip().casefold() - if key in seen: - continue - tags.append(name) - seen.add(key) - if len(tags) >= 3: - break - # 5) If still empty (no explicit and no additional), fall back to commander-recommended default - if not tags: - if combined_theme_pool: - tags = combined_theme_pool[:3] - else: - try: - rec = orch.recommended_tags_for_commander(sess["commander"]) or [] - if rec: - tags = [rec[0]] - except Exception: - pass - sess["tags"] = tags - sess["tag_mode"] = (tag_mode or "AND").upper() - try: - # Default to bracket 3 (Upgraded) when not provided - sess["bracket"] = int(bracket) if (bracket is not None) else 3 - except Exception: - try: - sess["bracket"] = int(bracket) - except Exception: - sess["bracket"] = 3 - # Ideals: use provided values if any, else defaults - ideals = orch.ideal_defaults() - overrides = {k: v for k, v in { - "ramp": ramp, - "lands": lands, - "basic_lands": basic_lands, - "creatures": creatures, - "removal": removal, - "wipes": wipes, - "card_advantage": card_advantage, - "protection": protection, - }.items() if v is not None} - for k, v in overrides.items(): - try: - ideals[k] = int(v) - except Exception: - pass - sess["ideals"] = ideals - if ENABLE_CUSTOM_THEMES: - try: - theme_mgr.refresh_resolution( - sess, - commander_tags=tags, - mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE), - ) - except ValueError as exc: - error_msg = str(exc) - ctx = { - "request": request, - "error": error_msg, - "brackets": orch.bracket_options(), - "labels": orch.ideal_labels(), - "defaults": orch.ideal_defaults(), - "allow_must_haves": ALLOW_MUST_HAVES, - "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, - "enable_custom_themes": ENABLE_CUSTOM_THEMES, - "enable_batch_build": ENABLE_BATCH_BUILD, - "form": _form_state(sess.get("commander", "")), - "tag_slot_html": None, - } - theme_ctx = _custom_theme_context(request, sess, message=error_msg, level="error") - for key, value in theme_ctx.items(): - if key == "request": - continue - ctx[key] = value - resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - # Persist preferences - try: - sess["prefer_combos"] = bool(prefer_combos) - except Exception: - sess["prefer_combos"] = False - try: - sess["use_owned_only"] = bool(use_owned_only) - except Exception: - sess["use_owned_only"] = False - try: - sess["prefer_owned"] = bool(prefer_owned) - except Exception: - sess["prefer_owned"] = False - try: - sess["swap_mdfc_basics"] = bool(swap_mdfc_basics) - except Exception: - sess["swap_mdfc_basics"] = False - # Combos config from modal - try: - if combo_count is not None: - sess["combo_target_count"] = max(0, min(10, int(combo_count))) - except Exception: - pass - try: - if combo_balance: - bval = str(combo_balance).strip().lower() - if bval in ("early","late","mix"): - sess["combo_balance"] = bval - except Exception: - pass - # Multi-Copy selection from modal (opt-in) - try: - # Clear any prior selection first; this flow should define it explicitly when present - if "multi_copy" in sess: - del sess["multi_copy"] - if enable_multicopy and multi_choice_id and str(multi_choice_id).strip(): - meta = bc.MULTI_COPY_ARCHETYPES.get(str(multi_choice_id), {}) - printed_cap = meta.get("printed_cap") - cnt: int - if multi_count is None: - cnt = int(meta.get("default_count", 25)) - else: - try: - cnt = int(multi_count) - except Exception: - cnt = int(meta.get("default_count", 25)) - if isinstance(printed_cap, int) and printed_cap > 0: - cnt = max(1, min(printed_cap, cnt)) - sess["multi_copy"] = { - "id": str(multi_choice_id), - "name": meta.get("name") or str(multi_choice_id), - "count": int(cnt), - "thrumming": True if (multi_thrumming and str(multi_thrumming).strip() in ("1","true","on","yes")) else False, - } - else: - # Ensure disabled when not opted-in - if "multi_copy" in sess: - del sess["multi_copy"] - # Reset the applied marker so the run can account for the new selection - if "mc_applied_key" in sess: - del sess["mc_applied_key"] - except Exception: - pass - - # Process include/exclude cards (M3: Phase 2 - Full Include/Exclude) - try: - from deck_builder.include_exclude_utils import parse_card_list_input, IncludeExcludeDiagnostics - - # Clear any old include/exclude data - for k in ["include_cards", "exclude_cards", "include_exclude_diagnostics", "enforcement_mode", "allow_illegal", "fuzzy_matching"]: - if k in sess: - del sess[k] - - # Process include cards - if include_cards and include_cards.strip(): - print(f"DEBUG: Raw include_cards input: '{include_cards}'") - include_list = parse_card_list_input(include_cards.strip()) - print(f"DEBUG: Parsed include_list: {include_list}") - sess["include_cards"] = include_list - else: - print(f"DEBUG: include_cards is empty or None: '{include_cards}'") - - # Process exclude cards - if exclude_cards and exclude_cards.strip(): - print(f"DEBUG: Raw exclude_cards input: '{exclude_cards}'") - exclude_list = parse_card_list_input(exclude_cards.strip()) - print(f"DEBUG: Parsed exclude_list: {exclude_list}") - sess["exclude_cards"] = exclude_list - else: - print(f"DEBUG: exclude_cards is empty or None: '{exclude_cards}'") - - # Store advanced options - sess["enforcement_mode"] = enforcement_mode - sess["allow_illegal"] = allow_illegal - sess["fuzzy_matching"] = fuzzy_matching - - # Create basic diagnostics for status tracking - if (include_cards and include_cards.strip()) or (exclude_cards and exclude_cards.strip()): - diagnostics = IncludeExcludeDiagnostics( - missing_includes=[], - ignored_color_identity=[], - illegal_dropped=[], - illegal_allowed=[], - excluded_removed=sess.get("exclude_cards", []), - duplicates_collapsed={}, - include_added=[], - include_over_ideal={}, - fuzzy_corrections={}, - confirmation_needed=[], - list_size_warnings={ - "includes_count": len(sess.get("include_cards", [])), - "excludes_count": len(sess.get("exclude_cards", [])), - "includes_limit": 10, - "excludes_limit": 15 - } - ) - sess["include_exclude_diagnostics"] = diagnostics.__dict__ - except Exception as e: - # If exclude parsing fails, log but don't block the build - import logging - logging.warning(f"Failed to parse exclude cards: {e}") - - # Clear any old staged build context - for k in ["build_ctx", "locks", "replace_mode"]: - if k in sess: - try: - del sess[k] - except Exception: - pass - # Reset multi-copy suggestion debounce for a fresh run (keep selected choice) - if "mc_seen_keys" in sess: - try: - del sess["mc_seen_keys"] - except Exception: - pass - # Persist optional custom export base name - if isinstance(name, str) and name.strip(): - sess["custom_export_base"] = name.strip() - else: - if "custom_export_base" in sess: - try: - del sess["custom_export_base"] - except Exception: - pass - # If setup/tagging is not ready or stale, show a modal prompt instead of auto-running. - try: - if not _is_setup_ready(): - return templates.TemplateResponse( - "build/_setup_prompt_modal.html", - { - "request": request, - "title": "Setup required", - "message": "The card database and tags need to be prepared before building a deck.", - "action_url": "/setup/running?start=1&next=/build", - "action_label": "Run Setup", - }, - ) - if _is_setup_stale(): - return templates.TemplateResponse( - "build/_setup_prompt_modal.html", - { - "request": request, - "title": "Data refresh recommended", - "message": "Your card database is stale. Refreshing ensures up-to-date results.", - "action_url": "/setup/running?start=1&force=1&next=/build", - "action_label": "Refresh Now", - }, - ) - except Exception: - # If readiness check fails, continue and let downstream handling surface errors - pass - # Immediately initialize a build context and run the first stage, like hitting Build Deck on review - if "replace_mode" not in sess: - sess["replace_mode"] = True - # Centralized staged context creation - sess["build_ctx"] = start_ctx_from_session(sess) - - # Validate and normalize build_count - try: - build_count = max(1, min(10, int(build_count))) - except Exception: - build_count = 1 - - # Check if this is a multi-build request (build_count > 1) - if build_count > 1: - # Multi-Build: Queue parallel builds and return batch progress page - from ..services.multi_build_orchestrator import queue_builds, run_batch_async - - # Create config dict from session for batch builds - batch_config = { - "commander": sess.get("commander"), - "tags": sess.get("tags", []), - "tag_mode": sess.get("tag_mode", "AND"), - "bracket": sess.get("bracket", 3), - "ideals": sess.get("ideals", {}), - "prefer_combos": sess.get("prefer_combos", False), - "combo_target_count": sess.get("combo_target_count"), - "combo_balance": sess.get("combo_balance"), - "multi_copy": sess.get("multi_copy"), - "use_owned_only": sess.get("use_owned_only", False), - "prefer_owned": sess.get("prefer_owned", False), - "swap_mdfc_basics": sess.get("swap_mdfc_basics", False), - "include_cards": sess.get("include_cards", []), - "exclude_cards": sess.get("exclude_cards", []), - "enforcement_mode": sess.get("enforcement_mode", "warn"), - "allow_illegal": sess.get("allow_illegal", False), - "fuzzy_matching": sess.get("fuzzy_matching", True), - "locks": list(sess.get("locks", [])), - } - - # Handle partner mechanics if present - if sess.get("partner_enabled"): - batch_config["partner_enabled"] = True - if sess.get("secondary_commander"): - batch_config["secondary_commander"] = sess["secondary_commander"] - if sess.get("background"): - batch_config["background"] = sess["background"] - if sess.get("partner_mode"): - batch_config["partner_mode"] = sess["partner_mode"] - if sess.get("combined_commander"): - batch_config["combined_commander"] = sess["combined_commander"] - - # Add color identity for synergy builder (needed for basic land allocation) - try: - tmp_builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) - - # Handle partner mechanics if present - if sess.get("partner_enabled") and sess.get("secondary_commander"): - from deck_builder.partner_selection import apply_partner_inputs - combined_obj = apply_partner_inputs( - tmp_builder, - primary_name=sess["commander"], - secondary_name=sess.get("secondary_commander"), - background_name=sess.get("background"), - feature_enabled=True, - ) - if combined_obj and hasattr(combined_obj, "color_identity"): - batch_config["colors"] = list(combined_obj.color_identity) - else: - # Single commander - df = tmp_builder.load_commander_data() - row = df[df["name"] == sess["commander"]] - if not row.empty: - # Get colorIdentity from dataframe (it's a string like "RG" or "G") - color_str = row.iloc[0].get("colorIdentity", "") - if color_str: - batch_config["colors"] = list(color_str) # Convert "RG" to ['R', 'G'] - except Exception as e: - import logging - logging.getLogger(__name__).warning(f"[Batch] Failed to load color identity for {sess.get('commander')}: {e}") - pass # Not critical, synergy builder will skip basics if missing - - # Queue the batch - batch_id = queue_builds(batch_config, build_count, sid) - - # Start background task for parallel builds - background_tasks.add_task(run_batch_async, batch_id, sid) - - # Return batch progress template - progress_ctx = { - "request": request, - "batch_id": batch_id, - "build_count": build_count, - "completed": 0, - "current_build": 1, - "status": "Starting builds..." - } - resp = templates.TemplateResponse("build/_batch_progress.html", progress_ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - # Check if Quick Build was requested (single build only) - is_quick_build = (quick_build or "").strip() == "1" - - if is_quick_build: - # Quick Build: Start background task and return progress template immediately - ctx = sess["build_ctx"] - - # Initialize progress tracking with dynamic counting (total starts at 0) - sess["quick_build_progress"] = { - "running": True, - "total": 0, - "completed": 0, - "current_stage": "Starting build..." - } - - # Start background task to run all stages - background_tasks.add_task(_run_quick_build_stages, sid) - - # Return progress template immediately - progress_ctx = { - "request": request, - "progress_pct": 0, - "completed": 0, - "total": 0, - "current_stage": "Starting build..." - } - resp = templates.TemplateResponse("build/_quick_build_progress.html", progress_ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - else: - # Normal build: Run first stage and wait for user input - res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False) - # If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue - try: - if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): - mc = sess.get("multi_copy") - sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" - except Exception: - pass - status = "Build complete" if res.get("done") else "Stage complete" - sess["last_step"] = 5 - ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False) - resp = templates.TemplateResponse("build/_step5.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.get("/step1", response_class=HTMLResponse) -async def build_step1(request: Request) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 1 - resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.post("/step1", response_class=HTMLResponse) -async def build_step1_search( - request: Request, - query: str = Form(""), - auto: str | None = Form(None), - active: str | None = Form(None), -) -> HTMLResponse: - query = (query or "").strip() - auto_enabled = True if (auto == "1") else False - candidates = [] - if query: - candidates = orch.commander_candidates(query, limit=10) - # Optional auto-select at a stricter threshold - if auto_enabled and candidates and len(candidates[0]) >= 2 and int(candidates[0][1]) >= 98: - top_name = candidates[0][0] - res = orch.commander_select(top_name) - if res.get("ok"): - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 2 - commander_name = res.get("name") - gc_flag = commander_name in getattr(bc, 'GAME_CHANGERS', []) - context = { - "request": request, - "commander": res, - "tags": orch.tags_for_commander(commander_name), - "recommended": orch.recommended_tags_for_commander(commander_name), - "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander_name), - "brackets": orch.bracket_options(), - "gc_commander": gc_flag, - "selected_bracket": (3 if gc_flag else None), - "clear_persisted": True, - } - context.update( - _partner_ui_context( - commander_name, - partner_enabled=False, - secondary_selection=None, - background_selection=None, - combined_preview=None, - warnings=None, - partner_error=None, - auto_note=None, - ) - ) - resp = templates.TemplateResponse("build/_step2.html", context) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 1 - resp = templates.TemplateResponse( - "build/_step1.html", - { - "request": request, - "query": query, - "candidates": candidates, - "auto": auto_enabled, - "active": active, - "count": len(candidates) if candidates else 0, - }, - ) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.post("/step1/inspect", response_class=HTMLResponse) -async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 1 - info = orch.commander_inspect(name) - resp = templates.TemplateResponse( - "build/_step1.html", - {"request": request, "inspect": info, "selected": name, "tags": orch.tags_for_commander(name)}, - ) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.post("/step1/confirm", response_class=HTMLResponse) -async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse: - res = orch.commander_select(name) - if not res.get("ok"): - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 1 - resp = templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name}) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - # Proceed to step2 placeholder and reset any prior build/session selections - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - # Reset sticky selections from previous runs - for k in [ - "tags", - "ideals", - "bracket", - "build_ctx", - "last_step", - "tag_mode", - "mc_seen_keys", - "multi_copy", - "partner_enabled", - "secondary_commander", - "background", - "partner_mode", - "partner_warnings", - "combined_commander", - "partner_auto_note", - ]: - try: - if k in sess: - del sess[k] - except Exception: - pass - sess["last_step"] = 2 - # Determine if commander is a Game Changer to drive bracket UI hiding - is_gc = False - try: - is_gc = bool(res.get("name") in getattr(bc, 'GAME_CHANGERS', [])) - except Exception: - is_gc = False - context = { - "request": request, - "commander": res, - "tags": orch.tags_for_commander(res["name"]), - "recommended": orch.recommended_tags_for_commander(res["name"]), - "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), - "brackets": orch.bracket_options(), - "gc_commander": is_gc, - "selected_bracket": (3 if is_gc else None), - # Signal that this navigation came from a fresh commander confirmation, - # so the Step 2 UI should clear any localStorage theme persistence. - "clear_persisted": True, - } - context.update( - _partner_ui_context( - res["name"], - partner_enabled=False, - secondary_selection=None, - background_selection=None, - combined_preview=None, - warnings=None, - partner_error=None, - auto_note=None, - ) - ) - resp = templates.TemplateResponse("build/_step2.html", context) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - -@router.post("/reset-all", response_class=HTMLResponse) -async def build_reset_all(request: Request) -> HTMLResponse: - """Clear all build-related session state and return Step 1.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - keys = [ - "commander","tags","tag_mode","bracket","ideals","build_ctx","last_step", - "locks","replace_mode" - ] - for k in keys: - try: - if k in sess: - del sess[k] - except Exception: - pass - sess["last_step"] = 1 - resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - -@router.post("/step5/rewind", response_class=HTMLResponse) -async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLResponse: - """Rewind the staged build to a previous visible stage by index or key and show that stage. - - Param `to` can be an integer index (1-based stage index) or a stage key string. - """ - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - ctx = sess.get("build_ctx") - if not ctx: - return await build_step5_get(request) - target_i: int | None = None - # Resolve by numeric index first - try: - idx_val = int(str(to).strip()) - target_i = idx_val - except Exception: - target_i = None - if target_i is None: - # attempt by key - key = str(to).strip() - try: - for h in ctx.get("history", []) or []: - if str(h.get("key")) == key or str(h.get("label")) == key: - target_i = int(h.get("i")) - break - except Exception: - target_i = None - if not target_i: - return await build_step5_get(request) - # Try to restore snapshot stored for that history entry - try: - hist = ctx.get("history", []) or [] - snap = None - for h in hist: - if int(h.get("i")) == int(target_i): - snap = h.get("snapshot") - break - if snap is not None: - orch._restore_builder(ctx["builder"], snap) - ctx["idx"] = int(target_i) - 1 - ctx["last_visible_idx"] = int(target_i) - 1 - except Exception: - # As a fallback, restart ctx and run forward until target - sess["build_ctx"] = start_ctx_from_session(sess) - ctx = sess["build_ctx"] - # Run forward until reaching target - while True: - res = orch.run_stage(ctx, rerun=False, show_skipped=False) - if int(res.get("idx", 0)) >= int(target_i): - break - if res.get("done"): - break - # Finally show the target stage by running it with show_skipped True to get a view - try: - res = orch.run_stage(ctx, rerun=False, show_skipped=True) - status = "Stage (rewound)" if not res.get("done") else "Build complete" - ctx_resp = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=True, extras={ - "history": ctx.get("history", []), - }) - except Exception as e: - sess["last_step"] = 5 - ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}") - resp = templates.TemplateResponse("build/_step5.html", ctx_resp) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx_resp.get("summary_token", 0)}}) - return resp - - -@router.get("/step2", response_class=HTMLResponse) -async def build_step2_get(request: Request) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 2 - commander = sess.get("commander") - if not commander: - # Fallback to step1 if no commander in session - resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - tags = orch.tags_for_commander(commander) - selected = sess.get("tags", []) - # Determine if the selected commander is considered a Game Changer (affects bracket choices) - is_gc = False - try: - is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', [])) - except Exception: - is_gc = False - # Selected bracket: if GC commander and bracket < 3 or missing, default to 3 - sel_br = sess.get("bracket") - try: - sel_br = int(sel_br) if sel_br is not None else None - except Exception: - sel_br = None - if is_gc and (sel_br is None or int(sel_br) < 3): - sel_br = 3 - partner_enabled = bool(sess.get("partner_enabled") and ENABLE_PARTNER_MECHANICS) - - import logging - logger = logging.getLogger(__name__) - logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}") - - context = { - "request": request, - "commander": {"name": commander}, - "tags": tags, - "recommended": orch.recommended_tags_for_commander(commander), - "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), - "brackets": orch.bracket_options(), - "primary_tag": selected[0] if len(selected) > 0 else "", - "secondary_tag": selected[1] if len(selected) > 1 else "", - "tertiary_tag": selected[2] if len(selected) > 2 else "", - "selected_bracket": sel_br, - "tag_mode": sess.get("tag_mode", "AND"), - "gc_commander": is_gc, - # If there are no server-side tags for this commander, let the client clear any persisted ones - # to avoid themes sticking between fresh runs. - "clear_persisted": False if selected else True, - } - context.update( - _partner_ui_context( - commander, - partner_enabled=partner_enabled, - secondary_selection=sess.get("secondary_commander") if partner_enabled else None, - background_selection=sess.get("background") if partner_enabled else None, - combined_preview=sess.get("combined_commander") if partner_enabled else None, - warnings=sess.get("partner_warnings") if partner_enabled else None, - partner_error=None, - auto_note=sess.get("partner_auto_note") if partner_enabled else None, - auto_assigned=sess.get("partner_auto_assigned") if partner_enabled else None, - auto_prefill_allowed=not bool(sess.get("partner_auto_opt_out")) if partner_enabled else True, - ) - ) - partner_tags = context.pop("partner_theme_tags", None) - if partner_tags: - import logging - logger = logging.getLogger(__name__) - context["tags"] = partner_tags - # Deduplicate recommended tags: remove any that are already in partner_tags - partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} - original_recommended = context.get("recommended", []) - deduplicated_recommended = [ - tag for tag in original_recommended - if str(tag).strip().casefold() not in partner_tags_lower - ] - logger.info( - f"Step2: partner_tags={len(partner_tags)}, " - f"original_recommended={len(original_recommended)}, " - f"deduplicated_recommended={len(deduplicated_recommended)}" - ) - context["recommended"] = deduplicated_recommended - resp = templates.TemplateResponse("build/_step2.html", context) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.post("/step2", response_class=HTMLResponse) -async def build_step2_submit( - request: Request, - commander: str = Form(...), - primary_tag: str | None = Form(None), - secondary_tag: str | None = Form(None), - tertiary_tag: str | None = Form(None), - tag_mode: str | None = Form("AND"), - bracket: int = Form(...), - partner_enabled: str | None = Form(None), - secondary_commander: str | None = Form(None), - background: str | None = Form(None), - partner_selection_source: str | None = Form(None), - partner_auto_opt_out: str | None = Form(None), -) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 2 - - partner_feature_enabled = ENABLE_PARTNER_MECHANICS - partner_flag = False - if partner_feature_enabled: - raw_partner_enabled = (partner_enabled or "").strip().lower() - partner_flag = raw_partner_enabled in {"1", "true", "on", "yes"} - auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"} - - # Validate primary tag selection if tags are available - available_tags = orch.tags_for_commander(commander) - if available_tags and not (primary_tag and primary_tag.strip()): - # Compute GC flag to hide disallowed brackets on error - is_gc = False - try: - is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', [])) - except Exception: - is_gc = False - try: - sel_br = int(bracket) if bracket is not None else None - except Exception: - sel_br = None - if is_gc and (sel_br is None or sel_br < 3): - sel_br = 3 - context = { - "request": request, - "commander": {"name": commander}, - "tags": available_tags, - "recommended": orch.recommended_tags_for_commander(commander), - "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), - "brackets": orch.bracket_options(), - "error": "Please choose a primary theme.", - "primary_tag": primary_tag or "", - "secondary_tag": secondary_tag or "", - "tertiary_tag": tertiary_tag or "", - "selected_bracket": sel_br, - "tag_mode": (tag_mode or "AND"), - "gc_commander": is_gc, - } - context.update( - _partner_ui_context( - commander, - partner_enabled=partner_flag, - secondary_selection=secondary_commander if partner_flag else None, - background_selection=background if partner_flag else None, - combined_preview=None, - warnings=[], - partner_error=None, - auto_note=None, - auto_assigned=None, - auto_prefill_allowed=not auto_opt_out_flag, - ) - ) - partner_tags = context.pop("partner_theme_tags", None) - if partner_tags: - context["tags"] = partner_tags - resp = templates.TemplateResponse("build/_step2.html", context) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - # Enforce bracket restrictions for Game Changer commanders (silently coerce to 3 if needed) - try: - is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', [])) - except Exception: - is_gc = False - if is_gc: - try: - if int(bracket) < 3: - bracket = 3 # coerce silently - except Exception: - bracket = 3 - - ( - partner_error, - combined_payload, - partner_warnings, - partner_auto_note, - resolved_secondary, - resolved_background, - partner_mode, - partner_auto_assigned_flag, - ) = _resolve_partner_selection( - commander, - feature_enabled=partner_feature_enabled, - partner_enabled=partner_flag, - secondary_candidate=secondary_commander, - background_candidate=background, - auto_opt_out=auto_opt_out_flag, - selection_source=partner_selection_source, - ) - - if partner_error: - try: - sel_br = int(bracket) - except Exception: - sel_br = None - context: dict[str, Any] = { - "request": request, - "commander": {"name": commander}, - "tags": available_tags, - "recommended": orch.recommended_tags_for_commander(commander), - "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), - "brackets": orch.bracket_options(), - "primary_tag": primary_tag or "", - "secondary_tag": secondary_tag or "", - "tertiary_tag": tertiary_tag or "", - "selected_bracket": sel_br, - "tag_mode": (tag_mode or "AND"), - "gc_commander": is_gc, - "error": None, - } - context.update( - _partner_ui_context( - commander, - partner_enabled=partner_flag, - secondary_selection=resolved_secondary or secondary_commander, - background_selection=resolved_background or background, - combined_preview=combined_payload, - warnings=partner_warnings, - partner_error=partner_error, - auto_note=partner_auto_note, - auto_assigned=partner_auto_assigned_flag, - auto_prefill_allowed=not auto_opt_out_flag, - ) - ) - partner_tags = context.pop("partner_theme_tags", None) - if partner_tags: - context["tags"] = partner_tags - resp = templates.TemplateResponse("build/_step2.html", context) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - # Save selection to session (basic MVP; real build will use this later) - sess["commander"] = commander - sess["tags"] = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] - sess["tag_mode"] = (tag_mode or "AND").upper() - sess["bracket"] = int(bracket) - - if partner_flag and combined_payload: - sess["partner_enabled"] = True - if resolved_secondary: - sess["secondary_commander"] = resolved_secondary - else: - sess.pop("secondary_commander", None) - if resolved_background: - sess["background"] = resolved_background - else: - sess.pop("background", None) - if partner_mode: - sess["partner_mode"] = partner_mode - else: - sess.pop("partner_mode", None) - sess["combined_commander"] = combined_payload - sess["partner_warnings"] = partner_warnings - if partner_auto_note: - sess["partner_auto_note"] = partner_auto_note - else: - sess.pop("partner_auto_note", None) - sess["partner_auto_assigned"] = bool(partner_auto_assigned_flag) - sess["partner_auto_opt_out"] = bool(auto_opt_out_flag) - else: - sess["partner_enabled"] = False - for key in [ - "secondary_commander", - "background", - "partner_mode", - "partner_warnings", - "combined_commander", - "partner_auto_note", - ]: - try: - sess.pop(key) - except KeyError: - pass - for key in ["partner_auto_assigned", "partner_auto_opt_out"]: - try: - sess.pop(key) - except KeyError: - pass - - # Clear multi-copy seen/selection to re-evaluate on Step 3 - try: - if "mc_seen_keys" in sess: - del sess["mc_seen_keys"] - if "multi_copy" in sess: - del sess["multi_copy"] - if "mc_applied_key" in sess: - del sess["mc_applied_key"] - except Exception: - pass - # Proceed to Step 3 placeholder for now - sess["last_step"] = 3 - resp = templates.TemplateResponse( - "build/_step3.html", - { - "request": request, - "commander": commander, - "tags": sess["tags"], - "bracket": sess["bracket"], - "defaults": orch.ideal_defaults(), - "labels": orch.ideal_labels(), - "values": orch.ideal_defaults(), - }, - ) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.post("/step3", response_class=HTMLResponse) -async def build_step3_submit( - request: Request, - ramp: int = Form(...), - lands: int = Form(...), - basic_lands: int = Form(...), - creatures: int = Form(...), - removal: int = Form(...), - wipes: int = Form(...), - card_advantage: int = Form(...), - protection: int = Form(...), -) -> HTMLResponse: - labels = orch.ideal_labels() - submitted = { - "ramp": ramp, - "lands": lands, - "basic_lands": basic_lands, - "creatures": creatures, - "removal": removal, - "wipes": wipes, - "card_advantage": card_advantage, - "protection": protection, - } - - errors: list[str] = [] - for k, v in submitted.items(): - try: - iv = int(v) - except Exception: - errors.append(f"{labels.get(k, k)} must be a number.") - continue - if iv < 0: - errors.append(f"{labels.get(k, k)} cannot be negative.") - submitted[k] = iv - # Cross-field validation: basic lands should not exceed total lands - if isinstance(submitted.get("basic_lands"), int) and isinstance(submitted.get("lands"), int): - if submitted["basic_lands"] > submitted["lands"]: - errors.append("Basic Lands cannot exceed Total Lands.") - - if errors: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 3 - resp = templates.TemplateResponse( - "build/_step3.html", - { - "request": request, - "defaults": orch.ideal_defaults(), - "labels": labels, - "values": submitted, - "error": " ".join(errors), - "commander": sess.get("commander"), - "tags": sess.get("tags", []), - "bracket": sess.get("bracket"), - }, - ) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - # Save to session - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["ideals"] = submitted - # Any change to ideals should clear the applied marker, we may want to re-stage - try: - if "mc_applied_key" in sess: - del sess["mc_applied_key"] - except Exception: - pass - - # Proceed to review (Step 4) - sess["last_step"] = 4 - resp = templates.TemplateResponse( - "build/_step4.html", - { - "request": request, - "labels": labels, - "values": submitted, - "commander": sess.get("commander"), - "owned_only": bool(sess.get("use_owned_only")), - "prefer_owned": bool(sess.get("prefer_owned")), - "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), - }, - ) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.get("/step3", response_class=HTMLResponse) -async def build_step3_get(request: Request) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 3 - defaults = orch.ideal_defaults() - values = sess.get("ideals") or defaults - - # Check if any skip flags are enabled to show skeleton automation page - skip_flags = { - "skip_lands": "land selection", - "skip_to_misc": "land selection", - "skip_basics": "basic lands", - "skip_staples": "staple lands", - "skip_kindred": "kindred lands", - "skip_fetches": "fetch lands", - "skip_duals": "dual lands", - "skip_triomes": "triome lands", - "skip_all_creatures": "creature selection", - "skip_creature_primary": "primary creatures", - "skip_creature_secondary": "secondary creatures", - "skip_creature_fill": "creature fills", - "skip_all_spells": "spell selection", - "skip_ramp": "ramp spells", - "skip_removal": "removal spells", - "skip_wipes": "board wipes", - "skip_card_advantage": "card advantage spells", - "skip_protection": "protection spells", - "skip_spell_fill": "spell fills", - } - - active_skips = [desc for key, desc in skip_flags.items() if sess.get(key, False)] - - if active_skips: - # Show skeleton automation page with auto-submit - automation_parts = [] - if any("land" in s for s in active_skips): - automation_parts.append("lands") - if any("creature" in s for s in active_skips): - automation_parts.append("creatures") - if any("spell" in s for s in active_skips): - automation_parts.append("spells") - - automation_message = f"Applying default values for {', '.join(automation_parts)}..." - - resp = templates.TemplateResponse( - "build/_step3_skeleton.html", - { - "request": request, - "defaults": defaults, - "commander": sess.get("commander"), - "automation_message": automation_message, - }, - ) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - # No skips enabled, show normal form - resp = templates.TemplateResponse( - "build/_step3.html", - { - "request": request, - "defaults": defaults, - "labels": orch.ideal_labels(), - "values": values, - "commander": sess.get("commander"), - "tags": sess.get("tags", []), - "bracket": sess.get("bracket"), - }, - ) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.get("/step4", response_class=HTMLResponse) -async def build_step4_get(request: Request) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 4 - labels = orch.ideal_labels() - values = sess.get("ideals") or orch.ideal_defaults() - commander = sess.get("commander") - return templates.TemplateResponse( - "build/_step4.html", - { - "request": request, - "labels": labels, - "values": values, - "commander": commander, - "owned_only": bool(sess.get("use_owned_only")), - "prefer_owned": bool(sess.get("prefer_owned")), - "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), - }, - ) - - -# --- Combos & Synergies panel (M3) --- -def _get_current_deck_names(sess: dict) -> list[str]: - try: - ctx = sess.get("build_ctx") or {} - b = ctx.get("builder") - lib = getattr(b, "card_library", {}) if b is not None else {} - names = [str(n) for n in lib.keys()] - return sorted(dict.fromkeys(names)) - except Exception: - return [] - - -@router.get("/combos", response_class=HTMLResponse) -async def build_combos_panel(request: Request) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - names = _get_current_deck_names(sess) - if not names: - # No active build; render nothing to avoid UI clutter - return HTMLResponse("") - - # Preferences (persisted in session) - policy = (sess.get("combos_policy") or "neutral").lower() - if policy not in {"avoid", "neutral", "prefer"}: - policy = "neutral" - try: - target = int(sess.get("combos_target") or 0) - except Exception: - target = 0 - if target < 0: - target = 0 - - # Load lists and run detection - _det = _detect_all(names) - combos = _det.get("combos", []) - synergies = _det.get("synergies", []) - combos_model = _det.get("combos_model") - synergies_model = _det.get("synergies_model") - - # Suggestions - suggestions: list[dict] = [] - present = {s.strip().lower() for s in names} - suggested_names: set[str] = set() - if combos_model is not None: - # Prefer policy: suggest adding a missing partner to hit target count - if policy == "prefer": - try: - for p in combos_model.pairs: - a = str(p.a).strip() - b = str(p.b).strip() - a_in = a.lower() in present - b_in = b.lower() in present - if a_in ^ b_in: # exactly one present - missing = b if a_in else a - have = a if a_in else b - item = { - "kind": "add", - "have": have, - "name": missing, - "cheap_early": bool(getattr(p, "cheap_early", False)), - "setup_dependent": bool(getattr(p, "setup_dependent", False)), - } - key = str(missing).strip().lower() - if key not in present and key not in suggested_names: - suggestions.append(item) - suggested_names.add(key) - # Rank: cheap/early first, then setup-dependent, then name - suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower())) - # If we still have room below target, add synergy-based suggestions - rem = (max(0, int(target)) if target > 0 else 8) - len(suggestions) - if rem > 0 and synergies_model is not None: - # lightweight tag weights to bias common engines - weights = { - "treasure": 3.0, "tokens": 2.8, "landfall": 2.6, "card draw": 2.5, "ramp": 2.3, - "engine": 2.2, "value": 2.1, "artifacts": 2.0, "enchantress": 2.0, "spellslinger": 1.9, - "counters": 1.8, "equipment matters": 1.7, "tribal": 1.6, "lifegain": 1.5, "mill": 1.4, - "damage": 1.3, "stax": 1.2 - } - syn_sugs: list[dict] = [] - for p in synergies_model.pairs: - a = str(p.a).strip() - b = str(p.b).strip() - a_in = a.lower() in present - b_in = b.lower() in present - if a_in ^ b_in: - missing = b if a_in else a - have = a if a_in else b - mkey = missing.strip().lower() - if mkey in present or mkey in suggested_names: - continue - tags = list(getattr(p, "tags", []) or []) - score = 1.0 + sum(weights.get(str(t).lower(), 1.0) for t in tags) / max(1, len(tags) or 1) - syn_sugs.append({ - "kind": "add", - "have": have, - "name": missing, - "cheap_early": False, - "setup_dependent": False, - "tags": tags, - "_score": score, - }) - suggested_names.add(mkey) - # rank by score desc then name - syn_sugs.sort(key=lambda s: (-float(s.get("_score", 0.0)), str(s.get("name")).lower())) - if rem > 0: - suggestions.extend(syn_sugs[:rem]) - # Finally trim to target or default cap - cap = (int(target) if target > 0 else 8) - suggestions = suggestions[:cap] - except Exception: - suggestions = [] - elif policy == "avoid": - # Avoid policy: suggest cutting one piece from detected combos - try: - for c in combos: - # pick the second card as default cut to vary suggestions - suggestions.append({ - "kind": "cut", - "name": c.b, - "partner": c.a, - "cheap_early": bool(getattr(c, "cheap_early", False)), - "setup_dependent": bool(getattr(c, "setup_dependent", False)), - }) - # Rank: cheap/early first - suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower())) - if target > 0: - suggestions = suggestions[: target] - else: - suggestions = suggestions[: 8] - except Exception: - suggestions = [] - - ctx = { - "request": request, - "policy": policy, - "target": target, - "combos": combos, - "synergies": synergies, - "versions": _det.get("versions", {}), - "suggestions": suggestions, - } - return templates.TemplateResponse("build/_combos_panel.html", ctx) - - -@router.post("/combos/prefs", response_class=HTMLResponse) -async def build_combos_save_prefs(request: Request, policy: str = Form("neutral"), target: int = Form(0)) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - pol = (policy or "neutral").strip().lower() - if pol not in {"avoid", "neutral", "prefer"}: - pol = "neutral" - try: - tgt = int(target) - except Exception: - tgt = 0 - if tgt < 0: - tgt = 0 - sess["combos_policy"] = pol - sess["combos_target"] = tgt - # Re-render the panel - return await build_combos_panel(request) - - -@router.post("/toggle-owned-review", response_class=HTMLResponse) -async def build_toggle_owned_review( - request: Request, - use_owned_only: str | None = Form(None), - prefer_owned: str | None = Form(None), - swap_mdfc_basics: str | None = Form(None), -) -> HTMLResponse: - """Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 4 - only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False - pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False - swap_val = True if (swap_mdfc_basics and str(swap_mdfc_basics).strip() in ("1","true","on","yes")) else False - sess["use_owned_only"] = only_val - sess["prefer_owned"] = pref_val - sess["swap_mdfc_basics"] = swap_val - # Do not touch build_ctx here; user hasn't started the build yet from review - labels = orch.ideal_labels() - values = sess.get("ideals") or orch.ideal_defaults() - commander = sess.get("commander") - resp = templates.TemplateResponse( - "build/_step4.html", - { - "request": request, - "labels": labels, - "values": values, - "commander": commander, - "owned_only": bool(sess.get("use_owned_only")), - "prefer_owned": bool(sess.get("prefer_owned")), - "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), - }, - ) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.get("/step5", response_class=HTMLResponse) -async def build_step5_get(request: Request) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - sess["last_step"] = 5 - # Default replace-mode to ON unless explicitly toggled off - if "replace_mode" not in sess: - sess["replace_mode"] = True - base = step5_empty_ctx(request, sess) - resp = templates.TemplateResponse("build/_step5.html", base) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}}) - return resp - -@router.post("/step5/continue", response_class=HTMLResponse) -async def build_step5_continue(request: Request) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - if "replace_mode" not in sess: - sess["replace_mode"] = True - # Validate commander; redirect to step1 if missing - if not sess.get("commander"): - resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."}) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - # Ensure build context exists; if not, start it first - if not sess.get("build_ctx"): - sess["build_ctx"] = start_ctx_from_session(sess) - else: - # If context exists already, rebuild ONLY when the multi-copy selection changed or hasn't been applied yet - try: - mc = sess.get("multi_copy") or None - selkey = None - if mc: - selkey = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" - applied = sess.get("mc_applied_key") if mc else None - if mc and (not applied or applied != selkey): - _rebuild_ctx_with_multicopy(sess) - # If we still have no stages (e.g., minimal test context), inject a minimal multi-copy stage inline - try: - ctx = sess.get("build_ctx") or {} - stages = ctx.get("stages") if isinstance(ctx, dict) else None - if (not stages or len(stages) == 0) and mc: - b = ctx.get("builder") if isinstance(ctx, dict) else None - if b is not None: - try: - setattr(b, "_web_multi_copy", mc) - except Exception: - pass - try: - if not isinstance(getattr(b, "card_library", None), dict): - b.card_library = {} - except Exception: - pass - try: - if not isinstance(getattr(b, "ideal_counts", None), dict): - b.ideal_counts = {} - except Exception: - pass - ctx["stages"] = [{"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}] - ctx["idx"] = 0 - ctx["last_visible_idx"] = 0 - except Exception: - pass - except Exception: - pass - # Read show_skipped from either query or form safely - show_skipped = True if (request.query_params.get('show_skipped') == '1') else False - try: - form = await request.form() - if form and form.get('show_skipped') == '1': - show_skipped = True - except Exception: - pass - try: - res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) - status = "Build complete" if res.get("done") else "Stage complete" - # Clear commander from session after build completes - if res.get("done"): - sess.pop("commander", None) - sess.pop("commander_name", None) - except Exception as e: - sess["last_step"] = 5 - err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}") - resp = templates.TemplateResponse("build/_step5.html", err_ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) - return resp - stage_label = res.get("label") - # If we just applied Multi-Copy, stamp the applied key so we don't rebuild again - try: - if stage_label == "Multi-Copy Package" and sess.get("multi_copy"): - mc = sess.get("multi_copy") - sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" - except Exception: - pass - # Note: no redirect; the inline compliance panel will render inside Step 5 - sess["last_step"] = 5 - ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) - resp = templates.TemplateResponse("build/_step5.html", ctx2) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}}) - return resp - -@router.post("/step5/rerun", response_class=HTMLResponse) -async def build_step5_rerun(request: Request) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - if "replace_mode" not in sess: - sess["replace_mode"] = True - if not sess.get("commander"): - resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."}) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - # Rerun requires an existing context; if missing, create it and run first stage as rerun - if not sess.get("build_ctx"): - sess["build_ctx"] = start_ctx_from_session(sess) - else: - # Ensure latest locks are reflected in the existing context - try: - sess["build_ctx"]["locks"] = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} - except Exception: - pass - show_skipped = False - try: - form = await request.form() - show_skipped = True if (form.get('show_skipped') == '1') else False - except Exception: - pass - # If replace-mode is OFF, keep the stage visible even if no new cards were added - if not bool(sess.get("replace_mode", True)): - show_skipped = True - try: - res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped, replace=bool(sess.get("replace_mode", True))) - status = "Stage rerun complete" if not res.get("done") else "Build complete" - except Exception as e: - sess["last_step"] = 5 - err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}") - resp = templates.TemplateResponse("build/_step5.html", err_ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) - return resp - sess["last_step"] = 5 - # Build locked cards list with ownership and in-deck presence - locked_cards = [] - try: - ctx = sess.get("build_ctx") or {} - b = ctx.get("builder") if isinstance(ctx, dict) else None - present: set[str] = builder_present_names(b) if b is not None else set() - # Display-map via combined df when available - lock_lower = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} - display_map: dict[str, str] = builder_display_map(b, lock_lower) if b is not None else {} - owned_lower = owned_set_helper() - for nm in (sess.get("locks", []) or []): - key = str(nm).strip().lower() - disp = display_map.get(key, nm) - locked_cards.append({ - "name": disp, - "owned": key in owned_lower, - "in_deck": key in present, - }) - except Exception: - locked_cards = [] - ctx3 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) - ctx3["locked_cards"] = locked_cards - resp = templates.TemplateResponse("build/_step5.html", ctx3) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx3.get("summary_token", 0)}}) - return resp - - -@router.post("/step5/start", response_class=HTMLResponse) -async def build_step5_start(request: Request) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - if "replace_mode" not in sess: - sess["replace_mode"] = True - # Validate commander exists before starting - commander = sess.get("commander") - if not commander: - resp = templates.TemplateResponse( - "build/_step1.html", - {"request": request, "candidates": [], "error": "Please select a commander first."}, - ) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - try: - # Initialize step-by-step build context and run first stage - sess["build_ctx"] = start_ctx_from_session(sess) - show_skipped = False - try: - form = await request.form() - show_skipped = True if (form.get('show_skipped') == '1') else False - except Exception: - pass - res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) - status = "Stage complete" if not res.get("done") else "Build complete" - # If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue - try: - if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): - mc = sess.get("multi_copy") - sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" - except Exception: - pass - # Note: no redirect; the inline compliance panel will render inside Step 5 - sess["last_step"] = 5 - ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) - resp = templates.TemplateResponse("build/_step5.html", ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}}) - return resp - except Exception as e: - # Surface a friendly error on the step 5 screen with normalized context - err_ctx = step5_error_ctx( - request, - sess, - f"Failed to start build: {e}", - include_name=False, - ) - # Ensure commander stays visible if set - err_ctx["commander"] = commander - resp = templates.TemplateResponse("build/_step5.html", err_ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) - return resp - -@router.get("/step5/start", response_class=HTMLResponse) -async def build_step5_start_get(request: Request) -> HTMLResponse: - # Allow GET as a fallback to start the build (delegates to POST handler) - return await build_step5_start(request) - - -@router.get("/banner", response_class=HTMLResponse) -async def build_banner(request: Request, step: str = "", i: int | None = None, n: int | None = None) -> HTMLResponse: - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - commander = sess.get("commander") - tags = sess.get("tags", []) - # Render only the inner text for the subtitle - return templates.TemplateResponse( - "build/_banner_subtitle.html", - {"request": request, "commander": commander, "tags": tags, "name": sess.get("custom_export_base")}, - ) - - -@router.post("/step5/toggle-replace") -async def build_step5_toggle_replace(request: Request, replace: str = Form("0")): - """Toggle replace-mode for reruns and return an updated button HTML.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - enabled = True if str(replace).strip() in ("1","true","on","yes") else False - sess["replace_mode"] = enabled - # Return the checkbox control snippet (same as template) - checked = 'checked' if enabled else '' - html = ( - '
' - '
' - f'' - '' - '
' - '
' - ) - return HTMLResponse(html) - - -@router.post("/step5/reset-stage", response_class=HTMLResponse) -async def build_step5_reset_stage(request: Request) -> HTMLResponse: - """Reset current visible stage to the pre-stage snapshot (if available) without running it.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - ctx = sess.get("build_ctx") - if not ctx or not ctx.get("snapshot"): - return await build_step5_get(request) - try: - orch._restore_builder(ctx["builder"], ctx["snapshot"]) - except Exception: - return await build_step5_get(request) - # Re-render step 5 with cleared added list - base = step5_empty_ctx(request, sess, extras={ - "status": "Stage reset", - "i": ctx.get("idx"), - "n": len(ctx.get("stages", [])), - }) - resp = templates.TemplateResponse("build/_step5.html", base) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}}) - return resp - - -@router.get("/step5/summary", response_class=HTMLResponse) -async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLResponse: - sid = request.cookies.get("sid") or request.headers.get("X-Session-ID") - if not sid: - sid = new_sid() - sess = get_session(sid) - - try: - session_token = int(sess.get("step5_summary_token", 0)) - except Exception: - session_token = 0 - try: - requested_token = int(token) - except Exception: - requested_token = 0 - ready = bool(sess.get("step5_summary_ready")) - summary_data = sess.get("step5_summary") if ready else None - if summary_data is None and ready: - summary_data = _current_builder_summary(sess) - if summary_data is not None: - try: - sess["step5_summary"] = summary_data - except Exception: - pass - - synergies: list[str] = [] - try: - raw_synergies = sess.get("step5_synergies") - if isinstance(raw_synergies, (list, tuple, set)): - synergies = [str(item) for item in raw_synergies if str(item).strip()] - except Exception: - synergies = [] - - active_token = session_token if session_token >= requested_token else requested_token - - if not ready or summary_data is None: - message = "Deck summary will appear after the build completes." if not ready else "Deck summary is not available yet. Try rerunning the current stage." - placeholder = _step5_summary_placeholder_html(active_token, message=message) - response = HTMLResponse(placeholder) - response.set_cookie("sid", sid, httponly=True, samesite="lax") - return response - - ctx = step5_base_ctx(request, sess) - ctx["summary"] = summary_data - ctx["synergies"] = synergies - ctx["summary_ready"] = True - ctx["summary_token"] = active_token - - # Add commander hover context for color identity and theme tags - hover_meta = commander_hover_context( - commander_name=ctx.get("commander"), - deck_tags=sess.get("tags"), - summary=summary_data, - combined=ctx.get("combined_commander"), - ) - ctx.update(hover_meta) - - response = templates.TemplateResponse("partials/deck_summary.html", ctx) - response.set_cookie("sid", sid, httponly=True, samesite="lax") - return response - - -@router.get("/quick-progress") -def quick_build_progress(request: Request): - """Poll endpoint for Quick Build progress. Returns either progress indicator or final Step 5.""" - import logging - logger = logging.getLogger(__name__) - - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - - progress = sess.get("quick_build_progress") - logger.info(f"[Progress Poll] sid={sid}, progress={progress is not None}, running={progress.get('running') if progress else None}") - - if not progress or not progress.get("running"): - # Build complete - return Step 5 content that replaces the entire wizard container - res = sess.get("last_result") - if res and res.get("done"): - ctx = step5_ctx_from_result(request, sess, res) - # Return Step 5 which will replace the whole wizard div - response = templates.TemplateResponse("build/_step5.html", ctx) - response.set_cookie("sid", sid, httponly=True, samesite="lax") - # Tell HTMX to target #wizard and swap outerHTML to replace the container - response.headers["HX-Retarget"] = "#wizard" - response.headers["HX-Reswap"] = "outerHTML" - return response - # Fallback if no result yet - return HTMLResponse('Build complete. Please refresh.') - - # Build still running - return progress content partial only (innerHTML swap) - current_stage = progress.get("current_stage", "Processing...") - - ctx = { - "request": request, - "current_stage": current_stage - } - response = templates.TemplateResponse("build/_quick_build_progress_content.html", ctx) - response.set_cookie("sid", sid, httponly=True, samesite="lax") - return response - - @router.get("/batch-progress") def batch_build_progress(request: Request, batch_id: str = Query(...)): """Poll endpoint for Batch Build progress. Returns either progress indicator or redirect to comparison.""" @@ -4046,1694 +208,23 @@ def batch_build_progress(request: Request, batch_id: str = Query(...)): response.set_cookie("sid", sid, httponly=True, samesite="lax") return response -# --- Phase 8: Lock/Replace/Compare/Permalink minimal API --- -@router.post("/lock") -async def build_lock_toggle(request: Request, name: str = Form(...), locked: str = Form("1"), from_list: str | None = Form(None)): - """Toggle lock for a card name in the current session; return an HTML button to swap in-place.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - locks = set(sess.get("locks", [])) - key = str(name).strip().lower() - want_lock = True if str(locked).strip() in ("1","true","on","yes") else False - if want_lock: - locks.add(key) - else: - locks.discard(key) - sess["locks"] = list(locks) - # If a build context exists, update it too - if sess.get("build_ctx"): - try: - sess["build_ctx"]["locks"] = {str(n) for n in locks} - except Exception: - pass - # Return a compact button HTML that flips state on next click, and an OOB last-action chip - next_state = "0" if want_lock else "1" - label = "Unlock" if want_lock else "Lock" - title = ("Click to unlock" if want_lock else "Click to lock") - icon = ("🔒" if want_lock else "🔓") - # Include data-locked to reflect the current state for client-side handler - btn = f'''''' - # Compute locks count for chip - locks_count = len(locks) - if locks_count > 0: - chip_html = f'🔒 {locks_count} locked' - else: - chip_html = '' - # Last action chip for feedback (use hx-swap-oob) - try: - disp = (name or '').strip() - except Exception: - disp = str(name) - action = "Locked" if want_lock else "Unlocked" - chip = ( - f'
' - f'{action} {disp}' - f'
' - ) - # If this request came from the locked-cards list and it's an unlock, remove the row inline - try: - if (from_list is not None) and (not want_lock): - # Also update the locks-count chip, and if no locks remain, remove the whole section - extra = chip_html - if locks_count == 0: - extra += '
' - # Return empty body to delete the
  • via hx-swap=outerHTML, plus OOB updates - return HTMLResponse('' + extra) - except Exception: - pass - return HTMLResponse(btn + chip + chip_html) - - -@router.get("/alternatives", response_class=HTMLResponse) -async def build_alternatives( - request: Request, - name: str, - stage: str | None = None, - owned_only: int = Query(0), - refresh: int = Query(0), -) -> HTMLResponse: - """Suggest alternative cards for a given card name, preferring role-specific pools. - - Strategy: - 1) Determine the seed card's role from the current deck (Role field) or optional `stage` hint. - 2) Build a candidate pool from the combined DataFrame using the same filters as the build phase - for that role (ramp/removal/wipes/card_advantage/protection). - 3) Exclude commander, lands (where applicable), in-deck, locked, and the seed itself; then sort - by edhrecRank/manaValue. Apply owned-only filter if requested. - 4) Fall back to tag-overlap similarity when role cannot be determined or data is missing. - - Returns an HTML partial listing up to ~10 alternatives with Replace buttons. - """ - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - ctx = sess.get("build_ctx") or {} - b = ctx.get("builder") if isinstance(ctx, dict) else None - # Owned library - owned_set = owned_set_helper() - require_owned = bool(int(owned_only or 0)) or bool(sess.get("use_owned_only")) - refresh_requested = bool(int(refresh or 0)) - # If builder context missing, show a guidance message - if not b: - html = '
    Start the build to see alternatives.
    ' - return HTMLResponse(html) - try: - name_disp = str(name).strip() - name_l = name_disp.lower() - commander_l = str((sess.get("commander") or "")).strip().lower() - locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} - # Exclusions from prior inline replacements - alts_exclude = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} - alts_exclude_v = int(sess.get("alts_exclude_v") or 0) - - # Resolve role from stage hint or current library entry - stage_hint = (stage or "").strip().lower() - stage_map = { - "ramp": "ramp", - "removal": "removal", - "wipes": "wipe", - "wipe": "wipe", - "board_wipe": "wipe", - "card_advantage": "card_advantage", - "draw": "card_advantage", - "protection": "protection", - # Additional mappings for creature stages - "creature": "creature", - "creatures": "creature", - "primary": "creature", - "secondary": "creature", - # Land-related hints - "land": "land", - "lands": "land", - "utility": "land", - "misc": "land", - "fetch": "land", - "dual": "land", - } - hinted_role = stage_map.get(stage_hint) if stage_hint else None - lib = getattr(b, "card_library", {}) or {} - # Case-insensitive lookup in deck library - lib_key = None - try: - if name_disp in lib: - lib_key = name_disp - else: - lm = {str(k).strip().lower(): k for k in lib.keys()} - lib_key = lm.get(name_l) - except Exception: - lib_key = None - entry = lib.get(lib_key) if lib_key else None - role = hinted_role or (entry.get("Role") if isinstance(entry, dict) else None) - if isinstance(role, str): - role = role.strip().lower() - - # Build role-specific pool from combined DataFrame - items: list[dict] = [] - - def _clean(value: Any) -> str: - try: - if value is None: - return "" - if isinstance(value, float) and value != value: - return "" - text = str(value) - return text.strip() - except Exception: - return "" - - def _normalize_tags(raw: Any) -> list[str]: - if not raw: - return [] - if isinstance(raw, (list, tuple, set)): - return [str(t).strip() for t in raw if str(t).strip()] - if isinstance(raw, str): - txt = raw.strip() - if not txt: - return [] - if txt.startswith("[") and txt.endswith("]"): - try: - import json as _json - parsed = _json.loads(txt) - if isinstance(parsed, list): - return [str(t).strip() for t in parsed if str(t).strip()] - except Exception: - pass - return [s.strip() for s in txt.split(',') if s.strip()] - return [] - - def _meta_from_row(row_obj: Any) -> dict[str, Any]: - meta = { - "mana": "", - "rarity": "", - "role": "", - "tags": [], - "hover_simple": True, - } - if row_obj is None: - meta["role"] = _clean(used_role or "") - return meta - - def _pull(*keys: str) -> Any: - for key in keys: - try: - if isinstance(row_obj, dict): - val = row_obj.get(key) - elif hasattr(row_obj, "get"): - val = row_obj.get(key) - else: - val = getattr(row_obj, key, None) - except Exception: - val = None - if val not in (None, ""): - if isinstance(val, float) and val != val: - continue - return val - return None - - meta["mana"] = _clean(_pull("mana_cost", "manaCost", "mana", "manaValue", "cmc", "mv")) - meta["rarity"] = _clean(_pull("rarity")) - role_val = _pull("role", "primaryRole", "subRole") - if not role_val: - role_val = used_role or "" - meta["role"] = _clean(role_val) - tags_val = _pull("themeTags", "_ltags", "tags") - meta_tags = _normalize_tags(tags_val) - meta["tags"] = meta_tags - meta["hover_simple"] = not (meta["mana"] or meta["rarity"] or (meta_tags and len(meta_tags) > 0)) - return meta - - def _build_meta_map(df_obj) -> dict[str, dict[str, Any]]: - mapping: dict[str, dict[str, Any]] = {} - try: - if df_obj is None or not hasattr(df_obj, "iterrows"): - return mapping - for _, row in df_obj.iterrows(): - try: - nm_val = str(row.get("name") or "").strip() - except Exception: - nm_val = "" - if not nm_val: - continue - key = nm_val.lower() - if key in mapping: - continue - mapping[key] = _meta_from_row(row) - except Exception: - return mapping - return mapping - - def _sampler(seq: list[str], limit: int) -> list[str]: - if limit <= 0: - return [] - if len(seq) <= limit: - return list(seq) - rng = getattr(b, "rng", None) - try: - if rng is not None: - return rng.sample(seq, limit) if len(seq) >= limit else list(seq) - import random as _rnd - return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq) - except Exception: - return list(seq[:limit]) - used_role = role if isinstance(role, str) and role else None - # Promote to 'land' role when the seed card is a land (regardless of stored role) - try: - if entry and isinstance(entry, dict): - ctype = str(entry.get("Card Type") or entry.get("Type") or "").lower() - if "land" in ctype: - used_role = "land" - except Exception: - pass - df = getattr(b, "_combined_cards_df", None) - - # Compute current deck fingerprint to avoid stale cached alternatives after stage changes - in_deck: set[str] = builder_present_names(b) - try: - import hashlib as _hl - deck_fp = _hl.md5( - ("|".join(sorted(in_deck)) if in_deck else "").encode("utf-8") - ).hexdigest()[:8] - except Exception: - deck_fp = str(len(in_deck)) - - # Use a cache key that includes the exclusions version and deck fingerprint - cache_key = (name_l, commander_l, used_role or "_fallback_", require_owned, alts_exclude_v, deck_fp) - cached = None - if used_role != 'land' and not refresh_requested: - cached = _alts_get_cached(cache_key) - if cached is not None: - return HTMLResponse(cached) - - def _render_and_cache(_items: list[dict]): - html_str = templates.get_template("build/_alternatives.html").render({ - "request": request, - "name": name_disp, - "require_owned": require_owned, - "items": _items, - }) - # Skip caching when used_role == land or refresh requested for per-call randomness - if used_role != 'land' and not refresh_requested: - try: - _alts_set_cached(cache_key, html_str) - except Exception: - pass - return HTMLResponse(html_str) - - # Helper: map display names - def _display_map_for(lower_pool: set[str]) -> dict[str, str]: - try: - return builder_display_map(b, lower_pool) - except Exception: - return {nm: nm for nm in lower_pool} - - # Common exclusions - # in_deck already computed above - - def _exclude(df0): - out = df0.copy() - if "name" in out.columns: - out["_lname"] = out["name"].astype(str).str.strip().str.lower() - mask = ~out["_lname"].isin({name_l} | in_deck | locked_set | alts_exclude | ({commander_l} if commander_l else set())) - out = out[mask] - return out - - # If we have data and a recognized role, mirror the phase logic - if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature","land"}): - pool = df.copy() - try: - pool["_ltags"] = pool.get("themeTags", []).apply(bu.normalize_tag_cell) - except Exception: - # best-effort normalize - pool["_ltags"] = pool.get("themeTags", []).apply(lambda x: [str(t).strip().lower() for t in (x or [])] if isinstance(x, list) else []) - # Role-specific base filtering - if used_role != "land": - # Exclude lands for non-land roles - if "type" in pool.columns: - pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)] - else: - # Keep only lands - if "type" in pool.columns: - pool = pool[pool["type"].fillna("").str.contains("Land", case=False, na=False)] - # Seed info to guide filtering - seed_is_basic = False - try: - seed_is_basic = bool(name_l in {b.strip().lower() for b in getattr(bc, 'BASIC_LANDS', [])}) - except Exception: - seed_is_basic = False - if seed_is_basic: - # For basics: show other basics (different colors) to allow quick swaps - try: - pool = pool[pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})] - except Exception: - pass - else: - # For non-basics: prefer other non-basics - try: - pool = pool[~pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})] - except Exception: - pass - # Apply mono-color misc land filters (no debug CSV dependency) - try: - colors = list(getattr(b, 'color_identity', []) or []) - mono = len(colors) <= 1 - mono_exclude = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', [])} - mono_keep = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', [])} - kindred_all = {n.lower() for n in getattr(bc, 'KINDRED_ALL_LAND_NAMES', [])} - any_color_phrases = [s.lower() for s in getattr(bc, 'ANY_COLOR_MANA_PHRASES', [])] - extra_rainbow_terms = [s.lower() for s in getattr(bc, 'MONO_COLOR_RAINBOW_TEXT_EXTRA', [])] - fetch_names = set() - for seq in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values(): - for nm in seq: - fetch_names.add(nm.lower()) - for nm in getattr(bc, 'GENERIC_FETCH_LANDS', []): - fetch_names.add(nm.lower()) - # World Tree check needs all five colors - need_all_colors = {'w','u','b','r','g'} - def _illegal_world_tree(nm: str) -> bool: - return nm == 'the world tree' and set(c.lower() for c in colors) != need_all_colors - # Text column fallback - text_col = 'text' - if text_col not in pool.columns: - for c in pool.columns: - if 'text' in c.lower(): - text_col = c - break - def _exclude_row(row) -> bool: - nm_l = str(row['name']).strip().lower() - if mono and nm_l in mono_exclude and nm_l not in mono_keep and nm_l not in kindred_all: - return True - if mono and nm_l not in mono_keep and nm_l not in kindred_all: - try: - txt = str(row.get(text_col, '') or '').lower() - if any(p in txt for p in any_color_phrases + extra_rainbow_terms): - return True - except Exception: - pass - if nm_l in fetch_names: - return True - if _illegal_world_tree(nm_l): - return True - return False - pool = pool[~pool.apply(_exclude_row, axis=1)] - except Exception: - pass - # Optional sub-role filtering (only if enough depth) - try: - subrole = str((entry or {}).get('SubRole') or '').strip().lower() - if subrole: - # Heuristic categories for grouping - cat_map = { - 'fetch': 'fetch', - 'dual': 'dual', - 'triple': 'triple', - 'misc': 'misc', - 'utility': 'misc', - 'basic': 'basic' - } - target_cat = None - for key, val in cat_map.items(): - if key in subrole: - target_cat = val - break - if target_cat and len(pool) > 25: - # Lightweight textual filter using known markers - def _cat_row(rname: str, rtype: str) -> str: - rl = rname.lower() - rt = rtype.lower() - if any(k in rl for k in ('vista','strand','delta','mire','heath','rainforest','mesa','foothills','catacombs','tarn','flat','expanse','wilds','landscape','tunnel','terrace','vista')): - return 'fetch' - if 'triple' in rt or 'three' in rt: - return 'triple' - if any(t in rt for t in ('forest','plains','island','swamp','mountain')) and any(sym in rt for sym in ('forest','plains','island','swamp','mountain')) and 'land' in rt: - # Basic-check crude - return 'basic' - return 'misc' - try: - tmp = pool.copy() - tmp['_cat'] = tmp.apply(lambda r: _cat_row(str(r.get('name','')), str(r.get('type',''))), axis=1) - sub_pool = tmp[tmp['_cat'] == target_cat] - if len(sub_pool) >= 10: - pool = sub_pool.drop(columns=['_cat']) - except Exception: - pass - except Exception: - pass - # Exclude commander explicitly - if "name" in pool.columns and commander_l: - pool = pool[pool["name"].astype(str).str.strip().str.lower() != commander_l] - # Role-specific filter - def _is_wipe(tags: list[str]) -> bool: - return any(("board wipe" in t) or ("mass removal" in t) for t in tags) - def _is_removal(tags: list[str]) -> bool: - return any(("removal" in t) or ("spot removal" in t) for t in tags) - def _is_draw(tags: list[str]) -> bool: - return any(("draw" in t) or ("card advantage" in t) for t in tags) - def _matches_selected(tags: list[str]) -> bool: - try: - sel = [str(t).strip().lower() for t in (sess.get("tags") or []) if str(t).strip()] - if not sel: - return True - st = set(sel) - return any(any(s in t for s in st) for t in tags) - except Exception: - return True - if used_role == "ramp": - pool = pool[pool["_ltags"].apply(lambda tags: any("ramp" in t for t in tags))] - elif used_role == "removal": - pool = pool[pool["_ltags"].apply(_is_removal) & ~pool["_ltags"].apply(_is_wipe)] - elif used_role == "wipe": - pool = pool[pool["_ltags"].apply(_is_wipe)] - elif used_role == "card_advantage": - pool = pool[pool["_ltags"].apply(_is_draw)] - elif used_role == "protection": - pool = pool[pool["_ltags"].apply(lambda tags: any("protection" in t for t in tags))] - elif used_role == "creature": - # Keep only creatures; bias toward selected theme tags when available - if "type" in pool.columns: - pool = pool[pool["type"].fillna("").str.contains("Creature", case=False, na=False)] - try: - pool = pool[pool["_ltags"].apply(_matches_selected)] - except Exception: - pass - elif used_role == "land": - # Already constrained to lands; no additional tag filter needed - pass - # Sort by priority like the builder - try: - pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) - except Exception: - pass - # Exclusions and ownership (for non-random roles this stays before slicing) - pool = _exclude(pool) - try: - if bool(sess.get("prefer_owned")) and getattr(b, "owned_card_names", None): - pool = bu.prefer_owned_first(pool, {str(n).lower() for n in getattr(b, "owned_card_names", set())}) - except Exception: - pass - row_meta = _build_meta_map(pool) - # Land role: random 12 from top 60-100 window - if used_role == 'land': - import random as _rnd - total = len(pool) - if total == 0: - pass - else: - cap = min(100, total) - floor = min(60, cap) # if fewer than 60 just use all - if cap <= 12: - window_size = cap - else: - if cap == floor: - window_size = cap - else: - rng_obj = getattr(b, 'rng', None) - if rng_obj: - window_size = rng_obj.randint(floor, cap) - else: - window_size = _rnd.randint(floor, cap) - window_df = pool.head(window_size) - names = window_df['name'].astype(str).str.strip().tolist() - # Random sample up to 12 distinct names - sample_n = min(12, len(names)) - if sample_n > 0: - if getattr(b, 'rng', None): - chosen = getattr(b,'rng').sample(names, sample_n) if len(names) >= sample_n else names - else: - chosen = _rnd.sample(names, sample_n) if len(names) >= sample_n else names - lower_map = {n.strip().lower(): n for n in chosen} - display_map = _display_map_for(set(k for k in lower_map.keys())) - for nm_lc, orig in lower_map.items(): - is_owned = (nm_lc in owned_set) - if require_owned and not is_owned: - continue - if nm_lc == name_l or (in_deck and nm_lc in in_deck): - continue - meta = row_meta.get(nm_lc) or _meta_from_row(None) - items.append({ - 'name': display_map.get(nm_lc, orig), - 'name_lower': nm_lc, - 'owned': is_owned, - 'tags': meta.get('tags') or [], - 'role': meta.get('role', ''), - 'mana': meta.get('mana', ''), - 'rarity': meta.get('rarity', ''), - 'hover_simple': bool(meta.get('hover_simple', True)), - }) - if items: - return _render_and_cache(items) - else: - # Default deterministic top-N (increase to 12 for parity) - lower_pool: list[str] = [] - try: - lower_pool = pool["name"].astype(str).str.strip().str.lower().tolist() - except Exception: - lower_pool = [] - display_map = _display_map_for(set(lower_pool)) - iteration_order = lower_pool - if refresh_requested and len(lower_pool) > 12: - window_size = min(len(lower_pool), 30) - window = lower_pool[:window_size] - sampled = _sampler(window, min(window_size, 12)) - seen_sampled = set(sampled) - iteration_order = sampled + [nm for nm in lower_pool if nm not in seen_sampled] - for nm_l in iteration_order: - is_owned = (nm_l in owned_set) - if require_owned and not is_owned: - continue - if nm_l == name_l or (in_deck and nm_l in in_deck): - continue - meta = row_meta.get(nm_l) or _meta_from_row(None) - items.append({ - "name": display_map.get(nm_l, nm_l), - "name_lower": nm_l, - "owned": is_owned, - "tags": meta.get("tags") or [], - "role": meta.get("role", ""), - "mana": meta.get("mana", ""), - "rarity": meta.get("rarity", ""), - "hover_simple": bool(meta.get("hover_simple", True)), - }) - if len(items) >= 12: - break - if items: - return _render_and_cache(items) - - # Fallback: tag-similarity suggestions (previous behavior) - tags_idx = getattr(b, "_card_name_tags_index", {}) or {} - seed_tags = set(tags_idx.get(name_l) or []) - all_names = set(tags_idx.keys()) - candidates: list[tuple[str, int]] = [] # (name, score) - for nm in all_names: - if nm == name_l: - continue - if commander_l and nm == commander_l: - continue - if in_deck and nm in in_deck: - continue - if locked_set and nm in locked_set: - continue - if nm in alts_exclude: - continue - tgs = set(tags_idx.get(nm) or []) - score = len(seed_tags & tgs) - if score <= 0: - continue - candidates.append((nm, score)) - # If no tag-based candidates, try shared trigger tag from library entry - if not candidates and isinstance(entry, dict): - try: - trig = str(entry.get("TriggerTag") or "").strip().lower() - except Exception: - trig = "" - if trig: - for nm, tglist in tags_idx.items(): - if nm == name_l: - continue - if nm in {str(k).strip().lower() for k in lib.keys()}: - continue - if trig in {str(t).strip().lower() for t in (tglist or [])}: - candidates.append((nm, 1)) - def _owned(nm: str) -> bool: - return nm in owned_set - candidates.sort(key=lambda x: (-x[1], 0 if _owned(x[0]) else 1, x[0])) - if refresh_requested and len(candidates) > 1: - name_sequence = [nm for nm, _score in candidates] - sampled_names = _sampler(name_sequence, min(len(name_sequence), 10)) - sampled_set = set(sampled_names) - reordered: list[tuple[str, int]] = [] - for nm in sampled_names: - for cand_nm, cand_score in candidates: - if cand_nm == nm: - reordered.append((cand_nm, cand_score)) - break - for cand_nm, cand_score in candidates: - if cand_nm not in sampled_set: - reordered.append((cand_nm, cand_score)) - candidates = reordered - pool_lower = {nm for (nm, _s) in candidates} - display_map = _display_map_for(pool_lower) - seen = set() - for nm, score in candidates: - if nm in seen: - continue - seen.add(nm) - is_owned = (nm in owned_set) - if require_owned and not is_owned: - continue - items.append({ - "name": display_map.get(nm, nm), - "name_lower": nm, - "owned": is_owned, - "tags": list(tags_idx.get(nm) or []), - "role": "", - "mana": "", - "rarity": "", - "hover_simple": True, - }) - if len(items) >= 10: - break - return _render_and_cache(items) - except Exception as e: - return HTMLResponse(f'
    No alternatives: {e}
    ') - - -@router.post("/replace", response_class=HTMLResponse) -async def build_replace(request: Request, old: str = Form(...), new: str = Form(...), owned_only: str = Form("0")) -> HTMLResponse: - """Inline replace: swap `old` with `new` in the current builder when possible, and suppress `old` from future alternatives. - - Falls back to lock-and-rerun guidance if no active builder is present. - """ - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - o_disp = str(old).strip() - n_disp = str(new).strip() - o = o_disp.lower() - n = n_disp.lower() - owned_only_flag = str(owned_only or "").strip().lower() - owned_only_int = 1 if owned_only_flag in {"1", "true", "yes", "on"} else 0 - - # Maintain locks to bias future picks and enforcement - locks = set(sess.get("locks", [])) - locks.discard(o) - locks.add(n) - sess["locks"] = list(locks) - # Track last replace for optional undo - try: - sess["last_replace"] = {"old": o, "new": n} - except Exception: - pass - ctx = sess.get("build_ctx") or {} - try: - ctx["locks"] = {str(x) for x in locks} - except Exception: - pass - # Record preferred replacements - try: - pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None - if not isinstance(pref, dict): - pref = {} - ctx["preferred_replacements"] = pref - pref[o] = n - except Exception: - pass - b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None - if b is not None: - try: - lib = getattr(b, "card_library", {}) or {} - # Find the exact key for `old` in a case-insensitive manner - old_key = None - if o_disp in lib: - old_key = o_disp - else: - for k in list(lib.keys()): - if str(k).strip().lower() == o: - old_key = k - break - if old_key is None: - raise KeyError("old card not in deck") - old_info = dict(lib.get(old_key) or {}) - role = str(old_info.get("Role") or "").strip() - subrole = str(old_info.get("SubRole") or "").strip() - try: - count = int(old_info.get("Count", 1)) - except Exception: - count = 1 - # Remove old entry - try: - del lib[old_key] - except Exception: - pass - # Resolve canonical name and info for new - df = getattr(b, "_combined_cards_df", None) - new_key = n_disp - card_type = "" - mana_cost = "" - trigger_tag = str(old_info.get("TriggerTag") or "") - if df is not None: - try: - row = df[df["name"].astype(str).str.strip().str.lower() == n] - if not row.empty: - new_key = str(row.iloc[0]["name"]) or n_disp - card_type = str(row.iloc[0].get("type", row.iloc[0].get("type_line", "")) or "") - mana_cost = str(row.iloc[0].get("mana_cost", row.iloc[0].get("manaCost", "")) or "") - except Exception: - pass - lib[new_key] = { - "Count": count, - "Card Type": card_type, - "Mana Cost": mana_cost, - "Role": role, - "SubRole": subrole, - "AddedBy": "Replace", - "TriggerTag": trigger_tag, - } - # Mirror preferred replacements onto the builder for enforcement - try: - cur = getattr(b, "preferred_replacements", {}) or {} - cur[str(o)] = str(n) - setattr(b, "preferred_replacements", cur) - except Exception: - pass - # Update alternatives exclusion set and bump version to invalidate caches - try: - ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} - ex.add(o) - sess["alts_exclude"] = list(ex) - sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1 - except Exception: - pass - # Success panel and OOB updates (refresh compliance panel) - # Compute ownership of the new card for UI badge update - is_owned = (n in owned_set_helper()) - refresh = ( - '' - ) - html = ( - '
    ' - f'
    Replaced {o_disp} with {new_key}.
    ' - '
    Compliance panel will refresh.
    ' - '
    ' - '' - '
    ' - + refresh + - '
    ' - ) - # Inline mutate the nearest card tile to reflect the new card without a rerun - mutator = """ - -""" - chip = ( - f'
    ' - f'Replaced {o_disp}{new_key}' - f'
    ' - ) - # OOB fetch to refresh compliance panel - refresher = ( - '
    ' - ) - # Include data attributes on the panel div for the mutator script - data_owned = '1' if is_owned else '0' - data_locked = '1' if (n in locks) else '0' - prefix = '
    ' - f'
    Locked {new} and unlocked {old}.
    ' - '
    Now click Rerun Stage with Replace: On to apply this change.
    ' - '
    ' - '
    ' - '' - '' - '
    ' - '
    ' - f'' - f'' - '' - '
    ' - '' - '
    ' - '
    ' - ) - chip = ( - f'
    ' - f'Replaced {old}{new}' - f'
    ' - ) - # Also add old to exclusions and bump version for future alt calls - try: - ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} - ex.add(o) - sess["alts_exclude"] = list(ex) - sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1 - except Exception: - pass - return HTMLResponse(hint + chip) - - -@router.post("/replace/undo", response_class=HTMLResponse) -async def build_replace_undo(request: Request, old: str = Form(None), new: str = Form(None)) -> HTMLResponse: - """Undo the last replace by restoring the previous lock state (best-effort).""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - last = sess.get("last_replace") or {} - try: - # Prefer provided args, else fallback to last recorded - o = (str(old).strip().lower() if old else str(last.get("old") or "")).strip() - n = (str(new).strip().lower() if new else str(last.get("new") or "")).strip() - except Exception: - o, n = "", "" - locks = set(sess.get("locks", [])) - changed = False - if n and n in locks: - locks.discard(n) - changed = True - if o: - locks.add(o) - changed = True - sess["locks"] = list(locks) - if sess.get("build_ctx"): - try: - sess["build_ctx"]["locks"] = {str(x) for x in locks} - except Exception: - pass - # Clear last_replace after undo - try: - if sess.get("last_replace"): - del sess["last_replace"] - except Exception: - pass - # Return confirmation panel and OOB chip - msg = 'Undid replace' if changed else 'No changes to undo' - html = ( - '
    ' - f'
    {msg}.
    ' - '
    Rerun the stage to recompute picks if needed.
    ' - '
    ' - '
    ' - '' - '' - '
    ' - '' - '
    ' - '
    ' - ) - chip = ( - f'
    ' - f'{msg}' - f'
    ' - ) - return HTMLResponse(html + chip) - - -@router.get("/compare") -async def build_compare(runA: str, runB: str): - """Stub: return empty diffs; later we can diff summary files under deck_files.""" - return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []}) - - -@router.get("/compliance", response_class=HTMLResponse) -async def build_compliance_panel(request: Request) -> HTMLResponse: - """Render a live Bracket compliance panel with manual enforcement controls. - - Computes compliance against the current builder state without exporting, attaches a non-destructive - enforcement plan (swaps with added=None) when FAIL, and returns a reusable HTML partial. - Returns empty content when no active build context exists. - """ - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - ctx = sess.get("build_ctx") or {} - b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None - if not b: - return HTMLResponse("") - # Compute compliance snapshot in-memory and attach planning preview - comp = None - try: - if hasattr(b, 'compute_and_print_compliance'): - comp = b.compute_and_print_compliance(base_stem=None) - except Exception: - comp = None - try: - if comp: - from ..services import orchestrator as orch - comp = orch._attach_enforcement_plan(b, comp) - except Exception: - pass - if not comp: - return HTMLResponse("") - # Build flagged metadata (role, owned) for visual tiles and role-aware alternatives - # For combo violations, expand pairs into individual cards (exclude commander) so each can be replaced. - flagged_meta: list[dict] = [] - try: - cats = comp.get('categories') or {} - owned_lower = owned_set_helper() - lib = getattr(b, 'card_library', {}) or {} - commander_l = str((sess.get('commander') or '')).strip().lower() - # map category key -> display label - labels = { - 'game_changers': 'Game Changers', - 'extra_turns': 'Extra Turns', - 'mass_land_denial': 'Mass Land Denial', - 'tutors_nonland': 'Nonland Tutors', - 'two_card_combos': 'Two-Card Combos', - } - seen_lower: set[str] = set() - for key, cat in cats.items(): - try: - status = str(cat.get('status') or '').upper() - # Only surface tiles for WARN and FAIL - if status not in {"WARN", "FAIL"}: - continue - # For two-card combos, split pairs into individual cards and skip commander - if key == 'two_card_combos' and status == 'FAIL': - # Prefer the structured combos list to ensure we only expand counted pairs - pairs = [] - try: - for p in (comp.get('combos') or []): - if p.get('cheap_early'): - pairs.append((str(p.get('a') or '').strip(), str(p.get('b') or '').strip())) - except Exception: - pairs = [] - # Fallback to parsing flagged strings like "A + B" - if not pairs: - try: - for s in (cat.get('flagged') or []): - if not isinstance(s, str): - continue - parts = [x.strip() for x in s.split('+') if x and x.strip()] - if len(parts) == 2: - pairs.append((parts[0], parts[1])) - except Exception: - pass - for a, bname in pairs: - for nm in (a, bname): - if not nm: - continue - nm_l = nm.strip().lower() - if nm_l == commander_l: - # Don't prompt replacing the commander - continue - if nm_l in seen_lower: - continue - seen_lower.add(nm_l) - entry = lib.get(nm) or lib.get(nm_l) or lib.get(str(nm).strip()) or {} - role = entry.get('Role') or '' - flagged_meta.append({ - 'name': nm, - 'category': labels.get(key, key.replace('_',' ').title()), - 'role': role, - 'owned': (nm_l in owned_lower), - 'severity': status, - }) - continue - # Default handling for list/tag categories - names = [n for n in (cat.get('flagged') or []) if isinstance(n, str)] - for nm in names: - nm_l = str(nm).strip().lower() - if nm_l in seen_lower: - continue - seen_lower.add(nm_l) - entry = lib.get(nm) or lib.get(str(nm).strip()) or lib.get(nm_l) or {} - role = entry.get('Role') or '' - flagged_meta.append({ - 'name': nm, - 'category': labels.get(key, key.replace('_',' ').title()), - 'role': role, - 'owned': (nm_l in owned_lower), - 'severity': status, - }) - except Exception: - continue - except Exception: - flagged_meta = [] - # Render partial - ctx2 = {"request": request, "compliance": comp, "flagged_meta": flagged_meta} - return templates.TemplateResponse("build/_compliance_panel.html", ctx2) - - -@router.post("/enforce/apply", response_class=HTMLResponse) -async def build_enforce_apply(request: Request) -> HTMLResponse: - """Apply bracket enforcement now using current locks as user guidance. - - This adds lock placeholders if needed, runs enforcement + re-export, reloads compliance, and re-renders Step 5. - """ - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - # Ensure build context exists - ctx = sess.get("build_ctx") or {} - b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None - if not b: - # No active build: show Step 5 with an error - err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.") - resp = templates.TemplateResponse("build/_step5.html", err_ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) - return resp - # Ensure we have a CSV base stem for consistent re-exports - base_stem = None - try: - csv_path = ctx.get("csv_path") - if isinstance(csv_path, str) and csv_path: - import os as _os - base_stem = _os.path.splitext(_os.path.basename(csv_path))[0] - except Exception: - base_stem = None - # If missing, export once to establish base - if not base_stem: - try: - ctx["csv_path"] = b.export_decklist_csv() - import os as _os - base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0] - # Also produce a text export for completeness - ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') - except Exception: - base_stem = None - # Add lock placeholders into the library before enforcement so user choices are present - try: - locks = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} - if locks: - df = getattr(b, "_combined_cards_df", None) - lib_l = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()} - for lname in locks: - if lname in lib_l: - continue - target_name = None - card_type = '' - mana_cost = '' - try: - if df is not None and not df.empty: - row = df[df['name'].astype(str).str.lower() == lname] - if not row.empty: - target_name = str(row.iloc[0]['name']) - card_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '') - mana_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '') - except Exception: - target_name = None - if target_name: - b.card_library[target_name] = { - 'Count': 1, - 'Card Type': card_type, - 'Mana Cost': mana_cost, - 'Role': 'Locked', - 'SubRole': '', - 'AddedBy': 'Lock', - 'TriggerTag': '', - } - except Exception: - pass - # Thread preferred replacements from context onto builder so enforcement can honor them - try: - pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None - if isinstance(pref, dict): - setattr(b, 'preferred_replacements', dict(pref)) - except Exception: - pass - # Run enforcement + re-exports (tops up to 100 internally) - try: - rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') - except Exception as e: - err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}") - resp = templates.TemplateResponse("build/_step5.html", err_ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) - return resp - # Reload compliance JSON and summary - compliance = None - try: - if base_stem: - import os as _os - import json as _json - comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json") - if _os.path.exists(comp_path): - with open(comp_path, 'r', encoding='utf-8') as _cf: - compliance = _json.load(_cf) - except Exception: - compliance = None - # Rebuild Step 5 context (done state) - # Ensure csv/txt paths on ctx reflect current base - try: - import os as _os - ctx["csv_path"] = _os.path.join('deck_files', f"{base_stem}.csv") if base_stem else ctx.get("csv_path") - ctx["txt_path"] = _os.path.join('deck_files', f"{base_stem}.txt") if base_stem else ctx.get("txt_path") - except Exception: - pass - # Compute total_cards - try: - total_cards = 0 - for _n, _e in getattr(b, 'card_library', {}).items(): - try: - total_cards += int(_e.get('Count', 1)) - except Exception: - total_cards += 1 - except Exception: - total_cards = None - res = { - "done": True, - "label": "Complete", - "log_delta": "", - "idx": len(ctx.get("stages", []) or []), - "total": len(ctx.get("stages", []) or []), - "csv_path": ctx.get("csv_path"), - "txt_path": ctx.get("txt_path"), - "summary": getattr(b, 'build_deck_summary', lambda: None)(), - "total_cards": total_cards, - "added_total": 0, - "compliance": compliance or rep, - } - page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True) - resp = templates.TemplateResponse(request, "build/_step5.html", page_ctx) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}}) - return resp - - -@router.get("/enforcement", response_class=HTMLResponse) -async def build_enforcement_fullpage(request: Request) -> HTMLResponse: - """Full-page enforcement review: show compliance panel with swaps and controls.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - ctx = sess.get("build_ctx") or {} - b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None - if not b: - # No active build - base = step5_empty_ctx(request, sess) - resp = templates.TemplateResponse("build/_step5.html", base) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - # Compute compliance snapshot and attach planning preview - comp = None - try: - if hasattr(b, 'compute_and_print_compliance'): - comp = b.compute_and_print_compliance(base_stem=None) - except Exception: - comp = None - try: - if comp: - from ..services import orchestrator as orch - comp = orch._attach_enforcement_plan(b, comp) - except Exception: - pass - try: - summary_token = int(sess.get("step5_summary_token", 0)) - except Exception: - summary_token = 0 - ctx2 = {"request": request, "compliance": comp, "summary_token": summary_token} - resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}}) - return resp - - -@router.get("/permalink") -async def build_permalink(request: Request): - """Return a URL-safe JSON payload representing current run config (basic).""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - payload = { - "commander": sess.get("commander"), - "tags": sess.get("tags", []), - "bracket": sess.get("bracket"), - "ideals": sess.get("ideals"), - "tag_mode": sess.get("tag_mode", "AND"), - "flags": { - "owned_only": bool(sess.get("use_owned_only")), - "prefer_owned": bool(sess.get("prefer_owned")), - "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), - }, - "locks": list(sess.get("locks", [])), - } - # Optional: random build fields (if present in session) - try: - rb = sess.get("random_build") or {} - if rb: - # Only include known keys to avoid leaking unrelated session data - inc: dict[str, Any] = {} - for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"): - if rb.get(key) is not None: - inc[key] = rb.get(key) - resolved_list = rb.get("resolved_themes") - if isinstance(resolved_list, list): - inc["resolved_themes"] = list(resolved_list) - resolved_info = rb.get("resolved_theme_info") - if isinstance(resolved_info, dict): - inc["resolved_theme_info"] = dict(resolved_info) - if rb.get("combo_fallback") is not None: - inc["combo_fallback"] = bool(rb.get("combo_fallback")) - if rb.get("synergy_fallback") is not None: - inc["synergy_fallback"] = bool(rb.get("synergy_fallback")) - if rb.get("fallback_reason") is not None: - inc["fallback_reason"] = rb.get("fallback_reason") - requested = rb.get("requested_themes") - if isinstance(requested, dict): - inc["requested_themes"] = dict(requested) - if rb.get("auto_fill_enabled") is not None: - inc["auto_fill_enabled"] = bool(rb.get("auto_fill_enabled")) - if rb.get("auto_fill_applied") is not None: - inc["auto_fill_applied"] = bool(rb.get("auto_fill_applied")) - auto_filled = rb.get("auto_filled_themes") - if isinstance(auto_filled, list): - inc["auto_filled_themes"] = list(auto_filled) - display = rb.get("display_themes") - if isinstance(display, list): - inc["display_themes"] = list(display) - if inc: - payload["random"] = inc - except Exception: - pass - - # Add include/exclude cards and advanced options if feature is enabled - if ALLOW_MUST_HAVES: - if sess.get("include_cards"): - - - payload["include_cards"] = sess.get("include_cards") - if sess.get("exclude_cards"): - payload["exclude_cards"] = sess.get("exclude_cards") - if sess.get("enforcement_mode"): - payload["enforcement_mode"] = sess.get("enforcement_mode") - if sess.get("allow_illegal") is not None: - payload["allow_illegal"] = sess.get("allow_illegal") - if sess.get("fuzzy_matching") is not None: - payload["fuzzy_matching"] = sess.get("fuzzy_matching") - try: - import base64 - import json as _json - raw = _json.dumps(payload, separators=(",", ":")) - token = base64.urlsafe_b64encode(raw.encode("utf-8")).decode("ascii").rstrip("=") - # Also include decoded state for convenience/testing - return JSONResponse({"ok": True, "permalink": f"/build/from?state={token}", "state": payload}) - except Exception: - return JSONResponse({"ok": True, "state": payload}) - - -@router.get("/from", response_class=HTMLResponse) -async def build_from(request: Request, state: str | None = None) -> HTMLResponse: - """Load a run from a permalink token.""" - sid = request.cookies.get("sid") or new_sid() - sess = get_session(sid) - if state: - try: - import base64 - import json as _json - pad = '=' * (-len(state) % 4) - raw = base64.urlsafe_b64decode((state + pad).encode("ascii")).decode("utf-8") - data = _json.loads(raw) - sess["commander"] = data.get("commander") - sess["tags"] = data.get("tags", []) - sess["bracket"] = data.get("bracket") - if data.get("ideals"): - sess["ideals"] = data.get("ideals") - sess["tag_mode"] = data.get("tag_mode", "AND") - flags = data.get("flags") or {} - sess["use_owned_only"] = bool(flags.get("owned_only")) - sess["prefer_owned"] = bool(flags.get("prefer_owned")) - sess["swap_mdfc_basics"] = bool(flags.get("swap_mdfc_basics")) - sess["locks"] = list(data.get("locks", [])) - # Optional random build rehydration - try: - r = data.get("random") or {} - if r: - rb_payload: dict[str, Any] = {} - for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"): - if r.get(key) is not None: - rb_payload[key] = r.get(key) - if isinstance(r.get("resolved_themes"), list): - rb_payload["resolved_themes"] = list(r.get("resolved_themes") or []) - if isinstance(r.get("resolved_theme_info"), dict): - rb_payload["resolved_theme_info"] = dict(r.get("resolved_theme_info")) - if r.get("combo_fallback") is not None: - rb_payload["combo_fallback"] = bool(r.get("combo_fallback")) - if r.get("synergy_fallback") is not None: - rb_payload["synergy_fallback"] = bool(r.get("synergy_fallback")) - if r.get("fallback_reason") is not None: - rb_payload["fallback_reason"] = r.get("fallback_reason") - if isinstance(r.get("requested_themes"), dict): - requested_payload = dict(r.get("requested_themes")) - if "auto_fill_enabled" in requested_payload: - requested_payload["auto_fill_enabled"] = bool(requested_payload.get("auto_fill_enabled")) - rb_payload["requested_themes"] = requested_payload - if r.get("auto_fill_enabled") is not None: - rb_payload["auto_fill_enabled"] = bool(r.get("auto_fill_enabled")) - if r.get("auto_fill_applied") is not None: - rb_payload["auto_fill_applied"] = bool(r.get("auto_fill_applied")) - auto_filled = r.get("auto_filled_themes") - if isinstance(auto_filled, list): - rb_payload["auto_filled_themes"] = list(auto_filled) - display = r.get("display_themes") - if isinstance(display, list): - rb_payload["display_themes"] = list(display) - if "seed" in rb_payload: - try: - seed_int = int(rb_payload["seed"]) - rb_payload["seed"] = seed_int - rb_payload.setdefault("recent_seeds", [seed_int]) - except Exception: - rb_payload.setdefault("recent_seeds", []) - sess["random_build"] = rb_payload - except Exception: - pass - - # Import exclude_cards if feature is enabled and present - if ALLOW_MUST_HAVES and data.get("exclude_cards"): - sess["exclude_cards"] = data.get("exclude_cards") - - sess["last_step"] = 4 - except Exception: - pass - locks_restored = 0 - try: - locks_restored = len(sess.get("locks", []) or []) - except Exception: - locks_restored = 0 - resp = templates.TemplateResponse(request, "build/_step4.html", { - "labels": orch.ideal_labels(), - "values": sess.get("ideals") or orch.ideal_defaults(), - "commander": sess.get("commander"), - "owned_only": bool(sess.get("use_owned_only")), - "prefer_owned": bool(sess.get("prefer_owned")), - "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), - "locks_restored": locks_restored, - }) - resp.set_cookie("sid", sid, httponly=True, samesite="lax") - return resp - - -@router.post("/validate/exclude_cards") -async def validate_exclude_cards( - request: Request, - exclude_cards: str = Form(default=""), - commander: str = Form(default="") -): - """Legacy exclude cards validation endpoint - redirect to new unified endpoint.""" - if not ALLOW_MUST_HAVES: - return JSONResponse({"error": "Feature not enabled"}, status_code=404) - - # Call new unified endpoint - result = await validate_include_exclude_cards( - request=request, - include_cards="", - exclude_cards=exclude_cards, - commander=commander, - enforcement_mode="warn", - allow_illegal=False, - fuzzy_matching=True - ) - - # Transform to legacy format for backward compatibility - if hasattr(result, 'body'): - import json - data = json.loads(result.body) - if 'excludes' in data: - excludes = data['excludes'] - return JSONResponse({ - "count": excludes.get("count", 0), - "limit": excludes.get("limit", 15), - "over_limit": excludes.get("over_limit", False), - "cards": excludes.get("cards", []), - "duplicates": excludes.get("duplicates", {}), - "warnings": excludes.get("warnings", []) - }) - - return result - - -@router.post("/validate/include_exclude") -async def validate_include_exclude_cards( - request: Request, - include_cards: str = Form(default=""), - exclude_cards: str = Form(default=""), - commander: str = Form(default=""), - enforcement_mode: str = Form(default="warn"), - allow_illegal: bool = Form(default=False), - fuzzy_matching: bool = Form(default=True) -): - """Validate include/exclude card lists with comprehensive diagnostics.""" - if not ALLOW_MUST_HAVES: - return JSONResponse({"error": "Feature not enabled"}, status_code=404) - - try: - from deck_builder.include_exclude_utils import ( - parse_card_list_input, collapse_duplicates, - fuzzy_match_card_name, MAX_INCLUDES, MAX_EXCLUDES - ) - from deck_builder.builder import DeckBuilder - - # Parse inputs - include_list = parse_card_list_input(include_cards) if include_cards.strip() else [] - exclude_list = parse_card_list_input(exclude_cards) if exclude_cards.strip() else [] - - # Collapse duplicates - include_unique, include_dupes = collapse_duplicates(include_list) - exclude_unique, exclude_dupes = collapse_duplicates(exclude_list) - - # Initialize result structure - result = { - "includes": { - "count": len(include_unique), - "limit": MAX_INCLUDES, - "over_limit": len(include_unique) > MAX_INCLUDES, - "duplicates": include_dupes, - "cards": include_unique[:10] if len(include_unique) <= 10 else include_unique[:7] + ["..."], - "warnings": [], - "legal": [], - "illegal": [], - "color_mismatched": [], - "fuzzy_matches": {} - }, - "excludes": { - "count": len(exclude_unique), - "limit": MAX_EXCLUDES, - "over_limit": len(exclude_unique) > MAX_EXCLUDES, - "duplicates": exclude_dupes, - "cards": exclude_unique[:10] if len(exclude_unique) <= 10 else exclude_unique[:7] + ["..."], - "warnings": [], - "legal": [], - "illegal": [], - "fuzzy_matches": {} - }, - "conflicts": [], # Cards that appear in both lists - "confirmation_needed": [], # Cards needing fuzzy match confirmation - "overall_warnings": [] - } - - # Check for conflicts (cards in both lists) - conflicts = set(include_unique) & set(exclude_unique) - if conflicts: - result["conflicts"] = list(conflicts) - result["overall_warnings"].append(f"Cards appear in both lists: {', '.join(list(conflicts)[:3])}{'...' if len(conflicts) > 3 else ''}") - - # Size warnings based on actual counts - if result["includes"]["over_limit"]: - result["includes"]["warnings"].append(f"Too many includes: {len(include_unique)}/{MAX_INCLUDES}") - elif len(include_unique) > MAX_INCLUDES * 0.8: # 80% capacity warning - result["includes"]["warnings"].append(f"Approaching limit: {len(include_unique)}/{MAX_INCLUDES}") - - if result["excludes"]["over_limit"]: - result["excludes"]["warnings"].append(f"Too many excludes: {len(exclude_unique)}/{MAX_EXCLUDES}") - elif len(exclude_unique) > MAX_EXCLUDES * 0.8: # 80% capacity warning - result["excludes"]["warnings"].append(f"Approaching limit: {len(exclude_unique)}/{MAX_EXCLUDES}") - - # If we have a commander, do advanced validation (color identity, etc.) - if commander and commander.strip(): - try: - # Create a temporary builder - builder = DeckBuilder() - - # Set up commander FIRST (before setup_dataframes) - df = builder.load_commander_data() - commander_rows = df[df["name"] == commander.strip()] - - if not commander_rows.empty: - # Apply commander selection (this sets commander_row properly) - builder._apply_commander_selection(commander_rows.iloc[0]) - - # Now setup dataframes (this will use the commander info) - builder.setup_dataframes() - - # Get available card names for fuzzy matching - name_col = 'name' if 'name' in builder._full_cards_df.columns else 'Name' - available_cards = set(builder._full_cards_df[name_col].tolist()) - - # Validate includes with fuzzy matching - for card_name in include_unique: - if fuzzy_matching: - match_result = fuzzy_match_card_name(card_name, available_cards) - if match_result.matched_name: - if match_result.auto_accepted: - result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name - result["includes"]["legal"].append(match_result.matched_name) - else: - # Needs confirmation - result["confirmation_needed"].append({ - "input": card_name, - "suggestions": match_result.suggestions, - "confidence": match_result.confidence, - "type": "include" - }) - else: - result["includes"]["illegal"].append(card_name) - else: - # Exact match only - if card_name in available_cards: - result["includes"]["legal"].append(card_name) - else: - result["includes"]["illegal"].append(card_name) - - # Validate excludes with fuzzy matching - for card_name in exclude_unique: - if fuzzy_matching: - match_result = fuzzy_match_card_name(card_name, available_cards) - if match_result.matched_name: - if match_result.auto_accepted: - result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name - result["excludes"]["legal"].append(match_result.matched_name) - else: - # Needs confirmation - result["confirmation_needed"].append({ - "input": card_name, - "suggestions": match_result.suggestions, - "confidence": match_result.confidence, - "type": "exclude" - }) - else: - result["excludes"]["illegal"].append(card_name) - else: - # Exact match only - if card_name in available_cards: - result["excludes"]["legal"].append(card_name) - else: - result["excludes"]["illegal"].append(card_name) - - # Color identity validation for includes (only if we have a valid commander with colors) - commander_colors = getattr(builder, 'color_identity', []) - if commander_colors: - color_validated_includes = [] - for card_name in result["includes"]["legal"]: - if builder._validate_card_color_identity(card_name): - color_validated_includes.append(card_name) - else: - # Add color-mismatched cards to illegal instead of separate category - result["includes"]["illegal"].append(card_name) - - # Update legal includes to only those that pass color identity - result["includes"]["legal"] = color_validated_includes - - except Exception as validation_error: - # Advanced validation failed, but return basic validation - result["overall_warnings"].append(f"Advanced validation unavailable: {str(validation_error)}") - else: - # No commander provided, do basic fuzzy matching only - if fuzzy_matching and (include_unique or exclude_unique): - try: - # Use cached available cards set (1st call populates cache) - available_cards = _available_cards() - - # Fast path: normalized exact matches via cached sets - norm_set, norm_map = _available_cards_normalized() - # Validate includes with fuzzy matching - for card_name in include_unique: - from deck_builder.include_exclude_utils import normalize_punctuation - n = normalize_punctuation(card_name) - if n in norm_set: - result["includes"]["fuzzy_matches"][card_name] = norm_map[n] - result["includes"]["legal"].append(norm_map[n]) - continue - match_result = fuzzy_match_card_name(card_name, available_cards) - - if match_result.matched_name and match_result.auto_accepted: - # Exact or high-confidence match - result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name - result["includes"]["legal"].append(match_result.matched_name) - elif not match_result.auto_accepted and match_result.suggestions: - # Needs confirmation - has suggestions but low confidence - result["confirmation_needed"].append({ - "input": card_name, - "suggestions": match_result.suggestions, - "confidence": match_result.confidence, - "type": "include" - }) - else: - # No match found at all, add to illegal - result["includes"]["illegal"].append(card_name) - # Validate excludes with fuzzy matching - for card_name in exclude_unique: - from deck_builder.include_exclude_utils import normalize_punctuation - n = normalize_punctuation(card_name) - if n in norm_set: - result["excludes"]["fuzzy_matches"][card_name] = norm_map[n] - result["excludes"]["legal"].append(norm_map[n]) - continue - match_result = fuzzy_match_card_name(card_name, available_cards) - if match_result.matched_name: - if match_result.auto_accepted: - result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name - result["excludes"]["legal"].append(match_result.matched_name) - else: - # Needs confirmation - result["confirmation_needed"].append({ - "input": card_name, - "suggestions": match_result.suggestions, - "confidence": match_result.confidence, - "type": "exclude" - }) - else: - # No match found, add to illegal - result["excludes"]["illegal"].append(card_name) - - except Exception as fuzzy_error: - result["overall_warnings"].append(f"Fuzzy matching unavailable: {str(fuzzy_error)}") - - return JSONResponse(result) - - except Exception as e: - return JSONResponse({"error": str(e)}, status_code=400) +# ============================================================================== +# Phase 5 Routes Moved to Focused Modules (Roadmap 9 M1) +# ============================================================================== +# Permalinks and Lock Management → build_permalinks.py: +# - POST /build/lock - Card lock toggle +# - GET /build/permalink - State serialization +# - GET /build/from - State restoration +# +# Alternatives → build_alternatives.py: +# - GET /build/alternatives - Role-based card suggestions +# +# Compliance and Replacement → build_compliance.py: +# - POST /build/replace - Inline card replacement +# - POST /build/replace/undo - Undo replacement +# - GET /build/compare - Batch comparison stub +# - GET /build/compliance - Compliance panel +# - POST /build/enforce/apply - Apply enforcement +# - GET /build/enforcement - Full-page enforcement +# ============================================================================== diff --git a/code/web/routes/build_alternatives.py b/code/web/routes/build_alternatives.py new file mode 100644 index 0000000..4c7a651 --- /dev/null +++ b/code/web/routes/build_alternatives.py @@ -0,0 +1,615 @@ +"""Build Alternatives Route + +Phase 5 extraction from build.py: +- GET /build/alternatives - Role-based card suggestions with tag overlap fallback + +This module provides intelligent alternative card suggestions based on deck role, +tags, and builder context. Supports owned-only filtering and caching. +""" +from __future__ import annotations + +from fastapi import APIRouter, Request, Query +from fastapi.responses import HTMLResponse +from typing import Any +from ..app import templates +from ..services.tasks import get_session, new_sid +from ..services.build_utils import owned_set as owned_set_helper, builder_present_names, builder_display_map +from deck_builder.builder import DeckBuilder +from deck_builder import builder_constants as bc +from deck_builder import builder_utils as bu +from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached + + +router = APIRouter(prefix="/build") + + +@router.get("/alternatives", response_class=HTMLResponse) +async def build_alternatives( + request: Request, + name: str, + stage: str | None = None, + owned_only: int = Query(0), + refresh: int = Query(0), +) -> HTMLResponse: + """Suggest alternative cards for a given card name, preferring role-specific pools. + + Strategy: + 1) Determine the seed card's role from the current deck (Role field) or optional `stage` hint. + 2) Build a candidate pool from the combined DataFrame using the same filters as the build phase + for that role (ramp/removal/wipes/card_advantage/protection). + 3) Exclude commander, lands (where applicable), in-deck, locked, and the seed itself; then sort + by edhrecRank/manaValue. Apply owned-only filter if requested. + 4) Fall back to tag-overlap similarity when role cannot be determined or data is missing. + + Returns an HTML partial listing up to ~10 alternatives with Replace buttons. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + ctx = sess.get("build_ctx") or {} + b = ctx.get("builder") if isinstance(ctx, dict) else None + # Owned library + owned_set = owned_set_helper() + require_owned = bool(int(owned_only or 0)) or bool(sess.get("use_owned_only")) + refresh_requested = bool(int(refresh or 0)) + # If builder context missing, show a guidance message + if not b: + html = '
    Start the build to see alternatives.
    ' + return HTMLResponse(html) + try: + name_disp = str(name).strip() + name_l = name_disp.lower() + commander_l = str((sess.get("commander") or "")).strip().lower() + locked_set = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} + # Exclusions from prior inline replacements + alts_exclude = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} + alts_exclude_v = int(sess.get("alts_exclude_v") or 0) + + # Resolve role from stage hint or current library entry + stage_hint = (stage or "").strip().lower() + stage_map = { + "ramp": "ramp", + "removal": "removal", + "wipes": "wipe", + "wipe": "wipe", + "board_wipe": "wipe", + "card_advantage": "card_advantage", + "draw": "card_advantage", + "protection": "protection", + # Additional mappings for creature stages + "creature": "creature", + "creatures": "creature", + "primary": "creature", + "secondary": "creature", + # Land-related hints + "land": "land", + "lands": "land", + "utility": "land", + "misc": "land", + "fetch": "land", + "dual": "land", + } + hinted_role = stage_map.get(stage_hint) if stage_hint else None + lib = getattr(b, "card_library", {}) or {} + # Case-insensitive lookup in deck library + lib_key = None + try: + if name_disp in lib: + lib_key = name_disp + else: + lm = {str(k).strip().lower(): k for k in lib.keys()} + lib_key = lm.get(name_l) + except Exception: + lib_key = None + entry = lib.get(lib_key) if lib_key else None + role = hinted_role or (entry.get("Role") if isinstance(entry, dict) else None) + if isinstance(role, str): + role = role.strip().lower() + + # Build role-specific pool from combined DataFrame + items: list[dict] = [] + + def _clean(value: Any) -> str: + try: + if value is None: + return "" + if isinstance(value, float) and value != value: + return "" + text = str(value) + return text.strip() + except Exception: + return "" + + def _normalize_tags(raw: Any) -> list[str]: + if not raw: + return [] + if isinstance(raw, (list, tuple, set)): + return [str(t).strip() for t in raw if str(t).strip()] + if isinstance(raw, str): + txt = raw.strip() + if not txt: + return [] + if txt.startswith("[") and txt.endswith("]"): + try: + import json as _json + parsed = _json.loads(txt) + if isinstance(parsed, list): + return [str(t).strip() for t in parsed if str(t).strip()] + except Exception: + pass + return [s.strip() for s in txt.split(',') if s.strip()] + return [] + + def _meta_from_row(row_obj: Any) -> dict[str, Any]: + meta = { + "mana": "", + "rarity": "", + "role": "", + "tags": [], + "hover_simple": True, + } + if row_obj is None: + meta["role"] = _clean(used_role or "") + return meta + + def _pull(*keys: str) -> Any: + for key in keys: + try: + if isinstance(row_obj, dict): + val = row_obj.get(key) + elif hasattr(row_obj, "get"): + val = row_obj.get(key) + else: + val = getattr(row_obj, key, None) + except Exception: + val = None + if val not in (None, ""): + if isinstance(val, float) and val != val: + continue + return val + return None + + meta["mana"] = _clean(_pull("mana_cost", "manaCost", "mana", "manaValue", "cmc", "mv")) + meta["rarity"] = _clean(_pull("rarity")) + role_val = _pull("role", "primaryRole", "subRole") + if not role_val: + role_val = used_role or "" + meta["role"] = _clean(role_val) + tags_val = _pull("themeTags", "_ltags", "tags") + meta_tags = _normalize_tags(tags_val) + meta["tags"] = meta_tags + meta["hover_simple"] = not (meta["mana"] or meta["rarity"] or (meta_tags and len(meta_tags) > 0)) + return meta + + def _build_meta_map(df_obj) -> dict[str, dict[str, Any]]: + mapping: dict[str, dict[str, Any]] = {} + try: + if df_obj is None or not hasattr(df_obj, "iterrows"): + return mapping + for _, row in df_obj.iterrows(): + try: + nm_val = str(row.get("name") or "").strip() + except Exception: + nm_val = "" + if not nm_val: + continue + key = nm_val.lower() + if key in mapping: + continue + mapping[key] = _meta_from_row(row) + except Exception: + return mapping + return mapping + + def _sampler(seq: list[str], limit: int) -> list[str]: + if limit <= 0: + return [] + if len(seq) <= limit: + return list(seq) + rng = getattr(b, "rng", None) + try: + if rng is not None: + return rng.sample(seq, limit) if len(seq) >= limit else list(seq) + import random as _rnd + return _rnd.sample(seq, limit) if len(seq) >= limit else list(seq) + except Exception: + return list(seq[:limit]) + used_role = role if isinstance(role, str) and role else None + # Promote to 'land' role when the seed card is a land (regardless of stored role) + try: + if entry and isinstance(entry, dict): + ctype = str(entry.get("Card Type") or entry.get("Type") or "").lower() + if "land" in ctype: + used_role = "land" + except Exception: + pass + df = getattr(b, "_combined_cards_df", None) + + # Compute current deck fingerprint to avoid stale cached alternatives after stage changes + in_deck: set[str] = builder_present_names(b) + try: + import hashlib as _hl + deck_fp = _hl.md5( + ("|".join(sorted(in_deck)) if in_deck else "").encode("utf-8") + ).hexdigest()[:8] + except Exception: + deck_fp = str(len(in_deck)) + + # Use a cache key that includes the exclusions version and deck fingerprint + cache_key = (name_l, commander_l, used_role or "_fallback_", require_owned, alts_exclude_v, deck_fp) + cached = None + if used_role != 'land' and not refresh_requested: + cached = _alts_get_cached(cache_key) + if cached is not None: + return HTMLResponse(cached) + + def _render_and_cache(_items: list[dict]): + html_str = templates.get_template("build/_alternatives.html").render({ + "request": request, + "name": name_disp, + "require_owned": require_owned, + "items": _items, + }) + # Skip caching when used_role == land or refresh requested for per-call randomness + if used_role != 'land' and not refresh_requested: + try: + _alts_set_cached(cache_key, html_str) + except Exception: + pass + return HTMLResponse(html_str) + + # Helper: map display names + def _display_map_for(lower_pool: set[str]) -> dict[str, str]: + try: + return builder_display_map(b, lower_pool) + except Exception: + return {nm: nm for nm in lower_pool} + + # Common exclusions + # in_deck already computed above + + def _exclude(df0): + out = df0.copy() + if "name" in out.columns: + out["_lname"] = out["name"].astype(str).str.strip().str.lower() + mask = ~out["_lname"].isin({name_l} | in_deck | locked_set | alts_exclude | ({commander_l} if commander_l else set())) + out = out[mask] + return out + + # If we have data and a recognized role, mirror the phase logic + if df is not None and hasattr(df, "copy") and (used_role in {"ramp","removal","wipe","card_advantage","protection","creature","land"}): + pool = df.copy() + try: + pool["_ltags"] = pool.get("themeTags", []).apply(bu.normalize_tag_cell) + except Exception: + # best-effort normalize + pool["_ltags"] = pool.get("themeTags", []).apply(lambda x: [str(t).strip().lower() for t in (x or [])] if isinstance(x, list) else []) + # Role-specific base filtering + if used_role != "land": + # Exclude lands for non-land roles + if "type" in pool.columns: + pool = pool[~pool["type"].fillna("").str.contains("Land", case=False, na=False)] + else: + # Keep only lands + if "type" in pool.columns: + pool = pool[pool["type"].fillna("").str.contains("Land", case=False, na=False)] + # Seed info to guide filtering + seed_is_basic = False + try: + seed_is_basic = bool(name_l in {b.strip().lower() for b in getattr(bc, 'BASIC_LANDS', [])}) + except Exception: + seed_is_basic = False + if seed_is_basic: + # For basics: show other basics (different colors) to allow quick swaps + try: + pool = pool[pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})] + except Exception: + pass + else: + # For non-basics: prefer other non-basics + try: + pool = pool[~pool['name'].astype(str).str.strip().str.lower().isin({x.lower() for x in getattr(bc, 'BASIC_LANDS', [])})] + except Exception: + pass + # Apply mono-color misc land filters (no debug CSV dependency) + try: + colors = list(getattr(b, 'color_identity', []) or []) + mono = len(colors) <= 1 + mono_exclude = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_EXCLUDE', [])} + mono_keep = {n.lower() for n in getattr(bc, 'MONO_COLOR_MISC_LAND_KEEP_ALWAYS', [])} + kindred_all = {n.lower() for n in getattr(bc, 'KINDRED_ALL_LAND_NAMES', [])} + any_color_phrases = [s.lower() for s in getattr(bc, 'ANY_COLOR_MANA_PHRASES', [])] + extra_rainbow_terms = [s.lower() for s in getattr(bc, 'MONO_COLOR_RAINBOW_TEXT_EXTRA', [])] + fetch_names = set() + for seq in getattr(bc, 'COLOR_TO_FETCH_LANDS', {}).values(): + for nm in seq: + fetch_names.add(nm.lower()) + for nm in getattr(bc, 'GENERIC_FETCH_LANDS', []): + fetch_names.add(nm.lower()) + # World Tree check needs all five colors + need_all_colors = {'w','u','b','r','g'} + def _illegal_world_tree(nm: str) -> bool: + return nm == 'the world tree' and set(c.lower() for c in colors) != need_all_colors + # Text column fallback + text_col = 'text' + if text_col not in pool.columns: + for c in pool.columns: + if 'text' in c.lower(): + text_col = c + break + def _exclude_row(row) -> bool: + nm_l = str(row['name']).strip().lower() + if mono and nm_l in mono_exclude and nm_l not in mono_keep and nm_l not in kindred_all: + return True + if mono and nm_l not in mono_keep and nm_l not in kindred_all: + try: + txt = str(row.get(text_col, '') or '').lower() + if any(p in txt for p in any_color_phrases + extra_rainbow_terms): + return True + except Exception: + pass + if nm_l in fetch_names: + return True + if _illegal_world_tree(nm_l): + return True + return False + pool = pool[~pool.apply(_exclude_row, axis=1)] + except Exception: + pass + # Optional sub-role filtering (only if enough depth) + try: + subrole = str((entry or {}).get('SubRole') or '').strip().lower() + if subrole: + # Heuristic categories for grouping + cat_map = { + 'fetch': 'fetch', + 'dual': 'dual', + 'triple': 'triple', + 'misc': 'misc', + 'utility': 'misc', + 'basic': 'basic' + } + target_cat = None + for key, val in cat_map.items(): + if key in subrole: + target_cat = val + break + if target_cat and len(pool) > 25: + # Lightweight textual filter using known markers + def _cat_row(rname: str, rtype: str) -> str: + rl = rname.lower() + rt = rtype.lower() + if any(k in rl for k in ('vista','strand','delta','mire','heath','rainforest','mesa','foothills','catacombs','tarn','flat','expanse','wilds','landscape','tunnel','terrace','vista')): + return 'fetch' + if 'triple' in rt or 'three' in rt: + return 'triple' + if any(t in rt for t in ('forest','plains','island','swamp','mountain')) and any(sym in rt for sym in ('forest','plains','island','swamp','mountain')) and 'land' in rt: + # Basic-check crude + return 'basic' + return 'misc' + try: + tmp = pool.copy() + tmp['_cat'] = tmp.apply(lambda r: _cat_row(str(r.get('name','')), str(r.get('type',''))), axis=1) + sub_pool = tmp[tmp['_cat'] == target_cat] + if len(sub_pool) >= 10: + pool = sub_pool.drop(columns=['_cat']) + except Exception: + pass + except Exception: + pass + # Exclude commander explicitly + if "name" in pool.columns and commander_l: + pool = pool[pool["name"].astype(str).str.strip().str.lower() != commander_l] + # Role-specific filter + def _is_wipe(tags: list[str]) -> bool: + return any(("board wipe" in t) or ("mass removal" in t) for t in tags) + def _is_removal(tags: list[str]) -> bool: + return any(("removal" in t) or ("spot removal" in t) for t in tags) + def _is_draw(tags: list[str]) -> bool: + return any(("draw" in t) or ("card advantage" in t) for t in tags) + def _matches_selected(tags: list[str]) -> bool: + try: + sel = [str(t).strip().lower() for t in (sess.get("tags") or []) if str(t).strip()] + if not sel: + return True + st = set(sel) + return any(any(s in t for s in st) for t in tags) + except Exception: + return True + if used_role == "ramp": + pool = pool[pool["_ltags"].apply(lambda tags: any("ramp" in t for t in tags))] + elif used_role == "removal": + pool = pool[pool["_ltags"].apply(_is_removal) & ~pool["_ltags"].apply(_is_wipe)] + elif used_role == "wipe": + pool = pool[pool["_ltags"].apply(_is_wipe)] + elif used_role == "card_advantage": + pool = pool[pool["_ltags"].apply(_is_draw)] + elif used_role == "protection": + pool = pool[pool["_ltags"].apply(lambda tags: any("protection" in t for t in tags))] + elif used_role == "creature": + # Keep only creatures; bias toward selected theme tags when available + if "type" in pool.columns: + pool = pool[pool["type"].fillna("").str.contains("Creature", case=False, na=False)] + try: + pool = pool[pool["_ltags"].apply(_matches_selected)] + except Exception: + pass + elif used_role == "land": + # Already constrained to lands; no additional tag filter needed + pass + # Sort by priority like the builder + try: + pool = bu.sort_by_priority(pool, ["edhrecRank","manaValue"]) + except Exception: + pass + # Exclusions and ownership (for non-random roles this stays before slicing) + pool = _exclude(pool) + try: + if bool(sess.get("prefer_owned")) and getattr(b, "owned_card_names", None): + pool = bu.prefer_owned_first(pool, {str(n).lower() for n in getattr(b, "owned_card_names", set())}) + except Exception: + pass + row_meta = _build_meta_map(pool) + # Land role: random 12 from top 60-100 window + if used_role == 'land': + import random as _rnd + total = len(pool) + if total == 0: + pass + else: + cap = min(100, total) + floor = min(60, cap) # if fewer than 60 just use all + if cap <= 12: + window_size = cap + else: + if cap == floor: + window_size = cap + else: + rng_obj = getattr(b, 'rng', None) + if rng_obj: + window_size = rng_obj.randint(floor, cap) + else: + window_size = _rnd.randint(floor, cap) + window_df = pool.head(window_size) + names = window_df['name'].astype(str).str.strip().tolist() + # Random sample up to 12 distinct names + sample_n = min(12, len(names)) + if sample_n > 0: + if getattr(b, 'rng', None): + chosen = getattr(b,'rng').sample(names, sample_n) if len(names) >= sample_n else names + else: + chosen = _rnd.sample(names, sample_n) if len(names) >= sample_n else names + lower_map = {n.strip().lower(): n for n in chosen} + display_map = _display_map_for(set(k for k in lower_map.keys())) + for nm_lc, orig in lower_map.items(): + is_owned = (nm_lc in owned_set) + if require_owned and not is_owned: + continue + if nm_lc == name_l or (in_deck and nm_lc in in_deck): + continue + meta = row_meta.get(nm_lc) or _meta_from_row(None) + items.append({ + 'name': display_map.get(nm_lc, orig), + 'name_lower': nm_lc, + 'owned': is_owned, + 'tags': meta.get('tags') or [], + 'role': meta.get('role', ''), + 'mana': meta.get('mana', ''), + 'rarity': meta.get('rarity', ''), + 'hover_simple': bool(meta.get('hover_simple', True)), + }) + if items: + return _render_and_cache(items) + else: + # Default deterministic top-N (increase to 12 for parity) + lower_pool: list[str] = [] + try: + lower_pool = pool["name"].astype(str).str.strip().str.lower().tolist() + except Exception: + lower_pool = [] + display_map = _display_map_for(set(lower_pool)) + iteration_order = lower_pool + if refresh_requested and len(lower_pool) > 12: + window_size = min(len(lower_pool), 30) + window = lower_pool[:window_size] + sampled = _sampler(window, min(window_size, 12)) + seen_sampled = set(sampled) + iteration_order = sampled + [nm for nm in lower_pool if nm not in seen_sampled] + for nm_l in iteration_order: + is_owned = (nm_l in owned_set) + if require_owned and not is_owned: + continue + if nm_l == name_l or (in_deck and nm_l in in_deck): + continue + meta = row_meta.get(nm_l) or _meta_from_row(None) + items.append({ + "name": display_map.get(nm_l, nm_l), + "name_lower": nm_l, + "owned": is_owned, + "tags": meta.get("tags") or [], + "role": meta.get("role", ""), + "mana": meta.get("mana", ""), + "rarity": meta.get("rarity", ""), + "hover_simple": bool(meta.get("hover_simple", True)), + }) + if len(items) >= 12: + break + if items: + return _render_and_cache(items) + + # Fallback: tag-similarity suggestions (previous behavior) + tags_idx = getattr(b, "_card_name_tags_index", {}) or {} + seed_tags = set(tags_idx.get(name_l) or []) + all_names = set(tags_idx.keys()) + candidates: list[tuple[str, int]] = [] # (name, score) + for nm in all_names: + if nm == name_l: + continue + if commander_l and nm == commander_l: + continue + if in_deck and nm in in_deck: + continue + if locked_set and nm in locked_set: + continue + if nm in alts_exclude: + continue + tgs = set(tags_idx.get(nm) or []) + score = len(seed_tags & tgs) + if score <= 0: + continue + candidates.append((nm, score)) + # If no tag-based candidates, try shared trigger tag from library entry + if not candidates and isinstance(entry, dict): + try: + trig = str(entry.get("TriggerTag") or "").strip().lower() + except Exception: + trig = "" + if trig: + for nm, tglist in tags_idx.items(): + if nm == name_l: + continue + if nm in {str(k).strip().lower() for k in lib.keys()}: + continue + if trig in {str(t).strip().lower() for t in (tglist or [])}: + candidates.append((nm, 1)) + def _owned(nm: str) -> bool: + return nm in owned_set + candidates.sort(key=lambda x: (-x[1], 0 if _owned(x[0]) else 1, x[0])) + if refresh_requested and len(candidates) > 1: + name_sequence = [nm for nm, _score in candidates] + sampled_names = _sampler(name_sequence, min(len(name_sequence), 10)) + sampled_set = set(sampled_names) + reordered: list[tuple[str, int]] = [] + for nm in sampled_names: + for cand_nm, cand_score in candidates: + if cand_nm == nm: + reordered.append((cand_nm, cand_score)) + break + for cand_nm, cand_score in candidates: + if cand_nm not in sampled_set: + reordered.append((cand_nm, cand_score)) + candidates = reordered + pool_lower = {nm for (nm, _s) in candidates} + display_map = _display_map_for(pool_lower) + seen = set() + for nm, score in candidates: + if nm in seen: + continue + seen.add(nm) + is_owned = (nm in owned_set) + if require_owned and not is_owned: + continue + items.append({ + "name": display_map.get(nm, nm), + "name_lower": nm, + "owned": is_owned, + "tags": list(tags_idx.get(nm) or []), + "role": "", + "mana": "", + "rarity": "", + "hover_simple": True, + }) + if len(items) >= 10: + break + return _render_and_cache(items) + except Exception as e: + return HTMLResponse(f'
    No alternatives: {e}
    ') diff --git a/code/web/routes/build_compliance.py b/code/web/routes/build_compliance.py new file mode 100644 index 0000000..4d893cb --- /dev/null +++ b/code/web/routes/build_compliance.py @@ -0,0 +1,653 @@ +"""Build Compliance and Card Replacement Routes + +Phase 5 extraction from build.py: +- POST /build/replace - Inline card replacement with undo tracking +- POST /build/replace/undo - Undo last replacement +- GET /build/compare - Batch build comparison stub +- GET /build/compliance - Bracket compliance panel +- POST /build/enforce/apply - Apply bracket enforcement +- GET /build/enforcement - Full-page enforcement review + +This module handles card replacement, bracket compliance checking, and enforcement. +""" +from __future__ import annotations + +from fastapi import APIRouter, Request, Form, Query +from fastapi.responses import HTMLResponse, JSONResponse +from typing import Any +import json +from ..app import templates +from ..services.tasks import get_session, new_sid +from ..services.build_utils import ( + step5_ctx_from_result, + step5_error_ctx, + step5_empty_ctx, + owned_set as owned_set_helper, +) +from ..services import orchestrator as orch +from deck_builder.builder import DeckBuilder +from html import escape as _esc +from urllib.parse import quote_plus + + +router = APIRouter(prefix="/build") + + +def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None: + if not payload or response is None: + return + try: + existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None + except Exception: + existing = None + try: + if existing: + try: + data = json.loads(existing) + except Exception: + data = {} + if isinstance(data, dict): + data.update(payload) + response.headers["HX-Trigger"] = json.dumps(data) + return + response.headers["HX-Trigger"] = json.dumps(payload) + except Exception: + try: + response.headers["HX-Trigger"] = json.dumps(payload) + except Exception: + pass + + +@router.post("/replace", response_class=HTMLResponse) +async def build_replace(request: Request, old: str = Form(...), new: str = Form(...), owned_only: str = Form("0")) -> HTMLResponse: + """Inline replace: swap `old` with `new` in the current builder when possible, and suppress `old` from future alternatives. + + Falls back to lock-and-rerun guidance if no active builder is present. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + o_disp = str(old).strip() + n_disp = str(new).strip() + o = o_disp.lower() + n = n_disp.lower() + owned_only_flag = str(owned_only or "").strip().lower() + owned_only_int = 1 if owned_only_flag in {"1", "true", "yes", "on"} else 0 + + # Maintain locks to bias future picks and enforcement + locks = set(sess.get("locks", [])) + locks.discard(o) + locks.add(n) + sess["locks"] = list(locks) + # Track last replace for optional undo + try: + sess["last_replace"] = {"old": o, "new": n} + except Exception: + pass + ctx = sess.get("build_ctx") or {} + try: + ctx["locks"] = {str(x) for x in locks} + except Exception: + pass + # Record preferred replacements + try: + pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None + if not isinstance(pref, dict): + pref = {} + ctx["preferred_replacements"] = pref + pref[o] = n + except Exception: + pass + b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None + if b is not None: + try: + lib = getattr(b, "card_library", {}) or {} + # Find the exact key for `old` in a case-insensitive manner + old_key = None + if o_disp in lib: + old_key = o_disp + else: + for k in list(lib.keys()): + if str(k).strip().lower() == o: + old_key = k + break + if old_key is None: + raise KeyError("old card not in deck") + old_info = dict(lib.get(old_key) or {}) + role = str(old_info.get("Role") or "").strip() + subrole = str(old_info.get("SubRole") or "").strip() + try: + count = int(old_info.get("Count", 1)) + except Exception: + count = 1 + # Remove old entry + try: + del lib[old_key] + except Exception: + pass + # Resolve canonical name and info for new + df = getattr(b, "_combined_cards_df", None) + new_key = n_disp + card_type = "" + mana_cost = "" + trigger_tag = str(old_info.get("TriggerTag") or "") + if df is not None: + try: + row = df[df["name"].astype(str).str.strip().str.lower() == n] + if not row.empty: + new_key = str(row.iloc[0]["name"]) or n_disp + card_type = str(row.iloc[0].get("type", row.iloc[0].get("type_line", "")) or "") + mana_cost = str(row.iloc[0].get("mana_cost", row.iloc[0].get("manaCost", "")) or "") + except Exception: + pass + lib[new_key] = { + "Count": count, + "Card Type": card_type, + "Mana Cost": mana_cost, + "Role": role, + "SubRole": subrole, + "AddedBy": "Replace", + "TriggerTag": trigger_tag, + } + # Mirror preferred replacements onto the builder for enforcement + try: + cur = getattr(b, "preferred_replacements", {}) or {} + cur[str(o)] = str(n) + setattr(b, "preferred_replacements", cur) + except Exception: + pass + # Update alternatives exclusion set and bump version to invalidate caches + try: + ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} + ex.add(o) + sess["alts_exclude"] = list(ex) + sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1 + except Exception: + pass + # Success panel and OOB updates (refresh compliance panel) + # Compute ownership of the new card for UI badge update + is_owned = (n in owned_set_helper()) + refresh = ( + '' + ) + html = ( + '
    ' + f'
    Replaced {o_disp} with {new_key}.
    ' + '
    Compliance panel will refresh.
    ' + '
    ' + '' + '
    ' + + refresh + + '
    ' + ) + # Inline mutate the nearest card tile to reflect the new card without a rerun + mutator = """ + +""" + chip = ( + f'
    ' + f'Replaced {o_disp}{new_key}' + f'
    ' + ) + # OOB fetch to refresh compliance panel + refresher = ( + '
    ' + ) + # Include data attributes on the panel div for the mutator script + data_owned = '1' if is_owned else '0' + data_locked = '1' if (n in locks) else '0' + prefix = '
    ' + f'
    Locked {new} and unlocked {old}.
    ' + '
    Now click Rerun Stage with Replace: On to apply this change.
    ' + '
    ' + '
    ' + '' + '' + '
    ' + '
    ' + f'' + f'' + '' + '
    ' + '' + '
    ' + '
    ' + ) + chip = ( + f'
    ' + f'Replaced {old}{new}' + f'
    ' + ) + # Also add old to exclusions and bump version for future alt calls + try: + ex = {str(x).strip().lower() for x in (sess.get("alts_exclude", []) or [])} + ex.add(o) + sess["alts_exclude"] = list(ex) + sess["alts_exclude_v"] = int(sess.get("alts_exclude_v") or 0) + 1 + except Exception: + pass + return HTMLResponse(hint + chip) + + +@router.post("/replace/undo", response_class=HTMLResponse) +async def build_replace_undo(request: Request, old: str = Form(None), new: str = Form(None)) -> HTMLResponse: + """Undo the last replace by restoring the previous lock state (best-effort).""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + last = sess.get("last_replace") or {} + try: + # Prefer provided args, else fallback to last recorded + o = (str(old).strip().lower() if old else str(last.get("old") or "")).strip() + n = (str(new).strip().lower() if new else str(last.get("new") or "")).strip() + except Exception: + o, n = "", "" + locks = set(sess.get("locks", [])) + changed = False + if n and n in locks: + locks.discard(n) + changed = True + if o: + locks.add(o) + changed = True + sess["locks"] = list(locks) + if sess.get("build_ctx"): + try: + sess["build_ctx"]["locks"] = {str(x) for x in locks} + except Exception: + pass + # Clear last_replace after undo + try: + if sess.get("last_replace"): + del sess["last_replace"] + except Exception: + pass + # Return confirmation panel and OOB chip + msg = 'Undid replace' if changed else 'No changes to undo' + html = ( + '
    ' + f'
    {msg}.
    ' + '
    Rerun the stage to recompute picks if needed.
    ' + '
    ' + '
    ' + '' + '' + '
    ' + '' + '
    ' + '
    ' + ) + chip = ( + f'
    ' + f'{msg}' + f'
    ' + ) + return HTMLResponse(html + chip) + + +@router.get("/compare") +async def build_compare(runA: str, runB: str): + """Stub: return empty diffs; later we can diff summary files under deck_files.""" + return JSONResponse({"ok": True, "added": [], "removed": [], "changed": []}) + + +@router.get("/compliance", response_class=HTMLResponse) +async def build_compliance_panel(request: Request) -> HTMLResponse: + """Render a live Bracket compliance panel with manual enforcement controls. + + Computes compliance against the current builder state without exporting, attaches a non-destructive + enforcement plan (swaps with added=None) when FAIL, and returns a reusable HTML partial. + Returns empty content when no active build context exists. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + ctx = sess.get("build_ctx") or {} + b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None + if not b: + return HTMLResponse("") + # Compute compliance snapshot in-memory and attach planning preview + comp = None + try: + if hasattr(b, 'compute_and_print_compliance'): + comp = b.compute_and_print_compliance(base_stem=None) + except Exception: + comp = None + try: + if comp: + from ..services import orchestrator as orch + comp = orch._attach_enforcement_plan(b, comp) + except Exception: + pass + if not comp: + return HTMLResponse("") + # Build flagged metadata (role, owned) for visual tiles and role-aware alternatives + # For combo violations, expand pairs into individual cards (exclude commander) so each can be replaced. + flagged_meta: list[dict] = [] + try: + cats = comp.get('categories') or {} + owned_lower = owned_set_helper() + lib = getattr(b, 'card_library', {}) or {} + commander_l = str((sess.get('commander') or '')).strip().lower() + # map category key -> display label + labels = { + 'game_changers': 'Game Changers', + 'extra_turns': 'Extra Turns', + 'mass_land_denial': 'Mass Land Denial', + 'tutors_nonland': 'Nonland Tutors', + 'two_card_combos': 'Two-Card Combos', + } + seen_lower: set[str] = set() + for key, cat in cats.items(): + try: + status = str(cat.get('status') or '').upper() + # Only surface tiles for WARN and FAIL + if status not in {"WARN", "FAIL"}: + continue + # For two-card combos, split pairs into individual cards and skip commander + if key == 'two_card_combos' and status == 'FAIL': + # Prefer the structured combos list to ensure we only expand counted pairs + pairs = [] + try: + for p in (comp.get('combos') or []): + if p.get('cheap_early'): + pairs.append((str(p.get('a') or '').strip(), str(p.get('b') or '').strip())) + except Exception: + pairs = [] + # Fallback to parsing flagged strings like "A + B" + if not pairs: + try: + for s in (cat.get('flagged') or []): + if not isinstance(s, str): + continue + parts = [x.strip() for x in s.split('+') if x and x.strip()] + if len(parts) == 2: + pairs.append((parts[0], parts[1])) + except Exception: + pass + for a, bname in pairs: + for nm in (a, bname): + if not nm: + continue + nm_l = nm.strip().lower() + if nm_l == commander_l: + # Don't prompt replacing the commander + continue + if nm_l in seen_lower: + continue + seen_lower.add(nm_l) + entry = lib.get(nm) or lib.get(nm_l) or lib.get(str(nm).strip()) or {} + role = entry.get('Role') or '' + flagged_meta.append({ + 'name': nm, + 'category': labels.get(key, key.replace('_',' ').title()), + 'role': role, + 'owned': (nm_l in owned_lower), + 'severity': status, + }) + continue + # Default handling for list/tag categories + names = [n for n in (cat.get('flagged') or []) if isinstance(n, str)] + for nm in names: + nm_l = str(nm).strip().lower() + if nm_l in seen_lower: + continue + seen_lower.add(nm_l) + entry = lib.get(nm) or lib.get(str(nm).strip()) or lib.get(nm_l) or {} + role = entry.get('Role') or '' + flagged_meta.append({ + 'name': nm, + 'category': labels.get(key, key.replace('_',' ').title()), + 'role': role, + 'owned': (nm_l in owned_lower), + 'severity': status, + }) + except Exception: + continue + except Exception: + flagged_meta = [] + # Render partial + ctx2 = {"request": request, "compliance": comp, "flagged_meta": flagged_meta} + return templates.TemplateResponse("build/_compliance_panel.html", ctx2) + + +@router.post("/enforce/apply", response_class=HTMLResponse) +async def build_enforce_apply(request: Request) -> HTMLResponse: + """Apply bracket enforcement now using current locks as user guidance. + + This adds lock placeholders if needed, runs enforcement + re-export, reloads compliance, and re-renders Step 5. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + # Ensure build context exists + ctx = sess.get("build_ctx") or {} + b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None + if not b: + # No active build: show Step 5 with an error + err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.") + resp = templates.TemplateResponse("build/_step5.html", err_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) + return resp + # Ensure we have a CSV base stem for consistent re-exports + base_stem = None + try: + csv_path = ctx.get("csv_path") + if isinstance(csv_path, str) and csv_path: + import os as _os + base_stem = _os.path.splitext(_os.path.basename(csv_path))[0] + except Exception: + base_stem = None + # If missing, export once to establish base + if not base_stem: + try: + ctx["csv_path"] = b.export_decklist_csv() + import os as _os + base_stem = _os.path.splitext(_os.path.basename(ctx["csv_path"]))[0] + # Also produce a text export for completeness + ctx["txt_path"] = b.export_decklist_text(filename=base_stem + '.txt') + except Exception: + base_stem = None + # Add lock placeholders into the library before enforcement so user choices are present + try: + locks = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} + if locks: + df = getattr(b, "_combined_cards_df", None) + lib_l = {str(n).strip().lower() for n in getattr(b, 'card_library', {}).keys()} + for lname in locks: + if lname in lib_l: + continue + target_name = None + card_type = '' + mana_cost = '' + try: + if df is not None and not df.empty: + row = df[df['name'].astype(str).str.lower() == lname] + if not row.empty: + target_name = str(row.iloc[0]['name']) + card_type = str(row.iloc[0].get('type', row.iloc[0].get('type_line', '')) or '') + mana_cost = str(row.iloc[0].get('mana_cost', row.iloc[0].get('manaCost', '')) or '') + except Exception: + target_name = None + if target_name: + b.card_library[target_name] = { + 'Count': 1, + 'Card Type': card_type, + 'Mana Cost': mana_cost, + 'Role': 'Locked', + 'SubRole': '', + 'AddedBy': 'Lock', + 'TriggerTag': '', + } + except Exception: + pass + # Thread preferred replacements from context onto builder so enforcement can honor them + try: + pref = ctx.get("preferred_replacements") if isinstance(ctx, dict) else None + if isinstance(pref, dict): + setattr(b, 'preferred_replacements', dict(pref)) + except Exception: + pass + # Run enforcement + re-exports (tops up to 100 internally) + try: + rep = b.enforce_and_reexport(base_stem=base_stem, mode='auto') + except Exception as e: + err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}") + resp = templates.TemplateResponse("build/_step5.html", err_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) + return resp + # Reload compliance JSON and summary + compliance = None + try: + if base_stem: + import os as _os + import json as _json + comp_path = _os.path.join('deck_files', f"{base_stem}_compliance.json") + if _os.path.exists(comp_path): + with open(comp_path, 'r', encoding='utf-8') as _cf: + compliance = _json.load(_cf) + except Exception: + compliance = None + # Rebuild Step 5 context (done state) + # Ensure csv/txt paths on ctx reflect current base + try: + import os as _os + ctx["csv_path"] = _os.path.join('deck_files', f"{base_stem}.csv") if base_stem else ctx.get("csv_path") + ctx["txt_path"] = _os.path.join('deck_files', f"{base_stem}.txt") if base_stem else ctx.get("txt_path") + except Exception: + pass + # Compute total_cards + try: + total_cards = 0 + for _n, _e in getattr(b, 'card_library', {}).items(): + try: + total_cards += int(_e.get('Count', 1)) + except Exception: + total_cards += 1 + except Exception: + total_cards = None + res = { + "done": True, + "label": "Complete", + "log_delta": "", + "idx": len(ctx.get("stages", []) or []), + "total": len(ctx.get("stages", []) or []), + "csv_path": ctx.get("csv_path"), + "txt_path": ctx.get("txt_path"), + "summary": getattr(b, 'build_deck_summary', lambda: None)(), + "total_cards": total_cards, + "added_total": 0, + "compliance": compliance or rep, + } + page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True) + resp = templates.TemplateResponse(request, "build/_step5.html", page_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}}) + return resp + + +@router.get("/enforcement", response_class=HTMLResponse) +async def build_enforcement_fullpage(request: Request) -> HTMLResponse: + """Full-page enforcement review: show compliance panel with swaps and controls.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + ctx = sess.get("build_ctx") or {} + b: DeckBuilder | None = ctx.get("builder") if isinstance(ctx, dict) else None + if not b: + # No active build + base = step5_empty_ctx(request, sess) + resp = templates.TemplateResponse("build/_step5.html", base) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + # Compute compliance snapshot and attach planning preview + comp = None + try: + if hasattr(b, 'compute_and_print_compliance'): + comp = b.compute_and_print_compliance(base_stem=None) + except Exception: + comp = None + try: + if comp: + from ..services import orchestrator as orch + comp = orch._attach_enforcement_plan(b, comp) + except Exception: + pass + try: + summary_token = int(sess.get("step5_summary_token", 0)) + except Exception: + summary_token = 0 + ctx2 = {"request": request, "compliance": comp, "summary_token": summary_token} + resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}}) + return resp diff --git a/code/web/routes/build_include_exclude.py b/code/web/routes/build_include_exclude.py new file mode 100644 index 0000000..caa4ed8 --- /dev/null +++ b/code/web/routes/build_include_exclude.py @@ -0,0 +1,216 @@ +""" +Include/Exclude card list management routes. + +Handles user-defined include (must-have) and exclude (forbidden) card lists +for deck building, including the card toggle endpoint and summary rendering. +""" + +from __future__ import annotations + +from typing import Any +from fastapi import APIRouter, Request, Form +from fastapi.responses import HTMLResponse, JSONResponse + +from ..app import ALLOW_MUST_HAVES, templates +from ..services.build_utils import step5_base_ctx +from ..services.tasks import get_session, new_sid +from ..services.telemetry import log_include_exclude_toggle +from .build import _merge_hx_trigger + + +router = APIRouter() + + +def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]: + """ + Extract include/exclude card lists and enforcement settings from session. + + Args: + sess: Session dictionary containing user state + + Returns: + Tuple of (state_dict, includes_list, excludes_list) where: + - state_dict contains enforcement mode, fuzzy matching, and list contents + - includes_list contains card names to include + - excludes_list contains card names to exclude + """ + includes = list(sess.get("include_cards") or []) + excludes = list(sess.get("exclude_cards") or []) + state = { + "includes": includes, + "excludes": excludes, + "enforcement_mode": (sess.get("enforcement_mode") or "warn"), + "allow_illegal": bool(sess.get("allow_illegal")), + "fuzzy_matching": bool(sess.get("fuzzy_matching", True)), + } + return state, includes, excludes + + +def _render_include_exclude_summary( + request: Request, + sess: dict, + sid: str, + *, + state: dict[str, Any] | None = None, + includes: list[str] | None = None, + excludes: list[str] | None = None, +) -> HTMLResponse: + """ + Render the include/exclude summary template. + + Args: + request: FastAPI request object + sess: Session dictionary + sid: Session ID for cookie + state: Optional pre-computed state dict + includes: Optional pre-computed includes list + excludes: Optional pre-computed excludes list + + Returns: + HTMLResponse with rendered include/exclude summary + """ + ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False) + if state is None or includes is None or excludes is None: + state, includes, excludes = _must_have_state(sess) + ctx["must_have_state"] = state + ctx["summary"] = sess.get("step5_summary") if sess.get("step5_summary_ready") else None + ctx["include_cards"] = includes + ctx["exclude_cards"] = excludes + response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response + + +@router.post("/must-haves/toggle", response_class=HTMLResponse) +async def toggle_must_haves( + request: Request, + card_name: str = Form(...), + list_type: str = Form(...), + enabled: str = Form("1"), +): + """ + Toggle a card's inclusion in the include or exclude list. + + This endpoint handles: + - Adding/removing cards from include (must-have) lists + - Adding/removing cards from exclude (forbidden) lists + - Mutual exclusivity (card can't be in both lists) + - List size limits (10 includes, 15 excludes) + - Case-insensitive duplicate detection + + Args: + request: FastAPI request object + card_name: Name of the card to toggle + list_type: Either "include" or "exclude" + enabled: "1"/"true"/"yes"/"on" to add, anything else to remove + + Returns: + HTMLResponse with updated include/exclude summary, or + JSONResponse with error if validation fails + + HX-Trigger Events: + must-haves:toggle: Payload with card, list, enabled status, and counts + """ + if not ALLOW_MUST_HAVES: + return JSONResponse({"error": "Must-have lists are disabled"}, status_code=403) + + name = str(card_name or "").strip() + if not name: + return JSONResponse({"error": "Card name is required"}, status_code=400) + + list_key = str(list_type or "").strip().lower() + if list_key not in {"include", "exclude"}: + return JSONResponse({"error": "Unsupported toggle type"}, status_code=400) + + enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"} + + sid = request.cookies.get("sid") or request.headers.get("X-Session-ID") + if not sid: + sid = new_sid() + sess = get_session(sid) + + includes = list(sess.get("include_cards") or []) + excludes = list(sess.get("exclude_cards") or []) + include_lookup = {str(v).strip().lower(): str(v) for v in includes if str(v).strip()} + exclude_lookup = {str(v).strip().lower(): str(v) for v in excludes if str(v).strip()} + key = name.lower() + display_name = include_lookup.get(key) or exclude_lookup.get(key) or name + + changed = False + include_limit = 10 + exclude_limit = 15 + + def _remove_casefold(items: list[str], item_key: str) -> list[str]: + """Remove items matching the given key (case-insensitive).""" + return [c for c in items if str(c).strip().lower() != item_key] + + if list_key == "include": + if enabled_flag: + if key not in include_lookup: + if len(include_lookup) >= include_limit: + return JSONResponse({"error": f"Include limit reached ({include_limit})."}, status_code=400) + includes.append(name) + include_lookup[key] = name + changed = True + if key in exclude_lookup: + excludes = _remove_casefold(excludes, key) + exclude_lookup.pop(key, None) + changed = True + else: + if key in include_lookup: + includes = _remove_casefold(includes, key) + include_lookup.pop(key, None) + changed = True + else: # exclude + if enabled_flag: + if key not in exclude_lookup: + if len(exclude_lookup) >= exclude_limit: + return JSONResponse({"error": f"Exclude limit reached ({exclude_limit})."}, status_code=400) + excludes.append(name) + exclude_lookup[key] = name + changed = True + if key in include_lookup: + includes = _remove_casefold(includes, key) + include_lookup.pop(key, None) + changed = True + else: + if key in exclude_lookup: + excludes = _remove_casefold(excludes, key) + exclude_lookup.pop(key, None) + changed = True + + if changed: + sess["include_cards"] = includes + sess["exclude_cards"] = excludes + if "include_exclude_diagnostics" in sess: + try: + del sess["include_exclude_diagnostics"] + except Exception: + pass + + response = _render_include_exclude_summary(request, sess, sid) + + try: + log_include_exclude_toggle( + request, + card_name=display_name, + action=list_key, + enabled=enabled_flag, + include_count=len(includes), + exclude_count=len(excludes), + ) + except Exception: + pass + + trigger_payload = { + "card": display_name, + "list": list_key, + "enabled": enabled_flag, + "include_count": len(includes), + "exclude_count": len(excludes), + } + try: + _merge_hx_trigger(response, {"must-haves:toggle": trigger_payload}) + except Exception: + pass + return response diff --git a/code/web/routes/build_multicopy.py b/code/web/routes/build_multicopy.py new file mode 100644 index 0000000..ee1eb16 --- /dev/null +++ b/code/web/routes/build_multicopy.py @@ -0,0 +1,349 @@ +"""Multi-copy archetype routes for deck building. + +Handles multi-copy package detection, selection, and integration with the deck builder. +Multi-copy archetypes allow multiple copies of specific cards (e.g., Hare Apparent, Dragon's Approach). + +Routes: + GET /multicopy/check - Check if commander/tags suggest multi-copy archetype + POST /multicopy/save - Save or skip multi-copy selection + GET /new/multicopy - Get multi-copy suggestions for New Deck modal (inline) + +Created: 2026-02-20 +Roadmap: R9 M1 Phase 2 +""" + +from __future__ import annotations + +from fastapi import APIRouter, Request, Form, Query +from fastapi.responses import HTMLResponse +from html import escape as _esc + +from deck_builder.builder import DeckBuilder +from deck_builder import builder_utils as bu, builder_constants as bc +from ..app import templates +from ..services.tasks import get_session, new_sid +from ..services import orchestrator as orch +from ..services.build_utils import owned_names as owned_names_helper + +router = APIRouter() + + +def _rebuild_ctx_with_multicopy(sess: dict) -> None: + """Rebuild the staged context so Multi-Copy runs first, avoiding overfill. + + This ensures the added cards are accounted for before lands and later phases, + which keeps totals near targets and shows the multi-copy additions ahead of basics. + + Args: + sess: Session dictionary containing build state + """ + try: + if not sess or not sess.get("commander"): + return + # Build fresh ctx with the same options, threading multi_copy explicitly + opts = orch.bracket_options() + default_bracket = (opts[0]["level"] if opts else 1) + bracket_val = sess.get("bracket") + try: + safe_bracket = int(bracket_val) if bracket_val is not None else default_bracket + except Exception: + safe_bracket = int(default_bracket) + ideals_val = sess.get("ideals") or orch.ideal_defaults() + use_owned = bool(sess.get("use_owned_only")) + prefer = bool(sess.get("prefer_owned")) + owned_names = owned_names_helper() if (use_owned or prefer) else None + locks = list(sess.get("locks", [])) + sess["build_ctx"] = orch.start_build_ctx( + commander=sess.get("commander"), + tags=sess.get("tags", []), + bracket=safe_bracket, + ideals=ideals_val, + tag_mode=sess.get("tag_mode", "AND"), + use_owned_only=use_owned, + prefer_owned=prefer, + owned_names=owned_names, + locks=locks, + custom_export_base=sess.get("custom_export_base"), + multi_copy=sess.get("multi_copy"), + prefer_combos=bool(sess.get("prefer_combos")), + combo_target_count=int(sess.get("combo_target_count", 2)), + combo_balance=str(sess.get("combo_balance", "mix")), + swap_mdfc_basics=bool(sess.get("swap_mdfc_basics")), + ) + except Exception: + # If rebuild fails (e.g., commander not found in test), fall back to injecting + # a minimal Multi-Copy stage on the existing builder so the UI can render additions. + try: + ctx = sess.get("build_ctx") + if not isinstance(ctx, dict): + return + b = ctx.get("builder") + if b is None: + return + # Thread selection onto the builder; runner will be resilient without full DFs + try: + setattr(b, "_web_multi_copy", sess.get("multi_copy") or None) + except Exception: + pass + # Ensure minimal structures exist + try: + if not isinstance(getattr(b, "card_library", None), dict): + b.card_library = {} + except Exception: + pass + try: + if not isinstance(getattr(b, "ideal_counts", None), dict): + b.ideal_counts = {} + except Exception: + pass + # Inject a single Multi-Copy stage + ctx["stages"] = [{"key": "multi_copy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}] + ctx["idx"] = 0 + ctx["last_visible_idx"] = 0 + except Exception: + # Leave existing context untouched on unexpected failure + pass + + +@router.get("/multicopy/check", response_class=HTMLResponse) +async def multicopy_check(request: Request) -> HTMLResponse: + """If current commander/tags suggest a multi-copy archetype, render a choose-one modal. + + Returns empty content when not applicable to avoid flashing a modal unnecessarily. + + Args: + request: FastAPI request object + + Returns: + HTMLResponse with multi-copy modal or empty string + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + commander = str(sess.get("commander") or "").strip() + tags = list(sess.get("tags") or []) + if not commander: + return HTMLResponse("") + # Avoid re-prompting repeatedly for the same selection context + key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()])) + seen = set(sess.get("mc_seen_keys", []) or []) + if key in seen: + return HTMLResponse("") + # Build a light DeckBuilder seeded with commander + tags (no heavy data load required) + try: + tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) + df = tmp.load_commander_data() + row = df[df["name"].astype(str) == commander] + if row.empty: + return HTMLResponse("") + tmp._apply_commander_selection(row.iloc[0]) + tmp.selected_tags = list(tags or []) + try: + tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None + tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None + tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None + except Exception: + pass + # Establish color identity from the selected commander + try: + tmp.determine_color_identity() + except Exception: + pass + # Detect viable archetypes + results = bu.detect_viable_multi_copy_archetypes(tmp) or [] + if not results: + # Remember this key to avoid re-checking until tags/commander change + try: + seen.add(key) + sess["mc_seen_keys"] = list(seen) + except Exception: + pass + return HTMLResponse("") + # Render modal template with top N (cap small for UX) + items = results[:5] + ctx = { + "request": request, + "items": items, + "commander": commander, + "tags": tags, + } + return templates.TemplateResponse("build/_multi_copy_modal.html", ctx) + except Exception: + return HTMLResponse("") + + +@router.post("/multicopy/save", response_class=HTMLResponse) +async def multicopy_save( + request: Request, + choice_id: str = Form(None), + count: int = Form(None), + thrumming: str | None = Form(None), + skip: str | None = Form(None), +) -> HTMLResponse: + """Persist user selection (or skip) for multi-copy archetype in session and close modal. + + Returns a tiny confirmation chip via OOB swap (optional) and removes the modal. + + Args: + request: FastAPI request object + choice_id: Multi-copy archetype ID (e.g., 'hare_apparent') + count: Number of copies to include + thrumming: Whether to include Thrumming Stone + skip: Whether to skip multi-copy for this build + + Returns: + HTMLResponse with confirmation chip (OOB swap) + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + commander = str(sess.get("commander") or "").strip() + tags = list(sess.get("tags") or []) + key = commander + "||" + ",".join(sorted([str(t).strip().lower() for t in tags if str(t).strip()])) + # Update seen set to avoid re-prompt next load + seen = set(sess.get("mc_seen_keys", []) or []) + seen.add(key) + sess["mc_seen_keys"] = list(seen) + # Handle skip explicitly + if skip and str(skip).strip() in ("1","true","on","yes"): + # Clear any prior choice for this run + try: + if sess.get("multi_copy"): + del sess["multi_copy"] + if sess.get("mc_applied_key"): + del sess["mc_applied_key"] + except Exception: + pass + # Return nothing (modal will be removed client-side) + # Also emit an OOB chip indicating skip + chip = ( + '
    ' + 'Dismissed multi-copy suggestions' + '
    ' + ) + return HTMLResponse(chip) + # Persist selection when provided + payload = None + try: + meta = bc.MULTI_COPY_ARCHETYPES.get(str(choice_id), {}) + name = meta.get("name") or str(choice_id) + printed_cap = meta.get("printed_cap") + # Coerce count with bounds: default -> rec_window[0], cap by printed_cap when present + if count is None: + count = int(meta.get("default_count", 25)) + try: + count = int(count) + except Exception: + count = int(meta.get("default_count", 25)) + if isinstance(printed_cap, int) and printed_cap > 0: + count = max(1, min(printed_cap, count)) + payload = { + "id": str(choice_id), + "name": name, + "count": int(count), + "thrumming": True if (thrumming and str(thrumming).strip() in ("1","true","on","yes")) else False, + } + sess["multi_copy"] = payload + # Mark as not yet applied so the next build start/continue can account for it once + try: + if sess.get("mc_applied_key"): + del sess["mc_applied_key"] + except Exception: + pass + # If there's an active build context, rebuild it so Multi-Copy runs first + if sess.get("build_ctx"): + _rebuild_ctx_with_multicopy(sess) + except Exception: + payload = None + # Return OOB chip summarizing the selection + if payload: + chip = ( + '
    ' + f'Selected multi-copy: ' + f"{_esc(payload.get('name',''))} x{int(payload.get('count',0))}" + f"{' + Thrumming Stone' if payload.get('thrumming') else ''}" + '
    ' + ) + else: + chip = ( + '
    ' + 'Saved' + '
    ' + ) + return HTMLResponse(chip) + + +@router.get("/new/multicopy", response_class=HTMLResponse) +async def build_new_multicopy( + request: Request, + commander: str = Query(""), + primary_tag: str | None = Query(None), + secondary_tag: str | None = Query(None), + tertiary_tag: str | None = Query(None), + tag_mode: str | None = Query("AND"), +) -> HTMLResponse: + """Return multi-copy suggestions for the New Deck modal based on commander + selected tags. + + This does not mutate the session; it simply renders a form snippet that posts with the main modal. + + Args: + request: FastAPI request object + commander: Commander name + primary_tag: Primary theme tag + secondary_tag: Secondary theme tag + tertiary_tag: Tertiary theme tag + tag_mode: Tag matching mode (AND/OR) + + Returns: + HTMLResponse with multi-copy suggestions or empty string + """ + name = (commander or "").strip() + if not name: + return HTMLResponse("") + try: + tmp = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) + df = tmp.load_commander_data() + row = df[df["name"].astype(str) == name] + if row.empty: + return HTMLResponse("") + tmp._apply_commander_selection(row.iloc[0]) + tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] + tmp.selected_tags = list(tags or []) + try: + tmp.primary_tag = tmp.selected_tags[0] if len(tmp.selected_tags) > 0 else None + tmp.secondary_tag = tmp.selected_tags[1] if len(tmp.selected_tags) > 1 else None + tmp.tertiary_tag = tmp.selected_tags[2] if len(tmp.selected_tags) > 2 else None + except Exception: + pass + try: + tmp.determine_color_identity() + except Exception: + pass + results = bu.detect_viable_multi_copy_archetypes(tmp) or [] + # For the New Deck modal, only show suggestions where the matched tags intersect + # the explicitly selected tags (ignore commander-default themes). + sel_tags = {str(t).strip().lower() for t in (tags or []) if str(t).strip()} + def _matched_reason_tags(item: dict) -> set[str]: + out = set() + try: + for r in item.get('reasons', []) or []: + if not isinstance(r, str): + continue + rl = r.strip().lower() + if rl.startswith('tags:'): + body = rl.split('tags:', 1)[1].strip() + parts = [p.strip() for p in body.split(',') if p.strip()] + out.update(parts) + except Exception: + return set() + return out + if sel_tags: + results = [it for it in results if (_matched_reason_tags(it) & sel_tags)] + else: + # If no selected tags, do not show any multi-copy suggestions in the modal + results = [] + if not results: + return HTMLResponse("") + items = results[:5] + ctx = {"request": request, "items": items} + return templates.TemplateResponse("build/_new_deck_multicopy.html", ctx) + except Exception: + return HTMLResponse("") diff --git a/code/web/routes/build_newflow.py b/code/web/routes/build_newflow.py new file mode 100644 index 0000000..13d1592 --- /dev/null +++ b/code/web/routes/build_newflow.py @@ -0,0 +1,1262 @@ +"""New Build Flow Routes + +Handles the New Deck modal, commander search/inspection, skip controls, +new deck submission, Quick Build automation, and batch builds. + +Extracted in Phase 4 of Roadmap 9 M1 Backend Standardization. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Request, Form, Query, BackgroundTasks +from fastapi.responses import HTMLResponse, JSONResponse +from typing import Any, Dict +from ..app import ( + ALLOW_MUST_HAVES, + ENABLE_CUSTOM_THEMES, + SHOW_MUST_HAVE_BUTTONS, + ENABLE_PARTNER_MECHANICS, + WEB_IDEALS_UI, + ENABLE_BATCH_BUILD, + DEFAULT_THEME_MATCH_MODE, +) +from ..services.build_utils import ( + step5_ctx_from_result, + start_ctx_from_session, +) +from ..app import templates +from deck_builder import builder_constants as bc +from ..services import orchestrator as orch +from ..services.orchestrator import is_setup_ready as _is_setup_ready, is_setup_stale as _is_setup_stale +from ..services.tasks import get_session, new_sid +from deck_builder.builder import DeckBuilder +from commander_exclusions import lookup_commander_detail +from .build_themes import _custom_theme_context +from .build_partners import ( + _partner_ui_context, + _resolve_partner_selection, +) +from ..services import custom_theme_manager as theme_mgr + +router = APIRouter() + + +# ============================================================================== +# New Deck Modal and Commander Search +# ============================================================================== + +@router.get("/new", response_class=HTMLResponse) +async def build_new_modal(request: Request) -> HTMLResponse: + """Return the New Deck modal content (for an overlay).""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + # Clear build context to allow skip controls to work + # (Otherwise toggle endpoint thinks build is in progress) + if "build_ctx" in sess: + try: + del sess["build_ctx"] + except Exception: + pass + + # M2: Clear all skip preferences for true "New Deck" + skip_keys = [ + "skip_lands", "skip_to_misc", "skip_basics", "skip_staples", + "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes", + "skip_all_creatures", + "skip_creature_primary", "skip_creature_secondary", "skip_creature_fill", + "skip_all_spells", + "skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", + "skip_protection", "skip_spell_fill", + "skip_post_adjust" + ] + for key in skip_keys: + sess.pop(key, None) + + # M2: Check if this is a quick-build scenario (from commander browser) + # Use the quick_build flag set by /build route when ?commander= param present + is_quick_build = sess.pop("quick_build", False) # Pop to consume the flag + + # M2: Clear commander and form selections for fresh start (unless quick build) + if not is_quick_build: + commander_keys = [ + "commander", "partner", "background", "commander_mode", + "themes", "bracket" + ] + for key in commander_keys: + sess.pop(key, None) + + theme_context = _custom_theme_context(request, sess) + ctx = { + "request": request, + "brackets": orch.bracket_options(), + "labels": orch.ideal_labels(), + "defaults": orch.ideal_defaults(), + "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag + "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, + "enable_custom_themes": ENABLE_CUSTOM_THEMES, + "enable_batch_build": ENABLE_BATCH_BUILD, + "ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider' + "form": { + "commander": sess.get("commander", ""), # Pre-fill for quick-build + "prefer_combos": bool(sess.get("prefer_combos")), + "combo_count": sess.get("combo_target_count"), + "combo_balance": sess.get("combo_balance"), + "enable_multicopy": bool(sess.get("multi_copy")), + "use_owned_only": bool(sess.get("use_owned_only")), + "prefer_owned": bool(sess.get("prefer_owned")), + "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), + # Add ideal values from session (will be None on first load, triggering defaults) + "ramp": sess.get("ideals", {}).get("ramp"), + "lands": sess.get("ideals", {}).get("lands"), + "basic_lands": sess.get("ideals", {}).get("basic_lands"), + "creatures": sess.get("ideals", {}).get("creatures"), + "removal": sess.get("ideals", {}).get("removal"), + "wipes": sess.get("ideals", {}).get("wipes"), + "card_advantage": sess.get("ideals", {}).get("card_advantage"), + "protection": sess.get("ideals", {}).get("protection"), + }, + "tag_slot_html": None, + } + for key, value in theme_context.items(): + if key == "request": + continue + ctx[key] = value + resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +@router.get("/new/candidates", response_class=HTMLResponse) +async def build_new_candidates(request: Request, commander: str = Query("")) -> HTMLResponse: + """Return a small list of commander candidates for the modal live search.""" + q = (commander or "").strip() + items = orch.commander_candidates(q, limit=8) if q else [] + candidates: list[dict[str, Any]] = [] + for name, score, colors in items: + detail = lookup_commander_detail(name) + preferred = name + warning = None + if detail: + eligible_raw = detail.get("eligible_faces") + eligible = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else [] + norm_name = str(name).strip().casefold() + eligible_norms = [face.casefold() for face in eligible] + if eligible and norm_name not in eligible_norms: + preferred = eligible[0] + primary = str(detail.get("primary_face") or detail.get("name") or name).strip() + if len(eligible) == 1: + warning = ( + f"Use the back face '{preferred}' when building. Front face '{primary}' can't lead a deck." + ) + else: + faces = ", ".join(f"'{face}'" for face in eligible) + warning = ( + f"This commander only works from specific faces: {faces}." + ) + candidates.append( + { + "display": name, + "value": preferred, + "score": score, + "colors": colors, + "warning": warning, + } + ) + ctx = {"request": request, "query": q, "candidates": candidates} + return templates.TemplateResponse("build/_new_deck_candidates.html", ctx) + + +@router.get("/new/inspect", response_class=HTMLResponse) +async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLResponse: + """When a candidate is chosen in the modal, show the commander preview and tag chips (OOB updates).""" + info = orch.commander_select(name) + if not info.get("ok"): + return HTMLResponse(f'
    Commander not found: {name}
    ') + tags = orch.tags_for_commander(info["name"]) or [] + recommended = orch.recommended_tags_for_commander(info["name"]) if tags else [] + recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {} + exclusion_detail = lookup_commander_detail(info["name"]) + # Render tags slot content and OOB commander preview simultaneously + # Game Changer flag for this commander (affects bracket UI in modal via tags partial consumer) + is_gc = False + try: + is_gc = bool(info["name"] in getattr(bc, 'GAME_CHANGERS', [])) + except Exception: + is_gc = False + ctx = { + "request": request, + "commander": {"name": info["name"], "exclusion": exclusion_detail}, + "tags": tags, + "recommended": recommended, + "recommended_reasons": recommended_reasons, + "gc_commander": is_gc, + "brackets": orch.bracket_options(), + } + ctx.update( + _partner_ui_context( + info["name"], + partner_enabled=False, + secondary_selection=None, + background_selection=None, + combined_preview=None, + warnings=None, + partner_error=None, + auto_note=None, + ) + ) + partner_tags = ctx.get("partner_theme_tags") or [] + if partner_tags: + merged_tags: list[str] = [] + seen: set[str] = set() + for source in (partner_tags, tags): + for tag in source: + token = str(tag).strip() + if not token: + continue + key = token.casefold() + if key in seen: + continue + seen.add(key) + merged_tags.append(token) + ctx["tags"] = merged_tags + + # Deduplicate recommended: remove any that are already in partner_tags + partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} + existing_recommended = ctx.get("recommended") or [] + deduplicated_recommended = [ + tag for tag in existing_recommended + if str(tag).strip().casefold() not in partner_tags_lower + ] + ctx["recommended"] = deduplicated_recommended + + reason_map = dict(ctx.get("recommended_reasons") or {}) + for tag in partner_tags: + if tag not in reason_map: + reason_map[tag] = "Synergizes with partner pairing" + ctx["recommended_reasons"] = reason_map + return templates.TemplateResponse("build/_new_deck_tags.html", ctx) + + +# ============================================================================== +# Skip Controls +# ============================================================================== + +@router.post("/new/toggle-skip", response_class=JSONResponse) +async def build_new_toggle_skip( + request: Request, + skip_key: str = Form(...), + enabled: str = Form(...), +) -> JSONResponse: + """Toggle a skip configuration flag (wizard-only, before build starts). + + Enforces mutual exclusivity: + - skip_lands and skip_to_misc are mutually exclusive with individual land flags + - Individual land flags are mutually exclusive with each other + """ + sid = request.cookies.get("sid") or request.headers.get("X-Session-ID") + if not sid: + return JSONResponse({"error": "No session ID"}, status_code=400) + + sess = get_session(sid) + + # Wizard-only: reject if build has started + if "build_ctx" in sess: + return JSONResponse({"error": "Cannot modify skip settings after build has started"}, status_code=400) + + # Validate skip_key + valid_keys = { + "skip_lands", "skip_to_misc", "skip_basics", "skip_staples", + "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes", + "skip_all_creatures", + "skip_creature_primary", "skip_creature_secondary", "skip_creature_fill", + "skip_all_spells", + "skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", + "skip_protection", "skip_spell_fill", + "skip_post_adjust" + } + + if skip_key not in valid_keys: + return JSONResponse({"error": f"Invalid skip key: {skip_key}"}, status_code=400) + + # Parse enabled flag + enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"} + + # Mutual exclusivity rules + land_group_flags = {"skip_lands", "skip_to_misc"} + individual_land_flags = {"skip_basics", "skip_staples", "skip_kindred", "skip_fetches", "skip_duals", "skip_triomes"} + creature_specific_flags = {"skip_creature_primary", "skip_creature_secondary", "skip_creature_fill"} + spell_specific_flags = {"skip_ramp", "skip_removal", "skip_wipes", "skip_card_advantage", "skip_protection", "skip_spell_fill"} + + # If enabling a flag, check for conflicts + if enabled_flag: + # Rule 1: skip_lands/skip_to_misc disables all individual land flags + if skip_key in land_group_flags: + for key in individual_land_flags: + sess[key] = False + + # Rule 2: Individual land flags disable skip_lands/skip_to_misc + elif skip_key in individual_land_flags: + for key in land_group_flags: + sess[key] = False + + # Rule 3: skip_all_creatures disables specific creature flags + elif skip_key == "skip_all_creatures": + for key in creature_specific_flags: + sess[key] = False + + # Rule 4: Specific creature flags disable skip_all_creatures + elif skip_key in creature_specific_flags: + sess["skip_all_creatures"] = False + + # Rule 5: skip_all_spells disables specific spell flags + elif skip_key == "skip_all_spells": + for key in spell_specific_flags: + sess[key] = False + + # Rule 6: Specific spell flags disable skip_all_spells + elif skip_key in spell_specific_flags: + sess["skip_all_spells"] = False + + # Set the requested flag + sess[skip_key] = enabled_flag + + # Auto-enable skip_post_adjust when any other skip is enabled + if enabled_flag and skip_key != "skip_post_adjust": + sess["skip_post_adjust"] = True + + # Auto-disable skip_post_adjust when all other skips are disabled + if not enabled_flag: + any_other_skip = any( + sess.get(k, False) for k in valid_keys + if k != "skip_post_adjust" and k != skip_key + ) + if not any_other_skip: + sess["skip_post_adjust"] = False + + return JSONResponse({ + "success": True, + "skip_key": skip_key, + "enabled": enabled_flag, + "skip_post_adjust": bool(sess.get("skip_post_adjust", False)) + }) + + +# ============================================================================== +# New Deck Submission (Main Handler) +# ============================================================================== + +@router.post("/new", response_class=HTMLResponse) +async def build_new_submit( + request: Request, + background_tasks: BackgroundTasks, + name: str = Form("") , + commander: str = Form(...), + primary_tag: str | None = Form(None), + secondary_tag: str | None = Form(None), + tertiary_tag: str | None = Form(None), + tag_mode: str | None = Form("AND"), + partner_enabled: str | None = Form(None), + secondary_commander: str | None = Form(None), + background: str | None = Form(None), + partner_auto_opt_out: str | None = Form(None), + partner_selection_source: str | None = Form(None), + bracket: int = Form(...), + ramp: int = Form(None), + lands: int = Form(None), + basic_lands: int = Form(None), + creatures: int = Form(None), + removal: int = Form(None), + wipes: int = Form(None), + card_advantage: int = Form(None), + protection: int = Form(None), + prefer_combos: bool = Form(False), + combo_count: int | None = Form(None), + combo_balance: str | None = Form(None), + enable_multicopy: bool = Form(False), + use_owned_only: bool = Form(False), + prefer_owned: bool = Form(False), + swap_mdfc_basics: bool = Form(False), + # Integrated Multi-Copy (optional) + multi_choice_id: str | None = Form(None), + multi_count: int | None = Form(None), + multi_thrumming: str | None = Form(None), + # Must-haves/excludes (optional) + include_cards: str = Form(""), + exclude_cards: str = Form(""), + enforcement_mode: str = Form("warn"), + allow_illegal: bool = Form(False), + fuzzy_matching: bool = Form(True), + # Build count for multi-build + build_count: int = Form(1), + # Quick Build flag + quick_build: str | None = Form(None), +) -> HTMLResponse: + """Handle New Deck modal submit and immediately start the build (skip separate review page).""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + partner_feature_enabled = ENABLE_PARTNER_MECHANICS + raw_partner_flag = (partner_enabled or "").strip().lower() + partner_checkbox = partner_feature_enabled and raw_partner_flag in {"1", "true", "on", "yes"} + initial_secondary = (secondary_commander or "").strip() + initial_background = (background or "").strip() + auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"} + partner_form_state: dict[str, Any] = { + "partner_enabled": bool(partner_checkbox), + "secondary_commander": initial_secondary, + "background": initial_background, + "partner_mode": None, + "partner_auto_note": None, + "partner_warnings": [], + "combined_preview": None, + "partner_auto_assigned": False, + } + + def _form_state(commander_value: str) -> dict[str, Any]: + return { + "name": name, + "commander": commander_value, + "primary_tag": primary_tag or "", + "secondary_tag": secondary_tag or "", + "tertiary_tag": tertiary_tag or "", + "tag_mode": tag_mode or "AND", + "bracket": bracket, + "combo_count": combo_count, + "combo_balance": (combo_balance or "mix"), + "prefer_combos": bool(prefer_combos), + "enable_multicopy": bool(enable_multicopy), + "use_owned_only": bool(use_owned_only), + "prefer_owned": bool(prefer_owned), + "swap_mdfc_basics": bool(swap_mdfc_basics), + "include_cards": include_cards or "", + "exclude_cards": exclude_cards or "", + "enforcement_mode": enforcement_mode or "warn", + "allow_illegal": bool(allow_illegal), + "fuzzy_matching": bool(fuzzy_matching), + "partner_enabled": partner_form_state["partner_enabled"], + "secondary_commander": partner_form_state["secondary_commander"], + "background": partner_form_state["background"], + } + + commander_detail = lookup_commander_detail(commander) + if commander_detail: + eligible_raw = commander_detail.get("eligible_faces") + eligible_faces = [str(face).strip() for face in eligible_raw or [] if str(face).strip()] if isinstance(eligible_raw, list) else [] + if eligible_faces: + norm_input = str(commander).strip().casefold() + eligible_norms = [face.casefold() for face in eligible_faces] + if norm_input not in eligible_norms: + suggested = eligible_faces[0] + primary_face = str(commander_detail.get("primary_face") or commander_detail.get("name") or commander).strip() + faces_str = ", ".join(f"'{face}'" for face in eligible_faces) + error_msg = ( + f"'{primary_face or commander}' can't lead a deck. Use {faces_str} as the commander instead. " + "We've updated the commander field for you." + ) + ctx = { + "request": request, + "error": error_msg, + "brackets": orch.bracket_options(), + "labels": orch.ideal_labels(), + "defaults": orch.ideal_defaults(), + "allow_must_haves": ALLOW_MUST_HAVES, + "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, + "enable_custom_themes": ENABLE_CUSTOM_THEMES, + "enable_batch_build": ENABLE_BATCH_BUILD, + "form": _form_state(suggested), + "tag_slot_html": None, + } + theme_ctx = _custom_theme_context(request, sess, message=error_msg, level="error") + for key, value in theme_ctx.items(): + if key == "request": + continue + ctx[key] = value + resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + # Normalize and validate commander selection (best-effort via orchestrator) + sel = orch.commander_select(commander) + if not sel.get("ok"): + # Re-render modal with error + ctx = { + "request": request, + "error": sel.get("error", "Commander not found"), + "brackets": orch.bracket_options(), + "labels": orch.ideal_labels(), + "defaults": orch.ideal_defaults(), + "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag + "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, + "enable_custom_themes": ENABLE_CUSTOM_THEMES, + "enable_batch_build": ENABLE_BATCH_BUILD, + "form": _form_state(commander), + "tag_slot_html": None, + } + theme_ctx = _custom_theme_context(request, sess, message=ctx["error"], level="error") + for key, value in theme_ctx.items(): + if key == "request": + continue + ctx[key] = value + resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + primary_commander_name = sel.get("name") or commander + # Enforce GC bracket restriction before saving session (silently coerce to 3) + try: + is_gc = bool(primary_commander_name in getattr(bc, 'GAME_CHANGERS', [])) + except Exception: + is_gc = False + if is_gc: + try: + if int(bracket) < 3: + bracket = 3 + except Exception: + bracket = 3 + # Save to session + sess["commander"] = primary_commander_name + ( + partner_error, + combined_payload, + partner_warnings, + partner_auto_note, + resolved_secondary, + resolved_background, + partner_mode, + partner_auto_assigned_flag, + ) = _resolve_partner_selection( + primary_commander_name, + feature_enabled=partner_feature_enabled, + partner_enabled=partner_checkbox, + secondary_candidate=secondary_commander, + background_candidate=background, + auto_opt_out=auto_opt_out_flag, + selection_source=partner_selection_source, + ) + + partner_form_state["partner_mode"] = partner_mode + partner_form_state["partner_auto_note"] = partner_auto_note + partner_form_state["partner_warnings"] = partner_warnings + partner_form_state["combined_preview"] = combined_payload + if resolved_secondary: + partner_form_state["secondary_commander"] = resolved_secondary + if resolved_background: + partner_form_state["background"] = resolved_background + partner_form_state["partner_auto_assigned"] = bool(partner_auto_assigned_flag) + + combined_theme_pool: list[str] = [] + if isinstance(combined_payload, dict): + raw_tags = combined_payload.get("theme_tags") or [] + for tag in raw_tags: + token = str(tag).strip() + if not token: + continue + if token not in combined_theme_pool: + combined_theme_pool.append(token) + + if partner_error: + available_tags = orch.tags_for_commander(primary_commander_name) + recommended_tags = orch.recommended_tags_for_commander(primary_commander_name) + recommended_reasons = orch.recommended_tag_reasons_for_commander(primary_commander_name) + inspect_ctx: dict[str, Any] = { + "request": request, + "commander": {"name": primary_commander_name, "exclusion": lookup_commander_detail(primary_commander_name)}, + "tags": available_tags, + "recommended": recommended_tags, + "recommended_reasons": recommended_reasons, + "gc_commander": is_gc, + "brackets": orch.bracket_options(), + } + inspect_ctx.update( + _partner_ui_context( + primary_commander_name, + partner_enabled=partner_checkbox, + secondary_selection=partner_form_state["secondary_commander"] or None, + background_selection=partner_form_state["background"] or None, + combined_preview=combined_payload, + warnings=partner_warnings, + partner_error=partner_error, + auto_note=partner_auto_note, + auto_assigned=partner_form_state["partner_auto_assigned"], + auto_prefill_allowed=not auto_opt_out_flag, + ) + ) + partner_tags = inspect_ctx.pop("partner_theme_tags", None) + if partner_tags: + inspect_ctx["tags"] = partner_tags + tag_slot_html = templates.get_template("build/_new_deck_tags.html").render(inspect_ctx) + ctx = { + "request": request, + "error": partner_error, + "brackets": orch.bracket_options(), + "labels": orch.ideal_labels(), + "defaults": orch.ideal_defaults(), + "allow_must_haves": ALLOW_MUST_HAVES, + "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, + "enable_custom_themes": ENABLE_CUSTOM_THEMES, + "enable_batch_build": ENABLE_BATCH_BUILD, + "form": _form_state(primary_commander_name), + "tag_slot_html": tag_slot_html, + } + theme_ctx = _custom_theme_context(request, sess, message=partner_error, level="error") + for key, value in theme_ctx.items(): + if key == "request": + continue + ctx[key] = value + resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + if partner_checkbox and combined_payload: + sess["partner_enabled"] = True + if resolved_secondary: + sess["secondary_commander"] = resolved_secondary + else: + sess.pop("secondary_commander", None) + if resolved_background: + sess["background"] = resolved_background + else: + sess.pop("background", None) + if partner_mode: + sess["partner_mode"] = partner_mode + else: + sess.pop("partner_mode", None) + sess["combined_commander"] = combined_payload + sess["partner_warnings"] = partner_warnings + if partner_auto_note: + sess["partner_auto_note"] = partner_auto_note + else: + sess.pop("partner_auto_note", None) + sess["partner_auto_assigned"] = bool(partner_auto_assigned_flag) + sess["partner_auto_opt_out"] = bool(auto_opt_out_flag) + else: + sess["partner_enabled"] = False + for key in [ + "secondary_commander", + "background", + "partner_mode", + "partner_warnings", + "combined_commander", + "partner_auto_note", + ]: + try: + sess.pop(key) + except KeyError: + pass + for key in ["partner_auto_assigned", "partner_auto_opt_out"]: + try: + sess.pop(key) + except KeyError: + pass + + # 1) Start from explicitly selected tags (order preserved) + tags = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] + user_explicit = bool(tags) # whether the user set any theme in the form + # 2) Consider user-added supplemental themes from the Additional Themes UI + additional_from_session = [] + try: + # custom_theme_manager stores resolved list here on add/resolve; present before submit + additional_from_session = [ + str(x) for x in (sess.get("additional_themes") or []) if isinstance(x, str) and x.strip() + ] + except Exception: + additional_from_session = [] + # 3) If no explicit themes were selected, prefer additional themes as primary/secondary/tertiary + if not user_explicit and additional_from_session: + # Cap to three and preserve order + tags = list(additional_from_session[:3]) + # 4) If user selected some themes, fill remaining slots with additional themes (deduping) + elif user_explicit and additional_from_session: + seen = {str(t).strip().casefold() for t in tags} + for name in additional_from_session: + key = name.strip().casefold() + if key in seen: + continue + tags.append(name) + seen.add(key) + if len(tags) >= 3: + break + # 5) If still empty (no explicit and no additional), fall back to commander-recommended default + if not tags: + if combined_theme_pool: + tags = combined_theme_pool[:3] + else: + try: + rec = orch.recommended_tags_for_commander(sess["commander"]) or [] + if rec: + tags = [rec[0]] + except Exception: + pass + sess["tags"] = tags + sess["tag_mode"] = (tag_mode or "AND").upper() + try: + # Default to bracket 3 (Upgraded) when not provided + sess["bracket"] = int(bracket) if (bracket is not None) else 3 + except Exception: + try: + sess["bracket"] = int(bracket) + except Exception: + sess["bracket"] = 3 + # Ideals: use provided values if any, else defaults + ideals = orch.ideal_defaults() + overrides = {k: v for k, v in { + "ramp": ramp, + "lands": lands, + "basic_lands": basic_lands, + "creatures": creatures, + "removal": removal, + "wipes": wipes, + "card_advantage": card_advantage, + "protection": protection, + }.items() if v is not None} + for k, v in overrides.items(): + try: + ideals[k] = int(v) + except Exception: + pass + sess["ideals"] = ideals + if ENABLE_CUSTOM_THEMES: + try: + theme_mgr.refresh_resolution( + sess, + commander_tags=tags, + mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE), + ) + except ValueError as exc: + error_msg = str(exc) + ctx = { + "request": request, + "error": error_msg, + "brackets": orch.bracket_options(), + "labels": orch.ideal_labels(), + "defaults": orch.ideal_defaults(), + "allow_must_haves": ALLOW_MUST_HAVES, + "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, + "enable_custom_themes": ENABLE_CUSTOM_THEMES, + "enable_batch_build": ENABLE_BATCH_BUILD, + "form": _form_state(sess.get("commander", "")), + "tag_slot_html": None, + } + theme_ctx = _custom_theme_context(request, sess, message=error_msg, level="error") + for key, value in theme_ctx.items(): + if key == "request": + continue + ctx[key] = value + resp = templates.TemplateResponse("build/_new_deck_modal.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + # Persist preferences + try: + sess["prefer_combos"] = bool(prefer_combos) + except Exception: + sess["prefer_combos"] = False + try: + sess["use_owned_only"] = bool(use_owned_only) + except Exception: + sess["use_owned_only"] = False + try: + sess["prefer_owned"] = bool(prefer_owned) + except Exception: + sess["prefer_owned"] = False + try: + sess["swap_mdfc_basics"] = bool(swap_mdfc_basics) + except Exception: + sess["swap_mdfc_basics"] = False + # Combos config from modal + try: + if combo_count is not None: + sess["combo_target_count"] = max(0, min(10, int(combo_count))) + except Exception: + pass + try: + if combo_balance: + bval = str(combo_balance).strip().lower() + if bval in ("early","late","mix"): + sess["combo_balance"] = bval + except Exception: + pass + # Multi-Copy selection from modal (opt-in) + try: + # Clear any prior selection first; this flow should define it explicitly when present + if "multi_copy" in sess: + del sess["multi_copy"] + if enable_multicopy and multi_choice_id and str(multi_choice_id).strip(): + meta = bc.MULTI_COPY_ARCHETYPES.get(str(multi_choice_id), {}) + printed_cap = meta.get("printed_cap") + cnt: int + if multi_count is None: + cnt = int(meta.get("default_count", 25)) + else: + try: + cnt = int(multi_count) + except Exception: + cnt = int(meta.get("default_count", 25)) + if isinstance(printed_cap, int) and printed_cap > 0: + cnt = max(1, min(printed_cap, cnt)) + sess["multi_copy"] = { + "id": str(multi_choice_id), + "name": meta.get("name") or str(multi_choice_id), + "count": int(cnt), + "thrumming": True if (multi_thrumming and str(multi_thrumming).strip() in ("1","true","on","yes")) else False, + } + else: + # Ensure disabled when not opted-in + if "multi_copy" in sess: + del sess["multi_copy"] + # Reset the applied marker so the run can account for the new selection + if "mc_applied_key" in sess: + del sess["mc_applied_key"] + except Exception: + pass + + # Process include/exclude cards (M3: Phase 2 - Full Include/Exclude) + try: + from deck_builder.include_exclude_utils import parse_card_list_input, IncludeExcludeDiagnostics + + # Clear any old include/exclude data + for k in ["include_cards", "exclude_cards", "include_exclude_diagnostics", "enforcement_mode", "allow_illegal", "fuzzy_matching"]: + if k in sess: + del sess[k] + + # Process include cards + if include_cards and include_cards.strip(): + print(f"DEBUG: Raw include_cards input: '{include_cards}'") + include_list = parse_card_list_input(include_cards.strip()) + print(f"DEBUG: Parsed include_list: {include_list}") + sess["include_cards"] = include_list + else: + print(f"DEBUG: include_cards is empty or None: '{include_cards}'") + + # Process exclude cards + if exclude_cards and exclude_cards.strip(): + print(f"DEBUG: Raw exclude_cards input: '{exclude_cards}'") + exclude_list = parse_card_list_input(exclude_cards.strip()) + print(f"DEBUG: Parsed exclude_list: {exclude_list}") + sess["exclude_cards"] = exclude_list + else: + print(f"DEBUG: exclude_cards is empty or None: '{exclude_cards}'") + + # Store advanced options + sess["enforcement_mode"] = enforcement_mode + sess["allow_illegal"] = allow_illegal + sess["fuzzy_matching"] = fuzzy_matching + + # Create basic diagnostics for status tracking + if (include_cards and include_cards.strip()) or (exclude_cards and exclude_cards.strip()): + diagnostics = IncludeExcludeDiagnostics( + missing_includes=[], + ignored_color_identity=[], + illegal_dropped=[], + illegal_allowed=[], + excluded_removed=sess.get("exclude_cards", []), + duplicates_collapsed={}, + include_added=[], + include_over_ideal={}, + fuzzy_corrections={}, + confirmation_needed=[], + list_size_warnings={ + "includes_count": len(sess.get("include_cards", [])), + "excludes_count": len(sess.get("exclude_cards", [])), + "includes_limit": 10, + "excludes_limit": 15 + } + ) + sess["include_exclude_diagnostics"] = diagnostics.__dict__ + except Exception as e: + # If exclude parsing fails, log but don't block the build + import logging + logging.warning(f"Failed to parse exclude cards: {e}") + + # Clear any old staged build context + for k in ["build_ctx", "locks", "replace_mode"]: + if k in sess: + try: + del sess[k] + except Exception: + pass + # Reset multi-copy suggestion debounce for a fresh run (keep selected choice) + if "mc_seen_keys" in sess: + try: + del sess["mc_seen_keys"] + except Exception: + pass + # Persist optional custom export base name + if isinstance(name, str) and name.strip(): + sess["custom_export_base"] = name.strip() + else: + if "custom_export_base" in sess: + try: + del sess["custom_export_base"] + except Exception: + pass + # If setup/tagging is not ready or stale, show a modal prompt instead of auto-running. + try: + if not _is_setup_ready(): + return templates.TemplateResponse( + "build/_setup_prompt_modal.html", + { + "request": request, + "title": "Setup required", + "message": "The card database and tags need to be prepared before building a deck.", + "action_url": "/setup/running?start=1&next=/build", + "action_label": "Run Setup", + }, + ) + if _is_setup_stale(): + return templates.TemplateResponse( + "build/_setup_prompt_modal.html", + { + "request": request, + "title": "Data refresh recommended", + "message": "Your card database is stale. Refreshing ensures up-to-date results.", + "action_url": "/setup/running?start=1&force=1&next=/build", + "action_label": "Refresh Now", + }, + ) + except Exception: + # If readiness check fails, continue and let downstream handling surface errors + pass + # Immediately initialize a build context and run the first stage, like hitting Build Deck on review + if "replace_mode" not in sess: + sess["replace_mode"] = True + # Centralized staged context creation + sess["build_ctx"] = start_ctx_from_session(sess) + + # Validate and normalize build_count + try: + build_count = max(1, min(10, int(build_count))) + except Exception: + build_count = 1 + + # Check if this is a multi-build request (build_count > 1) + if build_count > 1: + # Multi-Build: Queue parallel builds and return batch progress page + from ..services.multi_build_orchestrator import queue_builds, run_batch_async + + # Create config dict from session for batch builds + batch_config = { + "commander": sess.get("commander"), + "tags": sess.get("tags", []), + "tag_mode": sess.get("tag_mode", "AND"), + "bracket": sess.get("bracket", 3), + "ideals": sess.get("ideals", {}), + "prefer_combos": sess.get("prefer_combos", False), + "combo_target_count": sess.get("combo_target_count"), + "combo_balance": sess.get("combo_balance"), + "multi_copy": sess.get("multi_copy"), + "use_owned_only": sess.get("use_owned_only", False), + "prefer_owned": sess.get("prefer_owned", False), + "swap_mdfc_basics": sess.get("swap_mdfc_basics", False), + "include_cards": sess.get("include_cards", []), + "exclude_cards": sess.get("exclude_cards", []), + "enforcement_mode": sess.get("enforcement_mode", "warn"), + "allow_illegal": sess.get("allow_illegal", False), + "fuzzy_matching": sess.get("fuzzy_matching", True), + "locks": list(sess.get("locks", [])), + } + + # Handle partner mechanics if present + if sess.get("partner_enabled"): + batch_config["partner_enabled"] = True + if sess.get("secondary_commander"): + batch_config["secondary_commander"] = sess["secondary_commander"] + if sess.get("background"): + batch_config["background"] = sess["background"] + if sess.get("partner_mode"): + batch_config["partner_mode"] = sess["partner_mode"] + if sess.get("combined_commander"): + batch_config["combined_commander"] = sess["combined_commander"] + + # Add color identity for synergy builder (needed for basic land allocation) + try: + tmp_builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) + + # Handle partner mechanics if present + if sess.get("partner_enabled") and sess.get("secondary_commander"): + from deck_builder.partner_selection import apply_partner_inputs + combined_obj = apply_partner_inputs( + tmp_builder, + primary_name=sess["commander"], + secondary_name=sess.get("secondary_commander"), + background_name=sess.get("background"), + feature_enabled=True, + ) + if combined_obj and hasattr(combined_obj, "color_identity"): + batch_config["colors"] = list(combined_obj.color_identity) + else: + # Single commander + df = tmp_builder.load_commander_data() + row = df[df["name"] == sess["commander"]] + if not row.empty: + # Get colorIdentity from dataframe (it's a string like "RG" or "G") + color_str = row.iloc[0].get("colorIdentity", "") + if color_str: + batch_config["colors"] = list(color_str) # Convert "RG" to ['R', 'G'] + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"[Batch] Failed to load color identity for {sess.get('commander')}: {e}") + pass # Not critical, synergy builder will skip basics if missing + + # Queue the batch + batch_id = queue_builds(batch_config, build_count, sid) + + # Start background task for parallel builds + background_tasks.add_task(run_batch_async, batch_id, sid) + + # Return batch progress template + progress_ctx = { + "request": request, + "batch_id": batch_id, + "build_count": build_count, + "completed": 0, + "current_build": 1, + "status": "Starting builds..." + } + resp = templates.TemplateResponse("build/_batch_progress.html", progress_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + # Check if Quick Build was requested (single build only) + is_quick_build = (quick_build or "").strip() == "1" + + if is_quick_build: + # Quick Build: Start background task and return progress template immediately + ctx = sess["build_ctx"] + + # Initialize progress tracking with dynamic counting (total starts at 0) + sess["quick_build_progress"] = { + "running": True, + "total": 0, + "completed": 0, + "current_stage": "Starting build..." + } + + # Start background task to run all stages + background_tasks.add_task(_run_quick_build_stages, sid) + + # Return progress template immediately + progress_ctx = { + "request": request, + "progress_pct": 0, + "completed": 0, + "total": 0, + "current_stage": "Starting build..." + } + resp = templates.TemplateResponse("build/_quick_build_progress.html", progress_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + else: + # Normal build: Run first stage and wait for user input + res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=False) + # If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue + try: + if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): + mc = sess.get("multi_copy") + sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" + except Exception: + pass + status = "Build complete" if res.get("done") else "Stage complete" + sess["last_step"] = 5 + ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=False) + resp = templates.TemplateResponse("build/_step5.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +# ============================================================================== +# Quick Build Progress Polling +# ============================================================================== + +def _get_descriptive_stage_label(stage: Dict[str, Any], ctx: Dict[str, Any]) -> str: + """Generate a more descriptive label for Quick Build progress display.""" + key = stage.get("key", "") + base_label = stage.get("label", "") + + # Land stages - show what type of lands + land_types = { + "land1": "Basics", + "land2": "Staples", + "land3": "Fetches", + "land4": "Duals", + "land5": "Triomes", + "land6": "Kindred", + "land7": "Misc Utility", + "land8": "Final Lands" + } + if key in land_types: + return f"Lands: {land_types[key]}" + + # Creature stages - show associated theme + if "creatures" in key: + tags = ctx.get("tags", []) + if key == "creatures_all_theme": + if tags: + all_tags = " + ".join(tags[:3]) # Show up to 3 tags + return f"Creatures: All Themes ({all_tags})" + return "Creatures: All Themes" + elif key == "creatures_primary" and len(tags) >= 1: + return f"Creatures: {tags[0]}" + elif key == "creatures_secondary" and len(tags) >= 2: + return f"Creatures: {tags[1]}" + elif key == "creatures_tertiary" and len(tags) >= 3: + return f"Creatures: {tags[2]}" + # Let creatures_fill use default "Creatures: Fill" label + + # Theme spell fill stage - adds any card type (artifacts, enchantments, instants, etc.) that fits theme + if key == "spells_fill": + return "Theme Spell Fill" + + # Default: return original label + return base_label + + +def _run_quick_build_stages(sid: str): + """Background task: Run all stages for Quick Build and update progress in session.""" + import logging + logger = logging.getLogger(__name__) + + logger.info(f"[Quick Build] Starting background task for sid={sid}") + + sess = get_session(sid) + logger.info(f"[Quick Build] Retrieved session: {sess is not None}") + + ctx = sess.get("build_ctx") + if not ctx: + logger.error("[Quick Build] No build_ctx found in session") + sess["quick_build_progress"] = { + "running": False, + "current_stage": "Error: No build context", + "completed_stages": [] + } + return + + logger.info(f"[Quick Build] build_ctx found with {len(ctx.get('stages', []))} stages") + + # CRITICAL: Inject session reference into context so skip config can be read + ctx["session"] = sess + logger.info("[Quick Build] Injected session reference into context") + + stages = ctx.get("stages", []) + res = None + + # Initialize progress tracking + sess["quick_build_progress"] = { + "running": True, + "current_stage": "Starting build..." + } + + try: + logger.info("[Quick Build] Starting stage loop") + + # Track which phase we're in for simplified progress display + current_phase = None + + while True: + current_idx = ctx.get("idx", 0) + if current_idx >= len(stages): + logger.info(f"[Quick Build] Reached end of stages (idx={current_idx})") + break + + current_stage = stages[current_idx] + stage_key = current_stage.get("key", "") + logger.info(f"[Quick Build] Stage {current_idx} key: {stage_key}") + + # Determine simplified phase label + if stage_key.startswith("creatures"): + new_phase = "Adding Creatures" + elif stage_key.startswith("spells") or stage_key in ["spells_ramp", "spells_removal", "spells_wipes", "spells_card_advantage", "spells_protection", "spells_fill"]: + new_phase = "Adding Spells" + elif stage_key.startswith("land"): + new_phase = "Adding Lands" + elif stage_key in ["post_spell_land_adjust", "reporting"]: + new_phase = "Doing Some Final Touches" + else: + new_phase = "Building Deck" + + # Only update progress if phase changed + if new_phase != current_phase: + current_phase = new_phase + sess["quick_build_progress"]["current_stage"] = current_phase + logger.info(f"[Quick Build] Phase: {current_phase}") + + # Run stage with show_skipped=False + res = orch.run_stage(ctx, rerun=False, show_skipped=False) + logger.info(f"[Quick Build] Stage {stage_key} completed, done={res.get('done')}") + + # Handle Multi-Copy package marking + try: + if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): + mc = sess.get("multi_copy") + sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" + except Exception: + pass + + # Check if build is done (reporting stage marks done=True) + if res.get("done"): + break + + # run_stage() advances ctx["idx"] internally when stage completes successfully + # If stage is gated, it also advances the index, so we just continue the loop + + # Show summary generation message (stay here for a moment) + sess["quick_build_progress"]["current_stage"] = "Generating Summary" + import time + time.sleep(2) # Pause briefly so user sees this stage + + # Store final result for polling endpoint + sess["last_result"] = res or {} + sess["last_step"] = 5 + + # CRITICAL: Persist summary to session (bug fix from Phase 3) + if res and res.get("summary"): + sess["summary"] = res["summary"] + + # Small delay to show finishing message + time.sleep(1.5) + + except Exception as e: + # Store error state + logger.exception(f"[Quick Build] Error during stage execution: {e}") + sess["quick_build_progress"]["current_stage"] = f"Error: {str(e)}" + finally: + # Mark build as complete + logger.info("[Quick Build] Background task completed") + sess["quick_build_progress"]["running"] = False + sess["quick_build_progress"]["current_stage"] = "Complete" + + +@router.get("/quick-progress") +def quick_build_progress(request: Request): + """Poll endpoint for Quick Build progress. Returns either progress indicator or final Step 5.""" + import logging + logger = logging.getLogger(__name__) + + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + progress = sess.get("quick_build_progress") + logger.info(f"[Progress Poll] sid={sid}, progress={progress is not None}, running={progress.get('running') if progress else None}") + + if not progress or not progress.get("running"): + # Build complete - return Step 5 content that replaces the entire wizard container + res = sess.get("last_result") + if res and res.get("done"): + ctx = step5_ctx_from_result(request, sess, res) + # Return Step 5 which will replace the whole wizard div + response = templates.TemplateResponse("build/_step5.html", ctx) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + # Tell HTMX to target #wizard and swap outerHTML to replace the container + response.headers["HX-Retarget"] = "#wizard" + response.headers["HX-Reswap"] = "outerHTML" + return response + # Fallback if no result yet + return HTMLResponse('Build complete. Please refresh.') + + # Build still running - return progress content partial only (innerHTML swap) + current_stage = progress.get("current_stage", "Processing...") + + ctx = { + "request": request, + "current_stage": current_stage + } + response = templates.TemplateResponse("build/_quick_build_progress_content.html", ctx) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response diff --git a/code/web/routes/build_partners.py b/code/web/routes/build_partners.py new file mode 100644 index 0000000..118a3c2 --- /dev/null +++ b/code/web/routes/build_partners.py @@ -0,0 +1,737 @@ +""" +Partner mechanics routes and utilities for deck building. + +Handles partner commanders, backgrounds, Doctor/Companion pairings, +and partner preview/validation functionality. +""" + +from __future__ import annotations + +from typing import Any, Iterable +from urllib.parse import quote_plus +from fastapi import APIRouter, Request, Form +from fastapi.responses import JSONResponse + +from ..app import ( + ENABLE_PARTNER_MECHANICS, +) +from ..services.telemetry import log_partner_suggestion_selected +from ..services.partner_suggestions import get_partner_suggestions +from ..services.commander_catalog_loader import ( + load_commander_catalog, + find_commander_record, + CommanderRecord, + normalized_restricted_labels, + shared_restricted_partner_label, +) +from deck_builder.background_loader import load_background_cards +from deck_builder.partner_selection import apply_partner_inputs +from deck_builder.builder import DeckBuilder +from exceptions import CommanderPartnerError +from code.logging_util import get_logger + + +LOGGER = get_logger(__name__) +router = APIRouter() + + +_PARTNER_MODE_LABELS = { + "partner": "Partner", + "partner_restricted": "Partner (Restricted)", + "partner_with": "Partner With", + "background": "Choose a Background", + "doctor_companion": "Doctor & Companion", +} + + +_WUBRG_ORDER = ["W", "U", "B", "R", "G"] +_COLOR_NAME_MAP = { + "W": "White", + "U": "Blue", + "B": "Black", + "R": "Red", + "G": "Green", +} + + +def _color_code(identity: Iterable[str]) -> str: + """Convert color identity to standard WUBRG-ordered code.""" + colors = [str(c).strip().upper() for c in identity if str(c).strip()] + if not colors: + return "C" + ordered: list[str] = [c for c in _WUBRG_ORDER if c in colors] + for color in colors: + if color not in ordered: + ordered.append(color) + return "".join(ordered) or "C" + + +def _format_color_label(identity: Iterable[str]) -> str: + """Format color identity as human-readable label with code.""" + code = _color_code(identity) + if code == "C": + return "Colorless (C)" + names = [_COLOR_NAME_MAP.get(ch, ch) for ch in code] + return " / ".join(names) + f" ({code})" + + +def _partner_mode_label(mode: str | None) -> str: + """Convert partner mode to display label.""" + if not mode: + return "Partner Mechanics" + return _PARTNER_MODE_LABELS.get(mode, mode.title()) + + +def _scryfall_image_url(card_name: str, version: str = "normal") -> str | None: + """Generate Scryfall image URL for card.""" + name = str(card_name or "").strip() + if not name: + return None + return f"https://api.scryfall.com/cards/named?fuzzy={quote_plus(name)}&format=image&version={version}" + + +def _scryfall_page_url(card_name: str) -> str | None: + """Generate Scryfall search URL for card.""" + name = str(card_name or "").strip() + if not name: + return None + return f"https://scryfall.com/search?q={quote_plus(name)}" + + +def _secondary_role_label(mode: str | None, secondary_name: str | None) -> str | None: + """Determine the role label for the secondary commander based on pairing mode.""" + if not mode: + return None + mode_lower = mode.lower() + if mode_lower == "background": + return "Background" + if mode_lower == "partner_with": + return "Partner With" + if mode_lower == "doctor_companion": + record = find_commander_record(secondary_name or "") if secondary_name else None + if record and getattr(record, "is_doctor", False): + return "Doctor" + if record and getattr(record, "is_doctors_companion", False): + return "Doctor's Companion" + return "Doctor pairing" + return "Partner commander" + + +def _combined_to_payload(combined: Any) -> dict[str, Any]: + """Convert CombinedCommander object to JSON-serializable payload.""" + color_identity = tuple(getattr(combined, "color_identity", ()) or ()) + warnings = list(getattr(combined, "warnings", []) or []) + mode_obj = getattr(combined, "partner_mode", None) + mode_value = getattr(mode_obj, "value", None) if mode_obj is not None else None + secondary = getattr(combined, "secondary_name", None) + secondary_image = _scryfall_image_url(secondary) + secondary_url = _scryfall_page_url(secondary) + secondary_role = _secondary_role_label(mode_value, secondary) + return { + "primary_name": getattr(combined, "primary_name", None), + "secondary_name": secondary, + "partner_mode": mode_value, + "partner_mode_label": _partner_mode_label(mode_value), + "color_identity": list(color_identity), + "color_code": _color_code(color_identity), + "color_label": _format_color_label(color_identity), + "theme_tags": list(getattr(combined, "theme_tags", []) or []), + "warnings": warnings, + "secondary_image_url": secondary_image, + "secondary_scryfall_url": secondary_url, + "secondary_role_label": secondary_role, + } + + +def _build_partner_options(primary: CommanderRecord | None) -> tuple[list[dict[str, Any]], str | None]: + """ + Build list of valid partner options for a given primary commander. + + Returns: + Tuple of (partner_options_list, variant_type) where variant is + "partner", "doctor_companion", or None + """ + if not ENABLE_PARTNER_MECHANICS: + return [], None + try: + catalog = load_commander_catalog() + except Exception: + return [], None + + if primary is None: + return [], None + + primary_name = primary.display_name.casefold() + primary_partner_targets = {target.casefold() for target in (primary.partner_with or ())} + primary_is_partner = bool(primary.is_partner or primary_partner_targets) + primary_restricted_labels = normalized_restricted_labels(primary) + primary_is_doctor = bool(primary.is_doctor) + primary_is_companion = bool(primary.is_doctors_companion) + + variant: str | None = None + if primary_is_doctor or primary_is_companion: + variant = "doctor_companion" + elif primary_is_partner: + variant = "partner" + + options: list[dict[str, Any]] = [] + if variant is None: + return [], None + + for record in catalog.entries: + if record.display_name.casefold() == primary_name: + continue + + pairing_mode: str | None = None + role_label: str | None = None + restriction_label: str | None = None + record_name_cf = record.display_name.casefold() + is_direct_pair = bool(primary_partner_targets and record_name_cf in primary_partner_targets) + + if variant == "doctor_companion": + if is_direct_pair: + pairing_mode = "partner_with" + role_label = "Partner With" + elif primary_is_doctor and record.is_doctors_companion: + pairing_mode = "doctor_companion" + role_label = "Doctor's Companion" + elif primary_is_companion and record.is_doctor: + pairing_mode = "doctor_companion" + role_label = "Doctor" + else: + if not record.is_partner or record.is_background: + continue + if primary_partner_targets: + if not is_direct_pair: + continue + pairing_mode = "partner_with" + role_label = "Partner With" + elif primary_restricted_labels: + restriction = shared_restricted_partner_label(primary, record) + if not restriction: + continue + pairing_mode = "partner_restricted" + restriction_label = restriction + else: + if record.partner_with: + continue + if not getattr(record, "has_plain_partner", False): + continue + if record.is_doctors_companion: + continue + pairing_mode = "partner" + + if not pairing_mode: + continue + + options.append( + { + "name": record.display_name, + "color_code": _color_code(record.color_identity), + "color_label": _format_color_label(record.color_identity), + "partner_with": list(record.partner_with or ()), + "pairing_mode": pairing_mode, + "role_label": role_label, + "restriction_label": restriction_label, + "mode_label": _partner_mode_label(pairing_mode), + "image_url": _scryfall_image_url(record.display_name), + "scryfall_url": _scryfall_page_url(record.display_name), + } + ) + + options.sort(key=lambda item: item["name"].casefold()) + return options, variant + + +def _build_background_options() -> list[dict[str, Any]]: + """Build list of available background cards for Choose a Background commanders.""" + if not ENABLE_PARTNER_MECHANICS: + return [] + + options: list[dict[str, Any]] = [] + try: + catalog = load_background_cards() + except FileNotFoundError as exc: + LOGGER.warning("background_cards_missing fallback_to_commander_catalog", extra={"error": str(exc)}) + catalog = None + except Exception as exc: # pragma: no cover - unexpected loader failure + LOGGER.warning("background_cards_failed fallback_to_commander_catalog", exc_info=exc) + catalog = None + + if catalog and getattr(catalog, "entries", None): + seen: set[str] = set() + for card in catalog.entries: + name_key = card.display_name.casefold() + if name_key in seen: + continue + seen.add(name_key) + options.append( + { + "name": card.display_name, + "color_code": _color_code(card.color_identity), + "color_label": _format_color_label(card.color_identity), + "image_url": _scryfall_image_url(card.display_name), + "scryfall_url": _scryfall_page_url(card.display_name), + "role_label": "Background", + } + ) + if options: + options.sort(key=lambda item: item["name"].casefold()) + return options + + fallback_options = _background_options_from_commander_catalog() + if fallback_options: + return fallback_options + return options + + +def _background_options_from_commander_catalog() -> list[dict[str, Any]]: + """Fallback: load backgrounds from commander catalog when background_cards.json is unavailable.""" + try: + catalog = load_commander_catalog() + except Exception as exc: # pragma: no cover - catalog load issues handled elsewhere + LOGGER.warning("commander_catalog_background_fallback_failed", exc_info=exc) + return [] + + seen: set[str] = set() + options: list[dict[str, Any]] = [] + for record in getattr(catalog, "entries", ()): + if not getattr(record, "is_background", False): + continue + name = getattr(record, "display_name", None) + if not name: + continue + key = str(name).casefold() + if key in seen: + continue + seen.add(key) + color_identity = getattr(record, "color_identity", tuple()) + options.append( + { + "name": name, + "color_code": _color_code(color_identity), + "color_label": _format_color_label(color_identity), + "image_url": _scryfall_image_url(name), + "scryfall_url": _scryfall_page_url(name), + "role_label": "Background", + } + ) + + options.sort(key=lambda item: item["name"].casefold()) + return options + + +def _partner_ui_context( + commander_name: str, + *, + partner_enabled: bool, + secondary_selection: str | None, + background_selection: str | None, + combined_preview: dict[str, Any] | None, + warnings: Iterable[str] | None, + partner_error: str | None, + auto_note: str | None, + auto_assigned: bool | None = None, + auto_prefill_allowed: bool = True, +) -> dict[str, Any]: + """ + Build complete partner UI context for rendering partner selection components. + + This includes partner options, background options, preview payload, + suggestions, warnings, and all necessary state for the partner UI. + """ + record = find_commander_record(commander_name) + partner_options, partner_variant = _build_partner_options(record) + supports_backgrounds = bool(record.supports_backgrounds) if record else False + background_options = _build_background_options() if supports_backgrounds else [] + + selected_secondary = (secondary_selection or "").strip() + selected_background = (background_selection or "").strip() + warnings_list = list(warnings or []) + preview_payload: dict[str, Any] | None = combined_preview if isinstance(combined_preview, dict) else None + preview_error: str | None = None + + auto_prefill_applied = False + auto_default_name: str | None = None + auto_note_value = auto_note + + # Auto-prefill Partner With targets + if ( + ENABLE_PARTNER_MECHANICS + and partner_variant == "partner" + and record + and record.partner_with + and not selected_secondary + and not selected_background + and auto_prefill_allowed + ): + target_names = [name.strip() for name in record.partner_with if str(name).strip()] + for target in target_names: + for option in partner_options: + if option["name"].casefold() == target.casefold(): + selected_secondary = option["name"] + auto_default_name = option["name"] + auto_prefill_applied = True + if not auto_note_value: + auto_note_value = f"Automatically paired with {option['name']} (Partner With)." + break + if auto_prefill_applied: + break + + partner_active = bool((selected_secondary or selected_background) and ENABLE_PARTNER_MECHANICS) + partner_capable = bool(ENABLE_PARTNER_MECHANICS and (partner_options or background_options)) + + # Dynamic labels based on variant + placeholder = "Select a partner" + select_label = "Partner commander" + role_hint: str | None = None + if partner_variant == "doctor_companion" and record: + has_partner_with_option = any(option.get("pairing_mode") == "partner_with" for option in partner_options) + if record.is_doctor: + if has_partner_with_option: + placeholder = "Select a companion or Partner With match" + select_label = "Companion or Partner" + role_hint = "Choose a Doctor's Companion or Partner With match for this Doctor." + else: + placeholder = "Select a companion" + select_label = "Companion" + role_hint = "Choose a Doctor's Companion to pair with this Doctor." + elif record.is_doctors_companion: + if has_partner_with_option: + placeholder = "Select a Doctor or Partner With match" + select_label = "Doctor or Partner" + role_hint = "Choose a Doctor or Partner With pairing for this companion." + else: + placeholder = "Select a Doctor" + select_label = "Doctor partner" + role_hint = "Choose a Doctor to accompany this companion." + + # Partner suggestions + suggestions_enabled = bool(ENABLE_PARTNER_MECHANICS) + suggestions_visible: list[dict[str, Any]] = [] + suggestions_hidden: list[dict[str, Any]] = [] + suggestions_total = 0 + suggestions_metadata: dict[str, Any] = {} + suggestions_error: str | None = None + suggestions_loaded = False + + if suggestions_enabled and record: + try: + suggestion_result = get_partner_suggestions(record.display_name) + except Exception as exc: # pragma: no cover - defensive logging + LOGGER.warning("partner suggestions failed", exc_info=exc) + suggestion_result = None + if suggestion_result is None: + suggestions_error = "Partner suggestions dataset is unavailable." + else: + suggestions_loaded = True + partner_names = [opt.get("name") for opt in (partner_options or []) if opt.get("name")] + background_names = [opt.get("name") for opt in (background_options or []) if opt.get("name")] + try: + visible, hidden = suggestion_result.flatten(partner_names, background_names, visible_limit=3) + except Exception as exc: # pragma: no cover - defensive + LOGGER.warning("partner suggestions flatten failed", exc_info=exc) + visible = [] + hidden = [] + suggestions_visible = visible + suggestions_hidden = hidden + suggestions_total = suggestion_result.total + if isinstance(suggestion_result.metadata, dict): + suggestions_metadata = dict(suggestion_result.metadata) + + context = { + "partner_feature_available": ENABLE_PARTNER_MECHANICS, + "partner_capable": partner_capable, + "partner_enabled": partner_active, + "selected_secondary_commander": selected_secondary, + "selected_background": selected_background if supports_backgrounds else "", + "partner_options": partner_options if partner_options else [], + "background_options": background_options if background_options else [], + "primary_partner_with": list(record.partner_with) if record else [], + "primary_supports_backgrounds": supports_backgrounds, + "primary_is_partner": bool(record.is_partner) if record else False, + "primary_commander_display": record.display_name if record else commander_name, + "partner_preview": preview_payload, + "partner_warnings": warnings_list, + "partner_error": partner_error, + "partner_auto_note": auto_note_value, + "partner_auto_assigned": bool(auto_prefill_applied or auto_assigned), + "partner_auto_default": auto_default_name, + "partner_select_variant": partner_variant, + "partner_select_label": select_label, + "partner_select_placeholder": placeholder, + "partner_role_hint": role_hint, + "partner_suggestions_enabled": suggestions_enabled, + "partner_suggestions": suggestions_visible, + "partner_suggestions_hidden": suggestions_hidden, + "partner_suggestions_total": suggestions_total, + "partner_suggestions_metadata": suggestions_metadata, + "partner_suggestions_loaded": suggestions_loaded, + "partner_suggestions_error": suggestions_error, + "partner_suggestions_available": bool(suggestions_visible or suggestions_hidden), + "partner_suggestions_has_hidden": bool(suggestions_hidden), + "partner_suggestions_endpoint": "/api/partner/suggestions", + } + context["has_partner_options"] = bool(partner_options) + context["has_background_options"] = bool(background_options) + context["partner_hidden_value"] = "1" if partner_capable else "0" + context["partner_auto_opt_out"] = not bool(auto_prefill_allowed) + context["partner_prefill_available"] = bool(partner_variant == "partner" and partner_options) + + # Generate preview if not provided + if preview_payload is None and ENABLE_PARTNER_MECHANICS and (selected_secondary or selected_background): + try: + builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) + combined_obj = apply_partner_inputs( + builder, + primary_name=commander_name, + secondary_name=selected_secondary or None, + background_name=selected_background or None, + feature_enabled=True, + ) + except CommanderPartnerError as exc: + preview_error = str(exc) or "Invalid partner selection." + except Exception as exc: + preview_error = f"Partner preview failed: {exc}" + else: + if combined_obj is not None: + preview_payload = _combined_to_payload(combined_obj) + if combined_obj.warnings: + for warn in combined_obj.warnings: + if warn not in warnings_list: + warnings_list.append(warn) + if preview_payload: + context["partner_preview"] = preview_payload + preview_tags = preview_payload.get("theme_tags") + if preview_tags: + context["partner_theme_tags"] = list(preview_tags) + if preview_error and not partner_error: + context["partner_error"] = preview_error + partner_error = preview_error + context["partner_warnings"] = warnings_list + return context + + +def _resolve_partner_selection( + commander_name: str, + *, + feature_enabled: bool, + partner_enabled: bool, + secondary_candidate: str | None, + background_candidate: str | None, + auto_opt_out: bool = False, + selection_source: str | None = None, +) -> tuple[ + str | None, + dict[str, Any] | None, + list[str], + str | None, + str | None, + str | None, + str | None, + bool, +]: + """ + Resolve and validate partner selection, applying auto-pairing when appropriate. + + Returns: + Tuple of (error, preview_payload, warnings, auto_note, resolved_secondary, + resolved_background, partner_mode, auto_assigned_flag) + """ + if not (feature_enabled and ENABLE_PARTNER_MECHANICS): + return None, None, [], None, None, None, None, False + + secondary = (secondary_candidate or "").strip() + background = (background_candidate or "").strip() + auto_note: str | None = None + auto_assigned = False + selection_source_clean = (selection_source or "").strip().lower() or None + + record = find_commander_record(commander_name) + partner_options, partner_variant = _build_partner_options(record) + supports_backgrounds = bool(record and record.supports_backgrounds) + background_options = _build_background_options() if supports_backgrounds else [] + + if not partner_enabled and not secondary and not background: + return None, None, [], None, None, None, None, False + + if not supports_backgrounds: + background = "" + if not partner_options: + secondary = "" + + if secondary and background: + return "Provide either a secondary commander or a background, not both.", None, [], auto_note, secondary, background, None, False + + option_lookup = {opt["name"].casefold(): opt for opt in partner_options} + if secondary: + key = secondary.casefold() + if key not in option_lookup: + return "Selected partner is not valid for this commander.", None, [], auto_note, secondary, background or None, None, False + + if background: + normalized_backgrounds = {opt["name"].casefold() for opt in background_options} + if background.casefold() not in normalized_backgrounds: + return "Selected background is not available.", None, [], auto_note, secondary or None, background, None, False + + # Auto-assign Partner With targets + if not secondary and not background and not auto_opt_out and partner_variant == "partner" and record and record.partner_with: + target_names = [name.strip() for name in record.partner_with if str(name).strip()] + for target in target_names: + opt = option_lookup.get(target.casefold()) + if opt: + secondary = opt["name"] + auto_note = f"Automatically paired with {secondary} (Partner With)." + auto_assigned = True + break + + if not secondary and not background: + return None, None, [], auto_note, None, None, None, auto_assigned + + builder = DeckBuilder(output_func=lambda *_: None, input_func=lambda *_: "", headless=True) + try: + combined = apply_partner_inputs( + builder, + primary_name=commander_name, + secondary_name=secondary or None, + background_name=background or None, + feature_enabled=True, + selection_source=selection_source_clean, + ) + except CommanderPartnerError as exc: + message = str(exc) or "Invalid partner selection." + return message, None, [], auto_note, secondary or None, background or None, None, auto_assigned + except Exception as exc: + return f"Partner selection failed: {exc}", None, [], auto_note, secondary or None, background or None, None, auto_assigned + + if combined is None: + return "Unable to resolve partner selection.", None, [], auto_note, secondary or None, background or None, None, auto_assigned + + payload = _combined_to_payload(combined) + warnings = payload.get("warnings", []) or [] + mode = payload.get("partner_mode") + if mode == "background": + resolved_background = payload.get("secondary_name") + return None, payload, warnings, auto_note, None, resolved_background, mode, auto_assigned + return None, payload, warnings, auto_note, payload.get("secondary_name"), None, mode, auto_assigned + + +@router.post("/partner/preview", response_class=JSONResponse) +async def build_partner_preview( + request: Request, + commander: str = Form(...), + partner_enabled: str | None = Form(None), + secondary_commander: str | None = Form(None), + background: str | None = Form(None), + partner_auto_opt_out: str | None = Form(None), + scope: str | None = Form(None), + selection_source: str | None = Form(None), +) -> JSONResponse: + """ + Preview a partner pairing and return combined commander details. + + This endpoint validates partner selections and returns: + - Combined color identity and theme tags + - Partner preview payload with images and metadata + - Warnings about legality or capability mismatches + - Auto-pairing information for Partner With targets + + Args: + request: FastAPI request + commander: Primary commander name + partner_enabled: Whether partner mechanics are enabled ("1"/"true"/etc.) + secondary_commander: Secondary partner commander name + background: Background card name (for Choose a Background commanders) + partner_auto_opt_out: Opt-out of auto-pairing for Partner With + scope: Request scope identifier + selection_source: Source of selection (e.g., "suggestion", "manual") + + Returns: + JSONResponse with partner preview data and validation results + """ + partner_feature_enabled = ENABLE_PARTNER_MECHANICS + raw_partner_enabled = (partner_enabled or "").strip().lower() + partner_flag = partner_feature_enabled and raw_partner_enabled in {"1", "true", "on", "yes"} + auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"} + selection_source_value = (selection_source or "").strip().lower() or None + + try: + ( + partner_error, + combined_payload, + partner_warnings, + partner_auto_note, + resolved_secondary, + resolved_background, + partner_mode, + partner_auto_assigned_flag, + ) = _resolve_partner_selection( + commander, + feature_enabled=partner_feature_enabled, + partner_enabled=partner_flag, + secondary_candidate=secondary_commander, + background_candidate=background, + auto_opt_out=auto_opt_out_flag, + selection_source=selection_source_value, + ) + except Exception as exc: # pragma: no cover - defensive + return JSONResponse( + { + "ok": False, + "error": f"Partner preview failed: {exc}", + "scope": scope or "", + } + ) + + partner_ctx = _partner_ui_context( + commander, + partner_enabled=partner_flag, + secondary_selection=resolved_secondary or secondary_commander, + background_selection=resolved_background or background, + combined_preview=combined_payload, + warnings=partner_warnings, + partner_error=partner_error, + auto_note=partner_auto_note, + auto_assigned=partner_auto_assigned_flag, + auto_prefill_allowed=not auto_opt_out_flag, + ) + + preview_payload = partner_ctx.get("partner_preview") + theme_tags = partner_ctx.get("partner_theme_tags") or [] + warnings_list = partner_ctx.get("partner_warnings") or partner_warnings or [] + + response = { + "ok": True, + "scope": scope or "", + "preview": preview_payload, + "theme_tags": theme_tags, + "warnings": warnings_list, + "auto_note": partner_auto_note, + "resolved_secondary": resolved_secondary, + "resolved_background": resolved_background, + "partner_mode": partner_mode, + "auto_assigned": bool(partner_auto_assigned_flag), + } + if partner_error: + response["error"] = partner_error + try: + log_partner_suggestion_selected( + request, + commander=commander, + scope=scope, + partner_enabled=partner_flag, + auto_opt_out=auto_opt_out_flag, + auto_assigned=bool(partner_auto_assigned_flag), + selection_source=selection_source_value, + secondary_candidate=secondary_commander, + background_candidate=background, + resolved_secondary=resolved_secondary, + resolved_background=resolved_background, + partner_mode=partner_mode, + has_preview=bool(preview_payload), + warnings=warnings_list, + error=response.get("error"), + ) + except Exception: # pragma: no cover - telemetry should not break responses + pass + return JSONResponse(response) diff --git a/code/web/routes/build_permalinks.py b/code/web/routes/build_permalinks.py new file mode 100644 index 0000000..42bc0e4 --- /dev/null +++ b/code/web/routes/build_permalinks.py @@ -0,0 +1,257 @@ +"""Build Permalinks and Lock Management Routes + +Phase 5 extraction from build.py: +- POST /build/lock - Card lock toggle with HTMX swap +- GET /build/permalink - State serialization (base64 JSON) +- GET /build/from - State restoration from permalink + +This module handles build state persistence and card lock management. +""" +from __future__ import annotations + +from fastapi import APIRouter, Request, Form, Query +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from typing import Any +import json +import gzip +from ..app import ALLOW_MUST_HAVES, templates +from ..services.tasks import get_session, new_sid +from ..services import orchestrator as orch +from html import escape as _esc + + +router = APIRouter(prefix="/build") + + +def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None: + if not payload or response is None: + return + try: + existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None + except Exception: + existing = None + try: + if existing: + try: + data = json.loads(existing) + except Exception: + data = {} + if isinstance(data, dict): + data.update(payload) + response.headers["HX-Trigger"] = json.dumps(data) + return + response.headers["HX-Trigger"] = json.dumps(payload) + except Exception: + try: + response.headers["HX-Trigger"] = json.dumps(payload) + except Exception: + pass + + +@router.post("/lock") +async def build_lock(request: Request, name: str = Form(...), locked: int = Form(...), from_list: str = Form(None)) -> HTMLResponse: + """Toggle card lock for a given card name (HTMX-based). + + Maintains an in-session locks set and reflects changes in the build context. + Returns an updated HTML button with HTMX attributes for easy swapping. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + name_l = str(name).strip().lower() + locks = set(sess.get("locks", [])) + is_locked = bool(int(locked or 0)) + if is_locked: + locks.add(name_l) + else: + locks.discard(name_l) + sess["locks"] = list(locks) + # Update build context if it exists + try: + ctx = sess.get("build_ctx") or {} + if ctx and isinstance(ctx, dict): + ctx["locks"] = {str(x) for x in locks} + except Exception: + pass + # Build lock button HTML + if is_locked: + label = "🔒" + title = f"Unlock {name}" + next_state = 0 + else: + label = "🔓" + title = f"Lock {name}" + next_state = 1 + html = ( + f'' + ) + # OOB chip and lock count update + lock_count = len(locks) + chip = ( + f'
    ' + f'🔒 {lock_count}' + f'
    ' + ) + # If coming from locked-cards list, remove the row on unlock + if from_list and not is_locked: + # Return empty content to remove the
  • parent of the button + html = "" + return HTMLResponse(html + chip) + + +@router.get("/permalink") +async def build_permalink(request: Request): + """Return a URL-safe JSON payload representing current run config (basic).""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + payload: dict[str, Any] = { + "commander": sess.get("commander"), + "tags": sess.get("tags", []), + "bracket": sess.get("bracket"), + "ideals": sess.get("ideals"), + "locks": list(sess.get("locks", []) or []), + "tag_mode": sess.get("tag_mode", "AND"), + "flags": { + "owned_only": bool(sess.get("use_owned_only")), + "prefer_owned": bool(sess.get("prefer_owned")), + "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), + }, + } + # Include random build fields if present + try: + rb = sess.get("random_build") + if isinstance(rb, dict) and rb: + random_payload: dict[str, Any] = {} + for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"): + if rb.get(key) is not None: + random_payload[key] = rb.get(key) + if isinstance(rb.get("resolved_themes"), list): + random_payload["resolved_themes"] = list(rb.get("resolved_themes") or []) + if isinstance(rb.get("resolved_theme_info"), dict): + random_payload["resolved_theme_info"] = dict(rb.get("resolved_theme_info")) + if rb.get("combo_fallback") is not None: + random_payload["combo_fallback"] = bool(rb.get("combo_fallback")) + if rb.get("synergy_fallback") is not None: + random_payload["synergy_fallback"] = bool(rb.get("synergy_fallback")) + if rb.get("fallback_reason") is not None: + random_payload["fallback_reason"] = rb.get("fallback_reason") + if isinstance(rb.get("requested_themes"), dict): + requested_payload = dict(rb.get("requested_themes")) + if "auto_fill_enabled" in requested_payload: + requested_payload["auto_fill_enabled"] = bool(requested_payload.get("auto_fill_enabled")) + random_payload["requested_themes"] = requested_payload + if rb.get("auto_fill_enabled") is not None: + random_payload["auto_fill_enabled"] = bool(rb.get("auto_fill_enabled")) + if rb.get("auto_fill_applied") is not None: + random_payload["auto_fill_applied"] = bool(rb.get("auto_fill_applied")) + auto_filled = rb.get("auto_filled_themes") + if isinstance(auto_filled, list): + random_payload["auto_filled_themes"] = list(auto_filled) + display = rb.get("display_themes") + if isinstance(display, list): + random_payload["display_themes"] = list(display) + if random_payload: + payload["random"] = random_payload + except Exception: + pass + # Include exclude_cards if feature is enabled and present + if ALLOW_MUST_HAVES and sess.get("exclude_cards"): + payload["exclude_cards"] = sess.get("exclude_cards") + # Compress and base64 encode the JSON payload for shorter URLs + try: + import base64 + raw = json.dumps(payload, separators=(',', ':')).encode("utf-8") + # Use gzip compression to significantly reduce permalink length + compressed = gzip.compress(raw, compresslevel=9) + token = base64.urlsafe_b64encode(compressed).decode("ascii").rstrip("=") + except Exception: + return JSONResponse({"error": "Failed to generate permalink"}, status_code=500) + link = f"/build/from?state={token}" + return JSONResponse({ + "permalink": link, + "state": payload, + }) + + +@router.get("/from") +async def build_from(request: Request, state: str | None = None) -> RedirectResponse: + """Load a run from a permalink token and redirect to main build page.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + if state: + try: + import base64 + import json as _json + pad = '=' * (-len(state) % 4) + compressed = base64.urlsafe_b64decode((state + pad).encode("ascii")) + # Decompress the state data + raw = gzip.decompress(compressed).decode("utf-8") + data = _json.loads(raw) + sess["commander"] = data.get("commander") + sess["tags"] = data.get("tags", []) + sess["bracket"] = data.get("bracket") + if data.get("ideals"): + sess["ideals"] = data.get("ideals") + sess["tag_mode"] = data.get("tag_mode", "AND") + flags = data.get("flags") or {} + sess["use_owned_only"] = bool(flags.get("owned_only")) + sess["prefer_owned"] = bool(flags.get("prefer_owned")) + sess["swap_mdfc_basics"] = bool(flags.get("swap_mdfc_basics")) + sess["locks"] = list(data.get("locks", [])) + # Optional random build rehydration + try: + r = data.get("random") or {} + if r: + rb_payload: dict[str, Any] = {} + for key in ("seed", "theme", "constraints", "primary_theme", "secondary_theme", "tertiary_theme"): + if r.get(key) is not None: + rb_payload[key] = r.get(key) + if isinstance(r.get("resolved_themes"), list): + rb_payload["resolved_themes"] = list(r.get("resolved_themes") or []) + if isinstance(r.get("resolved_theme_info"), dict): + rb_payload["resolved_theme_info"] = dict(r.get("resolved_theme_info")) + if r.get("combo_fallback") is not None: + rb_payload["combo_fallback"] = bool(r.get("combo_fallback")) + if r.get("synergy_fallback") is not None: + rb_payload["synergy_fallback"] = bool(r.get("synergy_fallback")) + if r.get("fallback_reason") is not None: + rb_payload["fallback_reason"] = r.get("fallback_reason") + if isinstance(r.get("requested_themes"), dict): + requested_payload = dict(r.get("requested_themes")) + if "auto_fill_enabled" in requested_payload: + requested_payload["auto_fill_enabled"] = bool(requested_payload.get("auto_fill_enabled")) + rb_payload["requested_themes"] = requested_payload + if r.get("auto_fill_enabled") is not None: + rb_payload["auto_fill_enabled"] = bool(r.get("auto_fill_enabled")) + if r.get("auto_fill_applied") is not None: + rb_payload["auto_fill_applied"] = bool(r.get("auto_fill_applied")) + auto_filled = r.get("auto_filled_themes") + if isinstance(auto_filled, list): + rb_payload["auto_filled_themes"] = list(auto_filled) + display = r.get("display_themes") + if isinstance(display, list): + rb_payload["display_themes"] = list(display) + if "seed" in rb_payload: + try: + seed_int = int(rb_payload["seed"]) + rb_payload["seed"] = seed_int + rb_payload.setdefault("recent_seeds", [seed_int]) + except Exception: + rb_payload.setdefault("recent_seeds", []) + sess["random_build"] = rb_payload + except Exception: + pass + + # Import exclude_cards if feature is enabled and present + if ALLOW_MUST_HAVES and data.get("exclude_cards"): + sess["exclude_cards"] = data.get("exclude_cards") + + sess["last_step"] = 4 + except Exception: + pass + + # Redirect to main build page which will render the proper layout + resp = RedirectResponse(url="/build/", status_code=303) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp diff --git a/code/web/routes/build_themes.py b/code/web/routes/build_themes.py new file mode 100644 index 0000000..5255822 --- /dev/null +++ b/code/web/routes/build_themes.py @@ -0,0 +1,205 @@ +""" +Custom theme management routes for deck building. + +Handles user-defined custom themes including adding, removing, choosing +suggestions, and switching between permissive/strict matching modes. +""" + +from __future__ import annotations + +from typing import Any +from fastapi import APIRouter, Request, Form +from fastapi.responses import HTMLResponse + +from ..app import ( + ENABLE_CUSTOM_THEMES, + USER_THEME_LIMIT, + DEFAULT_THEME_MATCH_MODE, + _sanitize_theme, + templates, +) +from ..services.tasks import get_session, new_sid +from ..services import custom_theme_manager as theme_mgr + + +router = APIRouter() + + +_INVALID_THEME_MESSAGE = ( + "Theme names can only include letters, numbers, spaces, hyphens, apostrophes, and underscores." +) + + +def _custom_theme_context( + request: Request, + sess: dict, + *, + message: str | None = None, + level: str = "info", +) -> dict[str, Any]: + """ + Assemble the Additional Themes section context for the modal. + + Args: + request: FastAPI request object + sess: Session dictionary + message: Optional status message to display + level: Message level ("info", "success", "warning", "error") + + Returns: + Context dictionary for rendering the additional themes template + """ + if not ENABLE_CUSTOM_THEMES: + return { + "request": request, + "theme_state": None, + "theme_message": message, + "theme_message_level": level, + "theme_limit": USER_THEME_LIMIT, + "enable_custom_themes": False, + } + theme_mgr.set_limit(sess, USER_THEME_LIMIT) + state = theme_mgr.get_view_state(sess, default_mode=DEFAULT_THEME_MATCH_MODE) + return { + "request": request, + "theme_state": state, + "theme_message": message, + "theme_message_level": level, + "theme_limit": USER_THEME_LIMIT, + "enable_custom_themes": ENABLE_CUSTOM_THEMES, + } + + +@router.post("/themes/add", response_class=HTMLResponse) +async def build_theme_add(request: Request, theme: str = Form("")) -> HTMLResponse: + """ + Add a custom theme to the user's theme list. + + Validates theme name format and enforces theme count limits. + + Args: + request: FastAPI request object + theme: Theme name to add (will be trimmed and sanitized) + + Returns: + HTMLResponse with updated themes list and status message + """ + if not ENABLE_CUSTOM_THEMES: + return HTMLResponse("", status_code=204) + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + trimmed = theme.strip() + sanitized = _sanitize_theme(trimmed) if trimmed else "" + if trimmed and not sanitized: + ctx = _custom_theme_context(request, sess, message=_INVALID_THEME_MESSAGE, level="error") + else: + value = sanitized if sanitized is not None else trimmed + _, message, level = theme_mgr.add_theme( + sess, + value, + commander_tags=list(sess.get("tags", [])), + mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE), + limit=USER_THEME_LIMIT, + ) + ctx = _custom_theme_context(request, sess, message=message, level=level) + resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +@router.post("/themes/remove", response_class=HTMLResponse) +async def build_theme_remove(request: Request, theme: str = Form("")) -> HTMLResponse: + """ + Remove a custom theme from the user's theme list. + + Args: + request: FastAPI request object + theme: Theme name to remove + + Returns: + HTMLResponse with updated themes list and status message + """ + if not ENABLE_CUSTOM_THEMES: + return HTMLResponse("", status_code=204) + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + value = _sanitize_theme(theme) or theme + _, message, level = theme_mgr.remove_theme( + sess, + value, + commander_tags=list(sess.get("tags", [])), + mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE), + ) + ctx = _custom_theme_context(request, sess, message=message, level=level) + resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +@router.post("/themes/choose", response_class=HTMLResponse) +async def build_theme_choose( + request: Request, + original: str = Form(""), + choice: str = Form(""), +) -> HTMLResponse: + """ + Replace an invalid theme with a suggested alternative. + + When a user's custom theme doesn't perfectly match commander tags, + the system suggests alternatives. This route accepts the user's + choice from those suggestions. + + Args: + request: FastAPI request object + original: The original (invalid) theme name + choice: The selected suggestion to use instead + + Returns: + HTMLResponse with updated themes list and status message + """ + if not ENABLE_CUSTOM_THEMES: + return HTMLResponse("", status_code=204) + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + selection = _sanitize_theme(choice) or choice + _, message, level = theme_mgr.choose_suggestion( + sess, + original, + selection, + commander_tags=list(sess.get("tags", [])), + mode=sess.get("theme_match_mode", DEFAULT_THEME_MATCH_MODE), + ) + ctx = _custom_theme_context(request, sess, message=message, level=level) + resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +@router.post("/themes/mode", response_class=HTMLResponse) +async def build_theme_mode(request: Request, mode: str = Form("permissive")) -> HTMLResponse: + """ + Switch theme matching mode between permissive and strict. + + - Permissive: Suggests alternatives for invalid themes + - Strict: Rejects invalid themes outright + + Args: + request: FastAPI request object + mode: Either "permissive" or "strict" + + Returns: + HTMLResponse with updated themes list and status message + """ + if not ENABLE_CUSTOM_THEMES: + return HTMLResponse("", status_code=204) + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + _, message, level = theme_mgr.set_mode( + sess, + mode, + commander_tags=list(sess.get("tags", [])), + ) + ctx = _custom_theme_context(request, sess, message=message, level=level) + resp = templates.TemplateResponse("build/_new_deck_additional_themes.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp diff --git a/code/web/routes/build_validation.py b/code/web/routes/build_validation.py new file mode 100644 index 0000000..14e0d36 --- /dev/null +++ b/code/web/routes/build_validation.py @@ -0,0 +1,379 @@ +"""Validation endpoints for card name validation and include/exclude lists. + +This module handles validation of card names and include/exclude lists for the deck builder, +including fuzzy matching, color identity validation, and limit enforcement. +""" + +import os +from fastapi import APIRouter, Form, Request +from fastapi.responses import JSONResponse + +from path_util import csv_dir as _csv_dir + +router = APIRouter() + +# Read configuration directly to avoid circular import with app.py +def _as_bool(val: str | bool | None, default: bool = False) -> bool: + """Convert environment variable to boolean.""" + if isinstance(val, bool): + return val + if val is None: + return default + s = str(val).strip().lower() + return s in ("1", "true", "yes", "on") + +ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True) + +# Cache for available card names used by validation endpoints +_AVAILABLE_CARDS_CACHE: set[str] | None = None +_AVAILABLE_CARDS_NORM_SET: set[str] | None = None +_AVAILABLE_CARDS_NORM_MAP: dict[str, str] | None = None + + +def _available_cards() -> set[str]: + """Fast load of available card names using the csv module (no pandas). + + Reads only once and caches results in memory. + """ + global _AVAILABLE_CARDS_CACHE + if _AVAILABLE_CARDS_CACHE is not None: + return _AVAILABLE_CARDS_CACHE + try: + import csv + path = f"{_csv_dir()}/cards.csv" + with open(path, 'r', encoding='utf-8', newline='') as f: + reader = csv.DictReader(f) + fields = reader.fieldnames or [] + name_col = None + for col in ['name', 'Name', 'card_name', 'CardName']: + if col in fields: + name_col = col + break + if name_col is None and fields: + # Heuristic: pick first field containing 'name' + for col in fields: + if 'name' in col.lower(): + name_col = col + break + if name_col is None: + raise ValueError(f"No name-like column found in {path}: {fields}") + names: set[str] = set() + for row in reader: + try: + v = row.get(name_col) + if v: + names.add(str(v)) + except Exception: + continue + _AVAILABLE_CARDS_CACHE = names + return _AVAILABLE_CARDS_CACHE + except Exception: + _AVAILABLE_CARDS_CACHE = set() + return _AVAILABLE_CARDS_CACHE + + +def _available_cards_normalized() -> tuple[set[str], dict[str, str]]: + """Return cached normalized card names and mapping to originals.""" + global _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP + if _AVAILABLE_CARDS_NORM_SET is not None and _AVAILABLE_CARDS_NORM_MAP is not None: + return _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP + # Build from available cards set + names = _available_cards() + try: + from code.deck_builder.include_exclude_utils import normalize_punctuation + except Exception: + # Fallback: identity normalization + def normalize_punctuation(x: str) -> str: + return str(x).strip().casefold() + norm_map: dict[str, str] = {} + for name in names: + try: + n = normalize_punctuation(name) + if n not in norm_map: + norm_map[n] = name + except Exception: + continue + _AVAILABLE_CARDS_NORM_MAP = norm_map + _AVAILABLE_CARDS_NORM_SET = set(norm_map.keys()) + return _AVAILABLE_CARDS_NORM_SET, _AVAILABLE_CARDS_NORM_MAP + + +def warm_validation_name_cache() -> None: + """Pre-populate the available-cards caches to avoid first-call latency.""" + try: + _ = _available_cards() + _ = _available_cards_normalized() + except Exception: + # Best-effort warmup; proceed silently on failure + pass + + +@router.post("/validate/exclude_cards") +async def validate_exclude_cards( + request: Request, + exclude_cards: str = Form(default=""), + commander: str = Form(default="") +): + """Legacy exclude cards validation endpoint - redirect to new unified endpoint.""" + if not ALLOW_MUST_HAVES: + return JSONResponse({"error": "Feature not enabled"}, status_code=404) + + # Call new unified endpoint + result = await validate_include_exclude_cards( + request=request, + include_cards="", + exclude_cards=exclude_cards, + commander=commander, + enforcement_mode="warn", + allow_illegal=False, + fuzzy_matching=True + ) + + # Transform to legacy format for backward compatibility + if hasattr(result, 'body'): + import json + data = json.loads(result.body) + if 'excludes' in data: + excludes = data['excludes'] + return JSONResponse({ + "count": excludes.get("count", 0), + "limit": excludes.get("limit", 15), + "over_limit": excludes.get("over_limit", False), + "cards": excludes.get("cards", []), + "duplicates": excludes.get("duplicates", {}), + "warnings": excludes.get("warnings", []) + }) + + return result + + +@router.post("/validate/include_exclude") +async def validate_include_exclude_cards( + request: Request, + include_cards: str = Form(default=""), + exclude_cards: str = Form(default=""), + commander: str = Form(default=""), + enforcement_mode: str = Form(default="warn"), + allow_illegal: bool = Form(default=False), + fuzzy_matching: bool = Form(default=True) +): + """Validate include/exclude card lists with comprehensive diagnostics.""" + if not ALLOW_MUST_HAVES: + return JSONResponse({"error": "Feature not enabled"}, status_code=404) + + try: + from code.deck_builder.include_exclude_utils import ( + parse_card_list_input, collapse_duplicates, + fuzzy_match_card_name, MAX_INCLUDES, MAX_EXCLUDES + ) + from code.deck_builder.builder import DeckBuilder + + # Parse inputs + include_list = parse_card_list_input(include_cards) if include_cards.strip() else [] + exclude_list = parse_card_list_input(exclude_cards) if exclude_cards.strip() else [] + + # Collapse duplicates + include_unique, include_dupes = collapse_duplicates(include_list) + exclude_unique, exclude_dupes = collapse_duplicates(exclude_list) + + # Initialize result structure + result = { + "includes": { + "count": len(include_unique), + "limit": MAX_INCLUDES, + "over_limit": len(include_unique) > MAX_INCLUDES, + "duplicates": include_dupes, + "cards": include_unique[:10] if len(include_unique) <= 10 else include_unique[:7] + ["..."], + "warnings": [], + "legal": [], + "illegal": [], + "color_mismatched": [], + "fuzzy_matches": {} + }, + "excludes": { + "count": len(exclude_unique), + "limit": MAX_EXCLUDES, + "over_limit": len(exclude_unique) > MAX_EXCLUDES, + "duplicates": exclude_dupes, + "cards": exclude_unique[:10] if len(exclude_unique) <= 10 else exclude_unique[:7] + ["..."], + "warnings": [], + "legal": [], + "illegal": [], + "fuzzy_matches": {} + }, + "conflicts": [], # Cards that appear in both lists + "confirmation_needed": [], # Cards needing fuzzy match confirmation + "overall_warnings": [] + } + + # Check for conflicts (cards in both lists) + conflicts = set(include_unique) & set(exclude_unique) + if conflicts: + result["conflicts"] = list(conflicts) + result["overall_warnings"].append(f"Cards appear in both lists: {', '.join(list(conflicts)[:3])}{'...' if len(conflicts) > 3 else ''}") + + # Size warnings based on actual counts + if result["includes"]["over_limit"]: + result["includes"]["warnings"].append(f"Too many includes: {len(include_unique)}/{MAX_INCLUDES}") + elif len(include_unique) > MAX_INCLUDES * 0.8: # 80% capacity warning + result["includes"]["warnings"].append(f"Approaching limit: {len(include_unique)}/{MAX_INCLUDES}") + + if result["excludes"]["over_limit"]: + result["excludes"]["warnings"].append(f"Too many excludes: {len(exclude_unique)}/{MAX_EXCLUDES}") + elif len(exclude_unique) > MAX_EXCLUDES * 0.8: # 80% capacity warning + result["excludes"]["warnings"].append(f"Approaching limit: {len(exclude_unique)}/{MAX_EXCLUDES}") + + # If we have a commander, do advanced validation (color identity, etc.) + if commander and commander.strip(): + try: + # Create a temporary builder + builder = DeckBuilder() + + # Set up commander FIRST (before setup_dataframes) + df = builder.load_commander_data() + commander_rows = df[df["name"] == commander.strip()] + + if not commander_rows.empty: + # Apply commander selection (this sets commander_row properly) + builder._apply_commander_selection(commander_rows.iloc[0]) + + # Now setup dataframes (this will use the commander info) + builder.setup_dataframes() + + # Get available card names for fuzzy matching + name_col = 'name' if 'name' in builder._full_cards_df.columns else 'Name' + available_cards = set(builder._full_cards_df[name_col].tolist()) + + # Validate includes with fuzzy matching + for card_name in include_unique: + if fuzzy_matching: + match_result = fuzzy_match_card_name(card_name, available_cards) + if match_result.matched_name: + if match_result.auto_accepted: + result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name + result["includes"]["legal"].append(match_result.matched_name) + else: + # Needs confirmation + result["confirmation_needed"].append({ + "input": card_name, + "suggestions": match_result.suggestions, + "confidence": match_result.confidence, + "type": "include" + }) + else: + result["includes"]["illegal"].append(card_name) + else: + # Exact match only + if card_name in available_cards: + result["includes"]["legal"].append(card_name) + else: + result["includes"]["illegal"].append(card_name) + + # Validate excludes with fuzzy matching + for card_name in exclude_unique: + if fuzzy_matching: + match_result = fuzzy_match_card_name(card_name, available_cards) + if match_result.matched_name: + if match_result.auto_accepted: + result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name + result["excludes"]["legal"].append(match_result.matched_name) + else: + # Needs confirmation + result["confirmation_needed"].append({ + "input": card_name, + "suggestions": match_result.suggestions, + "confidence": match_result.confidence, + "type": "exclude" + }) + else: + result["excludes"]["illegal"].append(card_name) + else: + # Exact match only + if card_name in available_cards: + result["excludes"]["legal"].append(card_name) + else: + result["excludes"]["illegal"].append(card_name) + + # Color identity validation for includes (only if we have a valid commander with colors) + commander_colors = getattr(builder, 'color_identity', []) + if commander_colors: + color_validated_includes = [] + for card_name in result["includes"]["legal"]: + if builder._validate_card_color_identity(card_name): + color_validated_includes.append(card_name) + else: + # Add color-mismatched cards to illegal instead of separate category + result["includes"]["illegal"].append(card_name) + + # Update legal includes to only those that pass color identity + result["includes"]["legal"] = color_validated_includes + + except Exception as validation_error: + # Advanced validation failed, but return basic validation + result["overall_warnings"].append(f"Advanced validation unavailable: {str(validation_error)}") + else: + # No commander provided, do basic fuzzy matching only + if fuzzy_matching and (include_unique or exclude_unique): + try: + # Use cached available cards set (1st call populates cache) + available_cards = _available_cards() + + # Fast path: normalized exact matches via cached sets + norm_set, norm_map = _available_cards_normalized() + # Validate includes with fuzzy matching + for card_name in include_unique: + from code.deck_builder.include_exclude_utils import normalize_punctuation + n = normalize_punctuation(card_name) + if n in norm_set: + result["includes"]["fuzzy_matches"][card_name] = norm_map[n] + result["includes"]["legal"].append(norm_map[n]) + continue + match_result = fuzzy_match_card_name(card_name, available_cards) + + if match_result.matched_name and match_result.auto_accepted: + # Exact or high-confidence match + result["includes"]["fuzzy_matches"][card_name] = match_result.matched_name + result["includes"]["legal"].append(match_result.matched_name) + elif not match_result.auto_accepted and match_result.suggestions: + # Needs confirmation - has suggestions but low confidence + result["confirmation_needed"].append({ + "input": card_name, + "suggestions": match_result.suggestions, + "confidence": match_result.confidence, + "type": "include" + }) + else: + # No match found at all, add to illegal + result["includes"]["illegal"].append(card_name) + # Validate excludes with fuzzy matching + for card_name in exclude_unique: + from code.deck_builder.include_exclude_utils import normalize_punctuation + n = normalize_punctuation(card_name) + if n in norm_set: + result["excludes"]["fuzzy_matches"][card_name] = norm_map[n] + result["excludes"]["legal"].append(norm_map[n]) + continue + match_result = fuzzy_match_card_name(card_name, available_cards) + if match_result.matched_name: + if match_result.auto_accepted: + result["excludes"]["fuzzy_matches"][card_name] = match_result.matched_name + result["excludes"]["legal"].append(match_result.matched_name) + else: + # Needs confirmation + result["confirmation_needed"].append({ + "input": card_name, + "suggestions": match_result.suggestions, + "confidence": match_result.confidence, + "type": "exclude" + }) + else: + # No match found, add to illegal + result["excludes"]["illegal"].append(card_name) + + except Exception as fuzzy_error: + result["overall_warnings"].append(f"Fuzzy matching unavailable: {str(fuzzy_error)}") + + return JSONResponse(result) + + except Exception as e: + return JSONResponse({"error": str(e)}, status_code=400) diff --git a/code/web/routes/build_wizard.py b/code/web/routes/build_wizard.py new file mode 100644 index 0000000..0951dfe --- /dev/null +++ b/code/web/routes/build_wizard.py @@ -0,0 +1,1462 @@ +""" +Build Wizard Routes - Step-by-step deck building flow. + +Handles the 5-step wizard interface for deck building: +- Step 1: Commander selection +- Step 2: Theme and partner selection +- Step 3: Ideal card count targets +- Step 4: Owned card preferences and review +- Step 5: Build execution and results + +Extracted from build.py as part of Phase 3 modularization (Roadmap 9 M1). +""" +from __future__ import annotations + +from fastapi import APIRouter, Request, Form, Query +from fastapi.responses import HTMLResponse +from typing import Any + +from ..app import templates, ENABLE_PARTNER_MECHANICS +from ..services.build_utils import ( + step5_base_ctx, + step5_ctx_from_result, + step5_error_ctx, + step5_empty_ctx, + start_ctx_from_session, + owned_set as owned_set_helper, + builder_present_names, + builder_display_map, + commander_hover_context, +) +from ..services import orchestrator as orch +from ..services.tasks import get_session, new_sid +from deck_builder import builder_constants as bc +from ..services.combo_utils import detect_all as _detect_all +from .build_partners import _partner_ui_context, _resolve_partner_selection +from .build_multicopy import _rebuild_ctx_with_multicopy + +router = APIRouter() + + +def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None: + """Merge HX-Trigger header data into response.""" + if not payload or response is None: + return + try: + existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None + except Exception: + existing = None + try: + import json + if existing: + try: + data = json.loads(existing) + except Exception: + data = {} + if isinstance(data, dict): + data.update(payload) + response.headers["HX-Trigger"] = json.dumps(data) + return + response.headers["HX-Trigger"] = json.dumps(payload) + except Exception: + try: + import json + response.headers["HX-Trigger"] = json.dumps(payload) + except Exception: + pass + + +def _step5_summary_placeholder_html(token: int, *, message: str | None = None) -> str: + """Generate placeholder HTML for step 5 summary panel.""" + from html import escape as _esc + text = message or "Deck summary will appear after the build completes." + return ( + f'
    ' + f'
    {_esc(text)}
    ' + '
    ' + ) + + +def _current_builder_summary(sess: dict) -> Any | None: + """Get current builder's deck summary.""" + try: + ctx = sess.get("build_ctx") or {} + builder = ctx.get("builder") if isinstance(ctx, dict) else None + if builder is None: + return None + summary_fn = getattr(builder, "build_deck_summary", None) + if callable(summary_fn): + summary_data = summary_fn() + # Also save to session for consistency + if summary_data: + sess["summary"] = summary_data + return summary_data + except Exception: + return None + return None + + +def _get_current_deck_names(sess: dict) -> list[str]: + """Get names of cards currently in the deck.""" + try: + ctx = sess.get("build_ctx") or {} + b = ctx.get("builder") + lib = getattr(b, "card_library", {}) if b is not None else {} + names = [str(n) for n in lib.keys()] + return sorted(dict.fromkeys(names)) + except Exception: + return [] + + +# ============================================================================ +# Step 1: Commander Selection +# ============================================================================ + +@router.get("/step1", response_class=HTMLResponse) +async def build_step1(request: Request) -> HTMLResponse: + """Display commander search form.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 1 + resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +@router.post("/step1", response_class=HTMLResponse) +async def build_step1_search( + request: Request, + query: str = Form(""), + auto: str | None = Form(None), + active: str | None = Form(None), +) -> HTMLResponse: + """Search for commander candidates and optionally auto-select.""" + query = (query or "").strip() + auto_enabled = True if (auto == "1") else False + candidates = [] + if query: + candidates = orch.commander_candidates(query, limit=10) + # Optional auto-select at a stricter threshold + if auto_enabled and candidates and len(candidates[0]) >= 2 and int(candidates[0][1]) >= 98: + top_name = candidates[0][0] + res = orch.commander_select(top_name) + if res.get("ok"): + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 2 + commander_name = res.get("name") + gc_flag = commander_name in getattr(bc, 'GAME_CHANGERS', []) + context = { + "request": request, + "commander": res, + "tags": orch.tags_for_commander(commander_name), + "recommended": orch.recommended_tags_for_commander(commander_name), + "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander_name), + "brackets": orch.bracket_options(), + "gc_commander": gc_flag, + "selected_bracket": (3 if gc_flag else None), + "clear_persisted": True, + } + context.update( + _partner_ui_context( + commander_name, + partner_enabled=False, + secondary_selection=None, + background_selection=None, + combined_preview=None, + warnings=None, + partner_error=None, + auto_note=None, + ) + ) + resp = templates.TemplateResponse("build/_step2.html", context) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 1 + resp = templates.TemplateResponse( + "build/_step1.html", + { + "request": request, + "query": query, + "candidates": candidates, + "auto": auto_enabled, + "active": active, + "count": len(candidates) if candidates else 0, + }, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +@router.post("/step1/inspect", response_class=HTMLResponse) +async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse: + """Preview commander details before confirmation.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 1 + info = orch.commander_inspect(name) + resp = templates.TemplateResponse( + "build/_step1.html", + {"request": request, "inspect": info, "selected": name, "tags": orch.tags_for_commander(name)}, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +@router.post("/step1/confirm", response_class=HTMLResponse) +async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse: + """Confirm commander selection and proceed to step 2.""" + res = orch.commander_select(name) + if not res.get("ok"): + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 1 + resp = templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name}) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + # Proceed to step2 placeholder and reset any prior build/session selections + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + # Reset sticky selections from previous runs + for k in [ + "tags", + "ideals", + "bracket", + "build_ctx", + "last_step", + "tag_mode", + "mc_seen_keys", + "multi_copy", + "partner_enabled", + "secondary_commander", + "background", + "partner_mode", + "partner_warnings", + "combined_commander", + "partner_auto_note", + ]: + try: + if k in sess: + del sess[k] + except Exception: + pass + sess["last_step"] = 2 + # Determine if commander is a Game Changer to drive bracket UI hiding + is_gc = False + try: + is_gc = bool(res.get("name") in getattr(bc, 'GAME_CHANGERS', [])) + except Exception: + is_gc = False + context = { + "request": request, + "commander": res, + "tags": orch.tags_for_commander(res["name"]), + "recommended": orch.recommended_tags_for_commander(res["name"]), + "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), + "brackets": orch.bracket_options(), + "gc_commander": is_gc, + "selected_bracket": (3 if is_gc else None), + # Signal that this navigation came from a fresh commander confirmation, + # so the Step 2 UI should clear any localStorage theme persistence. + "clear_persisted": True, + } + context.update( + _partner_ui_context( + res["name"], + partner_enabled=False, + secondary_selection=None, + background_selection=None, + combined_preview=None, + warnings=None, + partner_error=None, + auto_note=None, + ) + ) + resp = templates.TemplateResponse("build/_step2.html", context) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +@router.post("/reset-all", response_class=HTMLResponse) +async def build_reset_all(request: Request) -> HTMLResponse: + """Clear all build-related session state and return Step 1.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + keys = [ + "commander","tags","tag_mode","bracket","ideals","build_ctx","last_step", + "locks","replace_mode" + ] + for k in keys: + try: + if k in sess: + del sess[k] + except Exception: + pass + sess["last_step"] = 1 + resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +# ============================================================================ +# Step 2: Theme and Partner Selection +# ============================================================================ + +@router.get("/step2", response_class=HTMLResponse) +async def build_step2_get(request: Request) -> HTMLResponse: + """Display theme picker and partner selection.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 2 + commander = sess.get("commander") + if not commander: + # Fallback to step1 if no commander in session + resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + tags = orch.tags_for_commander(commander) + selected = sess.get("tags", []) + # Determine if the selected commander is considered a Game Changer (affects bracket choices) + is_gc = False + try: + is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', [])) + except Exception: + is_gc = False + # Selected bracket: if GC commander and bracket < 3 or missing, default to 3 + sel_br = sess.get("bracket") + try: + sel_br = int(sel_br) if sel_br is not None else None + except Exception: + sel_br = None + if is_gc and (sel_br is None or int(sel_br) < 3): + sel_br = 3 + partner_enabled = bool(sess.get("partner_enabled") and ENABLE_PARTNER_MECHANICS) + + import logging + logger = logging.getLogger(__name__) + logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}") + + context = { + "request": request, + "commander": {"name": commander}, + "tags": tags, + "recommended": orch.recommended_tags_for_commander(commander), + "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), + "brackets": orch.bracket_options(), + "primary_tag": selected[0] if len(selected) > 0 else "", + "secondary_tag": selected[1] if len(selected) > 1 else "", + "tertiary_tag": selected[2] if len(selected) > 2 else "", + "selected_bracket": sel_br, + "tag_mode": sess.get("tag_mode", "AND"), + "gc_commander": is_gc, + # If there are no server-side tags for this commander, let the client clear any persisted ones + # to avoid themes sticking between fresh runs. + "clear_persisted": False if selected else True, + } + context.update( + _partner_ui_context( + commander, + partner_enabled=partner_enabled, + secondary_selection=sess.get("secondary_commander") if partner_enabled else None, + background_selection=sess.get("background") if partner_enabled else None, + combined_preview=sess.get("combined_commander") if partner_enabled else None, + warnings=sess.get("partner_warnings") if partner_enabled else None, + partner_error=None, + auto_note=sess.get("partner_auto_note") if partner_enabled else None, + auto_assigned=sess.get("partner_auto_assigned") if partner_enabled else None, + auto_prefill_allowed=not bool(sess.get("partner_auto_opt_out")) if partner_enabled else True, + ) + ) + partner_tags = context.pop("partner_theme_tags", None) + if partner_tags: + import logging + logger = logging.getLogger(__name__) + context["tags"] = partner_tags + # Deduplicate recommended tags: remove any that are already in partner_tags + partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} + original_recommended = context.get("recommended", []) + deduplicated_recommended = [ + tag for tag in original_recommended + if str(tag).strip().casefold() not in partner_tags_lower + ] + logger.info( + f"Step2: partner_tags={len(partner_tags)}, " + f"original_recommended={len(original_recommended)}, " + f"deduplicated_recommended={len(deduplicated_recommended)}" + ) + context["recommended"] = deduplicated_recommended + resp = templates.TemplateResponse("build/_step2.html", context) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +@router.post("/step2", response_class=HTMLResponse) +async def build_step2_submit( + request: Request, + commander: str = Form(...), + primary_tag: str | None = Form(None), + secondary_tag: str | None = Form(None), + tertiary_tag: str | None = Form(None), + tag_mode: str | None = Form("AND"), + bracket: int = Form(...), + partner_enabled: str | None = Form(None), + secondary_commander: str | None = Form(None), + background: str | None = Form(None), + partner_selection_source: str | None = Form(None), + partner_auto_opt_out: str | None = Form(None), +) -> HTMLResponse: + """Submit theme and partner selections, proceed to step 3.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 2 + + partner_feature_enabled = ENABLE_PARTNER_MECHANICS + partner_flag = False + if partner_feature_enabled: + raw_partner_enabled = (partner_enabled or "").strip().lower() + partner_flag = raw_partner_enabled in {"1", "true", "on", "yes"} + auto_opt_out_flag = (partner_auto_opt_out or "").strip().lower() in {"1", "true", "on", "yes"} + + # Validate primary tag selection if tags are available + available_tags = orch.tags_for_commander(commander) + if available_tags and not (primary_tag and primary_tag.strip()): + # Compute GC flag to hide disallowed brackets on error + is_gc = False + try: + is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', [])) + except Exception: + is_gc = False + try: + sel_br = int(bracket) if bracket is not None else None + except Exception: + sel_br = None + if is_gc and (sel_br is None or sel_br < 3): + sel_br = 3 + context = { + "request": request, + "commander": {"name": commander}, + "tags": available_tags, + "recommended": orch.recommended_tags_for_commander(commander), + "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), + "brackets": orch.bracket_options(), + "error": "Please choose a primary theme.", + "primary_tag": primary_tag or "", + "secondary_tag": secondary_tag or "", + "tertiary_tag": tertiary_tag or "", + "selected_bracket": sel_br, + "tag_mode": (tag_mode or "AND"), + "gc_commander": is_gc, + } + context.update( + _partner_ui_context( + commander, + partner_enabled=partner_flag, + secondary_selection=secondary_commander if partner_flag else None, + background_selection=background if partner_flag else None, + combined_preview=None, + warnings=[], + partner_error=None, + auto_note=None, + auto_assigned=None, + auto_prefill_allowed=not auto_opt_out_flag, + ) + ) + partner_tags = context.pop("partner_theme_tags", None) + if partner_tags: + context["tags"] = partner_tags + resp = templates.TemplateResponse("build/_step2.html", context) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + # Enforce bracket restrictions for Game Changer commanders (silently coerce to 3 if needed) + try: + is_gc = bool(commander in getattr(bc, 'GAME_CHANGERS', [])) + except Exception: + is_gc = False + if is_gc: + try: + if int(bracket) < 3: + bracket = 3 # coerce silently + except Exception: + bracket = 3 + + ( + partner_error, + combined_payload, + partner_warnings, + partner_auto_note, + resolved_secondary, + resolved_background, + partner_mode, + partner_auto_assigned_flag, + ) = _resolve_partner_selection( + commander, + feature_enabled=partner_feature_enabled, + partner_enabled=partner_flag, + secondary_candidate=secondary_commander, + background_candidate=background, + auto_opt_out=auto_opt_out_flag, + selection_source=partner_selection_source, + ) + + if partner_error: + try: + sel_br = int(bracket) + except Exception: + sel_br = None + context: dict[str, Any] = { + "request": request, + "commander": {"name": commander}, + "tags": available_tags, + "recommended": orch.recommended_tags_for_commander(commander), + "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), + "brackets": orch.bracket_options(), + "primary_tag": primary_tag or "", + "secondary_tag": secondary_tag or "", + "tertiary_tag": tertiary_tag or "", + "selected_bracket": sel_br, + "tag_mode": (tag_mode or "AND"), + "gc_commander": is_gc, + "error": None, + } + context.update( + _partner_ui_context( + commander, + partner_enabled=partner_flag, + secondary_selection=resolved_secondary or secondary_commander, + background_selection=resolved_background or background, + combined_preview=combined_payload, + warnings=partner_warnings, + partner_error=partner_error, + auto_note=partner_auto_note, + auto_assigned=partner_auto_assigned_flag, + auto_prefill_allowed=not auto_opt_out_flag, + ) + ) + partner_tags = context.pop("partner_theme_tags", None) + if partner_tags: + context["tags"] = partner_tags + resp = templates.TemplateResponse("build/_step2.html", context) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + # Save selection to session (basic MVP; real build will use this later) + sess["commander"] = commander + sess["tags"] = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] + sess["tag_mode"] = (tag_mode or "AND").upper() + sess["bracket"] = int(bracket) + + if partner_flag and combined_payload: + sess["partner_enabled"] = True + if resolved_secondary: + sess["secondary_commander"] = resolved_secondary + else: + sess.pop("secondary_commander", None) + if resolved_background: + sess["background"] = resolved_background + else: + sess.pop("background", None) + if partner_mode: + sess["partner_mode"] = partner_mode + else: + sess.pop("partner_mode", None) + sess["combined_commander"] = combined_payload + sess["partner_warnings"] = partner_warnings + if partner_auto_note: + sess["partner_auto_note"] = partner_auto_note + else: + sess.pop("partner_auto_note", None) + sess["partner_auto_assigned"] = bool(partner_auto_assigned_flag) + sess["partner_auto_opt_out"] = bool(auto_opt_out_flag) + else: + sess["partner_enabled"] = False + for key in [ + "secondary_commander", + "background", + "partner_mode", + "partner_warnings", + "combined_commander", + "partner_auto_note", + ]: + try: + sess.pop(key) + except KeyError: + pass + for key in ["partner_auto_assigned", "partner_auto_opt_out"]: + try: + sess.pop(key) + except KeyError: + pass + + # Clear multi-copy seen/selection to re-evaluate on Step 3 + try: + if "mc_seen_keys" in sess: + del sess["mc_seen_keys"] + if "multi_copy" in sess: + del sess["multi_copy"] + if "mc_applied_key" in sess: + del sess["mc_applied_key"] + except Exception: + pass + # Proceed to Step 3 placeholder for now + sess["last_step"] = 3 + resp = templates.TemplateResponse( + "build/_step3.html", + { + "request": request, + "commander": commander, + "tags": sess["tags"], + "bracket": sess["bracket"], + "defaults": orch.ideal_defaults(), + "labels": orch.ideal_labels(), + "values": orch.ideal_defaults(), + }, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +# ============================================================================ +# Step 3: Ideal Card Counts +# ============================================================================ + +@router.get("/step3", response_class=HTMLResponse) +async def build_step3_get(request: Request) -> HTMLResponse: + """Display ideal card count sliders.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 3 + defaults = orch.ideal_defaults() + values = sess.get("ideals") or defaults + + # Check if any skip flags are enabled to show skeleton automation page + skip_flags = { + "skip_lands": "land selection", + "skip_to_misc": "land selection", + "skip_basics": "basic lands", + "skip_staples": "staple lands", + "skip_kindred": "kindred lands", + "skip_fetches": "fetch lands", + "skip_duals": "dual lands", + "skip_triomes": "triome lands", + "skip_all_creatures": "creature selection", + "skip_creature_primary": "primary creatures", + "skip_creature_secondary": "secondary creatures", + "skip_creature_fill": "creature fills", + "skip_all_spells": "spell selection", + "skip_ramp": "ramp spells", + "skip_removal": "removal spells", + "skip_wipes": "board wipes", + "skip_card_advantage": "card advantage spells", + "skip_protection": "protection spells", + "skip_spell_fill": "spell fills", + } + + active_skips = [desc for key, desc in skip_flags.items() if sess.get(key, False)] + + if active_skips: + # Show skeleton automation page with auto-submit + automation_parts = [] + if any("land" in s for s in active_skips): + automation_parts.append("lands") + if any("creature" in s for s in active_skips): + automation_parts.append("creatures") + if any("spell" in s for s in active_skips): + automation_parts.append("spells") + + automation_message = f"Applying default values for {', '.join(automation_parts)}..." + + resp = templates.TemplateResponse( + "build/_step3_skeleton.html", + { + "request": request, + "defaults": defaults, + "commander": sess.get("commander"), + "automation_message": automation_message, + }, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + # No skips enabled, show normal form + resp = templates.TemplateResponse( + "build/_step3.html", + { + "request": request, + "defaults": defaults, + "labels": orch.ideal_labels(), + "values": values, + "commander": sess.get("commander"), + "tags": sess.get("tags", []), + "bracket": sess.get("bracket"), + }, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +@router.post("/step3", response_class=HTMLResponse) +async def build_step3_submit( + request: Request, + ramp: int = Form(...), + lands: int = Form(...), + basic_lands: int = Form(...), + creatures: int = Form(...), + removal: int = Form(...), + wipes: int = Form(...), + card_advantage: int = Form(...), + protection: int = Form(...), +) -> HTMLResponse: + """Submit ideal card counts, proceed to step 4.""" + labels = orch.ideal_labels() + submitted = { + "ramp": ramp, + "lands": lands, + "basic_lands": basic_lands, + "creatures": creatures, + "removal": removal, + "wipes": wipes, + "card_advantage": card_advantage, + "protection": protection, + } + + errors: list[str] = [] + for k, v in submitted.items(): + try: + iv = int(v) + except Exception: + errors.append(f"{labels.get(k, k)} must be a number.") + continue + if iv < 0: + errors.append(f"{labels.get(k, k)} cannot be negative.") + submitted[k] = iv + # Cross-field validation: basic lands should not exceed total lands + if isinstance(submitted.get("basic_lands"), int) and isinstance(submitted.get("lands"), int): + if submitted["basic_lands"] > submitted["lands"]: + errors.append("Basic Lands cannot exceed Total Lands.") + + if errors: + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 3 + resp = templates.TemplateResponse( + "build/_step3.html", + { + "request": request, + "defaults": orch.ideal_defaults(), + "labels": labels, + "values": submitted, + "error": " ".join(errors), + "commander": sess.get("commander"), + "tags": sess.get("tags", []), + "bracket": sess.get("bracket"), + }, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + # Save to session + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["ideals"] = submitted + # Any change to ideals should clear the applied marker, we may want to re-stage + try: + if "mc_applied_key" in sess: + del sess["mc_applied_key"] + except Exception: + pass + + # Proceed to review (Step 4) + sess["last_step"] = 4 + resp = templates.TemplateResponse( + "build/_step4.html", + { + "request": request, + "labels": labels, + "values": submitted, + "commander": sess.get("commander"), + "owned_only": bool(sess.get("use_owned_only")), + "prefer_owned": bool(sess.get("prefer_owned")), + "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), + }, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +# ============================================================================ +# Step 4: Review and Owned Cards +# ============================================================================ + +@router.get("/step4", response_class=HTMLResponse) +async def build_step4_get(request: Request) -> HTMLResponse: + """Display review page with owned card preferences.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 4 + labels = orch.ideal_labels() + values = sess.get("ideals") or orch.ideal_defaults() + commander = sess.get("commander") + return templates.TemplateResponse( + "build/_step4.html", + { + "request": request, + "labels": labels, + "values": values, + "commander": commander, + "owned_only": bool(sess.get("use_owned_only")), + "prefer_owned": bool(sess.get("prefer_owned")), + "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), + }, + ) + + +@router.post("/toggle-owned-review", response_class=HTMLResponse) +async def build_toggle_owned_review( + request: Request, + use_owned_only: str | None = Form(None), + prefer_owned: str | None = Form(None), + swap_mdfc_basics: str | None = Form(None), +) -> HTMLResponse: + """Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 4 + only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False + pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False + swap_val = True if (swap_mdfc_basics and str(swap_mdfc_basics).strip() in ("1","true","on","yes")) else False + sess["use_owned_only"] = only_val + sess["prefer_owned"] = pref_val + sess["swap_mdfc_basics"] = swap_val + # Do not touch build_ctx here; user hasn't started the build yet from review + labels = orch.ideal_labels() + values = sess.get("ideals") or orch.ideal_defaults() + commander = sess.get("commander") + resp = templates.TemplateResponse( + "build/_step4.html", + { + "request": request, + "labels": labels, + "values": values, + "commander": commander, + "owned_only": bool(sess.get("use_owned_only")), + "prefer_owned": bool(sess.get("prefer_owned")), + "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), + }, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +# ============================================================================ +# Step 5: Build Execution and Results +# ============================================================================ + +@router.get("/step5", response_class=HTMLResponse) +async def build_step5_get(request: Request) -> HTMLResponse: + """Display step 5 initial state (empty/ready to start build).""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 5 + # Default replace-mode to ON unless explicitly toggled off + if "replace_mode" not in sess: + sess["replace_mode"] = True + base = step5_empty_ctx(request, sess) + resp = templates.TemplateResponse("build/_step5.html", base) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}}) + return resp + + +@router.get("/step5/start", response_class=HTMLResponse) +async def build_step5_start_get(request: Request) -> HTMLResponse: + """Allow GET as a fallback to start the build (delegates to POST handler).""" + return await build_step5_start(request) + + +@router.post("/step5/start", response_class=HTMLResponse) +async def build_step5_start(request: Request) -> HTMLResponse: + """Initialize build context and run first stage.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + if "replace_mode" not in sess: + sess["replace_mode"] = True + # Validate commander exists before starting + commander = sess.get("commander") + if not commander: + resp = templates.TemplateResponse( + "build/_step1.html", + {"request": request, "candidates": [], "error": "Please select a commander first."}, + ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + try: + # Initialize step-by-step build context and run first stage + sess["build_ctx"] = start_ctx_from_session(sess) + show_skipped = False + try: + form = await request.form() + show_skipped = True if (form.get('show_skipped') == '1') else False + except Exception: + pass + res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) + # Save summary to session for deck_summary partial to access + if res.get("summary"): + sess["summary"] = res["summary"] + status = "Stage complete" if not res.get("done") else "Build complete" + # If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue + try: + if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): + mc = sess.get("multi_copy") + sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" + except Exception: + pass + # Note: no redirect; the inline compliance panel will render inside Step 5 + sess["last_step"] = 5 + ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) + resp = templates.TemplateResponse("build/_step5.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}}) + return resp + except Exception as e: + # Surface a friendly error on the step 5 screen with normalized context + err_ctx = step5_error_ctx( + request, + sess, + f"Failed to start build: {e}", + include_name=False, + ) + # Ensure commander stays visible if set + err_ctx["commander"] = commander + resp = templates.TemplateResponse("build/_step5.html", err_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) + return resp + + +@router.post("/step5/continue", response_class=HTMLResponse) +async def build_step5_continue(request: Request) -> HTMLResponse: + """Continue to next stage of the build.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + if "replace_mode" not in sess: + sess["replace_mode"] = True + # Validate commander; redirect to step1 if missing + if not sess.get("commander"): + resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."}) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + # Ensure build context exists; if not, start it first + if not sess.get("build_ctx"): + sess["build_ctx"] = start_ctx_from_session(sess) + else: + # If context exists already, rebuild ONLY when the multi-copy selection changed or hasn't been applied yet + try: + mc = sess.get("multi_copy") or None + selkey = None + if mc: + selkey = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" + applied = sess.get("mc_applied_key") if mc else None + if mc and (not applied or applied != selkey): + _rebuild_ctx_with_multicopy(sess) + # If we still have no stages (e.g., minimal test context), inject a minimal multi-copy stage inline + try: + ctx = sess.get("build_ctx") or {} + stages = ctx.get("stages") if isinstance(ctx, dict) else None + if (not stages or len(stages) == 0) and mc: + b = ctx.get("builder") if isinstance(ctx, dict) else None + if b is not None: + try: + setattr(b, "_web_multi_copy", mc) + except Exception: + pass + try: + if not isinstance(getattr(b, "card_library", None), dict): + b.card_library = {} + except Exception: + pass + try: + if not isinstance(getattr(b, "ideal_counts", None), dict): + b.ideal_counts = {} + except Exception: + pass + ctx["stages"] = [{"key": "multicopy", "label": "Multi-Copy Package", "runner_name": "__add_multi_copy__"}] + ctx["idx"] = 0 + ctx["last_visible_idx"] = 0 + except Exception: + pass + except Exception: + pass + # Read show_skipped from either query or form safely + show_skipped = True if (request.query_params.get('show_skipped') == '1') else False + try: + form = await request.form() + if form and form.get('show_skipped') == '1': + show_skipped = True + except Exception: + pass + try: + res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) + status = "Build complete" if res.get("done") else "Stage complete" + # Save summary to session for deck_summary partial to access + if res.get("summary"): + sess["summary"] = res["summary"] + # Keep commander in session for Step 5 display (will be overwritten on next build) + except Exception as e: + sess["last_step"] = 5 + err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}") + resp = templates.TemplateResponse("build/_step5.html", err_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) + return resp + stage_label = res.get("label") + # If we just applied Multi-Copy, stamp the applied key so we don't rebuild again + try: + if stage_label == "Multi-Copy Package" and sess.get("multi_copy"): + mc = sess.get("multi_copy") + sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" + except Exception: + pass + # Note: no redirect; the inline compliance panel will render inside Step 5 + sess["last_step"] = 5 + ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) + resp = templates.TemplateResponse("build/_step5.html", ctx2) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}}) + return resp + + +@router.post("/step5/rerun", response_class=HTMLResponse) +async def build_step5_rerun(request: Request) -> HTMLResponse: + """Rerun current stage with modifications.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + if "replace_mode" not in sess: + sess["replace_mode"] = True + if not sess.get("commander"): + resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": [], "error": "Please select a commander first."}) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + # Rerun requires an existing context; if missing, create it and run first stage as rerun + if not sess.get("build_ctx"): + sess["build_ctx"] = start_ctx_from_session(sess) + else: + # Ensure latest locks are reflected in the existing context + try: + sess["build_ctx"]["locks"] = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} + except Exception: + pass + show_skipped = False + try: + form = await request.form() + show_skipped = True if (form.get('show_skipped') == '1') else False + except Exception: + pass + # If replace-mode is OFF, keep the stage visible even if no new cards were added + if not bool(sess.get("replace_mode", True)): + show_skipped = True + try: + res = orch.run_stage(sess["build_ctx"], rerun=True, show_skipped=show_skipped, replace=bool(sess.get("replace_mode", True))) + # Save summary to session for deck_summary partial to access + if res.get("summary"): + sess["summary"] = res["summary"] + status = "Stage rerun complete" if not res.get("done") else "Build complete" + except Exception as e: + sess["last_step"] = 5 + err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}") + resp = templates.TemplateResponse("build/_step5.html", err_ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) + return resp + sess["last_step"] = 5 + # Build locked cards list with ownership and in-deck presence + locked_cards = [] + try: + ctx = sess.get("build_ctx") or {} + b = ctx.get("builder") if isinstance(ctx, dict) else None + present: set[str] = builder_present_names(b) if b is not None else set() + # Display-map via combined df when available + lock_lower = {str(x).strip().lower() for x in (sess.get("locks", []) or [])} + display_map: dict[str, str] = builder_display_map(b, lock_lower) if b is not None else {} + owned_lower = owned_set_helper() + for nm in (sess.get("locks", []) or []): + key = str(nm).strip().lower() + disp = display_map.get(key, nm) + locked_cards.append({ + "name": disp, + "owned": key in owned_lower, + "in_deck": key in present, + }) + except Exception: + locked_cards = [] + ctx3 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) + ctx3["locked_cards"] = locked_cards + resp = templates.TemplateResponse("build/_step5.html", ctx3) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx3.get("summary_token", 0)}}) + return resp + + +@router.post("/step5/rewind", response_class=HTMLResponse) +async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLResponse: + """Rewind the staged build to a previous visible stage by index or key and show that stage. + + Param `to` can be an integer index (1-based stage index) or a stage key string. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + ctx = sess.get("build_ctx") + if not ctx: + return await build_step5_get(request) + target_i: int | None = None + # Resolve by numeric index first + try: + idx_val = int(str(to).strip()) + target_i = idx_val + except Exception: + target_i = None + if target_i is None: + # attempt by key + key = str(to).strip() + try: + for h in ctx.get("history", []) or []: + if str(h.get("key")) == key or str(h.get("label")) == key: + target_i = int(h.get("i")) + break + except Exception: + target_i = None + if not target_i: + return await build_step5_get(request) + # Try to restore snapshot stored for that history entry + try: + hist = ctx.get("history", []) or [] + snap = None + for h in hist: + if int(h.get("i")) == int(target_i): + snap = h.get("snapshot") + break + if snap is not None: + orch._restore_builder(ctx["builder"], snap) + ctx["idx"] = int(target_i) - 1 + ctx["last_visible_idx"] = int(target_i) - 1 + except Exception: + # As a fallback, restart ctx and run forward until target + sess["build_ctx"] = start_ctx_from_session(sess) + ctx = sess["build_ctx"] + # Run forward until reaching target + while True: + res = orch.run_stage(ctx, rerun=False, show_skipped=False) + if int(res.get("idx", 0)) >= int(target_i): + break + if res.get("done"): + break + # Finally show the target stage by running it with show_skipped True to get a view + try: + res = orch.run_stage(ctx, rerun=False, show_skipped=True) + status = "Stage (rewound)" if not res.get("done") else "Build complete" + ctx_resp = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=True, extras={ + "history": ctx.get("history", []), + }) + except Exception as e: + sess["last_step"] = 5 + ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}") + resp = templates.TemplateResponse("build/_step5.html", ctx_resp) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx_resp.get("summary_token", 0)}}) + return resp + + +@router.post("/step5/toggle-replace") +async def build_step5_toggle_replace(request: Request, replace: str = Form("0")): + """Toggle replace-mode for reruns and return an updated button HTML.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + enabled = True if str(replace).strip() in ("1","true","on","yes") else False + sess["replace_mode"] = enabled + # Return the checkbox control snippet (same as template) + checked = 'checked' if enabled else '' + html = ( + '
    ' + '
    ' + f'' + '' + '
    ' + '
    ' + ) + return HTMLResponse(html) + + +@router.post("/step5/reset-stage", response_class=HTMLResponse) +async def build_step5_reset_stage(request: Request) -> HTMLResponse: + """Reset current visible stage to the pre-stage snapshot (if available) without running it.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + ctx = sess.get("build_ctx") + if not ctx or not ctx.get("snapshot"): + return await build_step5_get(request) + try: + orch._restore_builder(ctx["builder"], ctx["snapshot"]) + except Exception: + return await build_step5_get(request) + # Re-render step 5 with cleared added list + base = step5_empty_ctx(request, sess, extras={ + "status": "Stage reset", + "i": ctx.get("idx"), + "n": len(ctx.get("stages", [])), + }) + resp = templates.TemplateResponse("build/_step5.html", base) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}}) + return resp + + +@router.get("/step5/summary", response_class=HTMLResponse) +async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLResponse: + """Render deck summary panel for step 5 if build is ready.""" + sid = request.cookies.get("sid") or request.headers.get("X-Session-ID") + if not sid: + sid = new_sid() + sess = get_session(sid) + + try: + session_token = int(sess.get("step5_summary_token", 0)) + except Exception: + session_token = 0 + try: + requested_token = int(token) + except Exception: + requested_token = 0 + ready = bool(sess.get("step5_summary_ready")) + summary_data = sess.get("step5_summary") if ready else None + if summary_data is None and ready: + summary_data = _current_builder_summary(sess) + if summary_data is not None: + try: + sess["step5_summary"] = summary_data + except Exception: + pass + + synergies: list[str] = [] + try: + raw_synergies = sess.get("step5_synergies") + if isinstance(raw_synergies, (list, tuple, set)): + synergies = [str(item) for item in raw_synergies if str(item).strip()] + except Exception: + synergies = [] + + active_token = session_token if session_token >= requested_token else requested_token + + if not ready or summary_data is None: + message = "Deck summary will appear after the build completes." if not ready else "Deck summary is not available yet. Try rerunning the current stage." + placeholder = _step5_summary_placeholder_html(active_token, message=message) + response = HTMLResponse(placeholder) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response + + ctx = step5_base_ctx(request, sess) + ctx["summary"] = summary_data + ctx["synergies"] = synergies + ctx["summary_ready"] = True + ctx["summary_token"] = active_token + + # Add commander hover context for color identity and theme tags + hover_meta = commander_hover_context( + commander_name=ctx.get("commander"), + deck_tags=sess.get("tags"), + summary=summary_data, + combined=ctx.get("combined_commander"), + ) + ctx.update(hover_meta) + + # Add hover_tags_joined for template if missing + if "hover_tags_joined" not in ctx: + hover_tags_source = ctx.get("deck_theme_tags") if ctx.get("deck_theme_tags") else ctx.get("commander_combined_tags") + if hover_tags_source: + ctx["hover_tags_joined"] = ", ".join(str(t) for t in hover_tags_source) + + response = templates.TemplateResponse("partials/deck_summary.html", ctx) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response + + +# ============================================================================ +# Utility Routes +# ============================================================================ + +@router.get("/banner", response_class=HTMLResponse) +async def build_banner(request: Request, step: str = "", i: int | None = None, n: int | None = None) -> HTMLResponse: + """Render dynamic wizard banner subtitle.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + commander = sess.get("commander") + tags = sess.get("tags", []) + # Render only the inner text for the subtitle + return templates.TemplateResponse( + "build/_banner_subtitle.html", + {"request": request, "commander": commander, "tags": tags, "name": sess.get("custom_export_base")}, + ) + + +# ============================================================================ +# Combo & Synergy Panel +# ============================================================================ + +@router.get("/combos", response_class=HTMLResponse) +async def build_combos_panel(request: Request) -> HTMLResponse: + """Display combo and synergy detection panel.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + names = _get_current_deck_names(sess) + if not names: + # No active build; render nothing to avoid UI clutter + return HTMLResponse("") + + # Preferences (persisted in session) + policy = (sess.get("combos_policy") or "neutral").lower() + if policy not in {"avoid", "neutral", "prefer"}: + policy = "neutral" + try: + target = int(sess.get("combos_target") or 0) + except Exception: + target = 0 + if target < 0: + target = 0 + + # Load lists and run detection + _det = _detect_all(names) + combos = _det.get("combos", []) + synergies = _det.get("synergies", []) + combos_model = _det.get("combos_model") + synergies_model = _det.get("synergies_model") + + # Suggestions + suggestions: list[dict] = [] + present = {s.strip().lower() for s in names} + suggested_names: set[str] = set() + if combos_model is not None: + # Prefer policy: suggest adding a missing partner to hit target count + if policy == "prefer": + try: + for p in combos_model.pairs: + a = str(p.a).strip() + b = str(p.b).strip() + a_in = a.lower() in present + b_in = b.lower() in present + if a_in ^ b_in: # exactly one present + missing = b if a_in else a + have = a if a_in else b + item = { + "kind": "add", + "have": have, + "name": missing, + "cheap_early": bool(getattr(p, "cheap_early", False)), + "setup_dependent": bool(getattr(p, "setup_dependent", False)), + } + key = str(missing).strip().lower() + if key not in present and key not in suggested_names: + suggestions.append(item) + suggested_names.add(key) + # Rank: cheap/early first, then setup-dependent, then name + suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower())) + # If we still have room below target, add synergy-based suggestions + rem = (max(0, int(target)) if target > 0 else 8) - len(suggestions) + if rem > 0 and synergies_model is not None: + # lightweight tag weights to bias common engines + weights = { + "treasure": 3.0, "tokens": 2.8, "landfall": 2.6, "card draw": 2.5, "ramp": 2.3, + "engine": 2.2, "value": 2.1, "artifacts": 2.0, "enchantress": 2.0, "spellslinger": 1.9, + "counters": 1.8, "equipment matters": 1.7, "tribal": 1.6, "lifegain": 1.5, "mill": 1.4, + "damage": 1.3, "stax": 1.2 + } + syn_sugs: list[dict] = [] + for p in synergies_model.pairs: + a = str(p.a).strip() + b = str(p.b).strip() + a_in = a.lower() in present + b_in = b.lower() in present + if a_in ^ b_in: + missing = b if a_in else a + have = a if a_in else b + mkey = missing.strip().lower() + if mkey in present or mkey in suggested_names: + continue + tags = list(getattr(p, "tags", []) or []) + score = 1.0 + sum(weights.get(str(t).lower(), 1.0) for t in tags) / max(1, len(tags) or 1) + syn_sugs.append({ + "kind": "add", + "have": have, + "name": missing, + "cheap_early": False, + "setup_dependent": False, + "tags": tags, + "_score": score, + }) + suggested_names.add(mkey) + # rank by score desc then name + syn_sugs.sort(key=lambda s: (-float(s.get("_score", 0.0)), str(s.get("name")).lower())) + if rem > 0: + suggestions.extend(syn_sugs[:rem]) + # Finally trim to target or default cap + cap = (int(target) if target > 0 else 8) + suggestions = suggestions[:cap] + except Exception: + suggestions = [] + elif policy == "avoid": + # Avoid policy: suggest cutting one piece from detected combos + try: + for c in combos: + # pick the second card as default cut to vary suggestions + suggestions.append({ + "kind": "cut", + "name": c.b, + "partner": c.a, + "cheap_early": bool(getattr(c, "cheap_early", False)), + "setup_dependent": bool(getattr(c, "setup_dependent", False)), + }) + # Rank: cheap/early first + suggestions.sort(key=lambda s: (0 if s.get("cheap_early") else 1, 0 if s.get("setup_dependent") else 1, str(s.get("name")).lower())) + if target > 0: + suggestions = suggestions[: target] + else: + suggestions = suggestions[: 8] + except Exception: + suggestions = [] + + ctx = { + "request": request, + "policy": policy, + "target": target, + "combos": combos, + "synergies": synergies, + "versions": _det.get("versions", {}), + "suggestions": suggestions, + } + return templates.TemplateResponse("build/_combos_panel.html", ctx) + + +@router.post("/combos/prefs", response_class=HTMLResponse) +async def build_combos_save_prefs(request: Request, policy: str = Form("neutral"), target: int = Form(0)) -> HTMLResponse: + """Save combo preferences and re-render panel.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + pol = (policy or "neutral").strip().lower() + if pol not in {"avoid", "neutral", "prefer"}: + pol = "neutral" + try: + tgt = int(target) + except Exception: + tgt = 0 + if tgt < 0: + tgt = 0 + sess["combos_policy"] = pol + sess["combos_target"] = tgt + # Re-render the panel + return await build_combos_panel(request) diff --git a/code/web/routes/partner_suggestions.py b/code/web/routes/partner_suggestions.py index 00c21d2..8d9f30b 100644 --- a/code/web/routes/partner_suggestions.py +++ b/code/web/routes/partner_suggestions.py @@ -2,14 +2,15 @@ from __future__ import annotations from typing import Iterable, List, Optional -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, Query, Request from fastapi.responses import JSONResponse from deck_builder.combined_commander import PartnerMode -from ..app import ENABLE_PARTNER_MECHANICS, ENABLE_PARTNER_SUGGESTIONS +from ..app import ENABLE_PARTNER_MECHANICS from ..services.partner_suggestions import get_partner_suggestions from ..services.telemetry import log_partner_suggestions_generated +from code.exceptions import CommanderValidationError, FeatureDisabledError router = APIRouter(prefix="/api/partner", tags=["partner suggestions"]) @@ -64,12 +65,12 @@ async def partner_suggestions_api( mode: Optional[List[str]] = Query(None, description="Restrict results to specific partner modes"), refresh: bool = Query(False, description="When true, force a dataset refresh before scoring"), ): - if not (ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS): - raise HTTPException(status_code=404, detail="Partner suggestions are disabled") + if not ENABLE_PARTNER_MECHANICS: + raise FeatureDisabledError("partner_suggestions") commander_name = (commander or "").strip() if not commander_name: - raise HTTPException(status_code=400, detail="Commander name is required") + raise CommanderValidationError("Commander name is required") include_modes = _parse_modes(mode) result = get_partner_suggestions( @@ -79,7 +80,7 @@ async def partner_suggestions_api( refresh_dataset=refresh, ) if result is None: - raise HTTPException(status_code=503, detail="Partner suggestion dataset is unavailable") + raise FeatureDisabledError("partner_suggestion_dataset") partner_names = _coerce_name_list(partner) background_names = _coerce_name_list(background) diff --git a/code/web/services/base.py b/code/web/services/base.py new file mode 100644 index 0000000..e2bab2c --- /dev/null +++ b/code/web/services/base.py @@ -0,0 +1,306 @@ +"""Base classes for web services. + +Provides standardized patterns for service layer implementation including +state management, data loading, and caching. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, Generic, Optional, TypeVar +import threading +import time + + +T = TypeVar("T") +K = TypeVar("K") +V = TypeVar("V") + + +class ServiceError(Exception): + """Base exception for service layer errors.""" + pass + + +class ValidationError(ServiceError): + """Validation failed.""" + pass + + +class NotFoundError(ServiceError): + """Resource not found.""" + pass + + +class BaseService(ABC): + """Abstract base class for all services. + + Provides common patterns for initialization, validation, and error handling. + Services should be stateless where possible and inject dependencies via __init__. + """ + + def __init__(self) -> None: + """Initialize service. Override in subclasses to inject dependencies.""" + pass + + def _validate(self, condition: bool, message: str) -> None: + """Validate a condition, raise ValidationError if false. + + Args: + condition: Condition to check + message: Error message if validation fails + + Raises: + ValidationError: If condition is False + """ + if not condition: + raise ValidationError(message) + + +class StateService(BaseService): + """Base class for services that manage mutable state. + + Provides thread-safe state management with automatic cleanup. + Subclasses should implement _initialize_state and _should_cleanup. + """ + + def __init__(self) -> None: + super().__init__() + self._state: Dict[str, Dict[str, Any]] = {} + self._lock = threading.RLock() + + def get_state(self, key: str) -> Dict[str, Any]: + """Get or create state for a key. + + Args: + key: State key (e.g., session ID) + + Returns: + State dictionary + """ + with self._lock: + if key not in self._state: + self._state[key] = self._initialize_state(key) + return self._state[key] + + def set_state_value(self, key: str, field: str, value: Any) -> None: + """Set a field in state. + + Args: + key: State key + field: Field name + value: Value to set + """ + with self._lock: + state = self.get_state(key) + state[field] = value + + def get_state_value(self, key: str, field: str, default: Any = None) -> Any: + """Get a field from state. + + Args: + key: State key + field: Field name + default: Default value if field not found + + Returns: + Field value or default + """ + with self._lock: + state = self.get_state(key) + return state.get(field, default) + + def cleanup_state(self) -> int: + """Clean up expired or invalid state. + + Returns: + Number of entries cleaned up + """ + with self._lock: + to_remove = [k for k, v in self._state.items() if self._should_cleanup(k, v)] + for key in to_remove: + del self._state[key] + return len(to_remove) + + @abstractmethod + def _initialize_state(self, key: str) -> Dict[str, Any]: + """Initialize state for a new key. + + Args: + key: State key + + Returns: + Initial state dictionary + """ + pass + + @abstractmethod + def _should_cleanup(self, key: str, state: Dict[str, Any]) -> bool: + """Check if state should be cleaned up. + + Args: + key: State key + state: State dictionary + + Returns: + True if state should be removed + """ + pass + + +class DataService(BaseService, Generic[T]): + """Base class for services that load and manage data. + + Provides patterns for lazy loading, validation, and refresh. + Subclasses should implement _load_data. + """ + + def __init__(self) -> None: + super().__init__() + self._data: Optional[T] = None + self._loaded = False + self._lock = threading.RLock() + + def get_data(self, force_reload: bool = False) -> T: + """Get data, loading if necessary. + + Args: + force_reload: Force reload even if already loaded + + Returns: + Loaded data + + Raises: + ServiceError: If data loading fails + """ + with self._lock: + if force_reload or not self._loaded: + self._data = self._load_data() + self._loaded = True + if self._data is None: + raise ServiceError("Failed to load data") + return self._data + + def is_loaded(self) -> bool: + """Check if data is loaded. + + Returns: + True if data is loaded + """ + with self._lock: + return self._loaded + + def reload(self) -> T: + """Force reload data. + + Returns: + Reloaded data + """ + return self.get_data(force_reload=True) + + @abstractmethod + def _load_data(self) -> T: + """Load data from source. + + Returns: + Loaded data + + Raises: + ServiceError: If loading fails + """ + pass + + +class CachedService(BaseService, Generic[K, V]): + """Base class for services with caching behavior. + + Provides thread-safe caching with TTL and size limits. + Subclasses should implement _compute_value. + """ + + def __init__(self, ttl_seconds: Optional[int] = None, max_size: Optional[int] = None) -> None: + """Initialize cached service. + + Args: + ttl_seconds: Time-to-live for cache entries (None = no expiration) + max_size: Maximum cache size (None = no limit) + """ + super().__init__() + self._cache: Dict[K, tuple[V, float]] = {} + self._lock = threading.RLock() + self._ttl_seconds = ttl_seconds + self._max_size = max_size + + def get(self, key: K, force_recompute: bool = False) -> V: + """Get cached value or compute it. + + Args: + key: Cache key + force_recompute: Force recompute even if cached + + Returns: + Cached or computed value + """ + with self._lock: + now = time.time() + + # Check cache + if not force_recompute and key in self._cache: + value, timestamp = self._cache[key] + if self._ttl_seconds is None or (now - timestamp) < self._ttl_seconds: + return value + + # Compute new value + value = self._compute_value(key) + + # Store in cache + self._cache[key] = (value, now) + + # Enforce size limit (simple LRU: remove oldest) + if self._max_size is not None and len(self._cache) > self._max_size: + oldest_key = min(self._cache.keys(), key=lambda k: self._cache[k][1]) + del self._cache[oldest_key] + + return value + + def invalidate(self, key: Optional[K] = None) -> None: + """Invalidate cache entry or entire cache. + + Args: + key: Cache key to invalidate (None = invalidate all) + """ + with self._lock: + if key is None: + self._cache.clear() + elif key in self._cache: + del self._cache[key] + + def cleanup_expired(self) -> int: + """Remove expired cache entries. + + Returns: + Number of entries removed + """ + if self._ttl_seconds is None: + return 0 + + with self._lock: + now = time.time() + expired = [k for k, (_, ts) in self._cache.items() if (now - ts) >= self._ttl_seconds] + for key in expired: + del self._cache[key] + return len(expired) + + @abstractmethod + def _compute_value(self, key: K) -> V: + """Compute value for a cache key. + + Args: + key: Cache key + + Returns: + Computed value + + Raises: + ServiceError: If computation fails + """ + pass diff --git a/code/web/services/interfaces.py b/code/web/services/interfaces.py new file mode 100644 index 0000000..f58a771 --- /dev/null +++ b/code/web/services/interfaces.py @@ -0,0 +1,318 @@ +"""Service interfaces using Protocol for structural typing. + +Defines contracts for different types of services without requiring inheritance. +Use these for type hints and dependency injection. +""" +from __future__ import annotations + +from typing import Protocol, Any, Dict, List, Optional, TypeVar, runtime_checkable +import pandas as pd + + +T = TypeVar("T") +K = TypeVar("K") +V = TypeVar("V") + + +@runtime_checkable +class SessionService(Protocol): + """Interface for session management services.""" + + def new_session_id(self) -> str: + """Create a new session ID. + + Returns: + Unique session identifier + """ + ... + + def get_session(self, session_id: Optional[str]) -> Dict[str, Any]: + """Get or create session state. + + Args: + session_id: Session identifier (creates new if None) + + Returns: + Session state dictionary + """ + ... + + def set_value(self, session_id: str, key: str, value: Any) -> None: + """Set a value in session state. + + Args: + session_id: Session identifier + key: State key + value: Value to store + """ + ... + + def get_value(self, session_id: str, key: str, default: Any = None) -> Any: + """Get a value from session state. + + Args: + session_id: Session identifier + key: State key + default: Default value if key not found + + Returns: + Stored value or default + """ + ... + + def cleanup_expired(self) -> int: + """Clean up expired sessions. + + Returns: + Number of sessions cleaned up + """ + ... + + +@runtime_checkable +class CardLoaderService(Protocol): + """Interface for card data loading services.""" + + def get_cards(self, force_reload: bool = False) -> pd.DataFrame: + """Get card data. + + Args: + force_reload: Force reload from source + + Returns: + DataFrame with card data + """ + ... + + def is_loaded(self) -> bool: + """Check if card data is loaded. + + Returns: + True if data is loaded + """ + ... + + +@runtime_checkable +class CatalogService(Protocol): + """Interface for catalog services (commanders, themes, etc.).""" + + def get_catalog(self, force_reload: bool = False) -> pd.DataFrame: + """Get catalog data. + + Args: + force_reload: Force reload from source + + Returns: + DataFrame with catalog data + """ + ... + + def search(self, query: str, **filters: Any) -> pd.DataFrame: + """Search catalog with filters. + + Args: + query: Search query string + **filters: Additional filters + + Returns: + Filtered DataFrame + """ + ... + + +@runtime_checkable +class OwnedCardsService(Protocol): + """Interface for owned cards management.""" + + def get_owned_names(self) -> List[str]: + """Get list of owned card names. + + Returns: + List of card names + """ + ... + + def add_owned_names(self, names: List[str]) -> None: + """Add card names to owned list. + + Args: + names: Card names to add + """ + ... + + def remove_owned_name(self, name: str) -> bool: + """Remove a card name from owned list. + + Args: + name: Card name to remove + + Returns: + True if removed, False if not found + """ + ... + + def clear_owned(self) -> None: + """Clear all owned cards.""" + ... + + def import_from_file(self, file_content: str, format_type: str) -> int: + """Import owned cards from file content. + + Args: + file_content: File content to parse + format_type: Format type (csv, txt, etc.) + + Returns: + Number of cards imported + """ + ... + + +@runtime_checkable +class CacheService(Protocol[K, V]): + """Interface for caching services.""" + + def get(self, key: K, default: Optional[V] = None) -> Optional[V]: + """Get cached value. + + Args: + key: Cache key + default: Default value if not found + + Returns: + Cached value or default + """ + ... + + def set(self, key: K, value: V, ttl: Optional[int] = None) -> None: + """Set cached value. + + Args: + key: Cache key + value: Value to cache + ttl: Time-to-live in seconds (None = no expiration) + """ + ... + + def invalidate(self, key: Optional[K] = None) -> None: + """Invalidate cache entry or entire cache. + + Args: + key: Cache key (None = invalidate all) + """ + ... + + def cleanup_expired(self) -> int: + """Remove expired cache entries. + + Returns: + Number of entries removed + """ + ... + + +@runtime_checkable +class BuildOrchestratorService(Protocol): + """Interface for deck build orchestration.""" + + def orchestrate_build( + self, + session_id: str, + commander_name: str, + theme_tags: List[str], + **options: Any + ) -> Dict[str, Any]: + """Orchestrate a deck build. + + Args: + session_id: Session identifier + commander_name: Commander card name + theme_tags: List of theme tags + **options: Additional build options + + Returns: + Build result dictionary + """ + ... + + def get_build_status(self, session_id: str) -> Dict[str, Any]: + """Get build status for a session. + + Args: + session_id: Session identifier + + Returns: + Build status dictionary + """ + ... + + +@runtime_checkable +class ValidationService(Protocol): + """Interface for validation services.""" + + def validate_commander(self, name: str) -> tuple[bool, Optional[str]]: + """Validate commander name. + + Args: + name: Card name + + Returns: + (is_valid, error_message) tuple + """ + ... + + def validate_themes(self, themes: List[str]) -> tuple[bool, List[str]]: + """Validate theme tags. + + Args: + themes: List of theme tags + + Returns: + (is_valid, invalid_themes) tuple + """ + ... + + def normalize_card_name(self, name: str) -> str: + """Normalize card name for lookups. + + Args: + name: Raw card name + + Returns: + Normalized card name + """ + ... + + +@runtime_checkable +class TelemetryService(Protocol): + """Interface for telemetry/metrics services.""" + + def record_event(self, event_type: str, properties: Optional[Dict[str, Any]] = None) -> None: + """Record a telemetry event. + + Args: + event_type: Type of event + properties: Event properties + """ + ... + + def record_timing(self, operation: str, duration_ms: float) -> None: + """Record operation timing. + + Args: + operation: Operation name + duration_ms: Duration in milliseconds + """ + ... + + def increment_counter(self, counter_name: str, value: int = 1) -> None: + """Increment a counter. + + Args: + counter_name: Counter name + value: Increment value + """ + ... diff --git a/code/web/services/registry.py b/code/web/services/registry.py new file mode 100644 index 0000000..a03b8a4 --- /dev/null +++ b/code/web/services/registry.py @@ -0,0 +1,202 @@ +"""Service registry for dependency injection. + +Provides a centralized registry for managing service instances and dependencies. +Supports singleton and factory patterns with thread-safe access. +""" +from __future__ import annotations + +from typing import Any, Callable, Dict, Optional, Type, TypeVar, cast +import threading + + +T = TypeVar("T") + + +class ServiceRegistry: + """Thread-safe service registry for dependency injection. + + Manages service instances and factories with support for: + - Singleton services (one instance per registry) + - Factory services (new instance per request) + - Lazy initialization + - Thread-safe access + + Example: + registry = ServiceRegistry() + registry.register_singleton(SessionService, session_service_instance) + registry.register_factory(BuildService, lambda: BuildService(deps...)) + + # Get services + session_svc = registry.get(SessionService) + build_svc = registry.get(BuildService) + """ + + def __init__(self) -> None: + """Initialize empty registry.""" + self._singletons: Dict[Type[Any], Any] = {} + self._factories: Dict[Type[Any], Callable[[], Any]] = {} + self._lock = threading.RLock() + + def register_singleton(self, service_type: Type[T], instance: T) -> None: + """Register a singleton service instance. + + Args: + service_type: Service type/interface + instance: Service instance to register + + Raises: + ValueError: If service already registered + """ + with self._lock: + if service_type in self._singletons or service_type in self._factories: + raise ValueError(f"Service {service_type.__name__} already registered") + self._singletons[service_type] = instance + + def register_factory(self, service_type: Type[T], factory: Callable[[], T]) -> None: + """Register a factory for creating service instances. + + Args: + service_type: Service type/interface + factory: Factory function that returns service instance + + Raises: + ValueError: If service already registered + """ + with self._lock: + if service_type in self._singletons or service_type in self._factories: + raise ValueError(f"Service {service_type.__name__} already registered") + self._factories[service_type] = factory + + def register_lazy_singleton(self, service_type: Type[T], factory: Callable[[], T]) -> None: + """Register a lazy-initialized singleton service. + + The factory will be called once on first access, then the instance is cached. + + Args: + service_type: Service type/interface + factory: Factory function that returns service instance + + Raises: + ValueError: If service already registered + """ + with self._lock: + if service_type in self._singletons or service_type in self._factories: + raise ValueError(f"Service {service_type.__name__} already registered") + + # Wrap factory to cache result + instance_cache: Dict[str, Any] = {} + + def lazy_factory() -> T: + if "instance" not in instance_cache: + instance_cache["instance"] = factory() + return instance_cache["instance"] + + self._factories[service_type] = lazy_factory + + def get(self, service_type: Type[T]) -> T: + """Get service instance. + + Args: + service_type: Service type/interface + + Returns: + Service instance + + Raises: + KeyError: If service not registered + """ + with self._lock: + # Check singletons first + if service_type in self._singletons: + return cast(T, self._singletons[service_type]) + + # Check factories + if service_type in self._factories: + return cast(T, self._factories[service_type]()) + + raise KeyError(f"Service {service_type.__name__} not registered") + + def try_get(self, service_type: Type[T]) -> Optional[T]: + """Try to get service instance, return None if not registered. + + Args: + service_type: Service type/interface + + Returns: + Service instance or None + """ + try: + return self.get(service_type) + except KeyError: + return None + + def is_registered(self, service_type: Type[Any]) -> bool: + """Check if service is registered. + + Args: + service_type: Service type/interface + + Returns: + True if registered + """ + with self._lock: + return service_type in self._singletons or service_type in self._factories + + def unregister(self, service_type: Type[Any]) -> None: + """Unregister a service. + + Args: + service_type: Service type/interface + """ + with self._lock: + self._singletons.pop(service_type, None) + self._factories.pop(service_type, None) + + def clear(self) -> None: + """Clear all registered services.""" + with self._lock: + self._singletons.clear() + self._factories.clear() + + def get_registered_types(self) -> list[Type[Any]]: + """Get list of all registered service types. + + Returns: + List of service types + """ + with self._lock: + return list(self._singletons.keys()) + list(self._factories.keys()) + + +# Global registry instance +_global_registry: Optional[ServiceRegistry] = None +_global_registry_lock = threading.Lock() + + +def get_registry() -> ServiceRegistry: + """Get the global service registry instance. + + Creates registry on first access (lazy initialization). + + Returns: + Global ServiceRegistry instance + """ + global _global_registry + + if _global_registry is None: + with _global_registry_lock: + if _global_registry is None: + _global_registry = ServiceRegistry() + + return _global_registry + + +def reset_registry() -> None: + """Reset the global registry (primarily for testing). + + Clears all registered services and creates a new registry instance. + """ + global _global_registry + + with _global_registry_lock: + _global_registry = ServiceRegistry() diff --git a/code/web/services/tasks.py b/code/web/services/tasks.py index 48b40ae..a548252 100644 --- a/code/web/services/tasks.py +++ b/code/web/services/tasks.py @@ -4,45 +4,194 @@ import time import uuid from typing import Dict, Any, Optional -# Extremely simple in-memory session/task store for MVP -_SESSIONS: Dict[str, Dict[str, Any]] = {} -_TTL_SECONDS = 60 * 60 * 8 # 8 hours +from .base import StateService +from .interfaces import SessionService +# Session TTL: 8 hours +SESSION_TTL_SECONDS = 60 * 60 * 8 + + +class SessionManager(StateService): + """Session management service. + + Manages user sessions with automatic TTL-based cleanup. + Thread-safe with in-memory storage. + """ + + def __init__(self, ttl_seconds: int = SESSION_TTL_SECONDS) -> None: + """Initialize session manager. + + Args: + ttl_seconds: Session time-to-live in seconds + """ + super().__init__() + self._ttl_seconds = ttl_seconds + + def new_session_id(self) -> str: + """Create a new session ID. + + Returns: + Unique session identifier + """ + return uuid.uuid4().hex + + def touch_session(self, session_id: str) -> Dict[str, Any]: + """Update session last access time. + + Args: + session_id: Session identifier + + Returns: + Session state dictionary + """ + now = time.time() + state = self.get_state(session_id) + state["updated"] = now + return state + + def get_session(self, session_id: Optional[str]) -> Dict[str, Any]: + """Get or create session state. + + Args: + session_id: Session identifier (creates new if None) + + Returns: + Session state dictionary + """ + if not session_id: + session_id = self.new_session_id() + return self.touch_session(session_id) + + def set_value(self, session_id: str, key: str, value: Any) -> None: + """Set a value in session state. + + Args: + session_id: Session identifier + key: State key + value: Value to store + """ + self.touch_session(session_id)[key] = value + + def get_value(self, session_id: str, key: str, default: Any = None) -> Any: + """Get a value from session state. + + Args: + session_id: Session identifier + key: State key + default: Default value if key not found + + Returns: + Stored value or default + """ + return self.touch_session(session_id).get(key, default) + + def _initialize_state(self, key: str) -> Dict[str, Any]: + """Initialize state for a new session. + + Args: + key: Session ID + + Returns: + Initial session state + """ + now = time.time() + return {"created": now, "updated": now} + + def _should_cleanup(self, key: str, state: Dict[str, Any]) -> bool: + """Check if session should be cleaned up. + + Args: + key: Session ID + state: Session state + + Returns: + True if session is expired + """ + now = time.time() + updated = state.get("updated", 0) + return (now - updated) > self._ttl_seconds + + +# Global session manager instance +_session_manager: Optional[SessionManager] = None + + +def _get_manager() -> SessionManager: + """Get or create global session manager instance. + + Returns: + SessionManager instance + """ + global _session_manager + if _session_manager is None: + _session_manager = SessionManager() + return _session_manager + + +# Backward-compatible function API def new_sid() -> str: - return uuid.uuid4().hex + """Create a new session ID. + + Returns: + Unique session identifier + """ + return _get_manager().new_session_id() def touch_session(sid: str) -> Dict[str, Any]: - now = time.time() - s = _SESSIONS.get(sid) - if not s: - s = {"created": now, "updated": now} - _SESSIONS[sid] = s - else: - s["updated"] = now - return s + """Update session last access time. + + Args: + sid: Session identifier + + Returns: + Session state dictionary + """ + return _get_manager().touch_session(sid) def get_session(sid: Optional[str]) -> Dict[str, Any]: - if not sid: - sid = new_sid() - return touch_session(sid) + """Get or create session state. + + Args: + sid: Session identifier (creates new if None) + + Returns: + Session state dictionary + """ + return _get_manager().get_session(sid) def set_session_value(sid: str, key: str, value: Any) -> None: - touch_session(sid)[key] = value + """Set a value in session state. + + Args: + sid: Session identifier + key: State key + value: Value to store + """ + _get_manager().set_value(sid, key, value) def get_session_value(sid: str, key: str, default: Any = None) -> Any: - return touch_session(sid).get(key, default) + """Get a value from session state. + + Args: + sid: Session identifier + key: State key + default: Default value if key not found + + Returns: + Stored value or default + """ + return _get_manager().get_value(sid, key, default) -def cleanup_expired() -> None: - now = time.time() - expired = [sid for sid, s in _SESSIONS.items() if now - s.get("updated", 0) > _TTL_SECONDS] - for sid in expired: - try: - del _SESSIONS[sid] - except Exception: - pass +def cleanup_expired() -> int: + """Clean up expired sessions. + + Returns: + Number of sessions cleaned up + """ + return _get_manager().cleanup_state() diff --git a/code/web/templates/build/_new_deck_modal.html b/code/web/templates/build/_new_deck_modal.html index 95acee1..0000fcd 100644 --- a/code/web/templates/build/_new_deck_modal.html +++ b/code/web/templates/build/_new_deck_modal.html @@ -1137,7 +1137,7 @@ .then(function(r){ return r.text(); }) .then(function(html){ slot.innerHTML = html; }) .catch(function(){ slot.innerHTML = ''; }); - }catch(_){ } + }catch(e){ } } // Listen for OOB updates to the tags slot to trigger fetch document.body.addEventListener('htmx:afterSwap', function(ev){ diff --git a/code/web/templates/build/_new_deck_multicopy.html b/code/web/templates/build/_new_deck_multicopy.html index f5b976b..c636a1f 100644 --- a/code/web/templates/build/_new_deck_multicopy.html +++ b/code/web/templates/build/_new_deck_multicopy.html @@ -4,7 +4,7 @@
    We detected a viable multi-copy archetype for your commander/themes. Choose one or skip.
    {% for it in items %} -