mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 07:30:13 +01:00
feat: implement batch build and comparison
This commit is contained in:
parent
1d95c5cbd0
commit
f1e21873e7
20 changed files with 2691 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
19
CHANGELOG.md
19
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_
|
||||
|
|
|
|||
|
|
@ -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/<name>`. |
|
||||
| `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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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_
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
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')
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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('<div class="error">Batch not found. Please refresh.</div>')
|
||||
|
||||
if batch_status["status"] == "completed":
|
||||
# All builds complete - redirect to comparison page
|
||||
response = HTMLResponse(f'<script>window.location.href = "/compare/{batch_id}";</script>')
|
||||
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")
|
||||
|
|
|
|||
730
code/web/routes/compare.py
Normal file
730
code/web/routes/compare.py
Normal file
|
|
@ -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'<div class="error-message">Batch {batch_id} not found or has no builds</div>',
|
||||
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'<div class="error-message">Failed to build synergy deck: {str(e)}</div>',
|
||||
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}"'
|
||||
}
|
||||
)
|
||||
256
code/web/services/build_cache.py
Normal file
256
code/web/services/build_cache.py
Normal file
|
|
@ -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)
|
||||
264
code/web/services/multi_build_orchestrator.py
Normal file
264
code/web/services/multi_build_orchestrator.py
Normal file
|
|
@ -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"}
|
||||
607
code/web/services/synergy_builder.py
Normal file
607
code/web/services/synergy_builder.py
Normal file
|
|
@ -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
|
||||
8
code/web/templates/build/_batch_progress.html
Normal file
8
code/web/templates/build/_batch_progress.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{# Batch Build Progress Indicator - Multiple Builds Running in Parallel #}
|
||||
<div id="batch-progress-container" style="min-height: 300px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem;">
|
||||
<div hx-get="/build/batch-progress?batch_id={{ batch_id }}"
|
||||
hx-trigger="load, every 1s"
|
||||
hx-swap="innerHTML">
|
||||
{% include "build/_batch_progress_content.html" %}
|
||||
</div>
|
||||
</div>
|
||||
37
code/web/templates/build/_batch_progress_content.html
Normal file
37
code/web/templates/build/_batch_progress_content.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{# Batch Build Progress Content (inner content only, for HTMX updates) #}
|
||||
<div style="text-align: center; max-width: 600px;">
|
||||
<h3 style="margin-bottom: 1.5rem;">Building {{ build_count }} Decks...</h3>
|
||||
|
||||
<div style="margin: 2rem 0;">
|
||||
<div style="font-size: 3rem; font-weight: bold; color: var(--primary);">
|
||||
{{ completed }} / {{ build_count }}
|
||||
</div>
|
||||
<div class="muted" style="font-size: 0.9rem; margin-top: 0.5rem;">
|
||||
{{ status }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Progress Bar #}
|
||||
<div style="width: 100%; height: 8px; background: var(--bg-secondary); border-radius: 4px; overflow: hidden; margin: 1.5rem 0;">
|
||||
<div style="height: 100%; background: linear-gradient(90deg, #3b82f6, #8b5cf6); transition: width 0.3s ease; width: {{ progress_pct }}%;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2rem; padding: 1rem; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px;">
|
||||
<p style="margin: 0; font-size: 0.9rem; line-height: 1.6;">
|
||||
<strong>What's happening?</strong><br>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if has_errors %}
|
||||
<div class="error" style="margin-top: 1rem; text-align: left;">
|
||||
<strong>⚠️ Some builds encountered errors</strong>
|
||||
<p style="font-size: 0.85rem; margin-top: 0.5rem;">{{ error_count }} of {{ build_count }} builds failed. Completed builds will still be available for comparison.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="muted" style="font-size: 0.8rem; margin-top: 1.5rem;">
|
||||
This may take {{ time_estimate|default("1-3 minutes") }} depending on number of decks, theme complexity, and color count...
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -214,11 +214,37 @@
|
|||
{% endif %}
|
||||
{% endif %}
|
||||
{% include "build/_new_deck_skip_controls.html" %}
|
||||
{% if enable_batch_build %}
|
||||
<fieldset>
|
||||
<legend>Build Options</legend>
|
||||
<div style="display:flex; flex-direction:column; gap:0.75rem;">
|
||||
<label style="display:block;">
|
||||
<span>Number of decks to build</span>
|
||||
<small class="muted" style="display:block; font-size:11px; margin-top:.25rem;">Run the same configuration multiple times to see variance in results</small>
|
||||
</label>
|
||||
{% if ideals_ui_mode == 'slider' %}
|
||||
<div style="display:flex; align-items:center; gap:1rem;">
|
||||
<input type="range" name="build_count" id="build_count_slider" min="1" max="10" value="{{ form.build_count if form and form.build_count else 1 }}" style="flex:1;"
|
||||
oninput="document.getElementById('build_count_value').textContent = this.value; updateBuildCountLabel(this.value); updateButtonState(this.value);" />
|
||||
<span id="build_count_value" style="min-width:2.5rem; text-align:center; font-weight:500; font-size:1.1em;">{{ form.build_count if form and form.build_count else 1 }}</span>
|
||||
</div>
|
||||
<small id="build_count_label" class="muted" style="font-size:11px; text-align:center;">Build 1 deck (normal build)</small>
|
||||
{% else %}
|
||||
<input type="number" name="build_count" id="build_count_input" min="1" max="10" value="{{ form.build_count if form and form.build_count else 1 }}" style="width:6rem;"
|
||||
oninput="updateButtonState(this.value);" />
|
||||
<small class="muted" style="font-size:11px;">Enter 1 for normal build, 2-10 to compare multiple results</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% else %}
|
||||
{# Hidden input to always send build_count=1 when feature disabled #}
|
||||
<input type="hidden" name="build_count" value="1" />
|
||||
{% endif %}
|
||||
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:space-between; margin-top:1rem;">
|
||||
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
|
||||
<div style="display:flex; gap:.5rem;">
|
||||
<button type="submit" name="quick_build" value="1" class="btn-continue" id="quick-build-btn" title="Build entire deck automatically without approval steps">Quick Build</button>
|
||||
<button type="submit" class="btn-continue">Create</button>
|
||||
<button type="submit" class="btn-continue" id="create-btn">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -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/;
|
||||
|
|
|
|||
111
code/web/templates/compare/_synergy_preview.html
Normal file
111
code/web/templates/compare/_synergy_preview.html
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
{# Synergy Deck Preview - Shows the optimized "best-of" deck from batch builds #}
|
||||
|
||||
<div style="padding: 2rem; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 4px 12px rgba(0,0,0,.3);">
|
||||
<h2 style="margin-bottom: 1rem; color: var(--primary);">✨ Synergy Deck Preview</h2>
|
||||
|
||||
<div class="muted" style="margin-bottom: 1.5rem; font-size: 0.9rem;">
|
||||
This deck is built from the most synergistic cards across all {{ total_builds }} builds, scored by frequency, EDHREC rank, and theme alignment.
|
||||
</div>
|
||||
|
||||
{# Summary Stats #}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
||||
<div style="padding: 1rem; text-align: center; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;">
|
||||
<div style="font-size: 1.8rem; font-weight: bold; color: var(--primary);">{{ synergy_deck.total_cards }}</div>
|
||||
<div class="muted" style="font-size: 0.85rem;">Total Cards</div>
|
||||
</div>
|
||||
<div style="padding: 1rem; text-align: center; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;">
|
||||
<div style="font-size: 1.8rem; font-weight: bold; color: #10b981;">{{ (synergy_deck.avg_frequency * 100) | round(1) }}%</div>
|
||||
<div class="muted" style="font-size: 0.85rem;">Avg Frequency</div>
|
||||
</div>
|
||||
<div style="padding: 1rem; text-align: center; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;">
|
||||
<div style="font-size: 1.8rem; font-weight: bold; color: #3b82f6;">{{ synergy_deck.avg_score }}</div>
|
||||
<div class="muted" style="font-size: 0.85rem;">Avg Synergy Score</div>
|
||||
</div>
|
||||
<div style="padding: 1rem; text-align: center; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;">
|
||||
<div style="font-size: 1.8rem; font-weight: bold; color: #8b5cf6;">{{ synergy_deck.high_frequency_count }}</div>
|
||||
<div class="muted" style="font-size: 0.85rem;">High-Frequency Cards (80%+)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Cards by Category #}
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<h3 style="margin-bottom: 1rem;">Cards by Type</h3>
|
||||
<div style="display: grid; gap: 1.5rem;">
|
||||
{% for category, cards in synergy_deck.cards_by_category.items() %}
|
||||
<details>
|
||||
<summary style="cursor: pointer; font-size: 1rem; font-weight: 500; margin-bottom: 0.75rem; color: var(--primary);">
|
||||
{{ category }} ({{ cards | sum(attribute='count') }})
|
||||
</summary>
|
||||
<div style="padding: 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid var(--border);">
|
||||
<th style="text-align: left; padding: 0.5rem;">Card Name</th>
|
||||
<th style="text-align: center; padding: 0.5rem;">Frequency</th>
|
||||
<th style="text-align: center; padding: 0.5rem;">Synergy Score</th>
|
||||
<th style="text-align: center; padding: 0.5rem;">Appears In</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for card in cards %}
|
||||
<tr style="border-bottom: 1px solid var(--border);">
|
||||
<td style="padding: 0.5rem;">
|
||||
<div>
|
||||
{% if card.count and card.count > 1 %}
|
||||
<span style="font-weight: 600; color: var(--primary);">{{ card.count }}x</span>
|
||||
{% endif %}
|
||||
<span data-card-name="{{ card.name }}" style="cursor: help;">{{ card.name }}</span>
|
||||
</div>
|
||||
{% if card.type_line %}
|
||||
<div class="muted" style="font-size: 0.8rem; margin-top: 0.15rem;">{{ card.type_line }}</div>
|
||||
{% endif %}
|
||||
{% if card.role %}
|
||||
<div class="muted" style="font-size: 0.75rem; margin-top: 0.1rem; font-style: italic;">{{ card.role }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align: center; padding: 0.5rem;">
|
||||
<span style="
|
||||
font-weight: 500;
|
||||
color: {% if card.frequency >= 0.8 %}#10b981{% elif card.frequency >= 0.5 %}#3b82f6{% else %}#6b7280{% endif %};
|
||||
">
|
||||
{{ (card.frequency * 100) | round(0) | int }}%
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align: center; padding: 0.5rem;">
|
||||
<span style="
|
||||
font-weight: 500;
|
||||
color: {% if card.synergy_score >= 70 %}#8b5cf6{% elif card.synergy_score >= 50 %}#3b82f6{% else %}#6b7280{% endif %};
|
||||
">
|
||||
{{ card.synergy_score }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align: center; padding: 0.5rem; color: #6b7280;">
|
||||
{{ card.appearance_count }}/{{ total_builds }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div style="display: flex; gap: 1rem; justify-content: center;">
|
||||
<button class="btn" onclick="document.getElementById('synergy-preview').innerHTML = ''">
|
||||
Close Preview
|
||||
</button>
|
||||
<button class="btn-continue" onclick="exportSynergyDeck('{{ batch_id }}')" id="export-synergy-btn">
|
||||
Export Synergy Deck
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Attach card hover to new elements
|
||||
if (window.attachCardHover) {
|
||||
window.attachCardHover();
|
||||
}
|
||||
</script>
|
||||
221
code/web/templates/compare/index.html
Normal file
221
code/web/templates/compare/index.html
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Compare Builds - {{ config.commander }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 1400px; padding: 2rem;">
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<h1 style="margin-bottom: 0.5rem;">Compare {{ build_count }} Builds</h1>
|
||||
<div class="muted" style="font-size: 0.9rem;">
|
||||
<strong>Commander:</strong> {{ config.commander }}
|
||||
{% if config.tags %}
|
||||
| <strong>Themes:</strong> {{ config.tags | join(", ") }}
|
||||
{% endif %}
|
||||
| <strong>Bracket:</strong> {{ config.bracket }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Overview Stats #}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
||||
<div class="card" style="padding: 1rem; text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: bold; color: var(--primary);">{{ overlap_stats.total_unique_cards }}</div>
|
||||
<div class="muted" style="font-size: 0.85rem;">Unique Cards Total</div>
|
||||
</div>
|
||||
<div class="card" style="padding: 1rem; text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: bold; color: #10b981;">{{ overlap_stats.cards_in_all }}</div>
|
||||
<div class="muted" style="font-size: 0.85rem;">In All Builds</div>
|
||||
</div>
|
||||
<div class="card" style="padding: 1rem; text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: bold; color: #3b82f6;">{{ overlap_stats.cards_in_most }}</div>
|
||||
<div class="muted" style="font-size: 0.85rem;">In Most Builds (80%+)</div>
|
||||
</div>
|
||||
<div class="card" style="padding: 1rem; text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: bold; color: #f59e0b;">{{ overlap_stats.cards_in_some }}</div>
|
||||
<div class="muted" style="font-size: 0.85rem;">In Some Builds</div>
|
||||
</div>
|
||||
<div class="card" style="padding: 1rem; text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: bold; color: #ef4444;">{{ overlap_stats.cards_in_few }}</div>
|
||||
<div class="muted" style="font-size: 0.85rem;">In Few Builds</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Most Common Cards #}
|
||||
<details open style="margin-bottom: 2rem;">
|
||||
<summary style="cursor: pointer; font-size: 1.1rem; font-weight: 500; margin-bottom: 1rem;">
|
||||
📊 Most Common Cards
|
||||
</summary>
|
||||
<div class="card" style="padding: 1rem;">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 0.5rem;">
|
||||
{% for card_name, count in overlap_stats.most_common[:20] %}
|
||||
<div style="display: flex; justify-content: space-between; padding: 0.5rem; background: rgba(59, 130, 246, 0.1); border-radius: 4px;">
|
||||
<span data-card-name="{{ card_name }}" style="cursor: help;">{{ card_name }}</span>
|
||||
<span style="font-weight: 500; color: var(--primary);">{{ count }}/{{ build_count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{# Individual Build Comparisons #}
|
||||
<h2 style="margin-bottom: 1rem;">Individual Builds</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
|
||||
{% for build in builds %}
|
||||
<div class="card" style="padding: 1.5rem;">
|
||||
<h3 style="margin-bottom: 1rem; color: var(--primary);">Build #{{ build.build_number }}</h3>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem;">
|
||||
<div>
|
||||
<div class="muted" style="font-size: 0.8rem;">Total Cards</div>
|
||||
<div style="font-size: 1.5rem; font-weight: bold;">{{ build.total_cards }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted" style="font-size: 0.8rem;">Creatures</div>
|
||||
<div style="font-size: 1.5rem; font-weight: bold;">{{ build.creatures }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; font-size: 0.85rem;">
|
||||
<div>
|
||||
<div class="muted">Lands</div>
|
||||
<div>{{ build.lands }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted">Artifacts</div>
|
||||
<div>{{ build.artifacts }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted">Enchantments</div>
|
||||
<div>{{ build.enchantments }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted">Instants</div>
|
||||
<div>{{ build.instants }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted">Sorceries</div>
|
||||
<div>{{ build.sorceries }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="muted">Planeswalkers</div>
|
||||
<div>{{ build.planeswalkers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details style="margin-top: 1rem;">
|
||||
<summary style="cursor: pointer; font-size: 0.9rem; color: var(--primary);">
|
||||
View All Cards ({{ build.total_cards }})
|
||||
</summary>
|
||||
<div style="margin-top: 0.75rem; max-height: 300px; overflow-y: auto; font-size: 0.85rem;">
|
||||
{% for card in build.cards %}
|
||||
<div style="padding: 0.25rem 0; border-bottom: 1px solid var(--border);">
|
||||
<span data-card-name="{{ card.name if card is mapping else card }}" style="cursor: help;">
|
||||
{{ card.name if card is mapping else card }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; margin-top: 2rem;">
|
||||
<a href="/build" class="btn">Build New Deck</a>
|
||||
<form method="POST" action="/compare/{{ batch_id }}/rebuild" style="display: inline;">
|
||||
<button type="submit" class="btn" title="Run {{ build_count }} more builds with the same configuration">
|
||||
🔄 Rebuild {{ build_count }}x
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
id="build-synergy-btn"
|
||||
class="btn-continue"
|
||||
hx-post="/compare/{{ batch_id }}/build-synergy"
|
||||
hx-target="#synergy-preview"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
✨ Build Synergy Deck
|
||||
</button>
|
||||
<form method="POST" action="/compare/{{ batch_id }}/export" style="display: inline;">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-continue"
|
||||
{% if synergy_exported %}disabled title="Individual batch files have been deleted after synergy export"{% endif %}
|
||||
style="{% if synergy_exported %}opacity: 0.5; cursor: not-allowed;{% endif %}"
|
||||
>
|
||||
{% if synergy_exported %}Batch Files Deleted{% else %}Export All Decks as ZIP{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Synergy Preview Container #}
|
||||
<div id="synergy-preview" style="margin-top: 2rem;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Global function for exporting synergy deck (needed for HTMX-loaded content)
|
||||
function exportSynergyDeck(batchId) {
|
||||
const btn = document.getElementById('export-synergy-btn');
|
||||
|
||||
if (!batchId) {
|
||||
alert('Batch ID not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show warning about deleting batch files
|
||||
if (!confirm('⚠️ Warning: Exporting the synergy deck will delete all individual batch build files.\n\nThis action cannot be undone. After export, you will only be able to download the synergy deck.\n\nContinue with export?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable button and show loading state
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Exporting...';
|
||||
|
||||
// Create a form and submit it to trigger download
|
||||
fetch(`/compare/${batchId}/export-synergy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Export failed');
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = `synergy_deck_${batchId}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
// Update button state
|
||||
btn.textContent = '✓ Exported';
|
||||
btn.style.opacity = '0.6';
|
||||
|
||||
// Reload page to update batch export button state
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Export error:', error);
|
||||
alert('Failed to export synergy deck. Check console for details.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Export Synergy Deck';
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure card hover is attached after page load
|
||||
if (window.attachCardHover) {
|
||||
window.attachCardHover();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
160
docs/user_guides/batch_build_compare.md
Normal file
160
docs/user_guides/batch_build_compare.md
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue