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 c9a83ac..df68cb5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,7 +14,38 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- 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)
@@ -98,6 +129,10 @@ 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)
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 1e77f83..ecf1b93 100644
--- a/RELEASE_NOTES_TEMPLATE.md
+++ b/RELEASE_NOTES_TEMPLATE.md
@@ -3,7 +3,7 @@
## [Unreleased]
### Summary
-Backend standardization infrastructure, 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-M3 validated): response builders, telemetry, service layer, and validation framework. 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
- **Backend Standardization Framework**: Improved code organization and maintainability
@@ -11,8 +11,18 @@ Backend standardization infrastructure, web UI improvements with Tailwind CSS mi
- 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)
@@ -96,6 +106,15 @@ Backend standardization infrastructure, web UI improvements with Tailwind CSS mi
_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
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/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 e7bd427..fb468d4 100644
--- a/code/web/app.py
+++ b/code/web/app.py
@@ -166,6 +166,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 +174,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 +310,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,
@@ -2261,6 +2260,10 @@ 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
@@ -2279,6 +2282,10 @@ 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)
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 1eebaf1..1e7d729 100644
--- a/code/web/routes/build.py
+++ b/code/web/routes/build.py
@@ -1,73 +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
-from .build_themes import _custom_theme_context, _INVALID_THEME_MESSAGE # noqa: E402
-from .build_partners import ( # noqa: E402
- _partner_ui_context,
- _resolve_partner_selection,
- _scryfall_image_url,
- _scryfall_page_url,
-)
-
-LOGGER = get_logger(__name__)
+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:
@@ -105,8 +46,6 @@ def _step5_summary_placeholder_html(token: int, *, message: str | None = None) -
''
)
-# Include/Exclude helpers moved to build_include_exclude.py
-
def _current_builder_summary(sess: dict) -> Any | None:
try:
@@ -116,24 +55,18 @@ 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
-# Partner helpers moved to build_partners.py
-
router = APIRouter(prefix="/build")
-# Include/Exclude routes moved to build_include_exclude.py
-
-# Alternatives cache moved to services/alts_utils
-
-# Partner routes moved to build_partners.py
-
-# Theme helpers moved to build_themes.py
-
@router.get("/", response_class=HTMLResponse)
async def build_index(request: Request) -> HTMLResponse:
@@ -214,2513 +147,6 @@ async def build_index_alias(request: Request) -> HTMLResponse:
return await build_index(request)
-# 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)
-
-# Theme routes moved to build_themes.py
-
-
-@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 = (
- '
'
- ''
- '
'
- )
- 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."""
@@ -2782,1426 +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.
'
- '
'
- ''
- ''
- ''
- '
'
- '
'
- )
- 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
-
-
-# Validation endpoints moved to build_validation.py module
+# ==============================================================================
+# 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.
'
+ '
'
+ ''
+ ''
+ ''
+ '
'
+ '
'
+ )
+ 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_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
index cf089dc..118a3c2 100644
--- a/code/web/routes/build_partners.py
+++ b/code/web/routes/build_partners.py
@@ -14,7 +14,6 @@ from fastapi.responses import JSONResponse
from ..app import (
ENABLE_PARTNER_MECHANICS,
- ENABLE_PARTNER_SUGGESTIONS,
)
from ..services.telemetry import log_partner_suggestion_selected
from ..services.partner_suggestions import get_partner_suggestions
@@ -408,7 +407,7 @@ def _partner_ui_context(
role_hint = "Choose a Doctor to accompany this companion."
# Partner suggestions
- suggestions_enabled = bool(ENABLE_PARTNER_MECHANICS and ENABLE_PARTNER_SUGGESTIONS)
+ suggestions_enabled = bool(ENABLE_PARTNER_MECHANICS)
suggestions_visible: list[dict[str, Any]] = []
suggestions_hidden: list[dict[str, Any]] = []
suggestions_total = 0
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_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 = (
+ '
'
+ ''
+ '
'
+ )
+ 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..88b64c7 100644
--- a/code/web/routes/partner_suggestions.py
+++ b/code/web/routes/partner_suggestions.py
@@ -7,7 +7,7 @@ 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
@@ -64,7 +64,7 @@ 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):
+ if not ENABLE_PARTNER_MECHANICS:
raise HTTPException(status_code=404, detail="Partner suggestions are disabled")
commander_name = (commander or "").strip()
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/_step4.html b/code/web/templates/build/_step4.html
index ca989b5..42c22d8 100644
--- a/code/web/templates/build/_step4.html
+++ b/code/web/templates/build/_step4.html
@@ -10,11 +10,6 @@
- {% if locks_restored and locks_restored > 0 %}
-
- 🔒 {{ locks_restored }} locks restored
-
- {% endif %}
Chosen Ideals
{% for key, label in labels.items() %}
diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html
index 58b7237..0ea5461 100644
--- a/code/web/templates/build/_step5.html
+++ b/code/web/templates/build/_step5.html
@@ -186,9 +186,7 @@
{{ mc_summary }}
{% endif %}
{% if locks and locks|length > 0 %}🔒 {{ locks|length }} locked{% endif %}
-
-
+
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
{% set pct_clamped = (pct if pct <= 100 else 100) %}
diff --git a/code/web/templates/decks/index.html b/code/web/templates/decks/index.html
index 132f99d..d2b8193 100644
--- a/code/web/templates/decks/index.html
+++ b/code/web/templates/decks/index.html
@@ -27,7 +27,6 @@
Compare
-
@@ -127,7 +126,6 @@
var txtOnlyCb = document.getElementById('deck-txt-only');
var cmpSelBtn = document.getElementById('deck-compare-selected');
var cmpLatestBtn = document.getElementById('deck-compare-latest');
- var openPermalinkBtn = document.getElementById('deck-open-permalink');
if (!list) return;
// Panels and themes discovery from data-tags-pipe
@@ -416,18 +414,6 @@
} catch(_){ }
});
- // Open permalink prompt
- if (openPermalinkBtn) openPermalinkBtn.addEventListener('click', function(){
- try{
- var token = prompt('Paste a /build/from?state=... URL or token:');
- if(!token) return;
- var m = token.match(/state=([^&]+)/);
- var t = m ? m[1] : token.trim();
- if(!t) return;
- window.location.href = '/build/from?state=' + encodeURIComponent(t);
- }catch(_){ }
- });
-
if (resetAllBtn) resetAllBtn.addEventListener('click', function(){
// Clear UI state
try {
diff --git a/code/web/templates/partials/random_result.html b/code/web/templates/partials/random_result.html
index 30a3c4c..82e315f 100644
--- a/code/web/templates/partials/random_result.html
+++ b/code/web/templates/partials/random_result.html
@@ -18,9 +18,7 @@
Seed: {{ seed }}
{% if theme %}Theme: {{ theme }}{% endif %}
- {% if permalink %}
-
- {% endif %}
+
{% if show_diagnostics and diagnostics %}
diff --git a/code/web/templates/setup/index.html b/code/web/templates/setup/index.html
index 51a9bbe..1ea6035 100644
--- a/code/web/templates/setup/index.html
+++ b/code/web/templates/setup/index.html
@@ -148,10 +148,10 @@