""" routes/deckgen.py ================= Deck generation endpoints (classic, Perplexity V1, V3): GET /api/deck-generator/config POST /api/deck-generator/preview POST /api/deck-generator/commit GET /api/deck-generator/commander-search POST /api/deck-research POST /api/deck-generate GET /api/pplx/status GET /api/deck/v3/status POST /api/deck/v3/generate POST /api/deck/v3/commit POST /api/deck/v3/export/csv POST /api/deck/v3/export/dck POST /api/deck/v3/export/moxfield POST /api/deck/v3/export/shopping """ from __future__ import annotations import csv import io import json import os import re import time from datetime import datetime from pathlib import Path from typing import Optional from urllib.request import urlopen, Request from fastapi import APIRouter, HTTPException from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel import routes.coach as _coach # for _deck_gen_v3 / _deck_gen_v3_error module-level access from routes.shared import ( CFG, log_deckgen, log_pplx, _get_db_conn, _scryfall_rate_limit, _API_HEADERS, DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest, GeneratedDeckCard, DeckGenerationSourceConfig, ) router = APIRouter(tags=["deckgen"]) # ══════════════════════════════════════════════════════════════ # Auto Deck Generator # ══════════════════════════════════════════════════════════════ def _resolve_commander(req: DeckGenerationRequest) -> dict: """ Resolve commander info from a DeckGenerationRequest. Returns dict with: name, scryfall_id, color_identity, type_line, mana_cost, image_url """ conn = _get_db_conn() # Try scryfall_id first if req.commander_scryfall_id: row = conn.execute( "SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE scryfall_id = ? LIMIT 1", (req.commander_scryfall_id,) ).fetchone() if row: ci = row["color_identity"] if isinstance(ci, str): try: ci = json.loads(ci) except Exception: ci = [] return { "name": row["name"], "scryfall_id": row["scryfall_id"], "color_identity": ci, "type_line": row["type_line"], "mana_cost": row["mana_cost"] or "", "image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal", } # Fallback to Scryfall API _scryfall_rate_limit() try: url = f"https://api.scryfall.com/cards/{req.commander_scryfall_id}" rq = Request(url, headers={"User-Agent": "CommanderAILab/1.0", "Accept": "application/json"}) with urlopen(rq, timeout=15) as resp: data = json.loads(resp.read().decode("utf-8")) return _scryfall_to_commander(data) except Exception: pass # Try name search if req.commander_name: name_lower = req.commander_name.strip().lower() row = conn.execute( "SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE LOWER(name) = ? LIMIT 1", (name_lower,) ).fetchone() if row: ci = row["color_identity"] if isinstance(ci, str): try: ci = json.loads(ci) except Exception: ci = [] return { "name": row["name"], "scryfall_id": row["scryfall_id"], "color_identity": ci, "type_line": row["type_line"], "mana_cost": row["mana_cost"] or "", "image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal", } # Fallback to Scryfall fuzzy search _scryfall_rate_limit() try: import urllib.parse encoded = urllib.parse.quote(req.commander_name) url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}" rq = Request(url, headers={"User-Agent": "CommanderAILab/1.0", "Accept": "application/json"}) with urlopen(rq, timeout=15) as resp: data = json.loads(resp.read().decode("utf-8")) return _scryfall_to_commander(data) except Exception: pass return None def _scryfall_to_commander(data: dict) -> dict: """Convert Scryfall API response to commander info dict.""" ci = data.get("color_identity", []) image_uris = data.get("image_uris", {}) if not image_uris and data.get("card_faces"): image_uris = data["card_faces"][0].get("image_uris", {}) return { "name": data.get("name", ""), "scryfall_id": data.get("id", ""), "color_identity": ci, "type_line": data.get("type_line", ""), "mana_cost": data.get("mana_cost", ""), "image_url": image_uris.get("normal", ""), } def _get_collection_for_colors(color_identity: list) -> list: """ Fetch all collection cards compatible with the given color identity. Returns list of dicts with card details. """ conn = _get_db_conn() rows = conn.execute( "SELECT * FROM collection_entries WHERE quantity > 0" ).fetchall() valid = [] for row in rows: card = _row_to_dict(row) card_ci = card.get("color_identity", []) if isinstance(card_ci, str): try: card_ci = json.loads(card_ci) except Exception: card_ci = [] # Color identity check: all card colors must be within commander's identity if color_identity and card_ci: if not all(c in color_identity for c in card_ci): continue # Skip if no scryfall_id if not card.get("scryfall_id"): continue card["color_identity"] = card_ci valid.append(card) return valid def _generate_deck(req: DeckGenerationRequest) -> dict: """ Core deck generation algorithm. 1. Resolve commander 2. Load collection filtered to color identity 3. Call enabled source adapters (stubbed) 4. Build candidate pool, score by role/type fit 5. Fill slots per target ratios, preferring owned cards 6. Return response dict """ # 1. Resolve commander commander = _resolve_commander(req) if not commander: return {"error": "Commander not found. Please check the name or Scryfall ID."} color_identity = req.color_identity or commander.get("color_identity", []) log_deckgen.info(f" Commander: {commander['name']}, CI: {color_identity}") # 2. Load collection collection = _get_collection_for_colors(color_identity) log_deckgen.info(f" {len(collection)} cards in collection match color identity") # 3. Call source adapters (currently stubbed) template_cards = [] # list of (name, weight, source_name) sources = req.sources or DeckGenerationSourceConfig() try: if sources.use_archidekt: from deck_sources.archidekt_adapter import fetch_template_decks as archidekt_fetch for td in archidekt_fetch(commander["name"], color_identity, {"url": sources.archidekt_url}): for tc in td.cards: template_cards.append((tc.name, tc.quantity, "archidekt")) except Exception as e: log_deckgen.error(f" Archidekt adapter error: {e}") try: if sources.use_edhrec: from deck_sources.edhrec_adapter import fetch_template_decks as edhrec_fetch for td in edhrec_fetch(commander["name"], color_identity): for tc in td.cards: template_cards.append((tc.name, tc.quantity, "edhrec")) except Exception as e: log_deckgen.error(f" EDHREC adapter error: {e}") try: if sources.use_moxfield: from deck_sources.moxfield_adapter import fetch_template_decks as moxfield_fetch for td in moxfield_fetch(commander["name"], color_identity, {"url": sources.moxfield_url}): for tc in td.cards: template_cards.append((tc.name, tc.quantity, "moxfield")) except Exception as e: log_deckgen.error(f" Moxfield adapter error: {e}") try: if sources.use_mtggoldfish: from deck_sources.mtggoldfish_adapter import fetch_template_decks as mtggoldfish_fetch for td in mtggoldfish_fetch(commander["name"], color_identity, {"url": sources.mtggoldfish_url}): for tc in td.cards: template_cards.append((tc.name, tc.quantity, "mtggoldfish")) except Exception as e: log_deckgen.error(f" MTGGoldfish adapter error: {e}") # 4. Build candidate pool from collection, scored by type need # Build a map: name_lower -> card dict (deduped) candidate_map = {} for card in collection: name = card.get("name", "") if not name: continue key = name.lower() # Skip the commander itself if key == commander["name"].lower(): continue if key not in candidate_map: type_line = card.get("type_line", "") card_type = _classify_card_type(type_line) card_roles = _detect_card_roles( card.get("oracle_text", ""), type_line, card.get("keywords", []) ) candidate_map[key] = { "scryfall_id": card.get("scryfall_id", ""), "name": name, "type_line": type_line, "mana_cost": card.get("mana_cost", ""), "cmc": float(card.get("cmc", 0)), "card_type": card_type, "roles": card_roles, "source": "collection", "quantity": 1, "owned_qty": int(card.get("quantity", 0)), "is_proxy": False, "edhrec_rank": int(card.get("edhrec_rank", 99999)), "image_url": f"https://api.scryfall.com/cards/{card.get('scryfall_id', '')}?format=image&version=normal" if card.get("scryfall_id") else "", } else: # Merge quantities candidate_map[key]["owned_qty"] += int(card.get("quantity", 0)) # Add template cards that aren't in collection (as potential proxies) for tname, tqty, tsource in template_cards: key = tname.lower() if key == commander["name"].lower(): continue if key in candidate_map: # Boost priority for cards that appear in templates candidate_map[key]["_template_weight"] = candidate_map[key].get("_template_weight", 0) + tqty if candidate_map[key]["source"] == "collection": candidate_map[key]["source"] = f"collection+{tsource}" elif req.allow_proxies: candidate_map[key] = { "scryfall_id": "", "name": tname, "type_line": "", "mana_cost": "", "cmc": 0, "card_type": "Other", "roles": [], "source": tsource, "quantity": 1, "owned_qty": 0, "is_proxy": True, "edhrec_rank": 99999, "image_url": "", "_template_weight": tqty, } # 5. Fill slots per target ratios targets = { "Land": req.target_land_count, "Creature": req.target_creature_count, "Instant": req.target_instant_count, "Sorcery": req.target_sorcery_count, "Artifact": req.target_artifact_count, "Enchantment": req.target_enchantment_count, "Planeswalker": req.target_planeswalker_count, } # Group candidates by type by_type = {} for card in candidate_map.values(): ct = card["card_type"] by_type.setdefault(ct, []).append(card) # Score and sort candidates within each type for ct, cards in by_type.items(): for card in cards: score = 0 # Prefer owned cards heavily if card["owned_qty"] > 0: score += 100 # Prefer cards with functional roles for r in card.get("roles", []): if r in ("Ramp", "Draw", "Removal", "BoardWipe"): score += 10 else: score += 3 # Prefer lower EDHREC rank (more popular cards) edhrec_rank = card.get("edhrec_rank", 99999) if edhrec_rank < 500: score += 15 elif edhrec_rank < 2000: score += 10 elif edhrec_rank < 5000: score += 5 # Prefer cards from templates score += card.get("_template_weight", 0) * 5 # Prefer lower CMC (curve optimization) cmc = card.get("cmc", 0) if cmc <= 2: score += 5 elif cmc <= 4: score += 3 card["_score"] = score cards.sort(key=lambda x: x["_score"], reverse=True) # Pick cards for each slot type deck_cards = [] used_names = set() # track by name to avoid duplicates for card_type, target in targets.items(): candidates = by_type.get(card_type, []) picked = 0 for card in candidates: if picked >= target: break name_key = card["name"].lower() if name_key in used_names: continue # If only collection, skip proxies if req.only_cards_in_collection and card["owned_qty"] <= 0: continue used_names.add(name_key) deck_cards.append(card) picked += 1 # Fill remaining slots up to 99 (commander is #100) total = sum(targets.values()) if total < 99: remaining = 99 - len(deck_cards) # Fill with best remaining cards from any type all_remaining = [] for ct, cards in by_type.items(): for card in cards: if card["name"].lower() not in used_names: if not req.only_cards_in_collection or card["owned_qty"] > 0: all_remaining.append(card) all_remaining.sort(key=lambda x: x.get("_score", 0), reverse=True) for card in all_remaining[:remaining]: used_names.add(card["name"].lower()) deck_cards.append(card) elif len(deck_cards) > 99: deck_cards = deck_cards[:99] # 6. Compute stats stats = {"total": len(deck_cards) + 1, "land": 0, "nonland": 0, "by_type": {}, "owned": 0, "proxy": 0} for card in deck_cards: ct = card.get("card_type", "Other") stats["by_type"][ct] = stats["by_type"].get(ct, 0) + 1 if ct == "Land": stats["land"] += 1 else: stats["nonland"] += 1 if card.get("owned_qty", 0) > 0: stats["owned"] += 1 else: stats["proxy"] += 1 # Clean up internal scoring keys clean_cards = [] for card in deck_cards: clean_cards.append({ "scryfall_id": card.get("scryfall_id", ""), "name": card.get("name", ""), "type_line": card.get("type_line", ""), "mana_cost": card.get("mana_cost", ""), "cmc": card.get("cmc", 0), "card_type": card.get("card_type", "Other"), "roles": card.get("roles", []), "source": card.get("source", "collection"), "quantity": 1, "image_url": card.get("image_url", ""), "owned_qty": card.get("owned_qty", 0), "is_proxy": card.get("is_proxy", False), }) log_deckgen.info(f" Generated deck: {len(clean_cards)} cards + commander") log_deckgen.info(f" Stats: {stats}") return { "commander": commander, "color_identity": color_identity, "cards": clean_cards, "stats": stats, "targets": targets, } @router.get("/api/deck-generator/config") async def deck_generator_config(): """ Return default ratios, supported sources, and limits. Used by the frontend to prefill the generator form. """ return { "defaults": { "target_land_count": 37, "target_instant_count": 10, "target_sorcery_count": 8, "target_artifact_count": 10, "target_enchantment_count": 8, "target_creature_count": 25, "target_planeswalker_count": 2, "only_cards_in_collection": False, "allow_proxies": True, }, "sources": [ {"id": "archidekt", "name": "Archidekt", "enabled": True, "experimental": False}, {"id": "edhrec", "name": "EDHREC", "enabled": True, "experimental": False}, {"id": "moxfield", "name": "Moxfield", "enabled": False, "experimental": True}, {"id": "mtggoldfish", "name": "MTGGoldfish", "enabled": False, "experimental": True}, ], "limits": { "deck_size": 100, "max_external_templates": 5, }, } @router.post("/api/deck-generator/preview") async def deck_generator_preview(req: DeckGenerationRequest): """ Generate a deck preview without saving it. Returns the generated deck data for user review. """ result = _generate_deck(req) if "error" in result: raise HTTPException(400, result["error"]) return result @router.post("/api/deck-generator/commit") async def deck_generator_commit(req: DeckGenerationRequest): """ Generate a deck and save it to the Deck Builder. Returns the generated deck data plus deck_id and deck_name. """ result = _generate_deck(req) if "error" in result: raise HTTPException(400, result["error"]) commander = result["commander"] color_identity = result["color_identity"] cards = result["cards"] # Determine deck name deck_name = req.deck_name or f"Auto - {commander['name']} - {datetime.now().strftime('%Y-%m-%d %H:%M')}" # Create the deck in the database conn = _get_db_conn() color_identity_json = json.dumps(color_identity) cur = conn.execute( """ INSERT INTO decks (name, commander_scryfall_id, commander_name, color_identity, strategy_tag) VALUES (?, ?, ?, ?, ?) """, ( deck_name, commander.get("scryfall_id", ""), commander.get("name", ""), color_identity_json, "auto-generated", ) ) conn.commit() deck_id = cur.lastrowid # Insert commander as a deck card with is_commander=1 conn.execute( "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) VALUES (?, ?, ?, 1, 1, 'Commander')", (deck_id, commander.get("scryfall_id", ""), commander.get("name", "")) ) # Insert the 99 cards for card in cards: scryfall_id = card.get("scryfall_id", "") card_name = card.get("name", "") quantity = card.get("quantity", 1) if not scryfall_id and not card_name: continue conn.execute( "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) VALUES (?, ?, ?, ?, 0, ?)", (deck_id, scryfall_id, card_name, quantity, card.get("card_type", "")) ) conn.commit() # Also export a .dck file to the Forge decks directory so it appears in the Sim Lab try: decks_dir = CFG.forge_decks_dir if decks_dir and os.path.isdir(decks_dir): safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_') if not safe_name: safe_name = f"AutoDeck_{deck_id}" dck_path = os.path.join(decks_dir, f"{safe_name}.dck") dck_lines = [] dck_lines.append("[metadata]") dck_lines.append(f"Name={deck_name}") dck_lines.append("") dck_lines.append("[Commander]") dck_lines.append(f"1 {commander['name']}") dck_lines.append("") dck_lines.append("[Main]") for card in cards: card_name = card.get("name", "") if card_name: dck_lines.append(f"{card.get('quantity', 1)} {card_name}") with open(dck_path, "w", encoding="utf-8") as f: f.write("\n".join(dck_lines)) log_deckgen.info(f" Exported .dck file: {dck_path}") except Exception as e: log_deckgen.warning(f" Warning: Failed to export .dck file: {e}") log_deckgen.info(f" Saved deck '{deck_name}' (ID: {deck_id}) with {len(cards)} cards + commander") result["deck_id"] = deck_id result["deck_name"] = deck_name return result @router.get("/api/deck-generator/commander-search") async def deck_generator_commander_search(q: str = ""): """ Search for legendary creatures/planeswalkers to use as commander. First checks collection, then falls back to Scryfall. """ if not q or len(q) < 2: return {"results": []} conn = _get_db_conn() q_lower = f"%{q.lower()}%" # Search collection first rows = conn.execute( """ SELECT DISTINCT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE LOWER(name) LIKE ? AND is_legendary = 1 AND (type_line LIKE '%Creature%' OR type_line LIKE '%Planeswalker%') ORDER BY name ASC LIMIT 20 """, (q_lower,) ).fetchall() results = [] for r in rows: ci = r["color_identity"] if isinstance(ci, str): try: ci = json.loads(ci) except Exception: ci = [] results.append({ "name": r["name"], "type_line": r["type_line"], "color_identity": ci, "mana_cost": r["mana_cost"] or "", "scryfall_id": r["scryfall_id"], "in_collection": True, "image_url": f"https://api.scryfall.com/cards/{r['scryfall_id']}?format=image&version=normal" if r["scryfall_id"] else "", }) # If few results, supplement with Scryfall if len(results) < 5: try: import urllib.parse encoded = urllib.parse.quote(q) _scryfall_rate_limit() url = f"https://api.scryfall.com/cards/search?q={encoded}+t%3Alegendary+(t%3Acreature+OR+t%3Aplaneswalker)&order=edhrec&unique=cards" rq = Request(url, headers={"User-Agent": "CommanderAILab/1.0", "Accept": "application/json"}) with urlopen(rq, timeout=15) as resp: data = json.loads(resp.read().decode("utf-8")) existing_names = {r["name"].lower() for r in results} for card in data.get("data", [])[:10]: if card["name"].lower() in existing_names: continue image_uris = card.get("image_uris", {}) if not image_uris and card.get("card_faces"): image_uris = card["card_faces"][0].get("image_uris", {}) results.append({ "name": card["name"], "type_line": card.get("type_line", ""), "color_identity": card.get("color_identity", []), "mana_cost": card.get("mana_cost", ""), "scryfall_id": card.get("id", ""), "in_collection": False, "image_url": image_uris.get("normal", image_uris.get("small", "")), }) except Exception as e: log_deckgen.error(f" Scryfall search error: {e}") return {"results": results[:20]} # ══════════════════════════════════════════════════════════════ # Perplexity API — AI Deck Research & Generation # ══════════════════════════════════════════════════════════════ class DeckResearchRequest(BaseModel): deck_id: int # Deck to research goal: Optional[str] = "Identify weaknesses and suggest upgrades" budget_usd: Optional[float] = None omit_cards: Optional[list[str]] = [] use_collection: bool = True class DeckGenerateAIRequest(BaseModel): commander: str budget_usd: Optional[float] = None budget_mode: str = "total" # "total" or "per_card" omit_cards: Optional[list[str]] = [] use_collection: bool = True def _build_collection_summary(color_identity: list[str] | None = None) -> dict: """Build a compact collection summary for the AI, optionally filtered by color identity.""" conn = _get_db_conn() # Base query: all collection entries with oracle text where = "WHERE quantity > 0" params = [] # Filter by color identity if provided if color_identity: # Cards must only contain colors in the commander's identity (+ colorless) ci_set = set(c.upper() for c in color_identity) # We'll do a post-filter since color_identity is JSON in the DB pass # filter in Python after fetch rows = conn.execute(f""" SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity, color_identity, category, is_game_changer, salt_score FROM collection_entries {where} ORDER BY edhrec_rank ASC, tcg_price DESC """, params).fetchall() # Color identity filter def card_fits_identity(card_ci_str, ci_set): if not ci_set: return True try: card_ci = json.loads(card_ci_str) if card_ci_str else [] if not isinstance(card_ci, list): return True return all(c.upper() in ci_set for c in card_ci if c.upper() not in ('', 'C')) except Exception: return True # Role detection from type_line, oracle_text, and category def detect_role(row): tl = (row['type_line'] or '').lower() oracle = (row['oracle_text'] or '').lower() cat = (row['category'] or '').lower() kw = (row['keywords'] or '').lower() # Priority order if 'land' in tl: return 'lands' if any(w in cat for w in ['ramp', 'mana']): return 'ramp' if any(w in oracle for w in ['add {', 'add one mana', 'search your library for a basic land', 'search your library for a land']): return 'ramp' if 'mana' in cat or ('artifact' in tl and ('add' in oracle and '{' in oracle)): return 'ramp' if any(w in cat for w in ['draw', 'card advantage']): return 'card_draw' if 'draw' in oracle and 'card' in oracle: return 'card_draw' if any(w in cat for w in ['removal', 'targeted removal']): return 'removal' if 'destroy target' in oracle or 'exile target' in oracle or 'deals' in oracle: return 'removal' if any(w in cat for w in ['board wipe', 'boardwipe', 'wrath']): return 'board_wipes' if 'destroy all' in oracle or 'exile all' in oracle: return 'board_wipes' if any(w in cat for w in ['win', 'finisher', 'combo']): return 'win_conditions' if 'you win the game' in oracle or 'extra turn' in oracle or 'infinite' in oracle: return 'win_conditions' if 'creature' in tl: return 'creatures' return 'utility' # Group cards by role groups: dict[str, list] = { 'ramp': [], 'card_draw': [], 'removal': [], 'board_wipes': [], 'lands': [], 'win_conditions': [], 'creatures': [], 'utility': [] } filtered_count = 0 for r in rows: if color_identity and not card_fits_identity(r['color_identity'], ci_set): continue filtered_count += 1 role = detect_role(r) groups[role].append({ 'name': r['name'], 'count': r['quantity'], 'price': round(r['tcg_price'] or 0, 2), 'cmc': r['cmc'] or 0, }) # Limit each group to top 30 group_descriptions = { 'ramp': 'Mana rocks and land ramp in deck colors', 'card_draw': 'Card draw and card advantage engines', 'removal': 'Targeted removal spells (destroy, exile, bounce)', 'board_wipes': 'Board wipes and mass removal', 'lands': 'Non-basic lands that fit the color identity', 'win_conditions': 'Win conditions, combo pieces, and finishers', 'creatures': 'Creatures (non-commander)', 'utility': 'Utility spells, enchantments, artifacts, and planeswalkers', } result_groups = [] for gid, cards in groups.items(): if not cards: continue result_groups.append({ 'group_id': gid, 'description': group_descriptions.get(gid, ''), 'cards': cards[:30], }) return { 'total_cards': len(rows), 'filtered_cards': filtered_count, 'groups': result_groups, } def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str: """Call Perplexity API chat/completions endpoint. Returns the assistant message content.""" if not CFG.pplx_api_key: raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.') payload = { 'model': 'sonar', 'messages': messages, 'max_tokens': max_tokens, 'temperature': temperature, 'return_related_questions': False, } req_data = json.dumps(payload).encode('utf-8') req = Request('https://api.perplexity.ai/chat/completions', data=req_data, method='POST') req.add_header('Content-Type', 'application/json') req.add_header('Authorization', f'Bearer {CFG.pplx_api_key}') try: with urlopen(req, timeout=120) as resp: data = json.loads(resp.read()) choices = data.get('choices', []) if not choices: raise ValueError('Empty response from Perplexity API') content = choices[0].get('message', {}).get('content', '') usage = data.get('usage', {}) log_pplx.debug(f'tokens: prompt={usage.get("prompt_tokens","?")}, ' f'completion={usage.get("completion_tokens","?")}, ' f'model={data.get("model","?")}') return content except URLError as e: raise HTTPException(502, f'Perplexity API call failed: {e}') except json.JSONDecodeError as e: raise HTTPException(502, f'Perplexity API returned invalid JSON: {e}') def _extract_json_from_response(text: str) -> dict: """Extract JSON object from LLM response, handling markdown fences and extra text.""" # Strip markdown code fences cleaned = re.sub(r'^```(?:json)?\s*', '', text.strip()) cleaned = re.sub(r'\s*```$', '', cleaned.strip()) # Try direct parse try: return json.loads(cleaned) except json.JSONDecodeError: pass # Try to find JSON object in the text match = re.search(r'\{[\s\S]*\}', text) if match: try: return json.loads(match.group(0)) except json.JSONDecodeError: pass raise ValueError(f'Could not extract JSON from response: {text[:300]}') def _postprocess_deck_cards(cards: list[dict], color_identity: list[str] | None = None) -> list[dict]: """ Post-process AI-suggested cards: - Validate names against collection DB - Attach real prices - Set from_collection flag """ conn = _get_db_conn() processed = [] for card in cards: name = card.get('name', '') if not name: continue # Look up in collection row = conn.execute( "SELECT name, tcg_price, quantity, scryfall_id, type_line, cmc, oracle_text " "FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1", (name,) ).fetchone() entry = { 'name': row['name'] if row else name, 'count': card.get('count', 1), 'role': card.get('role', ''), 'from_collection': bool(row and row['quantity'] and row['quantity'] > 0), 'estimated_price_usd': round(row['tcg_price'], 2) if row and row['tcg_price'] else card.get('estimated_price_usd', 0), 'scryfall_id': row['scryfall_id'] if row else '', } # Preserve extra fields from AI response for extra_key in ('reason', 'synergy_with', 'priority', 'severity'): if extra_key in card: entry[extra_key] = card[extra_key] processed.append(entry) return processed # Color-to-basic-land mapping for deterministic fill _COLOR_TO_BASIC = { 'W': 'Plains', 'U': 'Island', 'B': 'Swamp', 'R': 'Mountain', 'G': 'Forest', } BASIC_LAND_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'} def _fill_basic_lands(cards: list[dict], color_identity: list[str] | None = None, target_total: int = 100) -> list[dict]: """ Deterministically fill basic lands so the deck hits exactly target_total cards. The LLM may list basics with wrong counts, duplicates, or omit them. This function: 1. Strips all basic-land entries the LLM provided 2. Counts remaining cards (non-basics) and their total count 3. Computes how many basic land slots are needed 4. Distributes basics evenly across the commander's colors """ # Separate basics from non-basics non_basics = [] for card in cards: name = card.get('name', '').strip() if name not in BASIC_LAND_NAMES: non_basics.append(card) # Count total non-basic cards non_basic_total = sum(c.get('count', 1) for c in non_basics) # How many basic land copies we need basics_needed = max(0, target_total - non_basic_total) if basics_needed == 0: return non_basics # Determine which basics to use from color identity ci = [c.upper() for c in (color_identity or [])] basic_names = [_COLOR_TO_BASIC[c] for c in ci if c in _COLOR_TO_BASIC] if not basic_names: # Colorless commander — use Wastes basic_names = ['Wastes'] # Distribute evenly, then add remainders one-by-one per_color = basics_needed // len(basic_names) remainder = basics_needed % len(basic_names) for i, bname in enumerate(basic_names): qty = per_color + (1 if i < remainder else 0) if qty > 0: non_basics.append({ 'name': bname, 'count': qty, 'role': 'land', 'category': 'Land', 'role_tags': [], 'reason': 'Basic land for mana fixing', 'estimated_price_usd': 0.10, 'from_collection': False, 'is_basic': True, }) return non_basics # ── Research Endpoint ───────────────────────────────────────── @router.post('/api/deck-research') async def deck_research(req: DeckResearchRequest): """Analyze an existing deck using Perplexity AI and suggest improvements.""" conn = _get_db_conn() # Load deck info deck = conn.execute('SELECT * FROM decks WHERE id = ?', (req.deck_id,)).fetchone() if not deck: raise HTTPException(404, f'Deck {req.deck_id} not found') deck_name = deck['name'] commander_name = deck['commander_name'] or 'Unknown' color_identity_str = deck['color_identity'] or '[]' try: color_identity = json.loads(color_identity_str) except Exception: color_identity = [] # Load deck cards card_rows = conn.execute(""" SELECT dc.card_name, dc.quantity, dc.is_commander, ce.type_line, ce.cmc, ce.oracle_text, ce.tcg_price FROM deck_cards dc LEFT JOIN ( SELECT scryfall_id, type_line, cmc, oracle_text, tcg_price FROM collection_entries GROUP BY scryfall_id ) ce ON ce.scryfall_id = dc.scryfall_id WHERE dc.deck_id = ? ORDER BY dc.is_commander DESC, dc.card_name ASC """, (req.deck_id,)).fetchall() if not card_rows: raise HTTPException(400, 'Deck has no cards') # Build decklist text decklist_lines = [] total_price = 0.0 for r in card_rows: price = r['tcg_price'] or 0 total_price += price * (r['quantity'] or 1) cmdr_tag = ' [COMMANDER]' if r['is_commander'] else '' decklist_lines.append(f"{r['quantity'] or 1}x {r['card_name']}{cmdr_tag}") decklist_text = '\n'.join(decklist_lines) # Build collection summary if requested collection_block = '' if req.use_collection: summary = _build_collection_summary(color_identity) if summary['filtered_cards'] > 0: coll_lines = [f'\nCOLLECTION SUMMARY ({summary["filtered_cards"]} cards in deck colors):'] for grp in summary['groups']: card_names = [f"{c['name']} (${c['price']})" for c in grp['cards'][:15]] coll_lines.append(f" {grp['group_id'].upper()} ({grp['description']}): {', '.join(card_names)}") collection_block = '\n'.join(coll_lines) omit_block = '' if req.omit_cards: omit_block = f'\nDO NOT suggest these cards: {", ".join(req.omit_cards)}' budget_block = '' if req.budget_usd: budget_block = f'\nBUDGET: ${req.budget_usd} total for upgrades. Current deck value: ~${total_price:.0f}' # Build messages system_msg = """You are an elite Magic: The Gathering Commander analyst with encyclopedic knowledge of the format, metagame, and every card ever printed. Provide a DEEP, COMPREHENSIVE analysis. Always respond with ONLY a JSON object, no markdown fences, no extra text. JSON schema: { "overall_rating": "1-10 integer", "rating_explanation": "2-3 sentence explanation of the rating", "deck_description": "3-5 sentence overview of what this deck does, its game plan, and how it wins", "archetype": "aggro|midrange|control|combo|stax|voltron|aristocrats|spellslinger|tokens|tribal|group_hug|lands|reanimator|other", "bracket_level": { "level": 1-4, "reasoning": "why this bracket", "power_ceiling": "what power level this deck could reach with upgrades" }, "win_conditions": [ {"name": "Win condition name", "cards_involved": ["card1", "card2"], "description": "How this wins the game", "reliability": "high|medium|low"} ], "synergy_packages": [ {"package_name": "Package Name (e.g. Sacrifice Engine, Blink Package)", "cards": ["card1", "card2", "card3"], "description": "How these cards work together", "strength": "strong|moderate|weak"} ], "strengths": ["strength 1", "strength 2"], "weaknesses": ["weakness 1", "weakness 2"], "threat_assessment": { "early_game": "1-2 sentences on turns 1-3 plan", "mid_game": "1-2 sentences on turns 4-7 plan", "late_game": "1-2 sentences on turns 8+ plan", "vulnerability": "What shuts this deck down (e.g. graveyard hate, board wipes)" }, "mana_analysis": { "land_count": "current land count assessment", "color_fixing": "assessment of color fixing quality", "ramp_package": "assessment of ramp quantity and quality", "curve_assessment": "is the mana curve appropriate for the strategy", "problem_cards": ["cards that are hard to cast or mana-inefficient"] }, "cuts": [{"name": "card to remove", "reason": "why", "severity": "must_cut|should_cut|consider_cutting"}], "adds": [{"name": "card to add", "count": 1, "role": "ramp|removal|draw|creature|utility|land|combo_piece|protection|finisher", "estimated_price_usd": 2.5, "reason": "why this card", "synergy_with": ["existing card it synergizes with"], "priority": "critical|high|medium|nice_to_have"}], "role_gaps": { "ramp": {"current": 8, "recommended": 10, "note": "needs 2 more ramp sources"}, "card_draw": {"current": 5, "recommended": 10, "note": ""}, "removal": {"current": 6, "recommended": 8, "note": ""}, "board_wipes": {"current": 2, "recommended": 3, "note": ""}, "protection": {"current": 1, "recommended": 3, "note": ""}, "lands": {"current": 35, "recommended": 36, "note": ""} }, "strategy_notes": "detailed strategic advice for piloting this deck" }""" user_msg = f"""Provide a DEEP, COMPREHENSIVE analysis of this Commander deck. DECK NAME: {deck_name} COMMANDER: {commander_name} COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'} CURRENT DECK VALUE: ~${total_price:.0f} GOAL: {req.goal} DECKLIST ({len(card_rows)} cards): {decklist_text} {budget_block}{omit_block}{collection_block} Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4 per Commander Rules Committee), ALL synergy packages between cards, ALL win conditions, game plan by phase (early/mid/late), mana base health, every role gap. Suggest specific cuts with severity and specific adds with priority and synergy tags. For adds, prioritize cards from the COLLECTION SUMMARY when available.""" content = _call_pplx_api([ {'role': 'system', 'content': system_msg}, {'role': 'user', 'content': user_msg}, ], max_tokens=8192) try: analysis = _extract_json_from_response(content) except ValueError as e: return JSONResponse({'error': str(e), 'raw_response': content[:500]}, status_code=422) # Post-process "adds" — validate against DB if 'adds' in analysis and isinstance(analysis['adds'], list): analysis['adds'] = _postprocess_deck_cards(analysis['adds'], color_identity) # Compute real total cost of adds adds_total = sum(c.get('estimated_price_usd', 0) * c.get('count', 1) for c in analysis.get('adds', [])) analysis['adds_total_usd'] = round(adds_total, 2) analysis['deck_name'] = deck_name analysis['commander'] = commander_name analysis['color_identity'] = color_identity analysis['card_count'] = len(card_rows) analysis['deck_value_usd'] = round(total_price, 2) return analysis # ── Generate Endpoint ───────────────────────────────────────── @router.post('/api/deck-generate') async def deck_generate_ai(req: DeckGenerateAIRequest): """Generate a full 100-card Commander deck using Perplexity AI.""" commander = req.commander.strip() if not commander: raise HTTPException(400, 'Commander name is required') # Look up commander on Scryfall for color identity color_identity = [] commander_type = '' try: scry_url = f'https://api.scryfall.com/cards/named?fuzzy={commander.replace(" ", "+")}' scry_req = Request(scry_url) scry_req.add_header('User-Agent', 'CommanderAILab/1.0') with urlopen(scry_req, timeout=10) as resp: scry_data = json.loads(resp.read()) color_identity = scry_data.get('color_identity', []) commander = scry_data.get('name', commander) # Use canonical name commander_type = scry_data.get('type_line', '') except Exception as e: log_pplx.error(f'Scryfall lookup failed for "{commander}": {e}') # Build collection summary collection_block = '' if req.use_collection: summary = _build_collection_summary(color_identity or None) if summary['filtered_cards'] > 0: coll_lines = [f'\nCOLLECTION SUMMARY ({summary["filtered_cards"]} cards available):'] for grp in summary['groups']: card_names = [f"{c['name']} (${c['price']})" for c in grp['cards'][:20]] coll_lines.append(f" {grp['group_id'].upper()}: {', '.join(card_names)}") collection_block = '\n'.join(coll_lines) omit_block = '' if req.omit_cards: omit_block = f'\nOMIT LIST (do NOT include): {", ".join(req.omit_cards)}' budget_block = '' if req.budget_usd: mode_desc = 'total deck cost' if req.budget_mode == 'total' else 'per card' budget_block = f'\nBUDGET: ${req.budget_usd} {mode_desc}. Stay within budget.' system_msg = """You are an expert Magic: The Gathering Commander deck builder. Build a complete, legal 100-card Commander deck (1 commander + 99 other cards). Prefer cards from the player's collection when available. Respect the budget and omit list. Always respond with ONLY a JSON object, no markdown fences, no extra text. JSON schema: { "commander": "Commander Name", "strategy": "1-2 sentence strategy description", "cards": [ {"name": "Card Name", "count": 1, "role": "ramp|removal|draw|creature|land|utility|win_condition", "estimated_price_usd": 2.5} ], "reasoning": { "strategy": "detailed strategy explanation", "mana_curve": "mana curve reasoning", "key_synergies": "key synergies and combos", "budget_notes": "how budget was managed", "collection_usage_notes": "which collection cards were used and why" }, "estimated_total_usd": 187.5 } Deck building rules: - Exactly 100 cards total (commander + 99) - No more than 1 copy of any card (except basic lands) - 36-38 lands including commander-colored basics and utility lands - ~10 ramp sources, ~10 card draw, ~8-10 removal, ~2-3 board wipes - Include the commander in the cards list with role "commander" - All cards must be legal in Commander format""" user_msg = f"""Build a complete 100-card Commander deck for: COMMANDER: {commander} TYPE: {commander_type} COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'} {budget_block}{omit_block}{collection_block} Build the deck as JSON. Prioritize collection cards when they fit the strategy.""" content = _call_pplx_api([ {'role': 'system', 'content': system_msg}, {'role': 'user', 'content': user_msg}, ], max_tokens=8192, temperature=0.3) try: result = _extract_json_from_response(content) except ValueError as e: return JSONResponse({'error': str(e), 'raw_response': content[:500]}, status_code=422) # Post-process cards if 'cards' in result and isinstance(result['cards'], list): result['cards'] = _postprocess_deck_cards(result['cards'], color_identity) # Fill basic lands deterministically to hit exactly 100 result['cards'] = _fill_basic_lands(result['cards'], color_identity, target_total=100) # Compute real totals real_total = sum(c.get('estimated_price_usd', 0) * c.get('count', 1) for c in result.get('cards', [])) from_collection_count = sum(1 for c in result.get('cards', []) if c.get('from_collection')) result['real_total_usd'] = round(real_total, 2) result['from_collection_count'] = from_collection_count result['total_cards'] = sum(c.get('count', 1) for c in result.get('cards', [])) result['color_identity'] = color_identity return result @router.get('/api/pplx/status') async def pplx_status(): """Check if Perplexity API is configured.""" return { 'configured': bool(CFG.pplx_api_key), 'key_prefix': CFG.pplx_api_key[:8] + '...' if CFG.pplx_api_key else '', } # ══════════════════════════════════════════════════════════════ # V3 Deck Generator (Perplexity Structured Output) # ══════════════════════════════════════════════════════════════ @router.get('/api/deck/v3/status') async def deck_gen_v3_status(): """Check V3 deck generator status.""" return { 'initialized': _coach._deck_gen_v3 is not None, 'pplx_configured': bool(CFG.pplx_api_key), 'model': _coach._deck_gen_v3.pplx.model if _coach._deck_gen_v3 else None, 'embeddings_loaded': ( _coach_embeddings.loaded if _coach_embeddings else False ), 'embedding_cards': ( _coach_embeddings.card_count if _coach_embeddings else 0 ), 'error': _coach._deck_gen_v3_error, } @router.post('/api/deck/v3/generate') async def deck_gen_v3_generate(req: DeckGenV3Request): """ V3 Deck Generation — Perplexity structured output with Smart Substitution. Pipeline: 1. Resolve commander via Scryfall 2. Build collection summary from DB 3. Call Perplexity Sonar with JSON schema enforcement 4. Cross-reference cards with collection for ownership 5. Run Smart Substitution (embedding + Perplexity fallback) 6. Return complete deck with substitution data """ if _coach._deck_gen_v3 is None: raise HTTPException(503, 'V3 Deck Generator not initialized. Check PPLX_API_KEY.') if not req.commander_name or len(req.commander_name.strip()) < 2: raise HTTPException(400, 'Commander name is required (min 2 chars)') try: # Generate deck result = _coach._deck_gen_v3.generate_deck( commander_name=req.commander_name.strip(), strategy=req.strategy, target_bracket=req.target_bracket, budget_usd=req.budget_usd, budget_mode=req.budget_mode, omit_cards=req.omit_cards, use_collection=req.use_collection, model=req.model, ) # Run substitution if requested if req.run_substitution and result.get('cards'): from coach.schemas.substitution_schema import DeckCardWithStatus cards = [DeckCardWithStatus(**c) for c in result['cards']] sub_result = _coach._deck_gen_v3.run_substitution( cards=cards, commander=result['commander'], strategy=req.strategy, ) result['cards'] = [c.model_dump() for c in sub_result.cards] result['substitution_stats'] = { 'owned': sub_result.owned_count, 'substituted': sub_result.substituted_count, 'missing': sub_result.missing_count, } # Recompute stats with updated cards from coach.services.deck_generator import DeckGeneratorV3 result['stats'] = DeckGeneratorV3._compute_stats(sub_result.cards) return result except ValueError as e: return JSONResponse({'error': str(e)}, status_code=422) except Exception as e: log_deckgen.error(f'Error: {e}') import traceback traceback.print_exc() raise HTTPException(500, f'Deck generation failed: {str(e)}') @router.post('/api/deck/v3/commit') async def deck_gen_v3_commit(req: DeckGenV3Request): """ V3 Generate + Commit — generates the deck and saves it to the Deck Builder DB. Also exports a .dck file for Forge Sim Lab. """ if _coach._deck_gen_v3 is None: raise HTTPException(503, 'V3 Deck Generator not initialized.') if not req.commander_name or len(req.commander_name.strip()) < 2: raise HTTPException(400, 'Commander name is required') try: # Generate deck (same as preview) result = _coach._deck_gen_v3.generate_deck( commander_name=req.commander_name.strip(), strategy=req.strategy, target_bracket=req.target_bracket, budget_usd=req.budget_usd, budget_mode=req.budget_mode, omit_cards=req.omit_cards, use_collection=req.use_collection, model=req.model, ) # Run substitution if req.run_substitution and result.get('cards'): from coach.schemas.substitution_schema import DeckCardWithStatus cards = [DeckCardWithStatus(**c) for c in result['cards']] sub_result = _coach._deck_gen_v3.run_substitution( cards=cards, commander=result['commander'], strategy=req.strategy, ) result['cards'] = [c.model_dump() for c in sub_result.cards] # Save to DB commander = result['commander'] color_identity = result.get('color_identity', []) cards = result.get('cards', []) strategy = result.get('strategy_summary', '') bracket = result.get('bracket', {}).get('level', 0) deck_name = ( req.deck_name or f"V3 - {commander['name']} - B{bracket} - {datetime.now().strftime('%Y-%m-%d %H:%M')}" ) conn = _get_db_conn() cur = conn.execute( "INSERT INTO decks (name, commander_scryfall_id, commander_name, color_identity, strategy_tag) " "VALUES (?, ?, ?, ?, ?)", (deck_name, commander.get('scryfall_id', ''), commander.get('name', ''), json.dumps(color_identity), f"v3-auto|B{bracket}|{strategy[:60]}") ) conn.commit() deck_id = cur.lastrowid # Insert commander conn.execute( "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) " "VALUES (?, ?, ?, 1, 1, 'Commander')", (deck_id, commander.get('scryfall_id', ''), commander.get('name', '')) ) # Insert the 99 for card in cards: card_name = card.get('name', '') if not card_name or card_name == commander.get('name', ''): continue # Skip commander (already inserted) scryfall_id = card.get('scryfall_id', '') # Use substitute name if substituted if card.get('status') == 'substituted' and card.get('selected_substitute'): card_name = card['selected_substitute'] conn.execute( "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) " "VALUES (?, ?, ?, ?, 0, ?)", (deck_id, scryfall_id, card_name, card.get('count', 1), card.get('category', '')) ) conn.commit() # Export .dck for Forge try: decks_dir = CFG.forge_decks_dir if decks_dir and os.path.isdir(decks_dir): safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_') if not safe_name: safe_name = f"V3Deck_{deck_id}" dck_path = os.path.join(decks_dir, f"{safe_name}.dck") dck_lines = ["[metadata]", f"Name={deck_name}", "", "[Commander]", f"1 {commander['name']}", "", "[Main]"] for card in cards: cname = card.get('name', '') if card.get('status') == 'substituted' and card.get('selected_substitute'): cname = card['selected_substitute'] if cname and cname != commander.get('name', ''): dck_lines.append(f"{card.get('count', 1)} {cname}") with open(dck_path, 'w', encoding='utf-8') as f: f.write('\n'.join(dck_lines)) log_deckgen.info(f" Exported .dck: {dck_path}") except Exception as e: log_deckgen.error(f" .dck export failed: {e}") result['deck_id'] = deck_id result['deck_name'] = deck_name log_deckgen.info(f" Committed deck '{deck_name}' (ID: {deck_id})") return result except ValueError as e: return JSONResponse({'error': str(e)}, status_code=422) except Exception as e: log_deckgen.error(f'Commit error: {e}') import traceback traceback.print_exc() raise HTTPException(500, f'Deck generation/commit failed: {str(e)}') @router.post('/api/deck/v3/export/csv') async def deck_gen_v3_export_csv(req: DeckGenV3Request): """Generate a deck and return as CSV.""" if _coach._deck_gen_v3 is None: raise HTTPException(503, 'V3 Deck Generator not initialized.') result = _coach._deck_gen_v3.generate_deck( commander_name=req.commander_name.strip(), strategy=req.strategy, target_bracket=req.target_bracket, budget_usd=req.budget_usd, budget_mode=req.budget_mode, omit_cards=req.omit_cards, use_collection=req.use_collection, model=req.model, ) cards = result.get('cards', []) commander = result.get('commander', {}) output = io.StringIO() writer = csv.writer(output) writer.writerow(['Count', 'Name', 'Category', 'Roles', 'Status', 'Price_USD', 'Reason']) # Commander row writer.writerow([1, commander.get('name', ''), 'Commander', '', 'owned', '', 'Commander']) for card in cards: if card.get('name') == commander.get('name', ''): continue writer.writerow([ card.get('count', 1), card.get('name', ''), card.get('category', ''), '; '.join(card.get('role_tags', [])), card.get('status', 'unknown'), card.get('estimated_price_usd', 0), card.get('reason', ''), ]) csv_content = output.getvalue() safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_') return StreamingResponse( io.BytesIO(csv_content.encode('utf-8')), media_type='text/csv', headers={'Content-Disposition': f'attachment; filename="{safe_name}_deck.csv"'}, ) @router.post('/api/deck/v3/export/dck') async def deck_gen_v3_export_dck(req: DeckGenV3Request): """Generate a deck and return as Forge .dck format.""" if _coach._deck_gen_v3 is None: raise HTTPException(503, 'V3 Deck Generator not initialized.') result = _coach._deck_gen_v3.generate_deck( commander_name=req.commander_name.strip(), strategy=req.strategy, target_bracket=req.target_bracket, budget_usd=req.budget_usd, budget_mode=req.budget_mode, omit_cards=req.omit_cards, use_collection=req.use_collection, model=req.model, ) cards = result.get('cards', []) commander = result.get('commander', {}) lines = [ '[metadata]', f'Name={commander.get("name", "Deck")} - V3 Auto', '', '[Commander]', f'1 {commander.get("name", "")}', '', '[Main]', ] for card in cards: cname = card.get('name', '') if card.get('status') == 'substituted' and card.get('selected_substitute'): cname = card['selected_substitute'] if cname and cname != commander.get('name', ''): lines.append(f"{card.get('count', 1)} {cname}") dck_content = '\n'.join(lines) safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_') return StreamingResponse( io.BytesIO(dck_content.encode('utf-8')), media_type='text/plain', headers={'Content-Disposition': f'attachment; filename="{safe_name}.dck"'}, ) @router.post('/api/deck/v3/export/moxfield') async def deck_gen_v3_export_moxfield(req: DeckGenV3Request): """Generate a deck and return in Moxfield paste format.""" if _coach._deck_gen_v3 is None: raise HTTPException(503, 'V3 Deck Generator not initialized.') result = _coach._deck_gen_v3.generate_deck( commander_name=req.commander_name.strip(), strategy=req.strategy, target_bracket=req.target_bracket, budget_usd=req.budget_usd, budget_mode=req.budget_mode, omit_cards=req.omit_cards, use_collection=req.use_collection, model=req.model, ) cards = result.get('cards', []) commander = result.get('commander', {}) # Moxfield format: card lines in main, commander in dedicated section lines = [] lines.append('// Commander') lines.append(f'1 {commander.get("name", "")}') lines.append('') lines.append('// Deck') for card in cards: cname = card.get('name', '') if card.get('status') == 'substituted' and card.get('selected_substitute'): cname = card['selected_substitute'] if cname and cname != commander.get('name', ''): lines.append(f"{card.get('count', 1)} {cname}") txt = '\n'.join(lines) return {'format': 'moxfield', 'content': txt, 'commander': commander.get('name', '')} @router.post('/api/deck/v3/export/shopping') async def deck_gen_v3_export_shopping(req: DeckGenV3Request): """Generate a deck and return a shopping list of cards not owned.""" if _coach._deck_gen_v3 is None: raise HTTPException(503, 'V3 Deck Generator not initialized.') result = _coach._deck_gen_v3.generate_deck( commander_name=req.commander_name.strip(), strategy=req.strategy, target_bracket=req.target_bracket, budget_usd=req.budget_usd, budget_mode=req.budget_mode, omit_cards=req.omit_cards, use_collection=req.use_collection, model=req.model, ) cards = result.get('cards', []) shopping = [] total = 0.0 for card in cards: if not card.get('owned', True) and card.get('status') != 'substituted': price = card.get('estimated_price_usd', 0) shopping.append({ 'name': card.get('name', ''), 'count': card.get('count', 1), 'category': card.get('category', ''), 'estimated_price_usd': price, 'reason': card.get('reason', ''), }) total += price * card.get('count', 1) return { 'commander': result.get('commander', {}).get('name', ''), 'shopping_list': shopping, 'total_missing': len(shopping), 'estimated_cost_usd': round(total, 2), }