diff --git a/.env.example b/.env.example index 4eef7c2..75119f7 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,7 @@ WEB_THEME_PICKER_DIAGNOSTICS=1 # dockerhub: WEB_THEME_PICKER_DIAGNOSTICS="1 ENABLE_CARD_DETAILS=1 # dockerhub: ENABLE_CARD_DETAILS="1" SIMILARITY_CACHE_ENABLED=1 # dockerhub: SIMILARITY_CACHE_ENABLED="1" SIMILARITY_CACHE_PATH="card_files/similarity_cache.parquet" # Path to Parquet cache file +ENABLE_BATCH_BUILD=1 # dockerhub: ENABLE_BATCH_BUILD="1" (enable Build X and Compare feature) ############################ # Partner / Background Mechanics diff --git a/CHANGELOG.md b/CHANGELOG.md index 0172a48..c6db31c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,24 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Added -_None_ +- **Build X and Compare** feature: Build multiple decks with same configuration and compare results side-by-side + - Build 1-10 decks in parallel to see variance from card selection randomness + - Real-time progress tracking with dynamic time estimates based on color count + - Comparison view with card overlap statistics and individual build summaries + - Smart filtering excludes guaranteed cards (basics, staples) from "Most Common Cards" + - Card hover support throughout comparison interface + - Rebuild button to rerun same configuration + - Export all decks as ZIP archive +- **Intelligent Synergy Builder**: Analyze multiple builds and create optimized "best-of" deck + - Scores cards by frequency (50%), EDHREC rank (25%), and theme tags (25%) + - 10% bonus for cards appearing in 80%+ of builds + - Color-coded synergy scores in preview (green=high, red=low) + - Partner commander support with combined color identity + - Multi-copy card tracking (e.g., 8 Mountains, 7 Islands) + - Export synergy deck with full metadata (CSV, TXT, JSON files) +- `ENABLE_BATCH_BUILD` environment variable to toggle feature (default: enabled) +- Detailed progress logging for multi-build orchestration +- User guide: `docs/user_guides/batch_build_compare.md` ### Changed _None_ diff --git a/DOCKER.md b/DOCKER.md index 6a5ba07..398140c 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -258,6 +258,7 @@ See `.env.example` for the full catalog. Common knobs: | `WEB_IDEALS_UI` | `slider` | Ideal counts interface: `slider` (range inputs with live validation) or `input` (text boxes with placeholders). | | `ENABLE_CARD_DETAILS` | `0` | Show card detail pages with similar card recommendations at `/cards/`. | | `SIMILARITY_CACHE_ENABLED` | `1` | Use pre-computed similarity cache for fast card detail pages. | +| `ENABLE_BATCH_BUILD` | `1` | Enable Build X and Compare feature (build multiple decks in parallel and compare results). | ### Random build controls diff --git a/README.md b/README.md index 5cd9338..e979b3a 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,12 @@ Every tile on the homepage connects to a workflow. Use these sections as your to ### Build a Deck Start here for interactive deck creation. - Pick commander, themes (primary/secondary/tertiary), bracket, and optional deck name in the unified modal. +- **Build X and Compare** (`ENABLE_BATCH_BUILD=1`, default): Build 1-10 decks with the same configuration to see variance + - Parallel execution (max 5 concurrent) with real-time progress and dynamic time estimates + - Comparison view shows card overlap statistics and individual build summaries + - **Synergy Builder**: Analyze builds and create optimized "best-of" deck scored by frequency, EDHREC rank, and theme tags + - Rebuild button for quick iterations, ZIP export for all builds + - See `docs/user_guides/batch_build_compare.md` for full guide - **Quick Build**: One-click automation runs the full workflow with live progress (Creatures → Spells → Lands → Final Touches → Summary). Available in New Deck wizard. - **Skip Controls**: Granular stage-skipping toggles in New Deck wizard (21 flags: land steps, creature stages, spell categories). Auto-advance without approval prompts. - Add supplemental themes in the **Additional Themes** section (ENABLE_CUSTOM_THEMES): fuzzy suggestions, removable chips, and strict/permissive matching toggles respect `THEME_MATCH_MODE` and `USER_THEME_LIMIT`. diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 7b914a9..c71d6af 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -3,10 +3,21 @@ ## [Unreleased] ### Summary -_No unreleased changes yet_ +Major new feature: Build X and Compare with Intelligent Synergy Builder. Run the same deck configuration multiple times to see variance, compare results side-by-side, and create optimized "best-of" decks. ### Added -_None_ +- **Build X and Compare**: Build 1-10 decks in parallel with same configuration + - Side-by-side comparison with card overlap statistics + - Smart filtering of guaranteed cards + - Rebuild button for quick iterations + - ZIP export of all builds +- **Synergy Builder**: Create optimized deck from multiple builds + - Intelligent scoring (frequency + EDHREC + themes) + - Color-coded synergy preview + - Full metadata export (CSV/TXT/JSON) + - Partner commander support +- Feature flag: `ENABLE_BATCH_BUILD` (default: on) +- User guide: `docs/user_guides/batch_build_compare.md` ### Changed _None_ diff --git a/code/settings.py b/code/settings.py index 445ed61..242e58a 100644 --- a/code/settings.py +++ b/code/settings.py @@ -160,4 +160,7 @@ SIMILARITY_CACHE_MAX_AGE_DAYS = int(os.getenv('SIMILARITY_CACHE_MAX_AGE_DAYS', ' # Allow downloading pre-built cache from GitHub (saves 15-20 min build time) # Set to '0' to always build locally (useful for custom seeds or offline environments) -SIMILARITY_CACHE_DOWNLOAD = os.getenv('SIMILARITY_CACHE_DOWNLOAD', '1').lower() not in ('0', 'false', 'off', 'disabled') \ No newline at end of file +SIMILARITY_CACHE_DOWNLOAD = os.getenv('SIMILARITY_CACHE_DOWNLOAD', '1').lower() not in ('0', 'false', 'off', 'disabled') + +# Batch build feature flag (Build X and Compare) +ENABLE_BATCH_BUILD = os.getenv('ENABLE_BATCH_BUILD', '1').lower() not in ('0', 'false', 'off', 'disabled') \ No newline at end of file diff --git a/code/web/app.py b/code/web/app.py index 437be4b..ac2854b 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -146,6 +146,7 @@ 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_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) THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False) @@ -2223,6 +2224,7 @@ from .routes import partner_suggestions as partner_suggestions_routes # noqa: E from .routes import telemetry as telemetry_routes # noqa: E402 from .routes import cards as cards_routes # noqa: E402 from .routes import card_browser as card_browser_routes # noqa: E402 +from .routes import compare as compare_routes # noqa: E402 app.include_router(build_routes.router) app.include_router(config_routes.router) app.include_router(decks_routes.router) @@ -2234,6 +2236,7 @@ app.include_router(partner_suggestions_routes.router) app.include_router(telemetry_routes.router) app.include_router(cards_routes.router) app.include_router(card_browser_routes.router) +app.include_router(compare_routes.router) # Warm validation cache early to reduce first-call latency in tests and dev try: diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 9723ab6..5a80829 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -14,6 +14,7 @@ from ..app import ( ENABLE_PARTNER_MECHANICS, ENABLE_PARTNER_SUGGESTIONS, WEB_IDEALS_UI, + ENABLE_BATCH_BUILD, ) from ..services.build_utils import ( step5_base_ctx, @@ -1357,6 +1358,7 @@ async def build_new_modal(request: Request) -> HTMLResponse: "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": { "prefer_combos": bool(sess.get("prefer_combos")), @@ -1952,6 +1954,8 @@ async def build_new_submit( 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: @@ -2025,6 +2029,7 @@ async def build_new_submit( "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, } @@ -2049,6 +2054,7 @@ async def build_new_submit( "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, } @@ -2153,6 +2159,7 @@ async def build_new_submit( "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, } @@ -2291,6 +2298,7 @@ async def build_new_submit( "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, } @@ -2479,7 +2487,101 @@ async def build_new_submit( # Centralized staged context creation sess["build_ctx"] = start_ctx_from_session(sess) - # Check if Quick Build was requested + # 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: @@ -3785,6 +3887,68 @@ def quick_build_progress(request: Request): 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.""" + import logging + logger = logging.getLogger(__name__) + + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + from ..services.build_cache import BuildCache + + batch_status = BuildCache.get_batch_status(sess, batch_id) + logger.info(f"[Batch Progress Poll] batch_id={batch_id}, status={batch_status}") + + if not batch_status: + return HTMLResponse('
Batch not found. Please refresh.
') + + if batch_status["status"] == "completed": + # All builds complete - redirect to comparison page + response = HTMLResponse(f'') + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response + + # Get config to determine color count for time estimate + config = BuildCache.get_batch_config(sess, batch_id) + commander_name = config.get("commander", "") if config else "" + + # Estimate time based on color count (from testing data) + time_estimate = "1-3 minutes" + if commander_name and config: + # Try to get commander's color identity + try: + from ..services import orchestrator as orch + cmd_data = orch.load_commander(commander_name) + if cmd_data and "colorIdentity" in cmd_data: + color_count = len(cmd_data.get("colorIdentity", [])) + if color_count <= 2: + time_estimate = "1-3 minutes" + elif color_count == 3: + time_estimate = "2-4 minutes" + else: # 4-5 colors + time_estimate = "3-5 minutes" + except Exception: + pass # Default to 1-3 if we can't determine + + # Build still running - return progress content partial only + ctx = { + "request": request, + "batch_id": batch_id, + "build_count": batch_status["count"], + "completed": batch_status["completed"], + "progress_pct": batch_status["progress_pct"], + "status": f"Building deck {batch_status['completed'] + 1} of {batch_status['count']}..." if batch_status['completed'] < batch_status['count'] else "Finalizing...", + "has_errors": batch_status["has_errors"], + "error_count": batch_status["error_count"], + "time_estimate": time_estimate + } + response = templates.TemplateResponse("build/_batch_progress_content.html", ctx) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response + # --- Phase 8: Lock/Replace/Compare/Permalink minimal API --- @router.post("/lock") diff --git a/code/web/routes/compare.py b/code/web/routes/compare.py new file mode 100644 index 0000000..6dea835 --- /dev/null +++ b/code/web/routes/compare.py @@ -0,0 +1,730 @@ +""" +Comparison Routes - Side-by-side deck comparison for batch builds. +""" + +from __future__ import annotations +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from typing import Any, Dict, List +from ..app import templates +from ..services.build_cache import BuildCache +from ..services.tasks import get_session, new_sid +from ..services.synergy_builder import analyze_and_build_synergy_deck +from code.logging_util import get_logger +import time + +logger = get_logger(__name__) +router = APIRouter() + + +def _is_guaranteed_card(card_name: str) -> bool: + """ + Check if a card is guaranteed/staple (should be filtered from interesting variance). + + Filters: + - Basic lands (Plains, Island, Swamp, Mountain, Forest, Wastes, Snow-Covered variants) + - Staple lands (Command Tower, Reliquary Tower, etc.) + - Kindred lands + - Generic fetch lands + + Args: + card_name: Card name to check + + Returns: + True if card should be filtered from "Most Common Cards" + """ + try: + from code.deck_builder import builder_constants as bc + + # Basic lands + basic_lands = set(getattr(bc, 'BASIC_LANDS', [])) + if card_name in basic_lands: + return True + + # Snow-covered basics + if card_name.startswith('Snow-Covered '): + base_name = card_name.replace('Snow-Covered ', '') + if base_name in basic_lands: + return True + + # Staple lands (keys from STAPLE_LAND_CONDITIONS) + staple_conditions = getattr(bc, 'STAPLE_LAND_CONDITIONS', {}) + if card_name in staple_conditions: + return True + + # Kindred lands + kindred_lands = set(getattr(bc, 'KINDRED_LAND_NAMES', [])) + if card_name in kindred_lands: + return True + + # Generic fetch lands + generic_fetches = set(getattr(bc, 'GENERIC_FETCH_LANDS', [])) + if card_name in generic_fetches: + return True + + # Color-specific fetch lands + color_fetches = getattr(bc, 'COLOR_TO_FETCH_LANDS', {}) + for fetch_list in color_fetches.values(): + if card_name in fetch_list: + return True + + return False + except Exception as e: + logger.debug(f"Error checking guaranteed card status for {card_name}: {e}") + return False + + +@router.get("/compare/{batch_id}", response_class=HTMLResponse) +async def compare_batch(request: Request, batch_id: str) -> HTMLResponse: + """Main comparison view for batch builds.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + # Get batch data + batch_status = BuildCache.get_batch_status(sess, batch_id) + if not batch_status: + return templates.TemplateResponse("error.html", { + "request": request, + "error": f"Batch {batch_id} not found. It may have expired.", + "back_link": "/build" + }) + + builds = BuildCache.get_batch_builds(sess, batch_id) + config = BuildCache.get_batch_config(sess, batch_id) + + if not builds: + return templates.TemplateResponse("error.html", { + "request": request, + "error": "No completed builds found in this batch.", + "back_link": "/build" + }) + + # Calculate card overlap statistics + overlap_stats = _calculate_overlap(builds) + + # Prepare deck summaries + summaries = [] + for build in builds: + summary = _build_summary(build["result"], build["index"]) + summaries.append(summary) + + ctx = { + "request": request, + "batch_id": batch_id, + "batch_status": batch_status, + "config": config, + "builds": summaries, + "overlap_stats": overlap_stats, + "build_count": len(summaries), + "synergy_exported": BuildCache.is_synergy_exported(sess, batch_id) + } + + resp = templates.TemplateResponse("compare/index.html", ctx) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + + +def _calculate_overlap(builds: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Calculate card overlap statistics across builds. + + Args: + builds: List of build result dicts + + Returns: + Dict with overlap statistics + """ + from collections import Counter + + # Collect all cards with their appearance counts + card_counts: Counter = Counter() + total_builds = len(builds) + + # Collect include cards (must-includes) from first build as they should be in all + include_cards_set = set() + if builds: + first_result = builds[0].get("result", {}) + first_summary = first_result.get("summary", {}) + if isinstance(first_summary, dict): + include_exclude = first_summary.get("include_exclude_summary", {}) + if isinstance(include_exclude, dict): + includes = include_exclude.get("include_cards", []) + if isinstance(includes, list): + include_cards_set = set(includes) + + for build in builds: + result = build.get("result", {}) + summary = result.get("summary", {}) + if not isinstance(summary, dict): + continue + + type_breakdown = summary.get("type_breakdown", {}) + if not isinstance(type_breakdown, dict): + continue + + # Track unique cards per build (from type_breakdown cards dict) + unique_cards = set() + type_cards = type_breakdown.get("cards", {}) + if isinstance(type_cards, dict): + for card_list in type_cards.values(): + if isinstance(card_list, list): + for card in card_list: + if isinstance(card, dict): + card_name = card.get("name") + if card_name: + unique_cards.add(card_name) + + # Increment counter for each unique card + for card_name in unique_cards: + card_counts[card_name] += 1 + + # Calculate statistics + total_unique_cards = len(card_counts) + cards_in_all = sum(1 for count in card_counts.values() if count == total_builds) + cards_in_most = sum(1 for count in card_counts.values() if count >= total_builds * 0.8) + cards_in_some = sum(1 for count in card_counts.values() if total_builds * 0.2 < count < total_builds * 0.8) + cards_in_few = sum(1 for count in card_counts.values() if count <= total_builds * 0.2) + + # Most common cards - filter out guaranteed/staple cards to highlight interesting variance + # Filter before taking top 20 to show random selections rather than guaranteed hits + filtered_counts = { + name: count for name, count in card_counts.items() + if not _is_guaranteed_card(name) and name not in include_cards_set + } + most_common = Counter(filtered_counts).most_common(20) + + return { + "total_unique_cards": total_unique_cards, + "cards_in_all": cards_in_all, + "cards_in_most": cards_in_most, + "cards_in_some": cards_in_some, + "cards_in_few": cards_in_few, + "most_common": most_common, + "total_builds": total_builds + } + + +def _build_summary(result: Dict[str, Any], index: int) -> Dict[str, Any]: + """ + Create a summary of a single build for comparison display. + + Args: + result: Build result from orchestrator + index: Build index + + Returns: + Summary dict + """ + # Get summary from result + summary = result.get("summary", {}) + if not isinstance(summary, dict): + summary = {} + + # Get type breakdown which contains card counts + type_breakdown = summary.get("type_breakdown", {}) + if not isinstance(type_breakdown, dict): + type_breakdown = {} + + # Get counts directly from type breakdown + counts = type_breakdown.get("counts", {}) + + # Use standardized keys from type breakdown + creatures = counts.get("Creature", 0) + lands = counts.get("Land", 0) + artifacts = counts.get("Artifact", 0) + enchantments = counts.get("Enchantment", 0) + instants = counts.get("Instant", 0) + sorceries = counts.get("Sorcery", 0) + planeswalkers = counts.get("Planeswalker", 0) + + # Get total from type breakdown + total_cards = type_breakdown.get("total", 0) + + # Get all cards from type breakdown cards dict + all_cards = [] + type_cards = type_breakdown.get("cards", {}) + if isinstance(type_cards, dict): + for card_list in type_cards.values(): + if isinstance(card_list, list): + all_cards.extend(card_list) + + return { + "index": index, + "build_number": index + 1, + "total_cards": total_cards, + "creatures": creatures, + "lands": lands, + "artifacts": artifacts, + "enchantments": enchantments, + "instants": instants, + "sorceries": sorceries, + "planeswalkers": planeswalkers, + "cards": all_cards, + "result": result + } + + +@router.post("/compare/{batch_id}/export") +async def export_batch(request: Request, batch_id: str): + """ + Export all decks in a batch as a ZIP archive. + + Args: + request: FastAPI request object + batch_id: Batch identifier + + Returns: + ZIP file with all deck CSV/TXT files + summary JSON + """ + import zipfile + import io + import json + from pathlib import Path + from fastapi.responses import StreamingResponse + from datetime import datetime + + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + # Get batch data + batch_status = BuildCache.get_batch_status(sess, batch_id) + if not batch_status: + return {"error": f"Batch {batch_id} not found"} + + builds = BuildCache.get_batch_builds(sess, batch_id) + config = BuildCache.get_batch_config(sess, batch_id) + + if not builds: + return {"error": "No completed builds found in this batch"} + + # Create ZIP in memory + zip_buffer = io.BytesIO() + + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + # Collect all deck files + commander_name = config.get("commander", "Unknown").replace("/", "-") + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + for i, build in enumerate(builds): + result = build.get("result", {}) + csv_path = result.get("csv_path") + txt_path = result.get("txt_path") + + # Add CSV file + if csv_path and Path(csv_path).exists(): + filename = f"Build_{i+1}_{commander_name}.csv" + with open(csv_path, 'rb') as f: + zip_file.writestr(filename, f.read()) + + # Add TXT file + if txt_path and Path(txt_path).exists(): + filename = f"Build_{i+1}_{commander_name}.txt" + with open(txt_path, 'rb') as f: + zip_file.writestr(filename, f.read()) + + # Add batch summary JSON + summary_data = { + "batch_id": batch_id, + "commander": config.get("commander"), + "themes": config.get("tags", []), + "bracket": config.get("bracket"), + "build_count": len(builds), + "exported_at": timestamp, + "builds": [ + { + "build_number": i + 1, + "csv_file": f"Build_{i+1}_{commander_name}.csv", + "txt_file": f"Build_{i+1}_{commander_name}.txt" + } + for i in range(len(builds)) + ] + } + zip_file.writestr("batch_summary.json", json.dumps(summary_data, indent=2)) + + # Prepare response + zip_buffer.seek(0) + zip_filename = f"{commander_name}_Batch_{timestamp}.zip" + + return StreamingResponse( + iter([zip_buffer.getvalue()]), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{zip_filename}"' + } + ) + + +@router.post("/compare/{batch_id}/rebuild") +async def rebuild_batch(request: Request, batch_id: str): + """ + Rebuild the same configuration with the same build count. + Creates a new batch with identical settings and redirects to batch progress. + + Args: + request: FastAPI request object + batch_id: Original batch identifier + + Returns: + Redirect to new batch progress page + """ + from fastapi.responses import RedirectResponse + from ..services.multi_build_orchestrator import MultiBuildOrchestrator + + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + # Get original config and build count + config = BuildCache.get_batch_config(sess, batch_id) + batch_status = BuildCache.get_batch_status(sess, batch_id) + + if not config or not batch_status: + return RedirectResponse(url="/build", status_code=302) + + # Get build count from original batch + build_count = batch_status.get("total_builds", 1) + + # Create new batch with same config + orchestrator = MultiBuildOrchestrator() + new_batch_id = orchestrator.queue_builds(config, build_count, sid) + + # Start builds in background + import asyncio + asyncio.create_task(orchestrator.run_batch_parallel(new_batch_id)) + + # Redirect to new batch progress + response = RedirectResponse(url=f"/build/batch/{new_batch_id}/progress", status_code=302) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response + + +@router.post("/compare/{batch_id}/build-synergy") +async def build_synergy_deck(request: Request, batch_id: str) -> HTMLResponse: + """ + Build a synergy deck from batch builds. + + Analyzes all builds in the batch and creates an optimized "best-of" deck + by scoring cards based on frequency, EDHREC rank, and theme alignment. + """ + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + # Get batch data + builds = BuildCache.get_batch_builds(sess, batch_id) + config = BuildCache.get_batch_config(sess, batch_id) + batch_status = BuildCache.get_batch_status(sess, batch_id) + + if not builds or not config or not batch_status: + return HTMLResponse( + content=f'
Batch {batch_id} not found or has no builds
', + status_code=404 + ) + + start_time = time.time() + + try: + # Analyze and build synergy deck + synergy_deck = analyze_and_build_synergy_deck(builds, config) + + elapsed_ms = int((time.time() - start_time) * 1000) + + logger.info( + f"[Synergy] Built deck for batch {batch_id}: " + f"{synergy_deck['total_cards']} cards, " + f"avg_score={synergy_deck['avg_score']}, " + f"elapsed={elapsed_ms}ms" + ) + + # Prepare cards_by_category for template + cards_by_category = { + category: [ + { + "name": card.name, + "frequency": card.frequency, + "synergy_score": card.synergy_score, + "appearance_count": card.appearance_count, + "role": card.role, + "tags": card.tags, + "type_line": card.type_line, + "count": card.count + } + for card in cards + ] + for category, cards in synergy_deck["by_category"].items() + } + + # Render preview template + return templates.TemplateResponse("compare/_synergy_preview.html", { + "request": request, + "batch_id": batch_id, + "synergy_deck": { + "total_cards": synergy_deck["total_cards"], + "avg_frequency": synergy_deck["avg_frequency"], + "avg_score": synergy_deck["avg_score"], + "high_frequency_count": synergy_deck["high_frequency_count"], + "cards_by_category": cards_by_category + }, + "total_builds": len(builds), + "build_time_ms": elapsed_ms + }) + + except Exception as e: + logger.error(f"[Synergy] Error building synergy deck: {e}", exc_info=True) + return HTMLResponse( + content=f'
Failed to build synergy deck: {str(e)}
', + status_code=500 + ) + + +@router.post("/compare/{batch_id}/export-synergy") +async def export_synergy_deck(request: Request, batch_id: str): + """ + Export the synergy deck as CSV and TXT files in a ZIP archive. + + Args: + request: FastAPI request object + batch_id: Batch identifier + + Returns: + ZIP file with synergy deck CSV/TXT files + """ + import io + import csv + import zipfile + import json + from fastapi.responses import StreamingResponse + from datetime import datetime + + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + + # Get batch data + batch_status = BuildCache.get_batch_status(sess, batch_id) + if not batch_status: + return {"error": f"Batch {batch_id} not found"} + + builds = BuildCache.get_batch_builds(sess, batch_id) + config = BuildCache.get_batch_config(sess, batch_id) + + if not builds: + return {"error": "No completed builds found in this batch"} + + # Build synergy deck (reuse the existing logic) + from code.web.services.synergy_builder import analyze_and_build_synergy_deck + + try: + synergy_deck = analyze_and_build_synergy_deck( + builds=builds, + config=config + ) + except Exception as e: + logger.error(f"[Export Synergy] Error building synergy deck: {e}", exc_info=True) + return {"error": f"Failed to build synergy deck: {str(e)}"} + + # Prepare file names + commander_name = config.get("commander", "Unknown").replace("/", "-").replace(" ", "") + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + base_filename = f"{commander_name}_Synergy_{timestamp}" + + # Prepare deck_files directory + from pathlib import Path + deck_files_dir = Path("deck_files") + deck_files_dir.mkdir(parents=True, exist_ok=True) + + # Create CSV content + csv_buffer = io.StringIO() + csv_writer = csv.writer(csv_buffer) + + # CSV Header + csv_writer.writerow([ + "Name", "Count", "Category", "Role", "Frequency", "Synergy Score", + "Appearance Count", "Tags", "Type" + ]) + + # CSV Rows - sort by category + category_order = ["Land", "Creature", "Artifact", "Enchantment", "Instant", "Sorcery", "Planeswalker", "Battle"] + by_category = synergy_deck.get("by_category", {}) + + for category in category_order: + cards = by_category.get(category, []) + for card in cards: + csv_writer.writerow([ + card.name, + card.count, + card.category, + card.role, + f"{card.frequency:.2%}", + f"{card.synergy_score:.2f}", + card.appearance_count, + "|".join(card.tags) if card.tags else "", + card.type_line + ]) + + csv_content = csv_buffer.getvalue() + + # Create TXT content (Moxfield/EDHREC format) + txt_buffer = io.StringIO() + + # TXT Header + txt_buffer.write(f"# Synergy Deck - {commander_name}\n") + txt_buffer.write(f"# Commander: {config.get('commander', 'Unknown')}\n") + txt_buffer.write(f"# Colors: {', '.join(config.get('colors', []))}\n") + txt_buffer.write(f"# Themes: {', '.join(config.get('tags', []))}\n") + txt_buffer.write(f"# Generated from {len(builds)} builds\n") + txt_buffer.write(f"# Total Cards: {synergy_deck['total_cards']}\n") + txt_buffer.write(f"# Avg Frequency: {synergy_deck['avg_frequency']:.1%}\n") + txt_buffer.write(f"# Avg Synergy Score: {synergy_deck['avg_score']:.2f}\n") + txt_buffer.write("\n") + + # TXT Card list + for category in category_order: + cards = by_category.get(category, []) + if not cards: + continue + + for card in cards: + line = f"{card.count} {card.name}" + if card.count > 1: + # Show count prominently for multi-copy cards + txt_buffer.write(f"{line}\n") + else: + txt_buffer.write(f"1 {card.name}\n") + + txt_content = txt_buffer.getvalue() + + # Save CSV and TXT to deck_files directory + csv_path = deck_files_dir / f"{base_filename}.csv" + txt_path = deck_files_dir / f"{base_filename}.txt" + summary_path = deck_files_dir / f"{base_filename}.summary.json" + compliance_path = deck_files_dir / f"{base_filename}_compliance.json" + + try: + csv_path.write_text(csv_content, encoding='utf-8') + txt_path.write_text(txt_content, encoding='utf-8') + + # Create summary JSON (similar to individual builds) + summary_data = { + "commander": config.get("commander", "Unknown"), + "tags": config.get("tags", []), + "colors": config.get("colors", []), + "bracket_level": config.get("bracket"), + "csv": str(csv_path), + "txt": str(txt_path), + "synergy_stats": { + "total_cards": synergy_deck["total_cards"], + "unique_cards": synergy_deck.get("unique_cards", len(synergy_deck["cards"])), + "avg_frequency": synergy_deck["avg_frequency"], + "avg_score": synergy_deck["avg_score"], + "high_frequency_count": synergy_deck["high_frequency_count"], + "source_builds": len(builds) + }, + "exported_at": timestamp + } + summary_path.write_text(json.dumps(summary_data, indent=2), encoding='utf-8') + + # Create compliance JSON (basic compliance for synergy deck) + compliance_data = { + "overall": "N/A", + "message": "Synergy deck - compliance checking not applicable", + "deck_size": synergy_deck["total_cards"], + "commander": config.get("commander", "Unknown"), + "source": "synergy_builder", + "build_count": len(builds) + } + compliance_path.write_text(json.dumps(compliance_data, indent=2), encoding='utf-8') + + logger.info(f"[Export Synergy] Saved synergy deck to {csv_path} and {txt_path}") + except Exception as e: + logger.error(f"[Export Synergy] Failed to save files to disk: {e}", exc_info=True) + + # Delete batch build files to avoid clutter + deleted_files = [] + for build in builds: + result = build.get("result", {}) + csv_file = result.get("csv_path") + txt_file = result.get("txt_path") + summary_file = result.get("summary_path") + + # Delete CSV file + if csv_file: + csv_p = Path(csv_file) + if csv_p.exists(): + try: + csv_p.unlink() + deleted_files.append(csv_p.name) + except Exception as e: + logger.warning(f"[Export Synergy] Failed to delete {csv_file}: {e}") + + # Delete TXT file + if txt_file: + txt_p = Path(txt_file) + if txt_p.exists(): + try: + txt_p.unlink() + deleted_files.append(txt_p.name) + except Exception as e: + logger.warning(f"[Export Synergy] Failed to delete {txt_file}: {e}") + + # Delete summary JSON file + if summary_file: + summary_p = Path(summary_file) + if summary_p.exists(): + try: + summary_p.unlink() + deleted_files.append(summary_p.name) + except Exception as e: + logger.warning(f"[Export Synergy] Failed to delete {summary_file}: {e}") + + if deleted_files: + logger.info(f"[Export Synergy] Cleaned up {len(deleted_files)} batch build files") + + # Mark batch as having synergy exported (to disable batch export button) + BuildCache.mark_synergy_exported(sess, batch_id) + + # Create ZIP in memory for download + zip_buffer = io.BytesIO() + + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + # Add CSV to ZIP + zip_file.writestr(f"{base_filename}.csv", csv_content) + + # Add TXT to ZIP + zip_file.writestr(f"{base_filename}.txt", txt_content) + + # Add summary JSON to ZIP + summary_json = json.dumps(summary_data, indent=2) + zip_file.writestr(f"{base_filename}.summary.json", summary_json) + + # Add compliance JSON to ZIP + compliance_json = json.dumps(compliance_data, indent=2) + zip_file.writestr(f"{base_filename}_compliance.json", compliance_json) + + # Add metadata JSON (export-specific info) + metadata = { + "batch_id": batch_id, + "commander": config.get("commander"), + "themes": config.get("tags", []), + "colors": config.get("colors", []), + "bracket": config.get("bracket"), + "build_count": len(builds), + "exported_at": timestamp, + "synergy_stats": { + "total_cards": synergy_deck["total_cards"], + "avg_frequency": synergy_deck["avg_frequency"], + "avg_score": synergy_deck["avg_score"], + "high_frequency_count": synergy_deck["high_frequency_count"] + }, + "cleaned_up_files": len(deleted_files) + } + zip_file.writestr("synergy_metadata.json", json.dumps(metadata, indent=2)) + + # Prepare response + zip_buffer.seek(0) + zip_filename = f"{base_filename}.zip" + + return StreamingResponse( + iter([zip_buffer.getvalue()]), + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{zip_filename}"' + } + ) diff --git a/code/web/services/build_cache.py b/code/web/services/build_cache.py new file mode 100644 index 0000000..1511cba --- /dev/null +++ b/code/web/services/build_cache.py @@ -0,0 +1,256 @@ +""" +Build Cache - Session-based storage for multi-build batch results. + +Stores completed deck builds in session for comparison view. +""" + +from __future__ import annotations +from typing import Any, Dict, List, Optional +import time +import uuid + + +class BuildCache: + """Manages storage and retrieval of batch build results in session.""" + + @staticmethod + def create_batch(sess: Dict[str, Any], config: Dict[str, Any], count: int) -> str: + """ + Create a new batch build entry in session. + + Args: + sess: Session dictionary + config: Deck configuration (commander, themes, ideals, etc.) + count: Number of builds in batch + + Returns: + batch_id: Unique identifier for this batch + """ + batch_id = f"batch_{uuid.uuid4().hex[:12]}" + + if "batch_builds" not in sess: + sess["batch_builds"] = {} + + sess["batch_builds"][batch_id] = { + "batch_id": batch_id, + "config": config, + "count": count, + "completed": 0, + "builds": [], + "started_at": time.time(), + "completed_at": None, + "status": "running", # running, completed, error + "errors": [] + } + + return batch_id + + @staticmethod + def store_build(sess: Dict[str, Any], batch_id: str, build_index: int, result: Dict[str, Any]) -> None: + """ + Store a completed build result in the batch. + + Args: + sess: Session dictionary + batch_id: Batch identifier + build_index: Index of this build (0-based) + result: Deck build result from orchestrator + """ + if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: + raise ValueError(f"Batch {batch_id} not found in session") + + batch = sess["batch_builds"][batch_id] + + # Ensure builds list has enough slots + while len(batch["builds"]) <= build_index: + batch["builds"].append(None) + + # Store build result with minimal data for comparison + batch["builds"][build_index] = { + "index": build_index, + "result": result, + "completed_at": time.time() + } + + batch["completed"] += 1 + + # Mark batch as completed if all builds done + if batch["completed"] >= batch["count"]: + batch["status"] = "completed" + batch["completed_at"] = time.time() + + @staticmethod + def store_build_error(sess: Dict[str, Any], batch_id: str, build_index: int, error: str) -> None: + """ + Store an error for a failed build. + + Args: + sess: Session dictionary + batch_id: Batch identifier + build_index: Index of this build (0-based) + error: Error message + """ + if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: + raise ValueError(f"Batch {batch_id} not found in session") + + batch = sess["batch_builds"][batch_id] + + batch["errors"].append({ + "build_index": build_index, + "error": error, + "timestamp": time.time() + }) + + batch["completed"] += 1 + + # Mark batch as completed if all builds done (even with errors) + if batch["completed"] >= batch["count"]: + batch["status"] = "completed" if not batch["errors"] else "error" + batch["completed_at"] = time.time() + + @staticmethod + def get_batch_status(sess: Dict[str, Any], batch_id: str) -> Optional[Dict[str, Any]]: + """ + Get current status of a batch build. + + Args: + sess: Session dictionary + batch_id: Batch identifier + + Returns: + Status dict with progress info, or None if not found + """ + if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: + return None + + batch = sess["batch_builds"][batch_id] + + return { + "batch_id": batch_id, + "status": batch["status"], + "count": batch["count"], + "completed": batch["completed"], + "progress_pct": int((batch["completed"] / batch["count"]) * 100) if batch["count"] > 0 else 0, + "has_errors": len(batch["errors"]) > 0, + "error_count": len(batch["errors"]), + "elapsed_time": time.time() - batch["started_at"] + } + + @staticmethod + def get_batch_builds(sess: Dict[str, Any], batch_id: str) -> Optional[List[Dict[str, Any]]]: + """ + Get all completed builds for a batch. + + Args: + sess: Session dictionary + batch_id: Batch identifier + + Returns: + List of build results, or None if batch not found + """ + if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: + return None + + batch = sess["batch_builds"][batch_id] + return [b for b in batch["builds"] if b is not None] + + @staticmethod + def get_batch_config(sess: Dict[str, Any], batch_id: str) -> Optional[Dict[str, Any]]: + """ + Get the original configuration for a batch. + + Args: + sess: Session dictionary + batch_id: Batch identifier + + Returns: + Config dict, or None if batch not found + """ + if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: + return None + + return sess["batch_builds"][batch_id]["config"] + + @staticmethod + def clear_batch(sess: Dict[str, Any], batch_id: str) -> bool: + """ + Remove a batch from session. + + Args: + sess: Session dictionary + batch_id: Batch identifier + + Returns: + True if batch was found and removed, False otherwise + """ + if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: + return False + + del sess["batch_builds"][batch_id] + return True + + @staticmethod + def list_batches(sess: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + List all batches in session with summary info. + + Args: + sess: Session dictionary + + Returns: + List of batch summary dicts + """ + if "batch_builds" not in sess: + return [] + + summaries = [] + for batch_id, batch in sess["batch_builds"].items(): + summaries.append({ + "batch_id": batch_id, + "status": batch["status"], + "count": batch["count"], + "completed": batch["completed"], + "commander": batch["config"].get("commander", "Unknown"), + "started_at": batch["started_at"], + "completed_at": batch.get("completed_at") + }) + + # Sort by start time, most recent first + summaries.sort(key=lambda x: x["started_at"], reverse=True) + return summaries + + @staticmethod + def mark_synergy_exported(sess: Dict[str, Any], batch_id: str) -> bool: + """ + Mark a batch as having its synergy deck exported (disables batch export). + + Args: + sess: Session dictionary + batch_id: Batch identifier + + Returns: + True if batch was found and marked, False otherwise + """ + if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: + return False + + sess["batch_builds"][batch_id]["synergy_exported"] = True + sess["batch_builds"][batch_id]["synergy_exported_at"] = time.time() + return True + + @staticmethod + def is_synergy_exported(sess: Dict[str, Any], batch_id: str) -> bool: + """ + Check if a batch's synergy deck has been exported. + + Args: + sess: Session dictionary + batch_id: Batch identifier + + Returns: + True if synergy has been exported, False otherwise + """ + if "batch_builds" not in sess or batch_id not in sess["batch_builds"]: + return False + + return sess["batch_builds"][batch_id].get("synergy_exported", False) diff --git a/code/web/services/multi_build_orchestrator.py b/code/web/services/multi_build_orchestrator.py new file mode 100644 index 0000000..65fcf1b --- /dev/null +++ b/code/web/services/multi_build_orchestrator.py @@ -0,0 +1,264 @@ +""" +Multi-Build Orchestrator - Parallel execution of identical deck builds. + +Runs the same deck configuration N times in parallel to analyze variance. +""" + +from __future__ import annotations +from typing import Any, Dict +from concurrent.futures import ThreadPoolExecutor +from .build_cache import BuildCache +from .tasks import get_session +from ..services import orchestrator as orch +from code.logging_util import get_logger + +logger = get_logger(__name__) + + +class MultiBuildOrchestrator: + """Manages parallel execution of multiple identical deck builds.""" + + def __init__(self, max_parallel: int = 5): + """ + Initialize orchestrator. + + Args: + max_parallel: Maximum number of builds to run concurrently (default 5) + """ + self.max_parallel = max_parallel + + def run_batch_parallel(self, batch_id: str, sid: str) -> None: + """ + Run a batch of builds in parallel (blocking call). + + This should be called from a background task. + + Args: + batch_id: Batch identifier + sid: Session ID + """ + logger.info(f"[Multi-Build] Starting parallel batch {batch_id} for session {sid}") + + sess = get_session(sid) + batch_status = BuildCache.get_batch_status(sess, batch_id) + + if not batch_status: + logger.error(f"[Multi-Build] Batch {batch_id} not found in session") + return + + count = batch_status["count"] + config = BuildCache.get_batch_config(sess, batch_id) + + if not config: + logger.error(f"[Multi-Build] Config not found for batch {batch_id}") + return + + logger.info(f"[Multi-Build] Running {count} builds in parallel (max {self.max_parallel} concurrent)") + + # Use ThreadPoolExecutor for parallel execution + # Each build runs in its own thread to avoid blocking + with ThreadPoolExecutor(max_workers=min(count, self.max_parallel)) as executor: + futures = [] + + for i in range(count): + future = executor.submit(self._run_single_build, batch_id, i, config, sid) + futures.append(future) + + # Wait for all builds to complete + for i, future in enumerate(futures): + try: + future.result() # This will raise if the build failed + logger.info(f"[Multi-Build] Build {i+1}/{count} completed successfully") + except Exception as e: + logger.error(f"[Multi-Build] Build {i+1}/{count} failed: {e}") + # Error already stored in _run_single_build + + logger.info(f"[Multi-Build] Batch {batch_id} completed") + + def _run_single_build(self, batch_id: str, build_index: int, config: Dict[str, Any], sid: str) -> None: + """ + Run a single build and store the result. + + Args: + batch_id: Batch identifier + build_index: Index of this build (0-based) + config: Deck configuration + sid: Session ID + """ + try: + logger.info(f"[Multi-Build] Build {build_index}: Starting for batch {batch_id}") + + # Get a fresh session reference for this thread + sess = get_session(sid) + + logger.debug(f"[Multi-Build] Build {build_index}: Creating build context") + + # Create a temporary build context for this specific build + # We need to ensure each build has isolated state + build_ctx = self._create_build_context(config, sess, build_index) + + logger.debug(f"[Multi-Build] Build {build_index}: Running all stages") + + # Run all stages to completion + result = self._run_all_stages(build_ctx, build_index) + + logger.debug(f"[Multi-Build] Build {build_index}: Storing result") + + # Store the result + BuildCache.store_build(sess, batch_id, build_index, result) + + logger.info(f"[Multi-Build] Build {build_index}: Completed, stored in batch {batch_id}") + + except Exception as e: + logger.exception(f"[Multi-Build] Build {build_index}: Error - {e}") + sess = get_session(sid) + BuildCache.store_build_error(sess, batch_id, build_index, str(e)) + + def _create_build_context(self, config: Dict[str, Any], sess: Dict[str, Any], build_index: int) -> Dict[str, Any]: + """ + Create a build context from configuration. + + Args: + config: Deck configuration + sess: Session dictionary + build_index: Index of this build + + Returns: + Build context dict ready for orchestrator + """ + # Import here to avoid circular dependencies + from .build_utils import start_ctx_from_session + + # Create a temporary session-like dict with the config + temp_sess = { + "commander": config.get("commander"), + "tags": config.get("tags", []), + "tag_mode": config.get("tag_mode", "AND"), + "bracket": config.get("bracket", 3), + "ideals": config.get("ideals", {}), + "prefer_combos": config.get("prefer_combos", False), + "combo_target_count": config.get("combo_target_count"), + "combo_balance": config.get("combo_balance"), + "multi_copy": config.get("multi_copy"), + "use_owned_only": config.get("use_owned_only", False), + "prefer_owned": config.get("prefer_owned", False), + "swap_mdfc_basics": config.get("swap_mdfc_basics", False), + "include_cards": config.get("include_cards", []), + "exclude_cards": config.get("exclude_cards", []), + "enforcement_mode": config.get("enforcement_mode", "warn"), + "allow_illegal": config.get("allow_illegal", False), + "fuzzy_matching": config.get("fuzzy_matching", True), + "locks": set(config.get("locks", [])), + "replace_mode": True, + # Add build index to context for debugging + "batch_build_index": build_index + } + + # Handle partner mechanics if present + if config.get("partner_enabled"): + temp_sess["partner_enabled"] = True + if config.get("secondary_commander"): + temp_sess["secondary_commander"] = config["secondary_commander"] + if config.get("background"): + temp_sess["background"] = config["background"] + if config.get("partner_mode"): + temp_sess["partner_mode"] = config["partner_mode"] + if config.get("combined_commander"): + temp_sess["combined_commander"] = config["combined_commander"] + + # Generate build context using existing utility + ctx = start_ctx_from_session(temp_sess) + + return ctx + + def _run_all_stages(self, ctx: Dict[str, Any], build_index: int = 0) -> Dict[str, Any]: + """ + Run all build stages to completion. + + Args: + ctx: Build context + build_index: Index of this build for logging + + Returns: + Final result dict from orchestrator + """ + stages = ctx.get("stages", []) + result = None + + logger.debug(f"[Multi-Build] Build {build_index}: Starting stage loop ({len(stages)} stages)") + + iteration = 0 + max_iterations = 100 # Safety limit to prevent infinite loops + + while iteration < max_iterations: + current_idx = ctx.get("idx", 0) + if current_idx >= len(stages): + logger.debug(f"[Multi-Build] Build {build_index}: All stages completed (idx={current_idx}/{len(stages)})") + break + + stage_name = stages[current_idx].get("name", f"Stage {current_idx}") if current_idx < len(stages) else "Unknown" + logger.debug(f"[Multi-Build] Build {build_index}: Running stage {current_idx}/{len(stages)}: {stage_name}") + + # Run stage with show_skipped=False for clean output + result = orch.run_stage(ctx, rerun=False, show_skipped=False) + + # Check if build is done + if result.get("done"): + logger.debug(f"[Multi-Build] Build {build_index}: Build marked as done after stage {stage_name}") + break + + iteration += 1 + + if iteration >= max_iterations: + logger.warning(f"[Multi-Build] Build {build_index}: Hit max iterations ({max_iterations}), possible infinite loop. Last stage: {stage_name}") + + logger.debug(f"[Multi-Build] Build {build_index}: Stage loop completed after {iteration} iterations") + return result or {} + + +# Global orchestrator instance +_orchestrator = MultiBuildOrchestrator(max_parallel=5) + + +def queue_builds(config: Dict[str, Any], count: int, sid: str) -> str: + """ + Queue a batch of builds for parallel execution. + + Args: + config: Deck configuration + count: Number of builds to run + sid: Session ID + + Returns: + batch_id: Unique identifier for this batch + """ + sess = get_session(sid) + batch_id = BuildCache.create_batch(sess, config, count) + return batch_id + + +def run_batch_async(batch_id: str, sid: str) -> None: + """ + Run a batch of builds in parallel (blocking call for background task). + + Args: + batch_id: Batch identifier + sid: Session ID + """ + _orchestrator.run_batch_parallel(batch_id, sid) + + +def get_batch_status(batch_id: str, sid: str) -> Dict[str, Any]: + """ + Get current status of a batch build. + + Args: + batch_id: Batch identifier + sid: Session ID + + Returns: + Status dict with progress info + """ + sess = get_session(sid) + status = BuildCache.get_batch_status(sess, batch_id) + return status or {"error": "Batch not found"} diff --git a/code/web/services/synergy_builder.py b/code/web/services/synergy_builder.py new file mode 100644 index 0000000..3bd49c9 --- /dev/null +++ b/code/web/services/synergy_builder.py @@ -0,0 +1,607 @@ +""" +Synergy Builder - Analyzes multiple deck builds and creates optimized "best-of" deck. + +Takes multiple builds of the same configuration and identifies cards that appear +frequently across builds, scoring them for synergy based on: +- Frequency of appearance (higher = more consistent with strategy) +- EDHREC rank (lower rank = more popular/powerful) +- Theme tag matches (more matching tags = better fit) +""" + +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional +from collections import Counter +from code.logging_util import get_logger +from code.deck_builder import builder_utils as bu +import pandas as pd +import os + +logger = get_logger(__name__) + + +@dataclass +class ScoredCard: + """A card with its synergy score and metadata.""" + name: str + frequency: float # 0.0-1.0, percentage of builds containing this card + appearance_count: int # Number of builds this card appears in + synergy_score: float # 0-100+ calculated score + category: str # Card type category (Creature, Land, etc.) + role: str = "" # Card role from tagging + tags: List[str] = field(default_factory=list) # Theme tags + edhrec_rank: Optional[int] = None # EDHREC rank if available + count: int = 1 # Number of copies (usually 1 for Commander) + type_line: str = "" # Full type line (e.g., "Creature — Rabbit Scout") + + +@dataclass +class CardPool: + """Aggregated pool of cards from multiple builds.""" + cards: Dict[str, ScoredCard] # card_name -> ScoredCard + total_builds: int + config: Dict[str, Any] # Original build configuration + themes: List[str] # Theme tags from config + + def get_by_category(self, category: str) -> List[ScoredCard]: + """Get all cards in a specific category.""" + return [card for card in self.cards.values() if card.category == category] + + def get_top_cards(self, limit: int = 100) -> List[ScoredCard]: + """Get top N cards by synergy score.""" + return sorted(self.cards.values(), key=lambda c: c.synergy_score, reverse=True)[:limit] + + def get_high_frequency_cards(self, min_frequency: float = 0.8) -> List[ScoredCard]: + """Get cards appearing in at least min_frequency of builds.""" + return [card for card in self.cards.values() if card.frequency >= min_frequency] + + +class SynergyAnalyzer: + """Analyzes multiple builds and scores cards for synergy.""" + + # Scoring weights + FREQUENCY_WEIGHT = 0.5 + EDHREC_WEIGHT = 0.25 + THEME_WEIGHT = 0.25 + HIGH_FREQUENCY_BONUS = 1.1 # 10% bonus for cards in 80%+ builds + + def __init__(self): + """Initialize synergy analyzer.""" + self._type_line_cache: Dict[str, str] = {} + + def _load_type_lines(self) -> Dict[str, str]: + """ + Load card type lines from parquet for all cards. + + Returns: + Dict mapping card name (lowercase) to type_line + """ + if self._type_line_cache: + return self._type_line_cache + + try: + parquet_path = os.path.join("card_files", "processed", "all_cards.parquet") + if not os.path.exists(parquet_path): + logger.warning(f"[Synergy] Card parquet not found at {parquet_path}") + return {} + + df = pd.read_parquet(parquet_path) + + # Try 'type' first, then 'type_line' + type_col = None + if 'type' in df.columns: + type_col = 'type' + elif 'type_line' in df.columns: + type_col = 'type_line' + + if not type_col or 'name' not in df.columns: + logger.warning(f"[Synergy] Card parquet missing required columns. Available: {list(df.columns)}") + return {} + + # Build mapping: lowercase name -> type_line + for _, row in df.iterrows(): + name = str(row.get('name', '')).strip() + type_line = str(row.get(type_col, '')).strip() + if name and type_line: + self._type_line_cache[name.lower()] = type_line + + logger.info(f"[Synergy] Loaded type lines for {len(self._type_line_cache)} cards from parquet") + return self._type_line_cache + + except Exception as e: + logger.warning(f"[Synergy] Error loading type lines from parquet: {e}") + return {} + + def analyze_builds(self, builds: List[Dict[str, Any]], config: Dict[str, Any]) -> CardPool: + """ + Aggregate all cards from builds and calculate appearance frequencies. + + Args: + builds: List of build results from BuildCache + config: Original deck configuration + + Returns: + CardPool with all unique cards and their frequencies + """ + logger.info(f"[Synergy] Analyzing {len(builds)} builds for synergy") + + if not builds: + raise ValueError("Cannot analyze synergy with no builds") + + total_builds = len(builds) + themes = config.get("tags", []) + + # Load type lines from card CSV + type_line_map = self._load_type_lines() + + # Count card appearances and cumulative counts across all builds + card_appearances: Counter = Counter() # card_name -> number of builds containing it + card_total_counts: Counter = Counter() # card_name -> sum of counts across all builds + card_metadata: Dict[str, Dict[str, Any]] = {} + + for build in builds: + result = build.get("result", {}) + summary = result.get("summary", {}) + + if not isinstance(summary, dict): + logger.warning("[Synergy] Build missing summary, skipping") + continue + + type_breakdown = summary.get("type_breakdown", {}) + if not isinstance(type_breakdown, dict): + continue + + type_cards = type_breakdown.get("cards", {}) + if not isinstance(type_cards, dict): + continue + + # Collect unique cards from this build + unique_cards_in_build = set() + + for category, card_list in type_cards.items(): + if not isinstance(card_list, list): + continue + + for card in card_list: + if not isinstance(card, dict): + continue + + card_name = card.get("name") + if not card_name: + continue + + card_count = card.get("count", 1) + unique_cards_in_build.add(card_name) + + # Track cumulative count across all builds (for multi-copy cards like basics) + card_total_counts[card_name] += card_count + + # Store metadata (first occurrence) + if card_name not in card_metadata: + # Get type_line from parquet, fallback to card data (which won't have it from summary) + type_line = type_line_map.get(card_name.lower(), "") + if not type_line: + type_line = card.get("type", card.get("type_line", "")) + + # Debug: Log first few cards + if len(card_metadata) < 3: + logger.info(f"[Synergy Debug] Card: {card_name}, Type line: {type_line}, From map: {card_name.lower() in type_line_map}") + + card_metadata[card_name] = { + "category": category, + "role": card.get("role", ""), + "tags": card.get("tags", []), + "type_line": type_line + } + + # Increment appearance count for each unique card in this build + for card_name in unique_cards_in_build: + card_appearances[card_name] += 1 + + # Create ScoredCard objects with frequencies and average counts + scored_cards: Dict[str, ScoredCard] = {} + + for card_name, appearance_count in card_appearances.items(): + frequency = appearance_count / total_builds + metadata = card_metadata.get(card_name, {}) + + scored_card = ScoredCard( + name=card_name, + frequency=frequency, + appearance_count=appearance_count, + synergy_score=0.0, # Will be calculated next + category=metadata.get("category", "Unknown"), + role=metadata.get("role", ""), + tags=metadata.get("tags", []), + count=1, # Default to 1 copy per card in synergy deck (basics override this later) + type_line=metadata.get("type_line", "") + ) + + # Debug: Log first few scored cards + if len(scored_cards) < 3: + logger.info(f"[Synergy Debug] ScoredCard: {scored_card.name}, type_line='{scored_card.type_line}', count={scored_card.count}, in_map={card_name.lower() in type_line_map}") + + # Calculate synergy score + scored_card.synergy_score = self.score_card(scored_card, themes) + + scored_cards[card_name] = scored_card + + logger.info(f"[Synergy] Analyzed {len(scored_cards)} unique cards from {total_builds} builds") + + return CardPool( + cards=scored_cards, + total_builds=total_builds, + config=config, + themes=themes + ) + + def score_card(self, card: ScoredCard, themes: List[str]) -> float: + """ + Calculate synergy score for a card. + + Score = frequency_weight * frequency * 100 + + edhrec_weight * (1 - rank/max_rank) * 100 + + theme_weight * (matching_tags / total_tags) * 100 + + Args: + card: ScoredCard to score + themes: Theme tags from config + + Returns: + Synergy score (0-100+) + """ + # Frequency component (0-100) + frequency_score = card.frequency * 100 + + # EDHREC component (placeholder - would need EDHREC data) + # For now, assume no EDHREC data available + edhrec_score = 50.0 # Neutral score + + # Theme component (0-100) + theme_score = 0.0 + if themes and card.tags: + theme_set = set(themes) + card_tag_set = set(card.tags) + matching_tags = len(theme_set & card_tag_set) + theme_score = (matching_tags / len(themes)) * 100 if themes else 0.0 + + # Calculate weighted score + score = ( + self.FREQUENCY_WEIGHT * frequency_score + + self.EDHREC_WEIGHT * edhrec_score + + self.THEME_WEIGHT * theme_score + ) + + # Bonus for high-frequency cards (appear in 80%+ builds) + if card.frequency >= 0.8: + score *= self.HIGH_FREQUENCY_BONUS + + return round(score, 2) + + +class SynergyDeckBuilder: + """Builds an optimized deck from a synergy-scored card pool.""" + + def __init__(self, analyzer: Optional[SynergyAnalyzer] = None): + """ + Initialize synergy deck builder. + + Args: + analyzer: SynergyAnalyzer instance (creates new if None) + """ + self.analyzer = analyzer or SynergyAnalyzer() + + def _allocate_basic_lands( + self, + selected_cards: List[ScoredCard], + by_category: Dict[str, List[ScoredCard]], + pool: CardPool, + ideals: Optional[Dict[str, int]] + ) -> List[ScoredCard]: + """ + Allocate basic lands based on color identity and remaining land slots. + + Separates basic lands from nonbasics, then allocates basics based on: + 1. Total lands target from ideals + 2. Color identity from config + 3. Current nonbasic land count + + Args: + selected_cards: Currently selected cards (may include basics from pool) + by_category: Cards grouped by category + pool: Card pool with configuration + ideals: Ideal card counts + + Returns: + Updated list of selected cards with properly allocated basics + """ + if not ideals: + return selected_cards # No ideals, keep as-is + + # Get basic land names + basic_names = bu.basic_land_names() + + # Separate basics from nonbasics + nonbasic_cards = [c for c in selected_cards if c.name not in basic_names] + + # Calculate how many basics we need + # Note: For nonbasics, count=1 per card (singleton rule), so count == number of unique cards + target_lands = ideals.get("lands", 35) + nonbasic_lands = [c for c in nonbasic_cards if c.category == "Land"] + current_nonbasic_count = len(nonbasic_lands) + + # If we have too many nonbasics, trim them + if current_nonbasic_count > target_lands: + logger.info(f"[Synergy] Too many nonbasics ({current_nonbasic_count}), trimming to {target_lands}") + # Keep the highest scoring nonbasics + sorted_nonbasic_lands = sorted(nonbasic_lands, key=lambda c: c.synergy_score, reverse=True) + trimmed_nonbasic_lands = sorted_nonbasic_lands[:target_lands] + # Update nonbasic_cards to exclude trimmed lands + other_nonbasics = [c for c in nonbasic_cards if c.category != "Land"] + nonbasic_cards = other_nonbasics + trimmed_nonbasic_lands + return nonbasic_cards # No room for basics + + needed_basics = max(0, target_lands - current_nonbasic_count) + + if needed_basics == 0: + logger.info("[Synergy] No basic lands needed (nonbasics exactly fill target)") + return nonbasic_cards + + logger.info(f"[Synergy] Need {needed_basics} basics to fill {target_lands} land target (have {current_nonbasic_count} nonbasics)") + + # Get color identity from config + color_identity = pool.config.get("colors", []) + if not color_identity: + logger.warning(f"[Synergy] No color identity in config (keys: {list(pool.config.keys())}), skipping basic land allocation") + return nonbasic_cards + + # Map colors to basic land names + from code.deck_builder import builder_constants as bc + basic_map = getattr(bc, 'BASIC_LAND_MAPPING', { + 'W': 'Plains', 'U': 'Island', 'B': 'Swamp', 'R': 'Mountain', 'G': 'Forest' + }) + + # Allocate basics evenly across colors + allocation: Dict[str, int] = {} + colors = [c.upper() for c in color_identity if c.upper() in basic_map] + + if not colors: + logger.warning(f"[Synergy] No valid colors found in identity: {color_identity}") + return nonbasic_cards + + # Distribute basics evenly, with remainder going to first colors + n = len(colors) + base = needed_basics // n + rem = needed_basics % n + + for idx, color in enumerate(sorted(colors)): # sorted for deterministic allocation + count = base + (1 if idx < rem else 0) + land_name = basic_map.get(color) + if land_name: + allocation[land_name] = count + + # Create ScoredCard objects for basics + basic_cards = [] + for land_name, count in allocation.items(): + # Try to get type_line from cache first (most reliable) + type_line = self.analyzer._type_line_cache.get(land_name.lower(), "") + if not type_line: + # Fallback: construct from land name + type_line = f"Basic Land — {land_name[:-1] if land_name.endswith('s') else land_name}" + + # Try to get existing scored data from pool, else create minimal entry + if land_name in pool.cards: + existing = pool.cards[land_name] + basic_card = ScoredCard( + name=land_name, + frequency=existing.frequency, + appearance_count=existing.appearance_count, + synergy_score=existing.synergy_score, + category="Land", + role="basic", + tags=[], + count=count, + type_line=type_line # Use looked-up type_line + ) + else: + # Not in pool (common for basics), create minimal entry + basic_card = ScoredCard( + name=land_name, + frequency=1.0, # Assume high frequency for basics + appearance_count=pool.total_builds, + synergy_score=50.0, # Neutral score + category="Land", + role="basic", + tags=[], + count=count, + type_line=type_line + ) + basic_cards.append(basic_card) + + # Update by_category to replace old basics with new allocation + land_category = by_category.get("Land", []) + land_category = [c for c in land_category if c.name not in basic_names] # Remove old basics + land_category.extend(basic_cards) # Add new basics + by_category["Land"] = land_category + + # Combine and return + result = nonbasic_cards + basic_cards + logger.info(f"[Synergy] Allocated {needed_basics} basic lands across {len(colors)} colors: {allocation}") + return result + + def build_deck( + self, + pool: CardPool, + ideals: Optional[Dict[str, int]] = None, + target_size: int = 99 # Commander + 99 cards = 100 + ) -> Dict[str, Any]: + """ + Build an optimized deck from the card pool, respecting ideal counts. + + Selects highest-scoring cards by category to meet ideal distributions. + + Args: + pool: CardPool with scored cards + ideals: Target card counts by category (e.g., {"Creature": 25, "Land": 35}) + target_size: Total number of cards to include (default 99, excluding commander) + + Returns: + Dict with deck list and metadata + """ + logger.info(f"[Synergy] Building deck from pool of {len(pool.cards)} cards") + + # Map category names to ideal keys (case-insensitive matching) + category_mapping = { + "Creature": "creatures", + "Land": "lands", + "Artifact": "artifacts", + "Enchantment": "enchantments", + "Instant": "instants", + "Sorcery": "sorceries", + "Planeswalker": "planeswalkers", + "Battle": "battles" + } + + selected_cards: List[ScoredCard] = [] + by_category: Dict[str, List[ScoredCard]] = {} + + if ideals: + # Build by category to meet ideals (±2 tolerance) + logger.info(f"[Synergy] Using ideals: {ideals}") + + # Get basic land names for filtering + basic_names = bu.basic_land_names() + + for category in ["Land", "Creature", "Artifact", "Enchantment", "Instant", "Sorcery", "Planeswalker", "Battle"]: + ideal_key = category_mapping.get(category, category.lower()) + target_count = ideals.get(ideal_key, 0) + + if target_count == 0: + continue + + # Get all cards in this category sorted by score + all_category_cards = pool.get_by_category(category) + + # For lands: only select nonbasics (basics allocated separately based on color identity) + if category == "Land": + # Filter out basics + nonbasic_lands = [c for c in all_category_cards if c.name not in basic_names] + category_cards = sorted( + nonbasic_lands, + key=lambda c: c.synergy_score, + reverse=True + ) + # Reserve space for basics - typically want 15-20 basics minimum + # So select fewer nonbasics to leave room + min_basics_estimate = 15 # Reasonable minimum for most decks + max_nonbasics = max(0, target_count - min_basics_estimate) + selected = category_cards[:max_nonbasics] + logger.info(f"[Synergy] Land: selected {len(selected)} nonbasics (max {max_nonbasics}, leaving room for basics)") + else: + category_cards = sorted( + all_category_cards, + key=lambda c: c.synergy_score, + reverse=True + ) + # Select top cards up to target count + selected = category_cards[:target_count] + + selected_cards.extend(selected) + by_category[category] = selected + + logger.info( + f"[Synergy] {category}: selected {len(selected)}/{target_count} " + f"(pool had {len(category_cards)} available)" + ) + + # Calculate how many basics we'll need before filling remaining slots + target_lands = ideals.get("lands", 35) + current_land_count = len(by_category.get("Land", [])) + estimated_basics = max(0, target_lands - current_land_count) + + # Fill remaining slots with highest-scoring cards from any category (except Land) + # But reserve space for basic lands that will be added later + remaining_slots = target_size - len(selected_cards) - estimated_basics + if remaining_slots > 0: + selected_names = {c.name for c in selected_cards} + # Exclude Land category from filler to avoid over-selecting lands + remaining_pool = [ + c for c in pool.get_top_cards(limit=len(pool.cards)) + if c.name not in selected_names and c.category != "Land" + ] + filler_cards = remaining_pool[:remaining_slots] + selected_cards.extend(filler_cards) + + # Add filler cards to by_category + for card in filler_cards: + by_category.setdefault(card.category, []).append(card) + + logger.info(f"[Synergy] Filled {len(filler_cards)} remaining slots (reserved {estimated_basics} for basics)") + else: + # No ideals provided - fall back to top-scoring cards + logger.info("[Synergy] No ideals provided, selecting top-scoring cards") + sorted_cards = pool.get_top_cards(limit=len(pool.cards)) + selected_cards = sorted_cards[:target_size] + + # Group by category for summary + for card in selected_cards: + by_category.setdefault(card.category, []).append(card) + + # Add basic lands after nonbasics are selected + selected_cards = self._allocate_basic_lands(selected_cards, by_category, pool, ideals) + + # Calculate stats (accounting for multi-copy cards) + unique_cards = len(selected_cards) + total_cards = sum(c.count for c in selected_cards) # Actual card count including duplicates + + # Debug: Check for cards with unexpected counts + cards_with_count = [(c.name, c.count) for c in selected_cards if c.count != 1] + if cards_with_count: + logger.info(f"[Synergy Debug] Cards with count != 1: {cards_with_count[:10]}") + + avg_frequency = sum(c.frequency for c in selected_cards) / unique_cards if unique_cards else 0 + avg_score = sum(c.synergy_score for c in selected_cards) / unique_cards if unique_cards else 0 + high_freq_count = len([c for c in selected_cards if c.frequency >= 0.8]) + + logger.info( + f"[Synergy] Built deck: {total_cards} cards ({unique_cards} unique), " + f"avg frequency={avg_frequency:.2f}, avg score={avg_score:.2f}, " + f"high-frequency cards={high_freq_count}" + ) + + return { + "cards": selected_cards, + "by_category": by_category, + "total_cards": total_cards, # Actual count including duplicates + "unique_cards": unique_cards, # Unique card types + "avg_frequency": round(avg_frequency, 3), + "avg_score": round(avg_score, 2), + "high_frequency_count": high_freq_count, + "commander": pool.config.get("commander"), + "themes": pool.themes + } + + +# Global analyzer instance +_analyzer = SynergyAnalyzer() +_builder = SynergyDeckBuilder(_analyzer) + + +def analyze_and_build_synergy_deck( + builds: List[Dict[str, Any]], + config: Dict[str, Any] +) -> Dict[str, Any]: + """ + Convenience function to analyze builds and create synergy deck in one call. + + Args: + builds: List of build results + config: Original deck configuration (includes ideals) + + Returns: + Synergy deck result dict + """ + pool = _analyzer.analyze_builds(builds, config) + ideals = config.get("ideals", {}) + deck = _builder.build_deck(pool, ideals=ideals) + return deck diff --git a/code/web/templates/build/_batch_progress.html b/code/web/templates/build/_batch_progress.html new file mode 100644 index 0000000..7aa06b9 --- /dev/null +++ b/code/web/templates/build/_batch_progress.html @@ -0,0 +1,8 @@ +{# Batch Build Progress Indicator - Multiple Builds Running in Parallel #} +
+
+ {% include "build/_batch_progress_content.html" %} +
+
diff --git a/code/web/templates/build/_batch_progress_content.html b/code/web/templates/build/_batch_progress_content.html new file mode 100644 index 0000000..2339528 --- /dev/null +++ b/code/web/templates/build/_batch_progress_content.html @@ -0,0 +1,37 @@ +{# Batch Build Progress Content (inner content only, for HTMX updates) #} +
+

Building {{ build_count }} Decks...

+ +
+
+ {{ completed }} / {{ build_count }} +
+
+ {{ status }} +
+
+ + {# Progress Bar #} +
+
+
+ +
+

+ What's happening?
+ We're running your deck configuration {{ build_count }} times in parallel to see how card selection varies. + Each build uses the same commander, themes, and preferences but produces different results due to randomness in card selection. +

+
+ + {% if has_errors %} +
+ ⚠️ Some builds encountered errors +

{{ error_count }} of {{ build_count }} builds failed. Completed builds will still be available for comparison.

+
+ {% endif %} + +

+ This may take {{ time_estimate|default("1-3 minutes") }} depending on number of decks, theme complexity, and color count... +

+
diff --git a/code/web/templates/build/_new_deck_modal.html b/code/web/templates/build/_new_deck_modal.html index 0ae200e..d86a126 100644 --- a/code/web/templates/build/_new_deck_modal.html +++ b/code/web/templates/build/_new_deck_modal.html @@ -214,11 +214,37 @@ {% endif %} {% endif %} {% include "build/_new_deck_skip_controls.html" %} + {% if enable_batch_build %} +
+ Build Options +
+ + {% if ideals_ui_mode == 'slider' %} +
+ + {{ form.build_count if form and form.build_count else 1 }} +
+ Build 1 deck (normal build) + {% else %} + + Enter 1 for normal build, 2-10 to compare multiple results + {% endif %} +
+
+ {% else %} + {# Hidden input to always send build_count=1 when feature disabled #} + + {% endif %} @@ -248,6 +274,63 @@ } })(); + // Update build count label based on slider value + function updateBuildCountLabel(count) { + var label = document.getElementById('build_count_label'); + if (!label) return; + + count = parseInt(count); + if (count === 1) { + label.textContent = 'Build 1 deck (normal build)'; + label.className = 'muted'; + } else { + label.textContent = 'Build ' + count + ' decks and compare results'; + label.className = 'muted'; + label.style.color = '#60a5fa'; + label.style.fontWeight = '500'; + } + } + + // Update button state based on build count + function updateButtonState(count) { + var quickBuildBtn = document.getElementById('quick-build-btn'); + var createBtn = document.getElementById('create-btn'); + + count = parseInt(count); + + if (count > 1) { + // Multi-build: force Quick Build, hide Create button + if (createBtn) { + createBtn.style.display = 'none'; + } + if (quickBuildBtn) { + quickBuildBtn.textContent = 'Build ' + count + ' Decks'; + quickBuildBtn.title = 'Build ' + count + ' decks automatically and compare results'; + } + } else { + // Single build: show both buttons normally + if (createBtn) { + createBtn.style.display = ''; + } + if (quickBuildBtn) { + quickBuildBtn.textContent = 'Quick Build'; + quickBuildBtn.title = 'Build entire deck automatically without approval steps'; + } + } + } + + // Initialize label and button state on page load + (function() { + var slider = document.getElementById('build_count_slider'); + var input = document.getElementById('build_count_input'); + var initialValue = slider ? slider.value : (input ? input.value : 1); + + if (slider) { + updateBuildCountLabel(initialValue); + } + updateButtonState(initialValue); + })(); + // Utility function for parsing card lists function parseCardList(content) { const newlineRegex = /\r?\n/; diff --git a/code/web/templates/compare/_synergy_preview.html b/code/web/templates/compare/_synergy_preview.html new file mode 100644 index 0000000..9ca2067 --- /dev/null +++ b/code/web/templates/compare/_synergy_preview.html @@ -0,0 +1,111 @@ +{# Synergy Deck Preview - Shows the optimized "best-of" deck from batch builds #} + +
+

✨ Synergy Deck Preview

+ +
+ This deck is built from the most synergistic cards across all {{ total_builds }} builds, scored by frequency, EDHREC rank, and theme alignment. +
+ + {# Summary Stats #} +
+
+
{{ synergy_deck.total_cards }}
+
Total Cards
+
+
+
{{ (synergy_deck.avg_frequency * 100) | round(1) }}%
+
Avg Frequency
+
+
+
{{ synergy_deck.avg_score }}
+
Avg Synergy Score
+
+
+
{{ synergy_deck.high_frequency_count }}
+
High-Frequency Cards (80%+)
+
+
+ + {# Cards by Category #} +
+

Cards by Type

+
+ {% for category, cards in synergy_deck.cards_by_category.items() %} +
+ + {{ category }} ({{ cards | sum(attribute='count') }}) + +
+ + + + + + + + + + + {% for card in cards %} + + + + + + + {% endfor %} + +
Card NameFrequencySynergy ScoreAppears In
+
+ {% if card.count and card.count > 1 %} + {{ card.count }}x + {% endif %} + {{ card.name }} +
+ {% if card.type_line %} +
{{ card.type_line }}
+ {% endif %} + {% if card.role %} +
{{ card.role }}
+ {% endif %} +
+ + {{ (card.frequency * 100) | round(0) | int }}% + + + + {{ card.synergy_score }} + + + {{ card.appearance_count }}/{{ total_builds }} +
+
+
+ {% endfor %} +
+
+ + {# Actions #} +
+ + +
+
+ + diff --git a/code/web/templates/compare/index.html b/code/web/templates/compare/index.html new file mode 100644 index 0000000..3149232 --- /dev/null +++ b/code/web/templates/compare/index.html @@ -0,0 +1,221 @@ +{% extends "base.html" %} + +{% block title %}Compare Builds - {{ config.commander }}{% endblock %} + +{% block content %} +
+
+

Compare {{ build_count }} Builds

+
+ Commander: {{ config.commander }} + {% if config.tags %} + | Themes: {{ config.tags | join(", ") }} + {% endif %} + | Bracket: {{ config.bracket }} +
+
+ + {# Overview Stats #} +
+
+
{{ overlap_stats.total_unique_cards }}
+
Unique Cards Total
+
+
+
{{ overlap_stats.cards_in_all }}
+
In All Builds
+
+
+
{{ overlap_stats.cards_in_most }}
+
In Most Builds (80%+)
+
+
+
{{ overlap_stats.cards_in_some }}
+
In Some Builds
+
+
+
{{ overlap_stats.cards_in_few }}
+
In Few Builds
+
+
+ + {# Most Common Cards #} +
+ + 📊 Most Common Cards + +
+
+ {% for card_name, count in overlap_stats.most_common[:20] %} +
+ {{ card_name }} + {{ count }}/{{ build_count }} +
+ {% endfor %} +
+
+
+ + {# Individual Build Comparisons #} +

Individual Builds

+
+ {% for build in builds %} +
+

Build #{{ build.build_number }}

+ +
+
+
Total Cards
+
{{ build.total_cards }}
+
+
+
Creatures
+
{{ build.creatures }}
+
+
+ +
+
+
Lands
+
{{ build.lands }}
+
+
+
Artifacts
+
{{ build.artifacts }}
+
+
+
Enchantments
+
{{ build.enchantments }}
+
+
+
Instants
+
{{ build.instants }}
+
+
+
Sorceries
+
{{ build.sorceries }}
+
+
+
Planeswalkers
+
{{ build.planeswalkers }}
+
+
+ +
+ + View All Cards ({{ build.total_cards }}) + +
+ {% for card in build.cards %} +
+ + {{ card.name if card is mapping else card }} + +
+ {% endfor %} +
+
+
+ {% endfor %} +
+ + {# Actions #} +
+ Build New Deck +
+ +
+ +
+ +
+
+ + {# Synergy Preview Container #} +
+
+ + +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml index fab4858..c98fd7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache) SIMILARITY_CACHE_ENABLED: "1" # 1=use pre-computed similarity cache; 0=real-time calculation SIMILARITY_CACHE_PATH: "card_files/similarity_cache.parquet" # Path to Parquet cache file + ENABLE_BATCH_BUILD: "1" # 1=enable Build X and Compare feature; 0=hide build count slider # HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON ENABLE_PARTNER_MECHANICS: "1" # 1=unlock partner/background commander inputs diff --git a/dockerhub-docker-compose.yml b/dockerhub-docker-compose.yml index 1a556af..801585c 100644 --- a/dockerhub-docker-compose.yml +++ b/dockerhub-docker-compose.yml @@ -34,6 +34,7 @@ services: ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache) SIMILARITY_CACHE_ENABLED: "1" # 1=use pre-computed similarity cache; 0=real-time calculation SIMILARITY_CACHE_PATH: "card_files/similarity_cache.parquet" # Path to Parquet cache file + ENABLE_BATCH_BUILD: "1" # 1=enable Build X and Compare feature; 0=hide build count slider # HEADLESS_EXPORT_JSON: "1" # 1=export resolved run config JSON ENABLE_PARTNER_MECHANICS: "1" # 1=unlock partner/background commander inputs diff --git a/docs/user_guides/batch_build_compare.md b/docs/user_guides/batch_build_compare.md new file mode 100644 index 0000000..59a982c --- /dev/null +++ b/docs/user_guides/batch_build_compare.md @@ -0,0 +1,160 @@ +# Build X and Compare User Guide + +## Overview + +The **Build X and Compare** feature allows you to build multiple decks using the same configuration and compare the results side-by-side. This is useful for: + +- **Seeing variance**: Understand which cards are consistent vs. which cards vary due to RNG +- **Finding optimal builds**: Compare multiple results to pick the best deck +- **Analyzing synergies**: Use the Synergy Builder to create an optimized "best-of" deck + +## Quick Start + +### 1. Build Multiple Decks + +1. Click **New Deck** to open the deck builder modal +2. Configure your commander, themes, ideals, and bracket as normal +3. At the bottom of the modal, adjust the **"Number of decks to build"** slider (1-10) + - Setting this to 2 or more enables batch build mode +4. Click **Quick Build** - the "Create" button is hidden for batch builds + +**Note**: All builds use the exact same configuration. There are no variations in commander, themes, or ideals - you're simply running the same build multiple times to see different card selections. + +### 2. Track Progress + +After starting a batch build, you'll see a progress screen showing: + +- **Progress bar**: Visual indicator of completion +- **Build status**: "Completed X of Y builds..." +- **Time estimate**: Dynamically adjusted based on commander color count + - 1-2 colors: 1-3 minutes + - 3 colors: 2-4 minutes + - 4-5 colors: 3-5 minutes +- **First deck time**: The first deck takes ~55-60% of total time + +### 3. Compare Results + +Once all builds complete, you'll be redirected to the **Comparison View** with: + +#### Overview Stats +- **Unique Cards Total**: All different cards across all builds +- **In All Builds**: Cards that appear in every single deck +- **In Most Builds (80%+)**: High-frequency cards +- **In Some Builds**: Medium-frequency cards +- **In Few Builds**: Low-frequency cards + +#### Most Common Cards +Shows the top 20 cards by appearance frequency, excluding guaranteed cards like: +- Basic lands +- Staple lands (Command Tower, Reliquary Tower, etc.) +- Must-include cards (if using the include/exclude feature) +- Fetch lands + +**Tip**: Hover over any card name to see the card image! + +#### Individual Build Summaries +Each build shows: +- Total card count and breakdown (Creatures, Lands, Artifacts, etc.) +- Expandable card list with full deck contents + +## Using the Synergy Builder + +The **Synergy Builder** analyzes all builds and creates an optimized "best-of" deck using the most synergistic cards. + +### How It Works + +The Synergy Builder scores each card based on: + +1. **Frequency (50%)**: How often the card appears across builds + - Cards in 80%+ of builds get a 10% bonus +2. **EDHREC Rank (25%)**: Community popularity data +3. **Theme Tags (25%)**: Alignment with your chosen themes + +### Building a Synergy Deck + +1. From the comparison view, click **✨ Build Synergy Deck** +2. Wait a few seconds while the synergy deck is generated +3. Review the results: + - **Synergy Preview**: Shows the full deck with color-coded synergy scores + - 🟢 Green (80-100): High synergy + - 🔵 Blue (60-79): Good synergy + - 🟡 Yellow (40-59): Medium synergy + - 🟠 Orange (20-39): Low synergy + - 🔴 Red (0-19): Very low synergy + - Cards are organized by type (Creature, Artifact, Enchantment, etc.) + - Each section can be expanded/collapsed for easier viewing + +### Exporting the Synergy Deck + +1. Click **Export Synergy Deck** at the bottom of the synergy preview +2. **Warning**: This will delete the individual batch build files and disable batch export +3. Confirm the export to download a ZIP containing: + - **SynergyDeck_CommanderName.csv**: Deck list in CSV format + - **SynergyDeck_CommanderName.txt**: Plain text deck list + - **summary.json**: Deck statistics and metadata + - **compliance.json**: Bracket compliance information + - **synergy_metadata.json**: Synergy scores and build source data + +## Additional Actions + +### Rebuild X Times +Click **🔄 Rebuild Xx** to run the same configuration again with the same build count. This creates a new batch and redirects to the progress page. + +### Export All Decks +Click **Export All Decks as ZIP** to download all individual build files as a ZIP archive containing: +- CSV and TXT files for each build (Build_1_CommanderName.csv, etc.) +- `batch_summary.json` with metadata + +**Note**: This button is disabled after exporting a synergy deck. + +## Performance Notes + +- **Parallel execution**: Builds run concurrently (max 5 at a time) for faster results +- **Build time scales**: More colors = longer build times + - Mono/dual color: ~1 minute per 10 builds + - 3 colors: ~2-3 minutes per 10 builds + - 4-5 colors: ~3-4 minutes per 10 builds +- **First deck overhead**: The first deck in a batch takes longer due to setup + +## Feature Flag + +To disable this feature entirely, set `ENABLE_BATCH_BUILD=0` in your environment variables or `.env` file. This will: + +- Hide the "Number of decks to build" slider +- Force all builds to be single-deck builds +- Hide comparison and synergy features + +## Tips & Best Practices + +1. **Start small**: Try 3-5 builds first to get a feel for variance +2. **Use for optimization**: Build 5-10 decks and pick the best result +3. **Check consistency**: Cards appearing in 80%+ of builds are core to your strategy +4. **Analyze variance**: Cards appearing in <50% of builds might be too situational +5. **Synergy builder**: Best results with 5-10 source builds +6. **Export early**: Export individual builds before creating synergy deck if you want both + +## Troubleshooting + +### Builds are slow +- Check your commander's color count - 4-5 color decks take longer +- System resources - close other applications +- First build takes longest - wait for completion before judging speed + +### All builds look identical +- Rare but possible - try adjusting themes or ideals for more variety +- Check if you're using strict constraints (e.g., "owned cards only" with limited pool) + +### Synergy deck doesn't meet ideals +- The synergy builder aims for ±2 cards per category +- If source builds don't have enough variety, it may relax constraints +- Try building more source decks (7-10) for better card pool + +### Export button disabled +- You've already exported a synergy deck, which deletes individual batch files +- Click "Rebuild Xx" to create a new batch if you need the files again + +## See Also + +- [Docker Setup Guide](../DOCKER.md) - Environment variables and configuration +- [README](../../README.md) - General project documentation +- [Changelog](../../CHANGELOG.md) - Feature updates and changes