From 36077c94ab8c58ed8b01e33076bad43b94dee45d Mon Sep 17 00:00:00 2001 From: KoalaTrapLord Date: Sat, 14 Mar 2026 18:55:32 +0000 Subject: [PATCH] refactor: extract 49 endpoints into 5 route modules - Extract collection (10), deckbuilder (18), precon (4), import (5), lab (12) routes - All share helpers/models/state from routes/shared.py - Gut lab_api.py from 8872 to 4487 lines (57 remaining endpoints) - Delete routes/_wire.py and routes/_autoload.py (replaced by include_router) - Add _scryfall_fuzzy_lookup to shared.py - 106 total endpoints verified: 49 extracted + 57 remaining --- lab_api.py | 4535 +-------------------------------------- routes/__init__.py | 7 +- routes/_autoload.py | 23 - routes/_wire.py | 142 -- routes/collection.py | 952 +++++++- routes/deckbuilder.py | 1116 +++++++++- routes/import_routes.py | 62 +- routes/lab.py | 467 +++- routes/precon.py | 43 +- routes/shared.py | 20 + 10 files changed, 2456 insertions(+), 4911 deletions(-) delete mode 100644 routes/_autoload.py delete mode 100644 routes/_wire.py diff --git a/lab_api.py b/lab_api.py index c54bb75..c069235 100644 --- a/lab_api.py +++ b/lab_api.py @@ -47,21 +47,66 @@ except ImportError: # ══════════════════════════════════════════════════════════════ -# Configuration +# Shared Module — configuration, models, helpers # ══════════════════════════════════════════════════════════════ -class Config: - """Runtime configuration — set via CLI args or env vars.""" - forge_jar: str = "" - forge_dir: str = "" - forge_decks_dir: str = "" - lab_jar: str = "" - results_dir: str = "results" - port: int = 8080 - ximilar_api_key: str = "" - pplx_api_key: str = "" # Perplexity API key for deck research/generation +from routes.shared import ( + # Configuration + Config, CFG, setup_logging, + # Loggers + # (we re-create named loggers locally below for convenience) + # In-memory state + BatchState, COMMANDER_META, + # Pydantic models + StartRequest, StartResponse, StatusResponse, DeckInfo, + ImportUrlRequest, ImportTextRequest, MetaFetchRequest, + CreateDeckRequest, UpdateDeckRequest, AddDeckCardRequest, + PatchDeckCardRequest, BulkAddRequest, BulkAddRecommendedRequest, + DeckGenerationSourceConfig, DeckGenerationRequest, GeneratedDeckCard, + DeckGenV3Request, DeckGenV3SubstituteRequest, + # Database + _get_db_conn, init_collection_db, + COLLECTION_DB_PATH, + # Collection helpers + _row_to_dict, _snake_to_camel, _add_image_url, + _build_collection_filters, + _detect_card_roles, _classify_card_type, + VALID_SORT_FIELDS, + # Deck helpers + _get_deck_or_404, _compute_deck_analysis, _check_ratio_limit, + # Scryfall + _ScryfallCache, _scryfall_cache, _scryfall_rate_limit, + _fetch_scryfall_api, _enrich_from_scryfall, _scryfall_fuzzy_lookup, + SCRYFALL_CACHE_DB_PATH, SCRYFALL_CACHE_TTL_SECONDS, + _API_HEADERS, + # Import/fetch helpers + _http_get, _import_from_url, _fetch_archidekt_deck, + _fetch_edhrec_average, _parse_text_decklist, + _save_profile_to_dck, _to_edhrec_slug, + _parse_finish, _parse_text_line, _auto_infer_mapping, _parse_csv_content, + # Precon helpers + load_precon_index, _sanitize_filename, _deck_to_dck, + download_precon_database, + PRECON_DIR, PRECON_INDEX, GITHUB_PRECON_URL, PRECON_CACHE_HOURS, + # Java/batch + _find_java17, get_java17, build_java_command, + parse_dck_file, _load_deck_cards_by_name, + run_batch_subprocess, _run_process_blocking, + _run_deepseek_batch_thread, + _get_deepseek_brain, + # AI profiles & ML + AI_PROFILES, _ml_logging_enabled, + # EDHREC cache + _edhrec_cache, _EDHREC_CACHE_TTL, + _edhrec_cache_get, _edhrec_cache_set, +) -CFG = Config() +# Route modules +from routes.collection import router as collection_router +from routes.deckbuilder import router as deckbuilder_router +from routes.precon import router as precon_router +from routes.import_routes import router as import_router +from routes.lab import router as lab_router # ══════════════════════════════════════════════════════════════ @@ -71,52 +116,18 @@ CFG = Config() _LOG_FORMAT = "%(asctime)s %(levelname)-5s [%(name)s] %(message)s" _LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S" _LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs")) -_LOG_MAX_BYTES = int(os.environ.get("CAL_LOG_MAX_MB", "25")) * 1024 * 1024 -_LOG_BACKUP_COUNT = int(os.environ.get("CAL_LOG_BACKUP_COUNT", "5")) - -def setup_logging(level: int = logging.INFO) -> None: - """Configure the root 'commander_ai_lab' logger with console + rotating file.""" - _LOG_DIR.mkdir(parents=True, exist_ok=True) - - root_logger = logging.getLogger("commander_ai_lab") - root_logger.setLevel(level) - - # Avoid duplicate handlers if called again - if root_logger.handlers: - return - - formatter = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATE_FMT) - - # Console handler — matches previous print() behaviour - console = logging.StreamHandler(sys.stdout) - console.setLevel(level) - console.setFormatter(formatter) - root_logger.addHandler(console) - - # Rotating file handler — 25 MB × 5 backups = 125 MB max - file_handler = logging.handlers.RotatingFileHandler( - _LOG_DIR / "commander-ai-lab.log", - maxBytes=_LOG_MAX_BYTES, - backupCount=_LOG_BACKUP_COUNT, - encoding="utf-8", - ) - file_handler.setLevel(level) - file_handler.setFormatter(formatter) - root_logger.addHandler(file_handler) - - -# Named loggers — import these where needed -log = logging.getLogger("commander_ai_lab.api") # API endpoints, server lifecycle -log_batch = logging.getLogger("commander_ai_lab.batch") # Batch sim (Java + DeepSeek) -log_sim = logging.getLogger("commander_ai_lab.sim") # DeepSeek game simulation -log_coach = logging.getLogger("commander_ai_lab.coach") # Coach + report generation -log_deckgen = logging.getLogger("commander_ai_lab.deckgen") # Deck generation (all versions) -log_collect = logging.getLogger("commander_ai_lab.collection") # Collection management -log_scan = logging.getLogger("commander_ai_lab.scanner") # Card scanner -log_ml = logging.getLogger("commander_ai_lab.ml") # ML training + inference -log_cache = logging.getLogger("commander_ai_lab.cache") # Scryfall cache -log_pplx = logging.getLogger("commander_ai_lab.pplx") # Perplexity API +# Named loggers — convenience aliases for remaining endpoints +log = logging.getLogger("commander_ai_lab.api") +log_batch = logging.getLogger("commander_ai_lab.batch") +log_sim = logging.getLogger("commander_ai_lab.sim") +log_coach = logging.getLogger("commander_ai_lab.coach") +log_deckgen = logging.getLogger("commander_ai_lab.deckgen") +log_collect = logging.getLogger("commander_ai_lab.collection") +log_scan = logging.getLogger("commander_ai_lab.scanner") +log_ml = logging.getLogger("commander_ai_lab.ml") +log_cache = logging.getLogger("commander_ai_lab.cache") +log_pplx = logging.getLogger("commander_ai_lab.pplx") # ══════════════════════════════════════════════════════════════ @@ -132,4416 +143,20 @@ app.add_middleware( allow_headers=["*"], ) -from routes import _autoload as _routes_autoload -_routes_autoload.register_startup(app) +# Register extracted route modules +app.include_router(collection_router) +app.include_router(deckbuilder_router) +app.include_router(precon_router) +app.include_router(import_router) +app.include_router(lab_router) # ══════════════════════════════════════════════════════════════ -# In-Memory State +# In-Memory State (used by remaining endpoints) # ══════════════════════════════════════════════════════════════ -class BatchState: - """Tracks a running or completed batch.""" - def __init__(self, batch_id: str, total_games: int, threads: int): - self.batch_id = batch_id - self.total_games = total_games - self.threads = threads - self.completed_games = 0 - self.running = True - self.start_time = datetime.now() - self.elapsed_ms = 0 - self.result_path: Optional[str] = None - self.error: Optional[str] = None - self.process: Optional[subprocess.Popen] = None - self.log_lines: list = [] - # v24 (Issue #5): Real-time throughput tracking - self.sims_per_sec: float = 0.0 - active_batches: dict[str, BatchState] = {} -# Commander meta mapping (loaded at startup) -COMMANDER_META: dict = {} - -# ══════════════════════════════════════════════════════════════ -# Request / Response Models -# ══════════════════════════════════════════════════════════════ - -class StartRequest(BaseModel): - decks: list[str] # Exactly 3 deck names - numGames: int = 100 - threads: int = 4 - seed: Optional[int] = None - clock: int = 120 - # v3: Optional source metadata per deck - deckSources: Optional[list[Optional[dict]]] = None # [{source, sourceUrl, commander, archetype}] - # v8: Learned policy support - useLearnedPolicy: bool = False - policyStyle: str = "midrange" - policyGreedy: bool = False - # v24 (Issues #4/#5): Performance settings - aiSimplified: bool = False # Use faster AI profile - aiThinkTimeMs: int = -1 # Cap AI think time (-1 unlimited) - maxQueueDepth: int = -1 # Backpressure limit (-1 unlimited) - -class StartResponse(BaseModel): - batchId: str - status: str = "started" - message: str = "" - -class StatusResponse(BaseModel): - batchId: str = "" - running: bool = False - completed: int = 0 - total: int = 0 - threads: int = 0 - elapsedMs: int = 0 - error: Optional[str] = None - simsPerSec: float = 0.0 - # React SPA aliases (same data, expected field names) - run_id: str = "" - games_completed: int = 0 - total_games: int = 0 - current_decks: list = [] - -class DeckInfo(BaseModel): - name: str - filename: str - -class ImportUrlRequest(BaseModel): - url: str - -class ImportTextRequest(BaseModel): - text: str - commander: Optional[str] = None - -class MetaFetchRequest(BaseModel): - commander: str - - -# ── Deck Builder Models ──────────────────────────────────────── - -class CreateDeckRequest(BaseModel): - name: str - commander_scryfall_id: Optional[str] = "" - commander_name: Optional[str] = "" - color_identity: Optional[list] = [] - strategy_tag: Optional[str] = "" - - -class UpdateDeckRequest(BaseModel): - name: Optional[str] = None - commander_scryfall_id: Optional[str] = None - commander_name: Optional[str] = None - color_identity: Optional[list] = None - strategy_tag: Optional[str] = None - - -class AddDeckCardRequest(BaseModel): - scryfall_id: str - card_name: Optional[str] = "" - quantity: Optional[int] = 1 - is_commander: Optional[int] = 0 - role_tag: Optional[str] = "" - - -class PatchDeckCardRequest(BaseModel): - quantity: Optional[int] = None - role_tag: Optional[str] = None - - -class BulkAddRequest(BaseModel): - cards: list[dict] # [{scryfall_id, quantity}] - respect_ratios: Optional[bool] = False - - -class BulkAddRecommendedRequest(BaseModel): - source: str = "collection" # "collection" | "edhrec" - only_owned: Optional[bool] = True - respect_ratios: Optional[bool] = False - types: Optional[list[str]] = None - roles: Optional[list[str]] = None - - -# ── Auto Deck Generator Models ───────────────────────────────────── - -class DeckGenerationSourceConfig(BaseModel): - use_archidekt: bool = True - use_edhrec: bool = True - use_moxfield: bool = False - use_mtggoldfish: bool = False - archidekt_url: Optional[str] = "" - moxfield_url: Optional[str] = "" - mtggoldfish_url: Optional[str] = "" - - -class DeckGenerationRequest(BaseModel): - commander_name: Optional[str] = "" - commander_scryfall_id: Optional[str] = "" - color_identity: Optional[list[str]] = None - sources: Optional[DeckGenerationSourceConfig] = None - target_land_count: int = 37 - target_instant_count: int = 10 - target_sorcery_count: int = 8 - target_artifact_count: int = 10 - target_enchantment_count: int = 8 - target_creature_count: int = 25 - target_planeswalker_count: int = 2 - only_cards_in_collection: bool = False - allow_proxies: bool = True - deck_name: Optional[str] = "" - - -class GeneratedDeckCard(BaseModel): - scryfall_id: str = "" - name: str = "" - type_line: str = "" - mana_cost: str = "" - cmc: float = 0 - card_type: str = "" # Land / Creature / Instant / etc. - roles: list[str] = [] - source: str = "collection" # collection / edhrec / archidekt / template - quantity: int = 1 - image_url: str = "" - owned_qty: int = 0 - is_proxy: bool = False - - -# ── V3 Deck Generation (Perplexity Structured Output) ──────── -class DeckGenV3Request(BaseModel): - commander_name: str = "" - strategy: str = "" # e.g. "zombie tokens", "voltron" - target_bracket: int = 3 # 1-4 per Commander Rules Committee - budget_usd: Optional[float] = None # None = no budget - budget_mode: str = "total" # "total" or "per_card" - omit_cards: list[str] = [] # Cards to exclude - use_collection: bool = True # Cross-ref with collection DB - run_substitution: bool = True # Enable Smart Substitution Engine - model: Optional[str] = None # Override: sonar, sonar-pro - deck_name: Optional[str] = "" # Custom deck name for commit - - -class DeckGenV3SubstituteRequest(BaseModel): - """Request to manually pick a substitute for a card.""" - card_name: str - substitute_name: str - - -# ══════════════════════════════════════════════════════════════ -# v3: Deck Import & Meta Endpoints -# ══════════════════════════════════════════════════════════════ - -@app.post("/api/lab/import/url") -async def import_from_url(req: ImportUrlRequest): - """Import a deck from Archidekt or EDHREC URL. Returns parsed DeckProfile + saves .dck file.""" - url = req.url.strip() - try: - profile = _import_from_url(url) - dck_path = _save_profile_to_dck(profile) - return { - "success": True, - "deckName": profile["name"], - "commander": profile["commander"], - "source": profile["source"], - "sourceUrl": profile["sourceUrl"], - "totalCards": profile["totalCards"], - "dckFile": dck_path.stem, - "colorIdentity": profile.get("colorIdentity", []), - "archetype": profile.get("archetype"), - } - except Exception as e: - raise HTTPException(400, str(e)) - - -@app.post("/api/lab/import/text") -async def import_from_text(req: ImportTextRequest): - """Import a deck from plain text card list.""" - try: - profile = _parse_text_decklist(req.text, req.commander) - dck_path = _save_profile_to_dck(profile) - return { - "success": True, - "deckName": profile["name"], - "commander": profile["commander"], - "source": "Text Import", - "totalCards": profile["totalCards"], - "dckFile": dck_path.stem, - } - except Exception as e: - raise HTTPException(400, str(e)) - - -@app.get("/api/lab/meta/commanders") -async def list_meta_commanders(): - """List available commanders in the meta mapping.""" - commanders = [] - for name, entries in COMMANDER_META.items(): - entry = entries[0] if entries else {} - commanders.append({ - "name": name, - "archetype": entry.get("archetype", ""), - "colorIdentity": entry.get("colorIdentity", []), - "source": entry.get("source", "edhrec"), - }) - return {"commanders": commanders} - - -@app.get("/api/lab/meta/search") -async def search_meta_commanders(q: str = ""): - """Search commanders by partial name match.""" - query = q.lower() - matches = [] - for name, entries in COMMANDER_META.items(): - if query in name.lower(): - entry = entries[0] if entries else {} - matches.append({ - "name": name, - "archetype": entry.get("archetype", ""), - "colorIdentity": entry.get("colorIdentity", []), - }) - return {"results": matches} - - -@app.post("/api/lab/meta/fetch") -async def fetch_meta_deck(req: MetaFetchRequest): - """Fetch EDHREC average deck for a commander and save as .dck file.""" - try: - profile = _fetch_edhrec_average(req.commander) - dck_path = _save_profile_to_dck(profile) - return { - "success": True, - "deckName": profile["name"], - "commander": profile["commander"], - "source": "EDHREC Average", - "sourceUrl": profile.get("sourceUrl", ""), - "totalCards": profile["totalCards"], - "dckFile": dck_path.stem, - "colorIdentity": profile.get("colorIdentity", []), - "sampleSize": profile.get("sampleSize"), - } - except Exception as e: - raise HTTPException(400, f"Failed to fetch meta deck for '{req.commander}': {str(e)}") - - -# ══════════════════════════════════════════════════════════════ -# Existing Endpoints (v1/v2) -# ══════════════════════════════════════════════════════════════ - -@app.post("/api/lab/start", response_model=StartResponse) -async def start_batch(req: StartRequest, background_tasks: BackgroundTasks): - """Start a new batch simulation run.""" - if len(req.decks) < 2 or len(req.decks) > 4: - raise HTTPException(400, "2-4 decks required") - if not all(req.decks): - raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled") - if req.numGames < 1 or req.numGames > 10000: - raise HTTPException(400, "numGames must be 1-10000") - if req.threads < 1 or req.threads > 16: - raise HTTPException(400, "threads must be 1-16") - - if not os.path.exists(CFG.forge_jar): - raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}") - if not os.path.isdir(CFG.forge_dir): - raise HTTPException(500, f"Forge dir not found: {CFG.forge_dir}") - - batch_id = str(uuid.uuid4())[:12] - state = BatchState(batch_id, req.numGames, req.threads) - active_batches[batch_id] = state - - os.makedirs(CFG.results_dir, exist_ok=True) - output_path = os.path.join(CFG.results_dir, f"batch-{batch_id}.json") - state.result_path = output_path - - background_tasks.add_task( - run_batch_subprocess, - state, - req.decks, - req.numGames, - req.threads, - req.seed, - req.clock, - output_path, - req.useLearnedPolicy, - req.policyStyle, - req.policyGreedy, - req.aiSimplified, - req.aiThinkTimeMs, - req.maxQueueDepth, - ) - - policy_msg = "" - if req.useLearnedPolicy: - policy_msg = f" [Learned Policy: {req.policyStyle}]" - return StartResponse( - batchId=batch_id, - status="started", - message=f"Running {req.numGames} games with {req.threads} threads{policy_msg}", - ) - - -@app.get("/api/lab/status", response_model=StatusResponse) -async def get_status(batchId: Optional[str] = None): - # If no batchId given, find the most recent active (or last) batch - state = None - if batchId: - state = active_batches.get(batchId) - else: - # Return the most recently active batch, or any batch - for s in active_batches.values(): - if s.running: - state = s - break - if state is None and active_batches: - state = list(active_batches.values())[-1] - if not state: - return StatusResponse( - batchId="", running=False, completed=0, total=0, - threads=0, elapsedMs=0, error=None, simsPerSec=0.0, - run_id="", games_completed=0, total_games=0, current_decks=[], - ) - elapsed = int((datetime.now() - state.start_time).total_seconds() * 1000) - deck_names = getattr(state, 'deck_names', []) if hasattr(state, 'deck_names') else [] - return StatusResponse( - batchId=state.batch_id, - running=state.running, - completed=state.completed_games, - total=state.total_games, - threads=state.threads, - elapsedMs=elapsed if state.running else state.elapsed_ms, - error=state.error, - simsPerSec=state.sims_per_sec, - # React SPA fields - run_id=state.batch_id, - games_completed=state.completed_games, - total_games=state.total_games, - current_decks=deck_names, - ) - - -@app.get("/api/lab/result") -async def get_result(batchId: Optional[str] = None): - state = None - if batchId: - state = active_batches.get(batchId) - else: - # Find most recent completed batch - for s in reversed(list(active_batches.values())): - if not s.running and s.result_path: - state = s - break - if not state: - raise HTTPException(404, "No batch result available") - if state.running: - raise HTTPException(409, "Batch still running") - if state.error: - raise HTTPException(500, state.error) - if not state.result_path or not os.path.exists(state.result_path): - raise HTTPException(500, "Result file not found") - with open(state.result_path, "r", encoding="utf-8") as f: - data = json.load(f) - return JSONResponse(content=data) - - -@app.get("/api/lab/decks") -async def list_decks(): - decks = [] - seen_names = set() - - # 1. Forge .dck files from decks directory - decks_dir = CFG.forge_decks_dir - if decks_dir and os.path.isdir(decks_dir): - for f in sorted(Path(decks_dir).glob("*.dck")): - deck_name = f.stem - decks.append({"name": deck_name, "filename": f.name, "source": "forge"}) - seen_names.add(deck_name.lower()) - - # 2. Decks from the Deck Builder database - try: - conn = _get_db_conn() - rows = conn.execute( - "SELECT id, name, commander_name FROM decks ORDER BY id DESC" - ).fetchall() - for row in rows: - db_name = row["name"] - if db_name.lower() not in seen_names: - decks.append({ - "name": db_name, - "filename": "", - "source": "deckbuilder", - "deck_id": row["id"], - "commander": row["commander_name"] or "", - }) - seen_names.add(db_name.lower()) - except Exception as e: - log.warning(f" WARNING: Failed to load DB decks for /api/lab/decks: {e}") - - return {"decks": decks} - - -@app.get("/api/lab/history") -async def list_history(): - results_dir = Path(CFG.results_dir) - if not results_dir.exists(): - return {"results": []} - results = [] - for f in sorted(results_dir.glob("batch-*.json"), reverse=True): - try: - with open(f, "r", encoding="utf-8") as fh: - data = json.load(fh) - meta = data.get("metadata", {}) - decks = data.get("decks", []) - results.append({ - "filename": f.name, - "batchId": meta.get("batchId", ""), - "timestamp": meta.get("timestamp", ""), - "totalGames": meta.get("completedGames", 0), - "threads": meta.get("threads", 1), - "elapsedMs": meta.get("elapsedMs", 0), - "decks": [{"name": d.get("deckName", ""), "source": d.get("source", "")} for d in decks], - }) - except Exception: - continue - return {"results": results[:50]} - - -# ════════════════════════════════════════════════════════════ -# AI Profiles -# ════════════════════════════════════════════════════════════ - -AI_PROFILES = { - "default": { - "name": "default", - "description": "Balanced \u2014 Forge's default AI behavior", - "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, - "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5, - }, - "aggro": { - "name": "aggro", - "description": "Aggressive \u2014 attacks early, prioritizes damage", - "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, - "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1, - }, - "control": { - "name": "control", - "description": "Control \u2014 defensive, removal-heavy, card advantage", - "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, - "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9, - }, - "combo": { - "name": "combo", - "description": "Combo \u2014 ramps, digs for pieces, assembles combos", - "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, - "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7, - }, - "midrange": { - "name": "midrange", - "description": "Midrange \u2014 flexible, strong board presence, value-oriented", - "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, - "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5, - }, -} - -@app.get("/api/lab/profiles") -async def list_profiles(): - return {"profiles": list(AI_PROFILES.values())} - -@app.get("/api/lab/profiles/{name}") -async def get_profile(name: str): - profile = AI_PROFILES.get(name.lower()) - if not profile: - raise HTTPException(404, f"Profile '{name}' not found. Available: {list(AI_PROFILES.keys())}") - return profile - - -@app.get("/api/lab/analytics/{deck_name}") -async def analyze_deck(deck_name: str): - decks_dir = CFG.forge_decks_dir - if not decks_dir or not os.path.isdir(decks_dir): - raise HTTPException(500, f"Decks directory not found: {decks_dir}") - deck_path = os.path.join(decks_dir, deck_name + ".dck") - if not os.path.exists(deck_path): - for f in os.listdir(decks_dir): - if f.lower() == deck_name.lower() + ".dck": - deck_path = os.path.join(decks_dir, f) - break - else: - raise HTTPException(404, f"Deck not found: {deck_name}") - try: - analysis = parse_dck_file(deck_path) - return analysis - except Exception as e: - raise HTTPException(500, f"Analysis failed: {str(e)}") - - -def parse_dck_file(deck_path: str) -> dict: - cards = [] - commander = None - section = "Main" - deck_name = Path(deck_path).stem - with open(deck_path, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line or line.startswith("//"): - continue - if line.startswith("[") and line.endswith("]"): - section = line[1:-1] - continue - if line.startswith("Name="): - deck_name = line[5:].strip() - continue - m = re.match(r"^(\d+)\s+(.+?)(?:\|(.+))?$", line) - if m: - qty = int(m.group(1)) - name = m.group(2).strip() - set_code = m.group(3).strip() if m.group(3) else "" - cards.append({ - "quantity": qty, "name": name, "set": set_code, - "section": section, - "is_commander": 1 if section == "Commander" else 0, - }) - if section == "Commander": - commander = name - total = sum(c["quantity"] for c in cards) - return { - "deckName": deck_name, - "commanderName": commander, - "totalCards": total, - "cardCount": len(cards), - "cards": cards[:200], - } - - -@app.get("/api/lab/trends/{deck_name}") -async def get_deck_trends(deck_name: str): - results_dir = Path(CFG.results_dir) - if not results_dir.exists(): - return {"deckName": deck_name, "history": []} - history = [] - for f in sorted(results_dir.glob("batch-*.json")): - try: - with open(f, "r", encoding="utf-8") as fh: - data = json.load(fh) - summary = data.get("summary", {}) - meta = data.get("metadata", {}) - per_deck = summary.get("perDeck", []) - for ds in per_deck: - if ds.get("deckName", "").lower() == deck_name.lower(): - history.append({ - "batchId": meta.get("batchId"), - "timestamp": meta.get("timestamp"), - "winRate": ds.get("winRate", 0), - "wins": ds.get("wins", 0), - "losses": ds.get("losses", 0), - "draws": ds.get("draws", 0), - "totalGames": meta.get("completedGames", 0), - }) - break - except Exception: - continue - return {"deckName": deck_name, "history": history} - - -@app.get("/api/lab/log") -async def get_log(batchId: str): - state = active_batches.get(batchId) - if not state: - raise HTTPException(404, f"Batch {batchId} not found") - return {"lines": state.log_lines[-200:]} - - -@app.get("/api/lab/debug-log") -async def get_debug_log(): - """Return the raw Forge subprocess debug log for diagnosing simulation issues.""" - # Check multiple possible locations for the debug log - candidates = [] - if CFG.forge_dir: - candidates.append(Path(CFG.forge_dir).parent / "forge-sim-debug.log") - candidates.append(Path(CFG.forge_dir) / "forge-sim-debug.log") - candidates.append(Path("forge-sim-debug.log")) - - for log_path in candidates: - if log_path.exists(): - text = log_path.read_text(encoding="utf-8", errors="replace") - # Return last 50KB to avoid huge responses - if len(text) > 50000: - text = "... (truncated) ...\n" + text[-50000:] - return {"path": str(log_path), "content": text} - - return {"path": None, "content": "No debug log found. Run a simulation first."} - - -# ══════════════════════════════════════════════════════════════ -# Precon Decks -# ══════════════════════════════════════════════════════════════ - -PRECON_DIR = Path(__file__).parent / "precon-decks" -PRECON_INDEX: list[dict] = [] - - -def load_precon_index(): - global PRECON_INDEX - idx_path = PRECON_DIR / "precon-index.json" - if idx_path.exists(): - with open(idx_path, "r", encoding="utf-8") as f: - PRECON_INDEX = json.load(f) - log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded") - else: - PRECON_INDEX = [] - log.info(f" Precons: index not found at {idx_path}") - - -@app.get("/api/lab/precons") -async def list_precons(): - """List all available precon decks.""" - return {"precons": PRECON_INDEX} - - -@app.post("/api/lab/precons/install") -async def install_precon(req: dict): - """Install a precon deck to the Forge decks directory. - Body: {"fileName": "Elven_Empire.dck"} - """ - file_name = req.get("fileName", "") - if not file_name: - raise HTTPException(400, "fileName is required") - - src = PRECON_DIR / file_name - if not src.exists(): - raise HTTPException(404, f"Precon not found: {file_name}") - - decks_dir = CFG.forge_decks_dir - if not decks_dir or not os.path.isdir(decks_dir): - raise HTTPException(500, f"Forge decks directory not found: {decks_dir}") - - import shutil - dst = Path(decks_dir) / file_name - shutil.copy2(str(src), str(dst)) - - deck_name = file_name.replace(".dck", "") - return { - "installed": True, - "deckName": deck_name, - "destination": str(dst), - "message": f"Installed {deck_name} to Forge decks", - } - - -@app.post("/api/lab/precons/install-batch") -async def install_precons_batch(req: dict): - """Install multiple precon decks at once. - Body: {"fileNames": ["Elven_Empire.dck", "Necron_Dynasties.dck"]} - """ - file_names = req.get("fileNames", []) - if not file_names: - raise HTTPException(400, "fileNames list is required") - - decks_dir = CFG.forge_decks_dir - if not decks_dir or not os.path.isdir(decks_dir): - raise HTTPException(500, f"Forge decks directory not found: {decks_dir}") - - import shutil - results = [] - for file_name in file_names: - src = PRECON_DIR / file_name - if not src.exists(): - results.append({"fileName": file_name, "installed": False, "error": "not found"}) - continue - dst = Path(decks_dir) / file_name - shutil.copy2(str(src), str(dst)) - deck_name = file_name.replace(".dck", "") - results.append({"fileName": file_name, "installed": True, "deckName": deck_name}) - - return {"results": results} - - -# ── Precon Auto-Download from GitHub ───────────────────────── - -GITHUB_PRECON_URL = ( - "https://raw.githubusercontent.com/taw/magic-preconstructed-decks-data/" - "master/decks_v2.json" -) -PRECON_CACHE_HOURS = 168 # re-download weekly - - -def _color_identity_from_cards(deck_data: dict) -> list[str]: - """Extract approximate color identity from commander section.""" - # The data doesn't include color info directly, so we track what we can - # from commander names — but we just leave it as a searchable field. - return [] - - -def _sanitize_filename(name: str) -> str: - """Turn a deck name into a safe filename.""" - safe = re.sub(r'[<>:"/\\|?*]', '', name) - safe = safe.replace(' ', '_').replace("'", '').replace('!', '') - safe = re.sub(r'_+', '_', safe).strip('_') - return safe - - -def _deck_to_dck(deck_data: dict) -> str: - """Convert a deck entry from decks_v2.json into .dck format.""" - lines = [] - lines.append("[metadata]") - lines.append(f"Name={deck_data['name']}") - - # Commander section - commanders = deck_data.get('commander', []) - if commanders: - lines.append("[Commander]") - for card in commanders: - count = card.get('count', 1) - lines.append(f"{count} {card['name']}") - - # Main deck - cards = deck_data.get('cards', []) - if cards: - lines.append("[Main]") - for card in cards: - count = card.get('count', 1) - lines.append(f"{count} {card['name']}") - - # Sideboard (rare in Commander but include if present) - sideboard = deck_data.get('sideboard', []) - if sideboard: - lines.append("[Sideboard]") - for card in sideboard: - count = card.get('count', 1) - lines.append(f"{count} {card['name']}") - - return "\n".join(lines) + "\n" - - -def download_precon_database(force: bool = False) -> dict: - """ - Download all Commander precon decks from GitHub, convert to .dck, - and rebuild precon-index.json. Skips if index is fresh (< PRECON_CACHE_HOURS old) - unless force=True. - - Returns {"downloaded": int, "skipped": bool, "error": str|None} - """ - global PRECON_INDEX - idx_path = PRECON_DIR / "precon-index.json" - - # ── Cache check ────────────────────────────────────────── - if not force and idx_path.exists(): - try: - with open(idx_path, "r", encoding="utf-8") as f: - existing = json.load(f) - # If we have a substantial number of decks and file is recent, skip - if len(existing) > 50: - age_hours = (time.time() - idx_path.stat().st_mtime) / 3600 - if age_hours < PRECON_CACHE_HOURS: - log.info(f" Precons: {len(existing)} decks cached " - f"({age_hours:.0f}h old, refresh after {PRECON_CACHE_HOURS}h)") - PRECON_INDEX = existing - return {"downloaded": 0, "skipped": True, "total": len(existing), - "error": None} - except Exception: - pass # Fall through to download - - # ── Download ───────────────────────────────────────────── - log.info(" Precons: Downloading full precon database from GitHub...") - try: - req = Request(GITHUB_PRECON_URL, headers={"User-Agent": "CommanderAILab/3.0"}) - with urlopen(req, timeout=120) as resp: - raw = resp.read() - all_decks = json.loads(raw) - except Exception as e: - msg = f"Failed to download precon database: {e}" - log.error(f" Precons: ERROR — {msg}") - # Fall back to whatever we have locally - if idx_path.exists(): - load_precon_index() - return {"downloaded": 0, "skipped": False, "error": msg} - - # ── Filter to Commander precon decks ───────────────────── - commander_decks = [ - d for d in all_decks - if d.get('type') == 'Commander Deck' - and (d.get('format') or '').lower() == 'commander' - ] - log.info(f" Precons: Found {len(commander_decks)} Commander precon decks") - - # ── Ensure precon-decks dir exists ─────────────────────── - PRECON_DIR.mkdir(parents=True, exist_ok=True) - - # ── Detect duplicate names and append set code for uniqueness ─ - from collections import Counter - name_counts = Counter(_sanitize_filename(d['name']) for d in commander_decks) - dup_names = {n for n, c in name_counts.items() if c > 1} - - # ── Convert and write .dck files + build index ─────────── - index = [] - written = 0 - for deck in sorted(commander_decks, key=lambda d: (d.get('release_date', ''), d.get('name', ''))): - safe_name = _sanitize_filename(deck['name']) - # Append set code for duplicate names (e.g. Anthology reprints) - if safe_name in dup_names: - sc = (deck.get('set_code') or 'unk').upper() - safe_name = f"{safe_name}_{sc}" - file_name = f"{safe_name}.dck" - dck_path = PRECON_DIR / file_name - - # Extract commander names - commanders = deck.get('commander', []) - cmdr_names = [c['name'] for c in commanders] if commanders else [] - - # Card count - total_cards = sum(c.get('count', 1) for c in deck.get('cards', [])) - total_cards += sum(c.get('count', 1) for c in commanders) - - # Write .dck file - dck_content = _deck_to_dck(deck) - with open(dck_path, "w", encoding="utf-8") as f: - f.write(dck_content) - written += 1 - - # Build index entry - release = deck.get('release_date', '') - year = int(release[:4]) if release and len(release) >= 4 else 0 - index.append({ - "name": deck['name'], - "commander": cmdr_names[0] if cmdr_names else "Unknown", - "commanders": cmdr_names, - "colors": [], # Color data not in source; UI can infer from cards - "set": deck.get('set_name', ''), - "setCode": deck.get('set_code', ''), - "year": year, - "releaseDate": release, - "theme": "", - "fileName": file_name, - "cardCount": total_cards, - }) - - # ── Write index ────────────────────────────────────────── - with open(idx_path, "w", encoding="utf-8") as f: - json.dump(index, f, indent=2, ensure_ascii=False) - - PRECON_INDEX = index - log.info(f" Precons: {written} .dck files written, index saved") - return {"downloaded": written, "skipped": False, "total": written, "error": None} - - -@app.post("/api/lab/precons/refresh") -async def refresh_precons(): - """Force re-download of all Commander precon decks from GitHub.""" - result = download_precon_database(force=True) - if result.get("error"): - raise HTTPException(502, result["error"]) - return { - "message": f"Downloaded {result['downloaded']} Commander precon decks", - "total": result.get("total", 0), - } - - -# ══════════════════════════════════════════════════════════════ -# Deck Import Helpers (Python-side) -# ══════════════════════════════════════════════════════════════ - -_API_HEADERS = {"User-Agent": "CommanderAILab/3.0", "Accept": "application/json"} - - -def _http_get(url: str) -> str: - req = Request(url, headers=_API_HEADERS) - with urlopen(req, timeout=30) as resp: - return resp.read().decode("utf-8") - - -def _import_from_url(url: str) -> dict: - """Import deck from URL (Archidekt or EDHREC).""" - url = url.strip() - - # Archidekt - if "archidekt.com/decks/" in url: - m = re.search(r"/decks/(\d+)", url) - if not m: - raise ValueError("Could not parse Archidekt deck ID from URL") - return _fetch_archidekt_deck(m.group(1)) - - # EDHREC average deck - if "edhrec.com/average-decks/" in url: - slug = re.search(r"/average-decks/([^/?#]+)", url).group(1) - commander_name = slug.replace("-", " ") - return _fetch_edhrec_average(commander_name) - - # EDHREC commander page - if "edhrec.com/commanders/" in url: - slug = re.search(r"/commanders/([^/?#]+)", url).group(1) - commander_name = slug.replace("-", " ") - return _fetch_edhrec_average(commander_name) - - raise ValueError(f"Unsupported URL: {url}. Supported: archidekt.com/decks/..., edhrec.com/average-decks/..., edhrec.com/commanders/...") - - -def _fetch_archidekt_deck(deck_id: str) -> dict: - """Fetch deck from Archidekt API.""" - url = f"https://archidekt.com/api/decks/{deck_id}/" - data = json.loads(_http_get(url)) - - profile = { - "name": data.get("name", f"Archidekt {deck_id}"), - "commander": None, - "source": "Archidekt", - "sourceUrl": f"https://archidekt.com/decks/{deck_id}", - "commanders": {}, - "mainboard": {}, - "colorIdentity": [], - "totalCards": 0, - } - - for card_entry in data.get("cards", []): - qty = card_entry.get("quantity", 1) - card = card_entry.get("card", {}) - oracle = card.get("oracleCard", {}) - card_name = oracle.get("name", "Unknown") - - categories = [c for c in card_entry.get("categories", [])] - is_commander = any(c.lower() == "commander" for c in categories) - - if is_commander: - profile["commanders"][card_name] = qty - if not profile["commander"]: - profile["commander"] = card_name - if oracle.get("colorIdentity"): - profile["colorIdentity"] = oracle["colorIdentity"] - else: - profile["mainboard"][card_name] = qty - - profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values()) - if not profile["name"] or profile["name"] == f"Archidekt {deck_id}": - profile["name"] = f"{profile['commander']} — Archidekt" - - return profile - - -def _fetch_edhrec_average(commander_name: str) -> dict: - """Fetch EDHREC average deck for a commander.""" - slug = _to_edhrec_slug(commander_name) - url = f"https://json.edhrec.com/pages/average-decks/{slug}.json" - data = json.loads(_http_get(url)) - - container = data.get("container", {}) - json_dict = container.get("json_dict", {}) - card_info = json_dict.get("card", {}) - real_name = card_info.get("name", commander_name.title()) - - profile = { - "name": f"{real_name} — EDHREC Average", - "commander": real_name, - "source": "EDHREC Average", - "sourceUrl": f"https://edhrec.com/average-decks/{slug}", - "commanders": {real_name: 1}, - "mainboard": {}, - "colorIdentity": card_info.get("color_identity", []), - "sampleSize": data.get("num_decks_avg"), - "totalCards": 0, - } - - # Parse card lists - for cl in json_dict.get("cardlists", []): - for cv in cl.get("cardviews", []): - card_name = cv.get("name", "") - if card_name: - profile["mainboard"][card_name] = 1 - - # Fix basic land quantities from archidekt export data - archidekt_data = data.get("archidekt", []) - if archidekt_data: - basic_names = [] - for cl in json_dict.get("cardlists", []): - if cl.get("tag") == "basics": - for cv in cl.get("cardviews", []): - basic_names.append(cv.get("name", "")) - - # Find high-quantity entries (basics) - basic_quantities = [] - for entry in archidekt_data: - if entry.get("q", 1) > 1: - basic_quantities.append(entry["q"]) - - for i, name in enumerate(basic_names): - if i < len(basic_quantities): - profile["mainboard"][name] = basic_quantities[i] - - profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values()) - return profile - - -def _parse_text_decklist(text: str, commander_override: str = None) -> dict: - """Parse a plain text card list into a profile.""" - profile = { - "name": "Text Import", - "commander": None, - "source": "Text Import", - "sourceUrl": None, - "commanders": {}, - "mainboard": {}, - "totalCards": 0, - } - - section = "main" - for line in text.strip().split("\n"): - line = line.strip() - if not line or line.startswith("//") or line.startswith("#"): - continue - - if line.lower().startswith("commander") or line == "[Commander]": - section = "commander" - continue - if line.lower().startswith("main") or line.lower().startswith("deck") or line == "[Main]": - section = "main" - continue - if line.lower().startswith("sideboard") or line == "[Sideboard]": - section = "sideboard" - continue - - # Clean line - clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip() - clean = re.sub(r"\s*\*.*$", "", clean).strip() - - m = re.match(r"^(\d+)x?\s+(.+)$", clean) - if m: - qty = int(m.group(1)) - card_name = m.group(2).strip() - else: - qty = 1 - card_name = clean - - if not card_name: - continue - - if section == "commander": - profile["commanders"][card_name] = qty - if not profile["commander"]: - profile["commander"] = card_name - elif section != "sideboard": - profile["mainboard"][card_name] = qty - - if commander_override: - profile["commander"] = commander_override - if not profile["commanders"]: - if commander_override in profile["mainboard"]: - qty = profile["mainboard"].pop(commander_override) - profile["commanders"][commander_override] = qty - else: - profile["commanders"][commander_override] = 1 - - if profile["commander"]: - profile["name"] = f"{profile['commander']} — Text Import" - - profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values()) - return profile - - -def _save_profile_to_dck(profile: dict) -> Path: - """Convert a profile dict to .dck and save to Forge decks dir.""" - lines = ["[metadata]"] - lines.append(f"Name={profile.get('name', 'Imported Deck')}") - lines.append("") - lines.append("[Commander]") - for name, qty in profile.get("commanders", {}).items(): - lines.append(f"{qty} {name}") - lines.append("") - lines.append("[Main]") - for name, qty in profile.get("mainboard", {}).items(): - lines.append(f"{qty} {name}") - - content = "\n".join(lines) - - # Save to Forge decks dir - safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", profile.get("name", "imported")).replace(" ", "_").strip() - if not safe_name: - safe_name = "imported_deck" - - save_dir = CFG.forge_decks_dir - if not save_dir or not os.path.isdir(save_dir): - save_dir = os.path.join(os.path.dirname(__file__), "imported-decks") - os.makedirs(save_dir, exist_ok=True) - - out_path = Path(save_dir) / f"{safe_name}.dck" - out_path.write_text(content, encoding="utf-8") - log_collect.info(f" Saved .dck: {out_path}") - return out_path - - -def _to_edhrec_slug(name: str) -> str: - slug = name.lower() - slug = re.sub(r"[',.]", "", slug) - slug = re.sub(r"[^a-z0-9]+", "-", slug) - slug = slug.strip("-") - return slug - - -# ══════════════════════════════════════════════════════════════ -# Background Task — Run the Java CLI subprocess -# ══════════════════════════════════════════════════════════════ - -async def run_batch_subprocess( - state: BatchState, - decks: list[str], - num_games: int, - threads: int, - seed: Optional[int], - clock: int, - output_path: str, - use_learned_policy: bool = False, - policy_style: str = "midrange", - policy_greedy: bool = False, - ai_simplified: bool = False, - ai_think_time_ms: int = -1, - max_queue_depth: int = -1, -): - try: - # Compute policy server URL from our own port - policy_server = f"http://localhost:{CFG.port}" - cmd = build_java_command( - decks, num_games, threads, seed, clock, output_path, - use_learned_policy=use_learned_policy, - policy_server=policy_server, - policy_style=policy_style, - policy_greedy=policy_greedy, - ai_simplified=ai_simplified, - ai_think_time_ms=ai_think_time_ms, - max_queue_depth=max_queue_depth, - ) - log_batch.info(f"Starting batch {state.batch_id}: {' '.join(cmd[:6])}...") - loop = asyncio.get_event_loop() - await loop.run_in_executor( - None, - lambda: _run_process_blocking(state, cmd), - ) - except Exception as e: - state.error = str(e) - state.running = False - log_batch.error(f"Batch {state.batch_id} ERROR: {e}") - - -def _run_process_blocking(state: BatchState, cmd: list[str]): - # Write debug log from Python side (doesn't depend on Java rebuild) - debug_log_path = Path(CFG.forge_dir).parent / "forge-sim-debug.log" if CFG.forge_dir else Path("forge-sim-debug.log") - try: - with open(debug_log_path, "a", encoding="utf-8") as dbg: - dbg.write(f"\n{'='*60}\n") - dbg.write(f"Batch {state.batch_id} @ {datetime.now().isoformat()}\n") - dbg.write(f"Command: {' '.join(cmd)}\n") - dbg.write(f"Lab JAR: {CFG.lab_jar}\n") - dbg.write(f"Forge JAR: {CFG.forge_jar}\n") - dbg.write(f"Forge Dir: {CFG.forge_dir}\n") - dbg.write(f"Lab JAR exists: {os.path.exists(CFG.lab_jar)}\n") - dbg.write(f"Forge JAR exists: {os.path.exists(CFG.forge_jar)}\n") - dbg.write(f"Forge Dir exists: {os.path.isdir(CFG.forge_dir)}\n") - dbg.write(f"{'-'*60}\n") - log_batch.warning(f"Debug log: {debug_log_path}") - except Exception as e: - log_batch.warning(f"Could not write debug log header: {e}") - - # Ensure Forge subprocesses use Java 17 (Forge crashes on Java 25+) - env = os.environ.copy() - java17 = get_java17() - if java17 != 'java': - java17_bin = os.path.dirname(java17) - java17_home = os.path.dirname(java17_bin) - env['JAVA_HOME'] = java17_home - env['PATH'] = java17_bin + os.pathsep + env.get('PATH', '') - log_batch.info(f'Using Java 17: {java17}') - - # NOTE: Do NOT set _JAVA_OPTIONS=-Djava.awt.headless=true here. - # Forge sim mode needs AWT classes for card data initialization; - # headless=true causes silent exit code 1 crash. - - # Per-game timeout: clock + 180s buffer for card DB init - per_game_timeout = state.total_games * 240 + 300 # generous: 4min/game + 5min startup - - try: - proc = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - cwd=None, - bufsize=1, - env=env, - ) - state.process = proc - - # Python-side watchdog: kill if no progress for too long - import threading - last_activity = [time.time()] - stall_limit = 300 # 5 minutes with no output = stalled - - def _watchdog(): - while proc.poll() is None: - time.sleep(30) - elapsed_since_activity = time.time() - last_activity[0] - if elapsed_since_activity > stall_limit: - log_batch.warning(f'WATCHDOG: No output for {int(elapsed_since_activity)}s. Killing process.') - state.log_lines.append(f'[WATCHDOG] Process stalled ({int(elapsed_since_activity)}s with no output). Killed.') - proc.kill() - return - - wd_thread = threading.Thread(target=_watchdog, daemon=True) - wd_thread.start() - - game_count = 0 - all_output_lines = [] - for line in proc.stdout: - line = line.rstrip() - last_activity[0] = time.time() - state.log_lines.append(line) - all_output_lines.append(line) - - if line.startswith("[Game "): - try: - parts = line.split("]")[0] - nums = parts.replace("[Game ", "").split("/") - current = int(nums[0]) - game_count = current - state.completed_games = current - except (ValueError, IndexError): - pass - - # v24 (Issue #5): Parse real-time sims/sec from Java output - if '[PROGRESS]' in line or '[BATCH]' in line: - import re - sps_match = re.search(r'([\d.]+)\s+sims/sec', line) - if sps_match: - try: - state.sims_per_sec = float(sps_match.group(1)) - except ValueError: - pass - - log_batch.info(f" [{state.batch_id}] {line}") - - proc.wait() - - elapsed = int((datetime.now() - state.start_time).total_seconds() * 1000) - state.elapsed_ms = elapsed - state.running = False - - # Write all captured output to debug log - try: - with open(debug_log_path, "a", encoding="utf-8") as dbg: - dbg.write(f"Exit code: {proc.returncode}\n") - dbg.write(f"Total lines: {len(all_output_lines)}\n") - dbg.write(f"Elapsed: {elapsed}ms\n") - dbg.write(f"-- FULL SUBPROCESS OUTPUT --\n") - if all_output_lines: - for ol in all_output_lines: - dbg.write(ol + "\n") - else: - dbg.write("<< NO OUTPUT >>\n") - dbg.write(f"-- END OUTPUT --\n\n") - except Exception as e: - log_batch.warning(f"Could not write debug log output: {e}") - - if proc.returncode != 0: - if proc.returncode == -9 or proc.returncode == 137: - state.error = 'Forge process killed by watchdog (stalled with no output). Check forge-sim-debug.log for details.' - else: - state.error = f'Java process exited with code {proc.returncode}. Check forge-sim-debug.log for details.' - else: - state.completed_games = state.total_games - log_batch.info(f"Batch {state.batch_id} completed in {elapsed}ms") - - # Auto-generate deck reports for the coach - if state.result_path and os.path.exists(state.result_path): - try: - from coach.report_generator import generate_single_deck_report - lab_root = Path(__file__).parent - reports_dir = str(lab_root / "deck-reports") - updated = generate_single_deck_report(state.result_path, reports_dir) - if updated: - log_batch.info(f"Deck reports updated: {', '.join(updated)}") - except Exception as rpt_err: - log_batch.info(f"Deck report generation failed (non-fatal): {rpt_err}") - - except Exception as e: - state.error = str(e) - state.running = False - try: - with open(debug_log_path, "a", encoding="utf-8") as dbg: - dbg.write(f"EXCEPTION: {e}\n") - except Exception: - pass - - -# ══════════════════════════════════════════════════════════════ -# DeepSeek Batch Sim (Python engine + LLM opponent) -# ══════════════════════════════════════════════════════════════ - -def _load_deck_cards_by_name(deck_name: str) -> list[dict]: - """Load card data for a deck by name. Checks DB first, then .dck files. - Each card dict includes 'is_commander' flag (0 or 1) when available.""" - # 1. Try deck builder DB - try: - conn = _get_db_conn() - row = conn.execute( - "SELECT id, commander_name FROM decks WHERE name = ? COLLATE NOCASE", (deck_name,) - ).fetchone() - if row: - deck_id = row["id"] - db_commander_name = row["commander_name"] or "" - cards = conn.execute(""" - SELECT dc.card_name, dc.quantity, dc.is_commander, - ce.type_line, ce.cmc, ce.power, ce.toughness, - ce.oracle_text, ce.keywords, ce.mana_cost, - ce.color_identity - FROM deck_cards dc - LEFT JOIN ( - SELECT scryfall_id, type_line, cmc, power, toughness, - oracle_text, keywords, mana_cost, color_identity - FROM collection_entries GROUP BY scryfall_id - ) ce ON ce.scryfall_id = dc.scryfall_id - WHERE dc.deck_id = ? - """, (deck_id,)).fetchall() - result = [] - for r in cards: - is_cmdr = r["is_commander"] or 0 - # Also detect by deck-level commander_name match - if not is_cmdr and db_commander_name and r["card_name"].lower() == db_commander_name.lower(): - is_cmdr = 1 - for _ in range(r["quantity"] or 1): - result.append({ - 'name': r["card_name"], - 'type_line': r["type_line"] or '', - 'cmc': r["cmc"] or 0, - 'power': r["power"] or '', - 'toughness': r["toughness"] or '', - 'oracle_text': r["oracle_text"] or '', - 'keywords': r["keywords"] or '', - 'mana_cost': r["mana_cost"] or '', - 'is_commander': is_cmdr, - 'color_identity': r["color_identity"] or '', - }) - if result: - return result - except Exception as e: - log_sim.error(f"DB lookup failed for '{deck_name}': {e}") - - # 2. Try .dck file - if CFG.forge_decks_dir and os.path.isdir(CFG.forge_decks_dir): - dck_path = Path(CFG.forge_decks_dir) / f"{deck_name}.dck" - if dck_path.exists(): - cards = [] - current_section = None - with open(dck_path, 'r', encoding='utf-8') as f: - for line in f: - line = line.strip() - low = line.lower() - if low == '[commander]': - current_section = 'commander' - continue - elif low in ('[main]', '[deck]'): - current_section = 'main' - continue - elif line.startswith('['): - current_section = None - continue - if current_section and line: - parts = line.split(' ', 1) - if len(parts) == 2: - try: - qty = int(parts[0]) - name = parts[1].strip() - is_cmdr = 1 if current_section == 'commander' else 0 - for _ in range(qty): - cards.append({ - 'name': name, - 'type_line': '', - 'cmc': 0, - 'is_commander': is_cmdr, - }) - except ValueError: - pass - if cards: - return cards - - return [] - - -def _run_deepseek_batch_thread( - state: BatchState, - deck_names: list[str], - num_games: int, - output_path: str, -): - """Run batch simulation using Python sim engine + DeepSeek AI opponent.""" - try: - import sys as _s, os as _o, time as _t - src_dir = _o.path.join(_o.path.dirname(_o.path.abspath(__file__)), 'src') - if src_dir not in _s.path: - _s.path.insert(0, src_dir) - - from commander_ai_lab.sim.models import Card - from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine - from commander_ai_lab.sim.rules import enrich_card - - state.log_lines.append('[DeepSeek Batch] Initializing DeepSeek brain...') - brain = _get_deepseek_brain() - if brain and not brain._connected: - brain.check_connection() - if not brain or not brain._connected: - state.error = 'DeepSeek LLM not connected. Go to Simulator > DeepSeek and connect first.' - state.running = False - return - - state.log_lines.append(f'[DeepSeek Batch] Connected to {brain.config.model}') - - # Load all decks - # ── Look up historical win rates from past batch results ── - deck_win_rates = {} # deck_name -> float (0-100) - try: - results_dir = CFG.results_dir - if _o.path.isdir(results_dir): - for fname in sorted(_o.listdir(results_dir), reverse=True): - if fname.startswith('batch-') and fname.endswith('.json'): - try: - with open(_o.path.join(results_dir, fname), 'r') as rf: - past = json.loads(rf.read()) - for dd in past.get('decks', []): - dname = dd.get('deckName', '') - wr = dd.get('winRate') - if dname and wr is not None and dname not in deck_win_rates: - deck_win_rates[dname] = float(wr) - except Exception: - pass - except Exception: - pass - - loaded_decks = {} - deck_meta = {} # deck_name -> {commander_name, color_identity, archetype} - for dn in deck_names: - raw_cards = _load_deck_cards_by_name(dn) - if not raw_cards: - state.log_lines.append(f'[DeepSeek Batch] WARNING: Could not load deck "{dn}", skipping.') - continue - - # Detect commander and color identity from card data - commander_name = '' - color_identity_set = set() - deck_objs = [] - for cd in raw_cards: - c = Card(name=cd['name']) - if cd.get('type_line'): c.type_line = cd['type_line'] - if cd.get('cmc'): c.cmc = float(cd['cmc']) - if cd.get('power') and cd.get('toughness'): - c.power = str(cd['power']) - c.toughness = str(cd['toughness']) - c.pt = c.power + '/' + c.toughness - if cd.get('oracle_text'): c.oracle_text = cd['oracle_text'] - if cd.get('mana_cost'): c.mana_cost = cd['mana_cost'] - if cd.get('keywords'): - kw = cd['keywords'] - if isinstance(kw, str): - try: kw = json.loads(kw) - except Exception: kw = [] - if isinstance(kw, list): c.keywords = kw - # Set is_commander flag from DB data - if cd.get('is_commander'): - c.is_commander = True - commander_name = cd['name'] - # Collect color identity - ci_str = cd.get('color_identity', '') - if ci_str: - try: - ci_parsed = json.loads(ci_str) if isinstance(ci_str, str) else ci_str - if isinstance(ci_parsed, list): - for color in ci_parsed: - color_identity_set.add(color) - except Exception: - pass - enrich_card(c) - deck_objs.append(c) - - # Infer archetype from deck composition - creature_count = sum(1 for c in deck_objs if c.is_creature()) - removal_count = sum(1 for c in deck_objs if c.is_removal) - ramp_count = sum(1 for c in deck_objs if c.is_ramp) - avg_cmc = sum(c.cmc or 0 for c in deck_objs if not c.is_land()) / max(sum(1 for c in deck_objs if not c.is_land()), 1) - oracle_all = ' '.join((c.oracle_text or '').lower() for c in deck_objs) - has_combo_text = any(kw in oracle_all for kw in ['you win the game', 'infinite', 'extra turn']) - - if has_combo_text: - archetype = 'combo' - elif creature_count >= 30 and avg_cmc <= 2.8: - archetype = 'aggro' - elif removal_count >= 10 or (creature_count <= 18 and avg_cmc >= 3.2): - archetype = 'control' - else: - archetype = 'midrange' - - color_identity_list = sorted(list(color_identity_set)) - deck_meta[dn] = { - 'commander_name': commander_name, - 'color_identity': color_identity_list, - 'archetype': archetype, - 'win_rate': deck_win_rates.get(dn), - } - - loaded_decks[dn] = deck_objs - cmdr_info = f' (Commander: {commander_name})' if commander_name else '' - wr_info = f' [History: {deck_win_rates[dn]:.0f}% WR]' if dn in deck_win_rates else '' - state.log_lines.append(f'[DeepSeek Batch] Loaded deck "{dn}" ({len(deck_objs)} cards, {archetype}){cmdr_info}{wr_info}') - - if not loaded_decks: - state.error = 'No decks could be loaded.' - state.running = False - return - - # Build matchup schedule: each deck plays num_games vs DeepSeek AI - deck_list = list(loaded_decks.keys()) - games_per_deck = max(1, num_games // len(deck_list)) - total_games = games_per_deck * len(deck_list) - state.total_games = total_games - - state.log_lines.append(f'[DeepSeek Batch] Running {games_per_deck} games per deck × {len(deck_list)} decks = {total_games} total') - - engine = DeepSeekGameEngine( - brain=brain, - ai_player_index=0, # AI pilots deck_a (user's deck) with full intelligence - max_turns=25, - record_log=True, - ml_log=True, - ) - - start_time = _t.time() - all_deck_results = [] - completed = 0 - - for deck_name in deck_list: - deck_a = loaded_decks[deck_name] - # Generate a training opponent for each game - from commander_ai_lab.lab.experiments import _generate_training_deck - - deck_stats = { - 'deckName': deck_name, - 'wins': 0, 'losses': 0, 'totalGames': games_per_deck, - 'totalTurns': 0, 'totalDamageDealt': 0, 'totalDamageReceived': 0, - 'totalSpellsCast': 0, 'totalCreaturesPlayed': 0, - 'games': [], - } - - meta = deck_meta.get(deck_name, {}) - dk_archetype = meta.get('archetype', 'midrange') - dk_commander = meta.get('commander_name', '') - dk_colors = meta.get('color_identity', []) - dk_win_rate = meta.get('win_rate') - - for g in range(games_per_deck): - try: - deck_b = _generate_training_deck() - game_id = f'ds-{state.batch_id}-{deck_name[:12]}-g{g+1}' - result = engine.run( - deck_a, deck_b, - name_a=deck_name + ' (AI)', - name_b='Training Opponent', - game_id=game_id, archetype=dk_archetype, - commander_name=dk_commander, - color_identity=dk_colors, - win_rate=dk_win_rate, - ) - gd = result.to_dict() - gd['gameNumber'] = g + 1 - deck_stats['games'].append(gd) - - if result.winner == 0: - deck_stats['wins'] += 1 - else: - deck_stats['losses'] += 1 - deck_stats['totalTurns'] += result.turns - if result.player_a_stats: - deck_stats['totalDamageDealt'] += result.player_a_stats.damage_dealt - deck_stats['totalDamageReceived'] += result.player_a_stats.damage_received - deck_stats['totalSpellsCast'] += result.player_a_stats.spells_cast - deck_stats['totalCreaturesPlayed'] += result.player_a_stats.creatures_played - - state.log_lines.append( - f'[Game {completed + 1}/{total_games}] {deck_name} (AI-piloted) → ' - f'{"WIN" if result.winner == 0 else "LOSS"} (turn {result.turns})' - ) - except Exception as ge: - state.log_lines.append(f'[Game {completed + 1}/{total_games}] ERROR: {ge}') - deck_stats['games'].append({'error': str(ge), 'gameNumber': g + 1}) - - completed += 1 - state.completed_games = completed - - n = deck_stats['totalGames'] - deck_stats['winRate'] = round(deck_stats['wins'] / n * 100, 1) if n > 0 else 0.0 - deck_stats['avgTurns'] = round(deck_stats['totalTurns'] / n, 1) if n > 0 else 0.0 - deck_stats['avgDamageDealt'] = round(deck_stats['totalDamageDealt'] / n, 1) if n > 0 else 0.0 - deck_stats['avgDamageReceived'] = round(deck_stats['totalDamageReceived'] / n, 1) if n > 0 else 0.0 - deck_stats['avgSpellsCast'] = round(deck_stats['totalSpellsCast'] / n, 1) if n > 0 else 0.0 - deck_stats['avgCreaturesPlayed'] = round(deck_stats['totalCreaturesPlayed'] / n, 1) if n > 0 else 0.0 - # Include deck intelligence metadata - deck_stats['archetype'] = dk_archetype - deck_stats['commander'] = dk_commander - deck_stats['colorIdentity'] = dk_colors - if dk_win_rate is not None: - deck_stats['priorWinRate'] = dk_win_rate - all_deck_results.append(deck_stats) - - elapsed = _t.time() - start_time - ds_stats = brain.get_stats() if brain else {} - - # Build result in compatible format - batch_result = { - 'metadata': { - 'batchId': state.batch_id, - 'timestamp': datetime.now().isoformat(), - 'completedGames': completed, - 'threads': 1, - 'elapsedMs': int(elapsed * 1000), - 'engine': 'deepseek', - 'model': brain.config.model if brain else 'unknown', - }, - 'decks': all_deck_results, - 'deepseekStats': ds_stats, - } - - # Write ML decision JSONL for the training pipeline - ml_decisions = engine.flush_ml_decisions() - if ml_decisions: - ml_jsonl_path = os.path.join(CFG.results_dir, f'ml-decisions-ds-{state.batch_id}.jsonl') - os.makedirs(os.path.dirname(ml_jsonl_path) or '.', exist_ok=True) - with open(ml_jsonl_path, 'w', encoding='utf-8') as mf: - for dec in ml_decisions: - mf.write(json.dumps(dec) + '\n') - state.log_lines.append(f'[ML Data] Wrote {len(ml_decisions)} decision snapshots to {os.path.basename(ml_jsonl_path)}') - else: - state.log_lines.append('[ML Data] No decision snapshots captured (0 decisions)') - - # Save to results dir - os.makedirs(os.path.dirname(output_path), exist_ok=True) - with open(output_path, 'w', encoding='utf-8') as f: - json.dump(batch_result, f, indent=2, default=str) - - state.result_path = output_path - elapsed_ms = int(elapsed * 1000) - state.elapsed_ms = elapsed_ms - state.running = False - state.completed_games = total_games - - state.log_lines.append(f'[DeepSeek Batch] Complete: {completed} games in {elapsed:.1f}s') - for ds in all_deck_results: - state.log_lines.append(f' {ds["deckName"]}: {ds["wins"]}W-{ds["losses"]}L ({ds["winRate"]}% WR)') - - log_sim.info(f'Batch {state.batch_id} complete: {completed} games in {elapsed:.1f}s') - - except Exception as e: - import traceback - state.error = str(e) - state.running = False - state.log_lines.append(f'[DeepSeek Batch] FATAL: {e}') - traceback.print_exc() - - -@app.post('/api/lab/start-deepseek') -async def start_batch_deepseek(request: FastAPIRequest, background_tasks: BackgroundTasks): - """Start a batch simulation using the Python sim engine + DeepSeek LLM opponent.""" - body = await request.json() - decks = body.get('decks', []) - num_games = body.get('numGames', 30) - # Note: threads not applicable for Python sim (single-threaded LLM calls) - - if not decks or len(decks) < 1: - raise HTTPException(400, 'At least 1 deck required') - if num_games < 1 or num_games > 500: - raise HTTPException(400, 'numGames must be 1-500') - - # Filter empty deck slots - decks = [d for d in decks if d] - if not decks: - raise HTTPException(400, 'No valid decks provided') - - batch_id = str(uuid.uuid4())[:12] - state = BatchState(batch_id, num_games, 1) - active_batches[batch_id] = state - - os.makedirs(CFG.results_dir, exist_ok=True) - output_path = os.path.join(CFG.results_dir, f'batch-{batch_id}.json') - state.result_path = output_path - - # Use threading instead of BackgroundTasks to avoid async issues with DB - t = threading.Thread( - target=_run_deepseek_batch_thread, - args=(state, decks, num_games, output_path), - daemon=True, - ) - t.start() - - return JSONResponse({ - 'batchId': batch_id, - 'status': 'started', - 'message': f'Running {num_games} games across {len(decks)} decks with DeepSeek AI', - 'engine': 'deepseek', - }) - - -# Global ML logging toggle (can be enabled via API) -_ml_logging_enabled = False - - -def _find_java17() -> str: - """Auto-detect Java 17 for Forge subprocesses. Forge crashes on Java 25+.""" - import glob as _glob - # Check common Adoptium/Oracle locations on Windows - search_dirs = [ - r'C:\Program Files\Eclipse Adoptium', - r'C:\Program Files\Java', - ] - for d in search_dirs: - if os.path.isdir(d): - for child in os.listdir(d): - if child.startswith('jdk-17'): - candidate = os.path.join(d, child, 'bin', 'java.exe') - if os.path.isfile(candidate): - return candidate - return 'java' # fallback to system java - -_JAVA17_PATH = None # cached result - -def get_java17() -> str: - global _JAVA17_PATH - if _JAVA17_PATH is None: - _JAVA17_PATH = _find_java17() - return _JAVA17_PATH - - -def build_java_command( - decks: list[str], - num_games: int, - threads: int, - seed: Optional[int], - clock: int, - output_path: str, - use_learned_policy: bool = False, - policy_server: str = "http://localhost:8080", - policy_style: str = "midrange", - policy_greedy: bool = False, - # v24 (Issues #4/#5): Performance flags - ai_simplified: bool = False, - ai_think_time_ms: int = -1, - max_queue_depth: int = -1, -) -> list[str]: - java17 = get_java17() - cmd = [ - java17, "-jar", CFG.lab_jar, - "--forge-jar", CFG.forge_jar, - "--forge-dir", CFG.forge_dir, - ] - # Add deck args dynamically (2-4 decks) - for i, deck in enumerate(decks): - cmd.extend([f"--deck{i+1}", deck]) - cmd += [ - "--games", str(num_games), - "--threads", str(threads), - "--clock", str(clock), - "--output", output_path, - ] - if seed is not None: - cmd.extend(["--seed", str(seed)]) - if _ml_logging_enabled: - cmd.append("--ml-log") - if use_learned_policy: - cmd.append("--learned-policy") - cmd.extend(["--policy-server", policy_server]) - cmd.extend(["--policy-style", policy_style]) - if policy_greedy: - cmd.append("--policy-greedy") - # v24 (Issues #4/#5): Performance flags - if ai_simplified: - cmd.append("--ai-simplified") - if ai_think_time_ms > 0: - cmd.extend(["--ai-think-time", str(ai_think_time_ms)]) - if max_queue_depth > 0: - cmd.extend(["--max-queue", str(max_queue_depth)]) - return cmd - - -# ══════════════════════════════════════════════════════════════ -# Collection Database Setup -# ══════════════════════════════════════════════════════════════ - -COLLECTION_DB_PATH = Path(__file__).parent / "collection.db" -_db_local = threading.local() - - -def _get_db_conn() -> sqlite3.Connection: - """Get a thread-local SQLite connection.""" - if not hasattr(_db_local, "conn") or _db_local.conn is None: - conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False) - conn.row_factory = sqlite3.Row - _db_local.conn = conn - return _db_local.conn - - -def init_collection_db(): - """Initialize the collection SQLite database with tables and indexes.""" - conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False) - conn.row_factory = sqlite3.Row - cur = conn.cursor() - - cur.executescript(""" - CREATE TABLE IF NOT EXISTS card_records ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type_line TEXT DEFAULT '', - subtypes TEXT DEFAULT '', - is_legendary INTEGER DEFAULT 0, - is_basic INTEGER DEFAULT 0, - color_identity TEXT DEFAULT '', - cmc REAL DEFAULT 0, - oracle_text TEXT DEFAULT '', - keywords TEXT DEFAULT '', - tcg_price REAL DEFAULT 0, - salt_score REAL DEFAULT 0, - is_game_changer INTEGER DEFAULT 0, - category TEXT DEFAULT '', - scryfall_id TEXT DEFAULT '', - tcgplayer_id TEXT DEFAULT '' - ); - - CREATE TABLE IF NOT EXISTS collection_entries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type_line TEXT DEFAULT '', - subtypes TEXT DEFAULT '', - is_legendary INTEGER DEFAULT 0, - is_basic INTEGER DEFAULT 0, - color_identity TEXT DEFAULT '', - cmc REAL DEFAULT 0, - mana_cost TEXT DEFAULT '', - oracle_text TEXT DEFAULT '', - keywords TEXT DEFAULT '', - power TEXT DEFAULT '', - toughness TEXT DEFAULT '', - rarity TEXT DEFAULT '', - set_name TEXT DEFAULT '', - edhrec_rank INTEGER DEFAULT 0, - tcg_price REAL DEFAULT 0, - salt_score REAL DEFAULT 0, - is_game_changer INTEGER DEFAULT 0, - category TEXT DEFAULT '', - scryfall_id TEXT DEFAULT '', - tcgplayer_id TEXT DEFAULT '', - quantity INTEGER DEFAULT 1, - finish TEXT DEFAULT 'NORMAL', - condition TEXT DEFAULT '', - language TEXT DEFAULT '', - notes TEXT DEFAULT '', - tags TEXT DEFAULT '', - set_code TEXT DEFAULT '', - collector_number TEXT DEFAULT '', - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) - ); - - CREATE INDEX IF NOT EXISTS idx_ce_name ON collection_entries(name); - CREATE INDEX IF NOT EXISTS idx_ce_color_identity ON collection_entries(color_identity); - CREATE INDEX IF NOT EXISTS idx_ce_type_line ON collection_entries(type_line); - CREATE INDEX IF NOT EXISTS idx_ce_is_legendary ON collection_entries(is_legendary); - CREATE INDEX IF NOT EXISTS idx_ce_cmc ON collection_entries(cmc); - CREATE INDEX IF NOT EXISTS idx_ce_category ON collection_entries(category); - CREATE INDEX IF NOT EXISTS idx_ce_set_code ON collection_entries(set_code); - CREATE INDEX IF NOT EXISTS idx_ce_collector_number ON collection_entries(collector_number); - CREATE INDEX IF NOT EXISTS idx_ce_finish ON collection_entries(finish); - - CREATE INDEX IF NOT EXISTS idx_cr_name ON card_records(name); - - CREATE TABLE IF NOT EXISTS decks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - commander_scryfall_id TEXT DEFAULT '', - commander_name TEXT DEFAULT '', - color_identity TEXT DEFAULT '[]', - strategy_tag TEXT DEFAULT '', - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) - ); - - CREATE TABLE IF NOT EXISTS deck_cards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - deck_id INTEGER NOT NULL, - scryfall_id TEXT NOT NULL, - card_name TEXT DEFAULT '', - quantity INTEGER DEFAULT 1, - is_commander INTEGER DEFAULT 0, - role_tag TEXT DEFAULT '', - FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id); - CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id); - CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id); - """) - - conn.commit() - - # ── Migration: add columns to existing DBs ───────────────── - _migrate_columns = [ - ("mana_cost", "TEXT DEFAULT ''"), - ("power", "TEXT DEFAULT ''"), - ("toughness", "TEXT DEFAULT ''"), - ("rarity", "TEXT DEFAULT ''"), - ("set_name", "TEXT DEFAULT ''"), - ("edhrec_rank", "INTEGER DEFAULT 0"), - ] - existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(collection_entries)").fetchall()} - for col_name, col_def in _migrate_columns: - if col_name not in existing_cols: - conn.execute(f"ALTER TABLE collection_entries ADD COLUMN {col_name} {col_def}") - log_collect.info(f" Migration: added column '{col_name}' to collection_entries") - conn.commit() - conn.close() - log_collect.info(f" Collection DB: {COLLECTION_DB_PATH}") - - -# Fields stored as JSON strings in SQLite that should be returned as parsed arrays -_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords") - - -def _row_to_dict(row) -> dict: - """Convert a sqlite3.Row to a plain dict, parsing JSON array fields.""" - if row is None: - return None - d = dict(row) - for f in _JSON_FIELDS: - if f in d and isinstance(d[f], str): - try: - d[f] = json.loads(d[f]) - except (json.JSONDecodeError, TypeError): - d[f] = [] - return d - - -def _snake_to_camel(s: str) -> str: - """Convert snake_case to camelCase.""" - parts = s.split("_") - return parts[0] + "".join(p.capitalize() for p in parts[1:]) - - -def _add_image_url(card: dict) -> dict: - """Add imageUrl field and camelCase aliases for JS consumption.""" - scryfall_id = card.get("scryfall_id", "") - if scryfall_id: - card["imageUrl"] = f"https://api.scryfall.com/cards/{scryfall_id}?format=image&version=normal" - else: - card["imageUrl"] = None - # Add camelCase aliases for all snake_case keys - for key in list(card.keys()): - if "_" in key: - camel = _snake_to_camel(key) - if camel not in card: - card[camel] = card[key] - return card - - -def _build_collection_filters( - q: Optional[str] = None, - colors: Optional[str] = None, - types: Optional[str] = None, - isLegendary: Optional[bool] = None, - isBasic: Optional[bool] = None, - isGameChanger: Optional[bool] = None, - highSalt: Optional[bool] = None, - finish: Optional[str] = None, - cmcMin: Optional[float] = None, - cmcMax: Optional[float] = None, - priceMin: Optional[float] = None, - priceMax: Optional[float] = None, - category: Optional[str] = None, - rarity: Optional[str] = None, - setCode: Optional[str] = None, - powerMin: Optional[str] = None, - powerMax: Optional[str] = None, - toughMin: Optional[str] = None, - toughMax: Optional[str] = None, - keyword: Optional[str] = None, - edhrecMin: Optional[int] = None, - edhrecMax: Optional[int] = None, - qtyMin: Optional[int] = None, - qtyMax: Optional[int] = None, -) -> tuple: - """Build WHERE clause and params for collection queries. Returns (where_str, params_list).""" - conditions = [] - params = [] - - if q: - like_q = f"%{q}%" - conditions.append("(name LIKE ? OR type_line LIKE ? OR oracle_text LIKE ?)") - params.extend([like_q, like_q, like_q]) - - if colors: - color_list = [c.strip().upper() for c in colors.split(",") if c.strip()] - for color in color_list: - conditions.append("color_identity LIKE ?") - params.append(f'%"{color}"%') - - if types: - type_list = [t.strip() for t in types.split(",") if t.strip()] - type_conditions = [] - for t in type_list: - type_conditions.append("type_line LIKE ?") - params.append(f"%{t}%") - if type_conditions: - conditions.append(f"({' OR '.join(type_conditions)})") - - if isLegendary is not None: - conditions.append("is_legendary = ?") - params.append(1 if isLegendary else 0) - - if isBasic is not None: - conditions.append("is_basic = ?") - params.append(1 if isBasic else 0) - - if isGameChanger is not None: - conditions.append("is_game_changer = ?") - params.append(1 if isGameChanger else 0) - - if highSalt: - conditions.append("salt_score > 2.0") - - if finish: - conditions.append("finish = ?") - params.append(finish.upper()) - - if cmcMin is not None: - conditions.append("cmc >= ?") - params.append(cmcMin) - - if cmcMax is not None: - conditions.append("cmc <= ?") - params.append(cmcMax) - - if priceMin is not None: - conditions.append("tcg_price >= ?") - params.append(priceMin) - - if priceMax is not None: - conditions.append("tcg_price <= ?") - params.append(priceMax) - - if category: - cat_list = [c.strip() for c in category.split(",") if c.strip()] - cat_conditions = [] - for c in cat_list: - cat_conditions.append("category LIKE ?") - params.append(f'%"{c}"%') - if cat_conditions: - conditions.append(f"({' OR '.join(cat_conditions)})") - - # ── New filters ── - - if rarity: - rar_list = [r.strip().lower() for r in rarity.split(",") if r.strip()] - if rar_list: - placeholders = ",".join(["?"] * len(rar_list)) - conditions.append(f"LOWER(rarity) IN ({placeholders})") - params.extend(rar_list) - - if setCode: - set_list = [s.strip().upper() for s in setCode.split(",") if s.strip()] - if set_list: - placeholders = ",".join(["?"] * len(set_list)) - conditions.append(f"UPPER(set_code) IN ({placeholders})") - params.extend(set_list) - - if powerMin is not None: - conditions.append("CAST(power AS REAL) >= ?") - params.append(float(powerMin)) - - if powerMax is not None: - conditions.append("CAST(power AS REAL) <= ?") - params.append(float(powerMax)) - - if toughMin is not None: - conditions.append("CAST(toughness AS REAL) >= ?") - params.append(float(toughMin)) - - if toughMax is not None: - conditions.append("CAST(toughness AS REAL) <= ?") - params.append(float(toughMax)) - - if keyword: - kw_list = [k.strip() for k in keyword.split(",") if k.strip()] - for kw in kw_list: - conditions.append("keywords LIKE ?") - params.append(f"%{kw}%") - - if edhrecMin is not None: - conditions.append("edhrec_rank >= ?") - params.append(edhrecMin) - - if edhrecMax is not None: - conditions.append("edhrec_rank <= ?") - params.append(edhrecMax) - - if qtyMin is not None: - conditions.append("quantity >= ?") - params.append(qtyMin) - - if qtyMax is not None: - conditions.append("quantity <= ?") - params.append(qtyMax) - - where_str = "" - if conditions: - where_str = "WHERE " + " AND ".join(conditions) - - return where_str, params - - -# ══════════════════════════════════════════════════════════════ -# Collection CRUD Endpoints -# ══════════════════════════════════════════════════════════════ - -VALID_SORT_FIELDS = {"name", "cmc", "tcg_price", "salt_score", "category", "color_identity", "quantity", "type_line", "finish", "rarity", "set_code", "power", "toughness", "edhrec_rank"} - - -@app.get("/api/collection/export") -async def export_collection( - format: str = "INTERNAL_CSV", - q: Optional[str] = None, - colors: Optional[str] = None, - types: Optional[str] = None, - isLegendary: Optional[bool] = None, - isBasic: Optional[bool] = None, - isGameChanger: Optional[bool] = None, - highSalt: Optional[bool] = None, - finish: Optional[str] = None, - cmcMin: Optional[float] = None, - cmcMax: Optional[float] = None, - priceMin: Optional[float] = None, - priceMax: Optional[float] = None, - category: Optional[str] = None, -): - """Export collection in various formats with optional filters.""" - where_str, params = _build_collection_filters( - q=q, colors=colors, types=types, isLegendary=isLegendary, - isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt, - finish=finish, cmcMin=cmcMin, cmcMax=cmcMax, - priceMin=priceMin, priceMax=priceMax, category=category, - ) - - sql = f"SELECT * FROM collection_entries {where_str} ORDER BY name ASC" - conn = _get_db_conn() - rows = [_row_to_dict(r) for r in conn.execute(sql, params).fetchall()] - - fmt = format.upper() - output = io.StringIO() - - if fmt == "INTERNAL_CSV": - if rows: - fieldnames = list(rows[0].keys()) - else: - fieldnames = [ - "id", "name", "type_line", "subtypes", "is_legendary", "is_basic", - "color_identity", "cmc", "oracle_text", "keywords", "tcg_price", - "salt_score", "is_game_changer", "category", "scryfall_id", "tcgplayer_id", - "quantity", "finish", "condition", "language", "notes", "tags", - "set_code", "collector_number", "created_at", "updated_at", - ] - writer = csv.DictWriter(output, fieldnames=fieldnames) - writer.writeheader() - writer.writerows(rows) - filename = "collection_export.csv" - media_type = "text/csv" - - elif fmt == "MOXFIELD_CSV": - fieldnames = ["Count", "Name", "Edition", "Condition", "Language", "Foil", "Collector Number"] - writer = csv.DictWriter(output, fieldnames=fieldnames) - writer.writeheader() - for row in rows: - finish_val = row.get("finish", "NORMAL").upper() - foil_val = "foil" if finish_val == "FOIL" else ("etched" if finish_val == "ETCHED" else "") - writer.writerow({ - "Count": row.get("quantity", 1), - "Name": row.get("name", ""), - "Edition": row.get("set_code", "").upper(), - "Condition": row.get("condition", ""), - "Language": row.get("language", ""), - "Foil": foil_val, - "Collector Number": row.get("collector_number", ""), - }) - filename = "collection_moxfield.csv" - media_type = "text/csv" - - elif fmt == "TEXT": - lines = [] - for row in rows: - qty = row.get("quantity", 1) - name = row.get("name", "") - set_code = row.get("set_code", "").upper() - coll_num = row.get("collector_number", "") - if set_code and coll_num: - lines.append(f"{qty} {name} ({set_code}) {coll_num}") - elif set_code: - lines.append(f"{qty} {name} ({set_code})") - else: - lines.append(f"{qty} {name}") - output.write("\n".join(lines)) - filename = "collection_export.txt" - media_type = "text/plain" - - else: - raise HTTPException(400, f"Unknown format: {format}. Use INTERNAL_CSV, MOXFIELD_CSV, or TEXT.") - - content = output.getvalue() - - def iter_content(): - yield content.encode("utf-8") - - return StreamingResponse( - iter_content(), - media_type=media_type, - headers={"Content-Disposition": f'attachment; filename="{filename}"'}, - ) - - -@app.get("/api/collection/sets") -async def collection_sets(): - """Return distinct set codes + names in the collection (for filter autocomplete).""" - conn = _get_db_conn() - rows = conn.execute( - "SELECT DISTINCT UPPER(set_code) as code, set_name as name FROM collection_entries WHERE set_code != '' ORDER BY set_name ASC" - ).fetchall() - result = [] - for r in rows: - code = r["code"] if "code" in r.keys() else r[0] - name = r["name"] if "name" in r.keys() else r[1] - if code: - result.append({"code": code, "name": name or code}) - return JSONResponse(result) - - -@app.get("/api/collection/keywords") -async def collection_keywords(): - """Return distinct keywords found in the collection.""" - conn = _get_db_conn() - rows = conn.execute("SELECT keywords FROM collection_entries WHERE keywords != '' AND keywords != '[]'").fetchall() - kw_set = set() - for r in rows: - try: - kws = json.loads(r["keywords"]) if isinstance(r["keywords"], str) else r["keywords"] - if isinstance(kws, list): - for k in kws: - if k: - kw_set.add(k) - except (json.JSONDecodeError, TypeError): - pass - return JSONResponse(sorted(kw_set)) - - -@app.get("/api/collection") -async def list_collection( - page: int = 1, - pageSize: int = 50, - q: Optional[str] = None, - sortField: Optional[str] = None, - sortDir: Optional[str] = None, - colors: Optional[str] = None, - types: Optional[str] = None, - isLegendary: Optional[bool] = None, - isBasic: Optional[bool] = None, - isGameChanger: Optional[bool] = None, - highSalt: Optional[bool] = None, - finish: Optional[str] = None, - cmcMin: Optional[float] = None, - cmcMax: Optional[float] = None, - priceMin: Optional[float] = None, - priceMax: Optional[float] = None, - category: Optional[str] = None, - deck_id: Optional[int] = None, - rarity: Optional[str] = None, - setCode: Optional[str] = None, - powerMin: Optional[str] = None, - powerMax: Optional[str] = None, - toughMin: Optional[str] = None, - toughMax: Optional[str] = None, - keyword: Optional[str] = None, - edhrecMin: Optional[int] = None, - edhrecMax: Optional[int] = None, - qtyMin: Optional[int] = None, - qtyMax: Optional[int] = None, -): - """List collection entries with search, sort, and filter. - Optional deck_id: when provided, includes in_deck_quantity for each result. - """ - where_str, params = _build_collection_filters( - q=q, colors=colors, types=types, isLegendary=isLegendary, - isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt, - finish=finish, cmcMin=cmcMin, cmcMax=cmcMax, - priceMin=priceMin, priceMax=priceMax, category=category, - rarity=rarity, setCode=setCode, powerMin=powerMin, powerMax=powerMax, - toughMin=toughMin, toughMax=toughMax, keyword=keyword, - edhrecMin=edhrecMin, edhrecMax=edhrecMax, qtyMin=qtyMin, qtyMax=qtyMax, - ) - - # Validate sort - sort_field = sortField if sortField in VALID_SORT_FIELDS else "name" - sort_dir = "DESC" if (sortDir or "").upper() == "DESC" else "ASC" - - conn = _get_db_conn() - - # Count total - count_sql = f"SELECT COUNT(*) FROM collection_entries {where_str}" - total = conn.execute(count_sql, params).fetchone()[0] - - # Paginate - offset = (max(1, page) - 1) * pageSize - data_sql = f""" - SELECT * FROM collection_entries {where_str} - ORDER BY {sort_field} {sort_dir} - LIMIT ? OFFSET ? - """ - rows = conn.execute(data_sql, params + [pageSize, offset]).fetchall() - items = [_add_image_url(_row_to_dict(r)) for r in rows] - - # If deck_id provided, annotate each item with how many copies are already in that deck - if deck_id is not None: - # Build a map of scryfall_id -> quantity in deck - deck_card_rows = conn.execute( - "SELECT scryfall_id, SUM(quantity) as qty FROM deck_cards WHERE deck_id = ? GROUP BY scryfall_id", - (deck_id,) - ).fetchall() - in_deck_map = {row["scryfall_id"]: row["qty"] for row in deck_card_rows} - for item in items: - item["in_deck_quantity"] = in_deck_map.get(item.get("scryfall_id", ""), 0) - - return {"items": items, "page": page, "pageSize": pageSize, "total": total} - - -@app.get("/api/collection/{cardId}") -async def get_collection_card(cardId: int): - """Get a single collection entry by ID.""" - conn = _get_db_conn() - row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone() - if not row: - raise HTTPException(404, f"Card with id {cardId} not found") - return _add_image_url(_row_to_dict(row)) - - -@app.patch("/api/collection/{cardId}") -async def update_collection_card(cardId: int, body: dict): - """Update mutable fields of a collection entry.""" - conn = _get_db_conn() - row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone() - if not row: - raise HTTPException(404, f"Card with id {cardId} not found") - - updates = {} - if "category" in body: - cat = body["category"] - if isinstance(cat, list): - updates["category"] = json.dumps(cat) - else: - updates["category"] = cat - if "tags" in body: - updates["tags"] = str(body["tags"]) - if "notes" in body: - updates["notes"] = str(body["notes"]) - if "finish" in body: - finish_val = str(body["finish"]).upper() - if finish_val not in ("NORMAL", "FOIL", "ETCHED"): - raise HTTPException(400, "finish must be NORMAL, FOIL, or ETCHED") - updates["finish"] = finish_val - - if not updates: - return _add_image_url(_row_to_dict(row)) - - updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") - set_clause = ", ".join(f"{k} = ?" for k in updates) - values = list(updates.values()) + [cardId] - conn.execute(f"UPDATE collection_entries SET {set_clause} WHERE id = ?", values) - conn.commit() - - updated_row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone() - return _add_image_url(_row_to_dict(updated_row)) - - -# ══════════════════════════════════════════════════════════════ -# Collection Import Helpers -# ══════════════════════════════════════════════════════════════ - -# ── Scryfall Response Cache ───────────────────────────────── -# SQLite-backed cache for Scryfall API responses. -# Keyed by (name, set_code, collector_number). Default TTL: 7 days. -# Scryfall data changes infrequently (set releases ~every 3 months). -# Eliminates redundant HTTP calls across imports and re-enrichments. - -SCRYFALL_CACHE_DB_PATH = Path(__file__).parent / "scryfall_cache.db" -SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600)) # 7 days - - -class _ScryfallCache: - """ - Thread-safe SQLite cache for raw Scryfall JSON responses. - - Schema: - cache_key TEXT PRIMARY KEY — "name|set|cn" normalised lowercase - json_blob TEXT — raw Scryfall API JSON response - fetched_at TEXT — ISO-8601 UTC timestamp - """ - - def __init__(self, db_path: Path = None, ttl_seconds: int = None): - self._db_path = db_path or SCRYFALL_CACHE_DB_PATH - self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS - self._lock = threading.Lock() - self._conn: Optional[sqlite3.Connection] = None - self._hits = 0 - self._misses = 0 - self._init_db() - - # ── internal ──────────────────────────────────────────── - - def _init_db(self): - self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False) - self._conn.execute("PRAGMA journal_mode=WAL") - self._conn.execute(""" - CREATE TABLE IF NOT EXISTS scryfall_cache ( - cache_key TEXT PRIMARY KEY, - json_blob TEXT NOT NULL, - fetched_at TEXT NOT NULL DEFAULT (datetime('now')) - ) - """) - self._conn.commit() - - @staticmethod - def _make_key(name: str, set_code: str, collector_number: str) -> str: - return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}" - - # ── public API ────────────────────────────────────────── - - def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]: - """Return cached Scryfall JSON dict, or None if missing / expired.""" - key = self._make_key(name, set_code, collector_number) - with self._lock: - row = self._conn.execute( - "SELECT json_blob, fetched_at FROM scryfall_cache WHERE cache_key = ?", - (key,), - ).fetchone() - if not row: - self._misses += 1 - return None - # TTL check - try: - fetched = datetime.fromisoformat(row[1]) - age = (datetime.utcnow() - fetched).total_seconds() - if age > self._ttl: - self._misses += 1 - return None - except (ValueError, TypeError): - self._misses += 1 - return None - self._hits += 1 - try: - return json.loads(row[0]) - except json.JSONDecodeError: - self._misses += 1 - return None - - def put(self, name: str, set_code: str, collector_number: str, card_data: dict): - """Store a Scryfall response in the cache.""" - key = self._make_key(name, set_code, collector_number) - blob = json.dumps(card_data, separators=(",", ":")) - with self._lock: - self._conn.execute( - "INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob, fetched_at) " - "VALUES (?, ?, datetime('now'))", - (key, blob), - ) - self._conn.commit() - - def stats(self) -> dict: - """Return cache statistics.""" - with self._lock: - total = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0] - oldest_row = self._conn.execute( - "SELECT MIN(fetched_at) FROM scryfall_cache" - ).fetchone() - newest_row = self._conn.execute( - "SELECT MAX(fetched_at) FROM scryfall_cache" - ).fetchone() - db_size_bytes = os.path.getsize(str(self._db_path)) if self._db_path.exists() else 0 - return { - "total_entries": total, - "hits": self._hits, - "misses": self._misses, - "hit_rate": round(self._hits / max(self._hits + self._misses, 1) * 100, 1), - "ttl_seconds": self._ttl, - "ttl_days": round(self._ttl / 86400, 1), - "oldest_entry": oldest_row[0] if oldest_row else None, - "newest_entry": newest_row[0] if newest_row else None, - "db_size_kb": round(db_size_bytes / 1024, 1), - "db_path": str(self._db_path), - } - - def clear(self) -> int: - """Delete all cached entries. Returns count of deleted rows.""" - with self._lock: - count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0] - self._conn.execute("DELETE FROM scryfall_cache") - self._conn.commit() - self._conn.execute("VACUUM") # must run outside transaction - self._hits = 0 - self._misses = 0 - return count - - def evict_expired(self) -> int: - """Remove entries older than TTL. Returns count of evicted rows.""" - cutoff = datetime.utcnow().isoformat() - with self._lock: - self._conn.execute( - "DELETE FROM scryfall_cache WHERE " - "(julianday(?) - julianday(fetched_at)) * 86400 > ?", - (cutoff, self._ttl), - ) - deleted = self._conn.total_changes - self._conn.commit() - return deleted - - -_scryfall_cache = _ScryfallCache() - - -# Scryfall rate-limit semaphore (max 10 req/sec) -_scryfall_lock = threading.Lock() -_scryfall_last_call = 0.0 - - -def _scryfall_rate_limit(): - """Enforce at most 10 Scryfall requests per second via simple sleep.""" - global _scryfall_last_call - with _scryfall_lock: - now = time.monotonic() - elapsed = now - _scryfall_last_call - if elapsed < 0.1: - time.sleep(0.1 - elapsed) - _scryfall_last_call = time.monotonic() - - -def _parse_finish(raw: str) -> str: - """Normalize finish string to NORMAL|FOIL|ETCHED.""" - if not raw: - return "NORMAL" - r = raw.strip().lower() - if r in ("etched", "foil etched", "foil_etched", "etched foil"): - return "ETCHED" - if r in ("yes", "foil", "true", "1"): - return "FOIL" - return "NORMAL" - - -def _parse_text_line(line: str) -> dict: - """ - Parse a text import line into {name, quantity, set_code, collector_number}. - Supports formats: - - "3 Sol Ring (CMR) 123" - - "1x Sol Ring (CMR)" - - "Sol Ring [CMR:123]" - - "1 Sol Ring" - """ - line = line.strip() - if not line or line.startswith("#") or line.startswith("//"): - return {} - - result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""} - - # Strip quantity prefix - qty_match = re.match(r"^(\d+)x?\s+", line) - if qty_match: - result["quantity"] = int(qty_match.group(1)) - line = line[qty_match.end():] - - # Format: "Name [SET:CollNum]" - bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line) - if bracket_match: - result["set_code"] = bracket_match.group(1).lower() - result["collector_number"] = bracket_match.group(2) - line = line[:bracket_match.start()].strip() - result["name"] = line - return result - - # Format: "Name (SET) CollNum" - paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line) - if paren_coll_match: - result["set_code"] = paren_coll_match.group(1).lower() - result["collector_number"] = paren_coll_match.group(2) - line = line[:paren_coll_match.start()].strip() - result["name"] = line - return result - - # Format: "Name (SET)" - paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line) - if paren_match: - result["set_code"] = paren_match.group(1).lower() - line = line[:paren_match.start()].strip() - result["name"] = line - return result - - result["name"] = line.strip() - return result - - -def _auto_infer_mapping(headers: list) -> dict: - """ - Auto-infer column → field mapping from header names. - Returns dict of {header: field_key}. - Field keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags - """ - mapping = {} - header_lower = {h: h.lower().strip() for h in headers} - field_patterns = { - "name": ["name", "card name", "card_name", "cardname", "title"], - "quantity": ["quantity", "qty", "count", "amount", "number", "#"], - "set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"], - "collector_number": [ - "collector number", "collector_number", "collectornumber", - "col #", "col#", "number", "card number", - ], - "finish": ["finish", "foil", "printing", "treatment"], - "condition": ["condition", "cond", "grade"], - "language": ["language", "lang", "locale"], - "notes": ["notes", "note", "comments", "comment"], - "tags": ["tags", "tag", "labels", "label"], - } - for header, lower_h in header_lower.items(): - for field, patterns in field_patterns.items(): - if lower_h in patterns: - mapping[header] = field - break - return mapping - - -def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list: - """ - Parse CSV/text content into a list of RawImportRow dicts. - Returns list of dicts with keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags - """ - rows = [] - source_upper = (source or "").upper() - - if source_upper == "TEXT": - for line in content.splitlines(): - parsed = _parse_text_line(line) - if parsed.get("name"): - rows.append(parsed) - return rows - - # CSV-based sources - reader = csv.DictReader(io.StringIO(content)) - headers = reader.fieldnames or [] - - # Build effective mapping - if mapping: - col_map = {h: mapping.get(h, "") for h in headers} - else: - col_map = _auto_infer_mapping(headers) - - # Normalize col_map field values to lowercase so both "NAME" (UI) and "name" (auto-infer) work - col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()} - - for csv_row in reader: - row = { - "name": "", - "quantity": 1, - "set_code": "", - "collector_number": "", - "finish": "NORMAL", - "condition": "", - "language": "", - "notes": "", - "tags": "", - } - for header, field in col_map.items(): - val = csv_row.get(header, "").strip() - if not val or field == "ignore" or not field: - continue - if field == "name": - # Strip finish suffix like "(Foil Etched)" - val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip() - row["name"] = val - elif field == "quantity": - try: - row["quantity"] = int(float(val)) - except ValueError: - row["quantity"] = 1 - elif field in ("set_code", "set_code_secondary"): - row["set_code"] = val.lower() - elif field in ("collector_number", "collector_number_secondary"): - row["collector_number"] = val - elif field == "finish": - row["finish"] = _parse_finish(val) - elif field == "condition": - row["condition"] = val - elif field == "language": - row["language"] = val - elif field == "notes": - row["notes"] = val - elif field == "tags": - row["tags"] = val - - if row["name"]: - rows.append(row) - - return rows - - -def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict: - """ - Fetch raw card data from Scryfall API (with caching). - Returns the raw Scryfall JSON dict, or a dict with '_error' on failure. - Cache is checked first; live HTTP call only on miss. - """ - from urllib.parse import quote - - # ── Cache check ────────────────────────────────────── - cached = _scryfall_cache.get(name, set_code, collector_number) - if cached is not None: - return cached - - # ── Live fetch ─────────────────────────────────────── - card_data = None - last_error = "" - - # Try set + collector number first - if set_code and collector_number: - _scryfall_rate_limit() - try: - url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}" - req = Request(url, headers=_API_HEADERS) - with urlopen(req, timeout=10) as resp: - card_data = json.loads(resp.read().decode("utf-8")) - except Exception as e: - last_error = str(e) - card_data = None - - # Fall back to exact name lookup - if not card_data: - _scryfall_rate_limit() - try: - encoded_name = quote(name) - url = f"https://api.scryfall.com/cards/named?exact={encoded_name}" - req = Request(url, headers=_API_HEADERS) - with urlopen(req, timeout=10) as resp: - card_data = json.loads(resp.read().decode("utf-8")) - except Exception as e: - last_error = str(e) - return {"_error": f"Scryfall lookup failed for '{name}': {last_error}"} - - if not card_data or card_data.get("object") == "error": - err_detail = card_data.get("details", "unknown error") if card_data else last_error - return {"_error": f"Scryfall returned error for '{name}': {err_detail}"} - - # ── Cache the successful response ──────────────────── - _scryfall_cache.put(name, set_code, collector_number, card_data) - # Also cache under the Scryfall-resolved name (handles spelling normalisation) - resolved_name = card_data.get("name", "") - if resolved_name and resolved_name.lower() != name.strip().lower(): - _scryfall_cache.put(resolved_name, set_code, collector_number, card_data) - - return card_data - - -def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict: - """ - Fetch card data from Scryfall (cached) and return enriched fields dict. - Tries set+collectorNumber first, then exact name lookup. - Returns empty dict on failure. - """ - card_data = _fetch_scryfall_api(name, set_code, collector_number) - - if not card_data or "_error" in card_data: - return card_data or {} - - # Extract fields - type_line = card_data.get("type_line", "") - color_identity = card_data.get("color_identity", []) - keywords = card_data.get("keywords", []) - - # Parse subtypes (after em-dash or hyphen) - subtypes = [] - for sep in [" \u2014 ", " - "]: - if sep in type_line: - parts = type_line.split(sep, 1) - subtypes = [s.strip() for s in parts[1].split() if s.strip()] - break - - is_legendary = 1 if "Legendary" in type_line else 0 - is_basic = 1 if "Basic" in type_line else 0 - - # Get price - prices = card_data.get("prices", {}) - tcg_price = 0.0 - try: - price_val = prices.get("usd") or prices.get("usd_foil") or "0" - tcg_price = float(price_val) if price_val else 0.0 - except (ValueError, TypeError): - tcg_price = 0.0 - - # Handle double-faced cards: oracle_text may be on card_faces - oracle_text = card_data.get("oracle_text", "") - if not oracle_text and card_data.get("card_faces"): - face_texts = [f.get("oracle_text", "") for f in card_data["card_faces"]] - oracle_text = "\n//\n".join(t for t in face_texts if t) - - # Power/toughness (may be on card_faces for DFCs) - power = card_data.get("power", "") - toughness = card_data.get("toughness", "") - if not power and card_data.get("card_faces"): - power = card_data["card_faces"][0].get("power", "") - toughness = card_data["card_faces"][0].get("toughness", "") - - # Mana cost (may be on card_faces for DFCs) - mana_cost = card_data.get("mana_cost", "") - if not mana_cost and card_data.get("card_faces"): - mana_cost = card_data["card_faces"][0].get("mana_cost", "") - - # Auto-detect functional categories - auto_roles = _detect_card_roles(oracle_text, type_line, keywords) - - return { - "name": card_data.get("name", name), - "type_line": type_line, - "subtypes": json.dumps(subtypes), - "is_legendary": is_legendary, - "is_basic": is_basic, - "color_identity": json.dumps(color_identity), - "cmc": card_data.get("cmc", 0.0), - "mana_cost": mana_cost, - "oracle_text": oracle_text, - "keywords": json.dumps(keywords), - "power": power or "", - "toughness": toughness or "", - "rarity": card_data.get("rarity", ""), - "set_name": card_data.get("set_name", ""), - "edhrec_rank": card_data.get("edhrec_rank", 0) or 0, - "tcg_price": tcg_price, - "salt_score": 0.0, - "is_game_changer": 0, - "category": json.dumps(auto_roles), - "scryfall_id": card_data.get("id", ""), - "tcgplayer_id": str(card_data.get("tcgplayer_id", "")), - } - - -# ══════════════════════════════════════════════════════════════ -# Collection Import Endpoint -# ══════════════════════════════════════════════════════════════ - -@app.post("/api/collection/import") -async def import_collection(body: dict): - """ - Import cards into the collection from CSV, Moxfield, Archidekt, or plain text. - - Body: - { - "source": "CSV" | "MOXFIELD" | "ARCHIDEKT" | "TEXT", - "mode": "MERGE" | "REPLACE", - "content": "...csv or text content...", - "mapping": { ... optional column mapping ... } - } - """ - source = str(body.get("source", "CSV")).upper() - mode = str(body.get("mode", "MERGE")).upper() - content = str(body.get("content", "")) - mapping = body.get("mapping", {}) - - if not content.strip(): - raise HTTPException(400, "content is required") - - imported_count = 0 - updated_count = 0 - failed_count = 0 - errors = [] - - conn = _get_db_conn() - - # REPLACE mode: clear existing entries - if mode == "REPLACE": - conn.execute("DELETE FROM collection_entries") - conn.commit() - - # Parse the content into raw rows - try: - raw_rows = _parse_csv_content(content, source, mapping if mapping else None) - except Exception as e: - raise HTTPException(400, f"Failed to parse content: {str(e)}") - - for raw in raw_rows: - name = raw.get("name", "").strip() - if not name: - continue - - quantity = int(raw.get("quantity", 1)) - set_code = raw.get("set_code", "").lower() - collector_number = raw.get("collector_number", "") - finish = _parse_finish(raw.get("finish", "")) - condition = raw.get("condition", "") - language = raw.get("language", "") - notes = raw.get("notes", "") - tags = raw.get("tags", "") - - # Scryfall enrichment - try: - enriched = _enrich_from_scryfall(name, set_code, collector_number) - except Exception as e: - errors.append(f"Scryfall error for '{name}': {str(e)}") - failed_count += 1 - continue - - if not enriched or "_error" in enriched: - err_msg = enriched.get("_error", f"Card not found on Scryfall: '{name}'") if enriched else f"Card not found on Scryfall: '{name}'" - errors.append(err_msg) - failed_count += 1 - continue - - # Use Scryfall-resolved name if available - resolved_name = enriched.get("name", name) - - # Check for existing entry (identity key) - existing = conn.execute( - """SELECT id, quantity FROM collection_entries - WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""", - (resolved_name, set_code, collector_number, finish), - ).fetchone() - - if existing and mode == "MERGE": - # Update quantity - new_qty = existing["quantity"] + quantity - conn.execute( - "UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?", - (new_qty, existing["id"]), - ) - conn.commit() - updated_count += 1 - else: - # Insert new row - conn.execute( - """INSERT INTO collection_entries - (name, type_line, subtypes, is_legendary, is_basic, color_identity, - cmc, mana_cost, oracle_text, keywords, - power, toughness, rarity, set_name, edhrec_rank, - tcg_price, salt_score, is_game_changer, - category, scryfall_id, tcgplayer_id, - quantity, finish, condition, language, notes, tags, - set_code, collector_number) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ( - resolved_name, - enriched.get("type_line", ""), - enriched.get("subtypes", "[]"), - enriched.get("is_legendary", 0), - enriched.get("is_basic", 0), - enriched.get("color_identity", "[]"), - enriched.get("cmc", 0.0), - enriched.get("mana_cost", ""), - enriched.get("oracle_text", ""), - enriched.get("keywords", "[]"), - enriched.get("power", ""), - enriched.get("toughness", ""), - enriched.get("rarity", ""), - enriched.get("set_name", ""), - enriched.get("edhrec_rank", 0), - enriched.get("tcg_price", 0.0), - enriched.get("salt_score", 0.0), - enriched.get("is_game_changer", 0), - enriched.get("category", "[]"), - enriched.get("scryfall_id", ""), - enriched.get("tcgplayer_id", ""), - quantity, - finish, - condition, - language, - notes, - tags, - set_code, - collector_number, - ), - ) - conn.commit() - imported_count += 1 - - return { - "importedCount": imported_count, - "updatedCount": updated_count, - "failedCount": failed_count, - "errors": errors, - } - - -# ══════════════════════════════════════════════════════════════ -# Scryfall Cache Management Endpoints -# ══════════════════════════════════════════════════════════════ - - -@app.get("/api/cache/scryfall") -async def scryfall_cache_stats(): - """Return Scryfall response cache statistics.""" - return _scryfall_cache.stats() - - -@app.delete("/api/cache/scryfall") -async def scryfall_cache_clear(): - """Clear all cached Scryfall responses.""" - deleted = _scryfall_cache.clear() - return {"cleared": deleted, "message": f"Deleted {deleted} cached entries"} - - -@app.post("/api/cache/scryfall/evict") -async def scryfall_cache_evict_expired(): - """Remove only expired entries (older than TTL) from the cache.""" - evicted = _scryfall_cache.evict_expired() - return {"evicted": evicted, "message": f"Evicted {evicted} expired entries"} - - -# ══════════════════════════════════════════════════════════════ -# Deck Builder Helpers -# ══════════════════════════════════════════════════════════════ - -# Type classification priority order -_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"] - -# Target ranges: {type: [min, max]} -_TYPE_TARGETS = { - "Land": [36, 38], - "Instant": [9, 11], - "Sorcery": [7, 9], - "Artifact": [9, 11], - "Creature": [20, 30], - "Enchantment": [5, 10], - "Planeswalker": [0, 5], -} - - -def _classify_card_type(type_line: str) -> str: - """Classify a card's primary type per priority order.""" - tl = type_line or "" - for t in _TYPE_PRIORITY: - if t in tl: - return t - return "Other" - - -def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list: - """ - Detect functional roles from oracle text, type line, and keywords. - Inspired by Archidekt auto-category system. - Returns a list of role strings. - """ - roles = [] - ot = (oracle_text or "").lower() - tl = (type_line or "").lower() - kw_raw = keywords or [] - if isinstance(kw_raw, str): - try: - kw_raw = json.loads(kw_raw) - except Exception: - kw_raw = [] - kw = " ".join(str(k).lower() for k in kw_raw) - - # ── Ramp ────────────────────────────────────────────── - if ( - "add {" in ot - or "add one mana" in ot - or ("search your library for a" in ot and "land" in ot) - or ("land" in tl and "enters" in ot and "add" in ot) - or "treasure token" in ot - or "add mana" in ot - ): - roles.append("Ramp") - - # ── Draw ────────────────────────────────────────────── - if ( - "draw a card" in ot - or "draw cards" in ot - or "draws a card" in ot - or "draw two cards" in ot - or "draw three cards" in ot - or re.search(r"draw \d+ cards?", ot) - or "whenever" in ot and "draw" in ot - ): - roles.append("Draw") - - # ── Removal ─────────────────────────────────────────── - if ( - "destroy target" in ot - or "exile target" in ot - or re.search(r"deals? \d+ damage to (target|any target)", ot) - or re.search(r"deals? x damage to (target|any target)", ot) - or "target creature gets -" in ot - or "fights target" in ot - ): - roles.append("Removal") - - # ── Board Wipe ──────────────────────────────────────── - if ( - "destroy all" in ot - or "exile all" in ot - or re.search(r"all creatures get -\d+/-\d+", ot) - or re.search(r"each creature gets -\d+/-\d+", ot) - or ("deals" in ot and "to each creature" in ot) - ): - roles.append("Board Wipe") - - # ── Anthem ──────────────────────────────────────────── - if ( - re.search(r"creatures? you control get \+", ot) - or re.search(r"creatures? you control have", ot) - or re.search(r"other creatures? you control get \+", ot) - or re.search(r"each creature you control gets? \+", ot) - ): - roles.append("Anthem") - - # ── Protection ──────────────────────────────────────── - if ( - "hexproof" in ot or "hexproof" in kw - or "indestructible" in ot or "indestructible" in kw - or "shroud" in ot or "shroud" in kw - or "ward" in ot or "ward" in kw - or "protection from" in ot - or "can't be the target" in ot - ): - roles.append("Protection") - - # ── Tutor ───────────────────────────────────────────── - if "search your library" in ot: - roles.append("Tutor") - - # ── Counter ─────────────────────────────────────────── - if "counter target" in ot: - roles.append("Counter") - - # ── Token ───────────────────────────────────────────── - if ( - re.search(r"create[sd]? .*token", ot) - or "creature token" in ot - ): - roles.append("Token") - - # ── Sacrifice ───────────────────────────────────────── - if ( - "sacrifice a " in ot - or "sacrifice another" in ot - or "when" in ot and "sacrifice" in ot and "dies" not in ot - or "each player sacrifices" in ot - ): - roles.append("Sacrifice") - - # ── Recursion / Reanimate ───────────────────────────── - if ( - "return" in ot and "from your graveyard" in ot - or "return" in ot and "from a graveyard" in ot - or "put" in ot and "from your graveyard" in ot and "onto the battlefield" in ot - or "put" in ot and "from a graveyard" in ot and "onto the battlefield" in ot - ): - roles.append("Recursion") - - # ── Graveyard ───────────────────────────────────────── - if ( - "graveyard" in ot - and ("mill" in ot or "put" in ot and "into" in ot and "graveyard" in ot) - ) or "mill" in kw: - roles.append("Graveyard") - - # ── Lifegain ────────────────────────────────────────── - if ( - "gain" in ot and "life" in ot - or "lifelink" in ot or "lifelink" in kw - ): - roles.append("Lifegain") - - # ── Burn ────────────────────────────────────────────── - if ( - re.search(r"deals? \d+ damage to (each|any|target) (opponent|player)", ot) - or "each opponent loses" in ot and "life" in ot - or "deals damage to each opponent" in ot - ): - roles.append("Burn") - - # ── Stax ────────────────────────────────────────────── - if ( - "can't cast" in ot - or "can't attack" in ot - or "can't activate" in ot - or "enters the battlefield tapped" in ot and "opponents" in ot - or "each player can't" in ot - or "players can't" in ot - or "cost {" in ot and "more to cast" in ot - or "cost more" in ot and "spells" in ot - ): - roles.append("Stax") - - # ── Evasion ─────────────────────────────────────────── - if ( - "flying" in kw - or "trample" in kw - or "menace" in kw - or "shadow" in kw - or "fear" in kw - or "intimidate" in kw - or "can't be blocked" in ot - or "unblockable" in ot - ): - roles.append("Evasion") - - # ── Finisher ────────────────────────────────────────── - if ( - "you win the game" in ot - or "extra turn" in ot - or "infinite" in ot - or "loses the game" in ot - or "damage equal to" in ot and ("number" in ot or "total" in ot) - ): - roles.append("Finisher") - - # ── Combo ───────────────────────────────────────────── - if ( - "untap all" in ot - or "copy" in ot and "spell" in ot - or "take an extra" in ot - or "double" in ot and ("damage" in ot or "counters" in ot or "tokens" in ot) - ): - roles.append("Combo") - - return roles - - -def _get_deck_or_404(deck_id: int): - """Fetch a deck row or raise 404.""" - conn = _get_db_conn() - row = conn.execute("SELECT * FROM decks WHERE id = ?", (deck_id,)).fetchone() - if not row: - raise HTTPException(404, f"Deck {deck_id} not found") - d = dict(row) - try: - d["color_identity"] = json.loads(d.get("color_identity", "[]")) - except Exception: - d["color_identity"] = [] - return d - - -def _compute_deck_analysis(deck_id: int) -> dict: - """ - Build the full analysis dict for a deck. - Joins deck_cards with collection_entries via scryfall_id. - """ - conn = _get_db_conn() - - rows = conn.execute( - """ - SELECT dc.id, dc.scryfall_id, dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, - ce.type_line, ce.oracle_text, ce.keywords, ce.cmc, ce.color_identity - FROM deck_cards dc - LEFT JOIN ( - SELECT scryfall_id, type_line, oracle_text, keywords, cmc, color_identity - FROM collection_entries GROUP BY scryfall_id - ) ce ON ce.scryfall_id = dc.scryfall_id - WHERE dc.deck_id = ? - """, - (deck_id,) - ).fetchall() - - counts_by_type = {t: 0 for t in _TYPE_PRIORITY} - counts_by_type["Other"] = 0 - mana_curve = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, "6+": 0} - color_pips = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0} - roles_count = {"Ramp": 0, "Draw": 0, "Removal": 0, "BoardWipe": 0, - "Protection": 0, "Tutor": 0, "Counter": 0} - total_cards = 0 - - for row in rows: - qty = row["quantity"] or 1 - total_cards += qty - type_line = row["type_line"] or "" - oracle_text = row["oracle_text"] or "" - keywords = row["keywords"] or "[]" - cmc = float(row["cmc"] or 0) - col_id_raw = row["color_identity"] or "[]" - - card_type = _classify_card_type(type_line) - counts_by_type[card_type] = counts_by_type.get(card_type, 0) + qty - - # Mana curve (exclude lands) - if card_type != "Land": - cmc_int = int(cmc) - if cmc_int >= 6: - mana_curve["6+"] += qty - else: - mana_curve[cmc_int] = mana_curve.get(cmc_int, 0) + qty - - # Color pips from color_identity - try: - ci = json.loads(col_id_raw) if isinstance(col_id_raw, str) else col_id_raw - except Exception: - ci = [] - for c in ci: - if c in color_pips: - color_pips[c] += qty - - # Detect roles - card_roles = _detect_card_roles(oracle_text, type_line, keywords) - for role in card_roles: - if role in roles_count: - roles_count[role] += qty - - # Compute deltas: current - midpoint of target range - deltas = {} - for t, (lo, hi) in _TYPE_TARGETS.items(): - mid = (lo + hi) / 2 - current = counts_by_type.get(t, 0) - delta = current - mid - deltas[t] = round(delta, 1) - - return { - "counts_by_type": counts_by_type, - "targets": _TYPE_TARGETS, - "deltas": deltas, - "mana_curve": mana_curve, - "color_pips": color_pips, - "total_cards": total_cards, - "roles": roles_count, - } - - -# ══════════════════════════════════════════════════════════════ -# Deck Builder Endpoints -# ══════════════════════════════════════════════════════════════ - -# ── 2.1 Deck CRUD ───────────────────────────────────────────── - -@app.post("/api/decks") -async def create_deck(req: CreateDeckRequest): - """Create a new deck.""" - conn = _get_db_conn() - color_identity_json = json.dumps(req.color_identity or []) - cur = conn.execute( - """ - INSERT INTO decks (name, commander_scryfall_id, commander_name, color_identity, strategy_tag) - VALUES (?, ?, ?, ?, ?) - """, - ( - req.name, - req.commander_scryfall_id or "", - req.commander_name or "", - color_identity_json, - req.strategy_tag or "", - ) - ) - conn.commit() - deck_id = cur.lastrowid - return _get_deck_or_404(deck_id) - - -@app.get("/api/decks") -async def list_decks_db(): - """List all decks with card counts.""" - conn = _get_db_conn() - rows = conn.execute( - """ - SELECT d.*, - COALESCE(SUM(dc.quantity), 0) AS total_cards, - COUNT(dc.id) AS card_slots - FROM decks d - LEFT JOIN deck_cards dc ON dc.deck_id = d.id - GROUP BY d.id - ORDER BY d.updated_at DESC - """ - ).fetchall() - decks = [] - for row in rows: - d = dict(row) - try: - d["color_identity"] = json.loads(d.get("color_identity", "[]")) - except Exception: - d["color_identity"] = [] - decks.append(d) - return {"decks": decks} - - -@app.get("/api/decks/{deck_id}") -async def get_deck(deck_id: int): - """Get full deck info with composition summary.""" - deck = _get_deck_or_404(deck_id) - conn = _get_db_conn() - card_rows = conn.execute( - "SELECT * FROM deck_cards WHERE deck_id = ? ORDER BY is_commander DESC, card_name ASC", - (deck_id,) - ).fetchall() - deck["cards"] = [dict(r) for r in card_rows] - deck["total_cards"] = sum(r["quantity"] for r in card_rows) - deck["card_slots"] = len(card_rows) - # Composition summary by type - analysis = _compute_deck_analysis(deck_id) - deck["composition"] = analysis["counts_by_type"] - return deck - - -@app.put("/api/decks/{deck_id}") -async def update_deck(deck_id: int, req: UpdateDeckRequest): - """Update deck metadata.""" - _get_deck_or_404(deck_id) # ensure exists - conn = _get_db_conn() - - updates = {} - if req.name is not None: - updates["name"] = req.name - if req.commander_scryfall_id is not None: - updates["commander_scryfall_id"] = req.commander_scryfall_id - if req.commander_name is not None: - updates["commander_name"] = req.commander_name - if req.color_identity is not None: - updates["color_identity"] = json.dumps(req.color_identity) - if req.strategy_tag is not None: - updates["strategy_tag"] = req.strategy_tag - - if not updates: - return _get_deck_or_404(deck_id) - - updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") - set_clause = ", ".join(f"{k} = ?" for k in updates) - conn.execute(f"UPDATE decks SET {set_clause} WHERE id = ?", list(updates.values()) + [deck_id]) - conn.commit() - return _get_deck_or_404(deck_id) - - -@app.delete("/api/decks/{deck_id}") -async def delete_deck(deck_id: int): - """Delete a deck and all its cards (cascade).""" - _get_deck_or_404(deck_id) # ensure exists - conn = _get_db_conn() - conn.execute("DELETE FROM deck_cards WHERE deck_id = ?", (deck_id,)) - conn.execute("DELETE FROM decks WHERE id = ?", (deck_id,)) - conn.commit() - return {"deleted": True, "deck_id": deck_id} - - -@app.delete("/api/decks") -async def delete_all_decks(): - """Delete ALL decks and their cards.""" - conn = _get_db_conn() - conn.execute("DELETE FROM deck_cards") - count = conn.execute("SELECT COUNT(*) FROM decks").fetchone()[0] - conn.execute("DELETE FROM decks") - conn.commit() - return {"deleted": True, "count": count} - - -# ── 2.2 Deck Card Manipulation ─────────────────────────────── - -@app.get("/api/decks/{deck_id}/cards") -async def get_deck_cards(deck_id: int): - """Get all cards in a deck with joined collection data.""" - _get_deck_or_404(deck_id) - conn = _get_db_conn() - rows = conn.execute( - """ - SELECT - dc.id, dc.deck_id, dc.scryfall_id, dc.card_name, dc.quantity, - dc.is_commander, dc.role_tag, - ce.type_line, ce.cmc, ce.mana_cost, ce.color_identity, ce.oracle_text, ce.keywords, - ce.tcg_price, ce.quantity AS owned_qty, ce.is_legendary, - ce.salt_score, ce.is_game_changer - FROM deck_cards dc - LEFT JOIN ( - SELECT scryfall_id, type_line, cmc, mana_cost, color_identity, - oracle_text, keywords, tcg_price, quantity, is_legendary, - salt_score, is_game_changer - 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 - """, - (deck_id,) - ).fetchall() - cards = [] - for row in rows: - d = dict(row) - # Parse JSON fields - for f in ("color_identity", "keywords"): - if isinstance(d.get(f), str): - try: - d[f] = json.loads(d[f]) - except Exception: - d[f] = [] - d["image_url"] = ( - f"https://api.scryfall.com/cards/{d['scryfall_id']}?format=image&version=normal" - if d.get("scryfall_id") else None - ) - cards.append(d) - return {"cards": cards, "total": len(cards)} - - -@app.post("/api/decks/{deck_id}/cards") -async def add_deck_card(deck_id: int, req: AddDeckCardRequest): - """Add or update a card in the deck.""" - _get_deck_or_404(deck_id) - conn = _get_db_conn() - - # Look up card_name from collection_entries by scryfall_id, fall back to request - ce_row = conn.execute( - "SELECT name FROM collection_entries WHERE scryfall_id = ? LIMIT 1", - (req.scryfall_id,) - ).fetchone() - card_name = ce_row["name"] if ce_row else (req.card_name or "") - - # Check if this scryfall_id is already in the deck - existing = conn.execute( - "SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?", - (deck_id, req.scryfall_id) - ).fetchone() - - if existing: - new_qty = existing["quantity"] + (req.quantity or 1) - conn.execute( - "UPDATE deck_cards SET quantity = ?, role_tag = ?, is_commander = ? WHERE id = ?", - (new_qty, req.role_tag or "", req.is_commander or 0, existing["id"]) - ) - conn.commit() - row = conn.execute("SELECT * FROM deck_cards WHERE id = ?", (existing["id"],)).fetchone() - else: - cur = conn.execute( - """ - INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) - VALUES (?, ?, ?, ?, ?, ?) - """, - ( - deck_id, req.scryfall_id, card_name, - req.quantity or 1, - req.is_commander or 0, - req.role_tag or "" - ) - ) - conn.commit() - row = conn.execute("SELECT * FROM deck_cards WHERE id = ?", (cur.lastrowid,)).fetchone() - - # Update deck updated_at - conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,)) - conn.commit() - return dict(row) - - -@app.delete("/api/decks/{deck_id}/cards/{card_id}") -async def remove_deck_card(deck_id: int, card_id: int): - """Remove a card from a deck.""" - _get_deck_or_404(deck_id) - conn = _get_db_conn() - row = conn.execute( - "SELECT id FROM deck_cards WHERE id = ? AND deck_id = ?", (card_id, deck_id) - ).fetchone() - if not row: - raise HTTPException(404, f"Card slot {card_id} not found in deck {deck_id}") - conn.execute("DELETE FROM deck_cards WHERE id = ?", (card_id,)) - conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,)) - conn.commit() - return {"deleted": True, "card_id": card_id, "deck_id": deck_id} - - -@app.patch("/api/decks/{deck_id}/cards/{card_id}") -async def patch_deck_card(deck_id: int, card_id: int, req: PatchDeckCardRequest): - """Update quantity or role_tag for a card in a deck.""" - _get_deck_or_404(deck_id) - conn = _get_db_conn() - row = conn.execute( - "SELECT * FROM deck_cards WHERE id = ? AND deck_id = ?", (card_id, deck_id) - ).fetchone() - if not row: - raise HTTPException(404, f"Card slot {card_id} not found in deck {deck_id}") - - updates = {} - if req.quantity is not None: - if req.quantity < 1: - raise HTTPException(400, "quantity must be >= 1") - updates["quantity"] = req.quantity - if req.role_tag is not None: - updates["role_tag"] = req.role_tag - - if updates: - set_clause = ", ".join(f"{k} = ?" for k in updates) - conn.execute( - f"UPDATE deck_cards SET {set_clause} WHERE id = ?", - list(updates.values()) + [card_id] - ) - conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,)) - conn.commit() - - updated = conn.execute("SELECT * FROM deck_cards WHERE id = ?", (card_id,)).fetchone() - return dict(updated) - - -# ── 2.3 Deck Analysis ──────────────────────────────────────────── - -@app.get("/api/decks/{deck_id}/analysis") -async def deck_analysis(deck_id: int): - """Analyze deck composition — type counts, mana curve, color pips, roles, targets, deltas.""" - _get_deck_or_404(deck_id) - return _compute_deck_analysis(deck_id) - - -# ── 2.5 Recommendations from Collection ─────────────────────── - -@app.get("/api/decks/{deck_id}/recommended-from-collection") -async def recommend_from_collection(deck_id: int, max_results: int = 20, roles: Optional[str] = None): - """ - Recommend cards from the user's collection for a deck. - Finds shortfalls, then scores owned cards by how well they fit. - """ - deck = _get_deck_or_404(deck_id) - deck_color_identity = deck.get("color_identity", []) - conn = _get_db_conn() - - # Run analysis to find shortfalls - analysis = _compute_deck_analysis(deck_id) - deltas = analysis["deltas"] - counts_by_type = analysis["counts_by_type"] - - # Find types that are below their target midpoint (shortfall) - shortfall_types = set() - for t, delta in deltas.items(): - lo, hi = _TYPE_TARGETS.get(t, [0, 0]) - if counts_by_type.get(t, 0) < lo: - shortfall_types.add(t) - - # Get scryfall_ids already in deck to exclude - in_deck_ids = set( - r["scryfall_id"] for r in - conn.execute("SELECT scryfall_id FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall() - ) - - # Parse role filter - role_filter = [r.strip() for r in roles.split(",") if r.strip()] if roles else [] - - # Query collection for candidate cards - coll_rows = conn.execute( - "SELECT * FROM collection_entries WHERE quantity > 0" - ).fetchall() - - scored = [] - for row in coll_rows: - card = _row_to_dict(row) - card_id = card.get("scryfall_id", "") - - # Skip cards already in deck - if card_id in in_deck_ids: - continue - - # Color identity check: all card colors must be in deck colors - card_ci = card.get("color_identity", []) - if isinstance(card_ci, str): - try: - card_ci = json.loads(card_ci) - except Exception: - card_ci = [] - if deck_color_identity and card_ci: - if not all(c in deck_color_identity for c in card_ci): - continue - - type_line = card.get("type_line", "") - oracle_text = card.get("oracle_text", "") - keywords = card.get("keywords", []) - card_type = _classify_card_type(type_line) - card_roles = _detect_card_roles(oracle_text, type_line, keywords) - - # Role filter - if role_filter and not any(r in card_roles for r in role_filter): - continue - - # Scoring - score = 0 - # +3 if card type is in shortfall - if card_type in shortfall_types: - score += 3 - # +2 per matching role - for r in card_roles: - if r in ("Ramp", "Draw", "Removal", "BoardWipe"): - score += 2 - else: - score += 1 - # +1 for lower CMC (curve fit) - cmc = float(card.get("cmc", 0)) - if cmc <= 3: - score += 1 - - scored.append({ - "id": card.get("id"), - "scryfall_id": card_id, - "name": card.get("name"), - "type_line": type_line, - "card_type": card_type, - "cmc": cmc, - "color_identity": card_ci, - "owned_qty": card.get("quantity", 0), - "roles": card_roles, - "score": score, - "image_url": f"https://api.scryfall.com/cards/{card_id}?format=image&version=normal" if card_id else None, - }) - - scored.sort(key=lambda x: x["score"], reverse=True) - - # Group by type - grouped = {} - for card in scored[:max_results]: - ct = card["card_type"] - grouped.setdefault(ct, []).append(card) - - return { - "shortfall_types": list(shortfall_types), - "role_filter": role_filter, - "grouped": grouped, - "total": len(scored[:max_results]), - } - - -# ── 2.6 EDHREC-style Recommendations ──────────────────────── - -@app.get("/api/decks/{deck_id}/edh-recs") -async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int = 30): - """ - Fetch EDHREC average deck recommendations for this deck's commander. - Cross-references with collection for owned status. - """ - deck = _get_deck_or_404(deck_id) - commander_name = deck.get("commander_name", "") - if not commander_name: - raise HTTPException(400, "Deck has no commander_name set. Update the deck first.") - - # Cache check - cache_key = f"edhrec:avg:{_to_edhrec_slug(commander_name)}" - cached_profile = _edhrec_cache_get(cache_key) - if cached_profile is None: - try: - cached_profile = _fetch_edhrec_average(commander_name) - _edhrec_cache_set(cache_key, cached_profile) - except Exception as e: - raise HTTPException(502, f"Failed to fetch EDHREC data for '{commander_name}': {str(e)}") - - edhrec_profile = cached_profile - mainboard = edhrec_profile.get("mainboard", {}) - - conn = _get_db_conn() - - # Get cards already in deck - in_deck_names = set( - r["card_name"].lower() for r in - conn.execute("SELECT card_name FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall() - ) - - # Build collection lookup: name (lower) -> {owned, qty, scryfall_id} - coll_rows = conn.execute( - "SELECT name, quantity, scryfall_id FROM collection_entries" - ).fetchall() - coll_map = {} - for r in coll_rows: - key = r["name"].lower() - existing = coll_map.get(key) - if not existing or r["quantity"] > existing["qty"]: - coll_map[key] = {"owned": True, "qty": r["quantity"], "scryfall_id": r["scryfall_id"]} - - # Also look up card details from collection_entries for type/role - coll_details = {} - for r in conn.execute("SELECT name, type_line, oracle_text, keywords, scryfall_id FROM collection_entries").fetchall(): - key = r["name"].lower() - if key not in coll_details: - coll_details[key] = dict(r) - - results = [] - for card_name in mainboard: - name_lower = card_name.lower() - # Skip cards already in deck - if name_lower in in_deck_names: - continue - - owned_info = coll_map.get(name_lower, {"owned": False, "qty": 0, "scryfall_id": ""}) - - if only_owned and not owned_info["owned"]: - continue - - # Get type/role from collection if available - details = coll_details.get(name_lower, {}) - type_line = details.get("type_line", "") - oracle_text = details.get("oracle_text", "") - keywords = details.get("keywords", "[]") - card_roles = _detect_card_roles(oracle_text, type_line, keywords) - scryfall_id = owned_info.get("scryfall_id") or details.get("scryfall_id", "") - - results.append({ - "name": card_name, - "type_line": type_line, - "role": card_roles[0] if card_roles else "Other", - "roles": card_roles, - "inclusion_pct": None, # EDHREC average doesn't expose % directly in this path - "synergy_score": None, - "owned": owned_info["owned"], - "owned_qty": owned_info["qty"], - "scryfall_id": scryfall_id, - "image_url": f"https://api.scryfall.com/cards/{scryfall_id}?format=image&version=normal" if scryfall_id else None, - }) - - return { - "commander": commander_name, - "source": "EDHREC Average", - "total": len(results[:max_results]), - "recommendations": results[:max_results], - } - - -# ── 2.7 Bulk Add Operations ──────────────────────────────── - -def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool: - """ - Return True if adding count_to_add cards of card_type stays within target max. - """ - analysis = _compute_deck_analysis(deck_id) - current = analysis["counts_by_type"].get(card_type, 0) - target_max = _TYPE_TARGETS.get(card_type, [0, 9999])[1] - return (current + count_to_add) <= target_max - - -@app.post("/api/decks/{deck_id}/bulk-add") -async def bulk_add_cards(deck_id: int, req: BulkAddRequest): - """ - Bulk-add cards to a deck by scryfall_id. - If respect_ratios, skips cards of a type when the target max is already reached. - """ - _get_deck_or_404(deck_id) - conn = _get_db_conn() - - added = 0 - skipped = 0 - details = [] - - for card_entry in req.cards: - scryfall_id = str(card_entry.get("scryfall_id", "")).strip() - quantity = int(card_entry.get("quantity", 1)) - if not scryfall_id: - skipped += 1 - details.append({"scryfall_id": scryfall_id, "status": "skipped", "reason": "missing scryfall_id"}) - continue - - # Look up card name and type from collection - ce_row = conn.execute( - "SELECT name, type_line, oracle_text, keywords FROM collection_entries WHERE scryfall_id = ? LIMIT 1", - (scryfall_id,) - ).fetchone() - card_name = ce_row["name"] if ce_row else "" - type_line = ce_row["type_line"] if ce_row else "" - card_type = _classify_card_type(type_line) - - if req.respect_ratios and not _check_ratio_limit(deck_id, card_type, quantity): - skipped += 1 - details.append({ - "scryfall_id": scryfall_id, - "name": card_name, - "card_type": card_type, - "status": "skipped", - "reason": f"Type '{card_type}' at or above target max", - }) - continue - - # Upsert - existing = conn.execute( - "SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?", - (deck_id, scryfall_id) - ).fetchone() - - if existing: - conn.execute( - "UPDATE deck_cards SET quantity = ? WHERE id = ?", - (existing["quantity"] + quantity, existing["id"]) - ) - else: - conn.execute( - "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, ?)", - (deck_id, scryfall_id, card_name, quantity) - ) - conn.commit() - added += 1 - details.append({"scryfall_id": scryfall_id, "name": card_name, "card_type": card_type, "status": "added", "quantity": quantity}) - - conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,)) - conn.commit() - - analysis = _compute_deck_analysis(deck_id) - return {"added": added, "skipped": skipped, "details": details, "analysis": analysis} - - -@app.post("/api/decks/{deck_id}/bulk-add-recommended") -async def bulk_add_recommended(deck_id: int, req: BulkAddRecommendedRequest): - """ - Fetch recommendations from 'collection' or 'edhrec', filter, and bulk-add to deck. - """ - _get_deck_or_404(deck_id) - conn = _get_db_conn() - - source = (req.source or "collection").lower() - type_filter = [t.strip() for t in req.types] if req.types else [] - role_filter = [r.strip() for r in req.roles] if req.roles else [] - - candidates = [] - - if source == "collection": - # Reuse the recommend_from_collection logic - deck = _get_deck_or_404(deck_id) - deck_color_identity = deck.get("color_identity", []) - analysis = _compute_deck_analysis(deck_id) - counts_by_type = analysis["counts_by_type"] - shortfall_types = {t for t, (lo, _) in _TYPE_TARGETS.items() if counts_by_type.get(t, 0) < lo} - - in_deck_ids = set( - r["scryfall_id"] for r in - conn.execute("SELECT scryfall_id FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall() - ) - coll_rows = conn.execute("SELECT * FROM collection_entries WHERE quantity > 0").fetchall() - for row in coll_rows: - card = _row_to_dict(row) - cid = card.get("scryfall_id", "") - if cid in in_deck_ids: - continue - card_ci = card.get("color_identity", []) - if isinstance(card_ci, str): - try: - card_ci = json.loads(card_ci) - except Exception: - card_ci = [] - if deck_color_identity and card_ci: - if not all(c in deck_color_identity for c in card_ci): - continue - tl = card.get("type_line", "") - ct = _classify_card_type(tl) - card_roles = _detect_card_roles(card.get("oracle_text", ""), tl, card.get("keywords", [])) - if type_filter and ct not in type_filter: - continue - if role_filter and not any(r in card_roles for r in role_filter): - continue - candidates.append({"scryfall_id": cid, "card_type": ct, "quantity": 1}) - - elif source == "edhrec": - deck = _get_deck_or_404(deck_id) - commander_name = deck.get("commander_name", "") - if not commander_name: - raise HTTPException(400, "Deck has no commander_name set") - try: - edhrec_profile = _fetch_edhrec_average(commander_name) - except Exception as e: - raise HTTPException(502, f"EDHREC fetch failed: {e}") - mainboard = edhrec_profile.get("mainboard", {}) - in_deck_names = set( - r["card_name"].lower() for r in - conn.execute("SELECT card_name FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall() - ) - coll_rows = conn.execute("SELECT name, scryfall_id, type_line, oracle_text, keywords, quantity FROM collection_entries").fetchall() - coll_map = {r["name"].lower(): dict(r) for r in coll_rows} - - for card_name in mainboard: - name_lower = card_name.lower() - if name_lower in in_deck_names: - continue - coll_info = coll_map.get(name_lower) - if req.only_owned and not coll_info: - continue - if not coll_info: - continue # can't add without scryfall_id - scryfall_id = coll_info.get("scryfall_id", "") - if not scryfall_id: - continue - tl = coll_info.get("type_line", "") - ct = _classify_card_type(tl) - card_roles = _detect_card_roles(coll_info.get("oracle_text", ""), tl, coll_info.get("keywords", [])) - if type_filter and ct not in type_filter: - continue - if role_filter and not any(r in card_roles for r in role_filter): - continue - candidates.append({"scryfall_id": scryfall_id, "card_type": ct, "quantity": 1}) - else: - raise HTTPException(400, f"Unknown source '{source}'. Use 'collection' or 'edhrec'.") - - # Now bulk-add candidates - added = 0 - skipped = 0 - details = [] - - for c in candidates: - scryfall_id = c["scryfall_id"] - card_type = c["card_type"] - quantity = c["quantity"] - - if req.respect_ratios and not _check_ratio_limit(deck_id, card_type, quantity): - skipped += 1 - details.append({"scryfall_id": scryfall_id, "card_type": card_type, "status": "skipped", "reason": "ratio limit"}) - continue - - ce_row = conn.execute( - "SELECT name FROM collection_entries WHERE scryfall_id = ? LIMIT 1", (scryfall_id,) - ).fetchone() - card_name = ce_row["name"] if ce_row else "" - - existing = conn.execute( - "SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?", - (deck_id, scryfall_id) - ).fetchone() - if existing: - conn.execute( - "UPDATE deck_cards SET quantity = ? WHERE id = ?", - (existing["quantity"] + quantity, existing["id"]) - ) - else: - conn.execute( - "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, ?)", - (deck_id, scryfall_id, card_name, quantity) - ) - conn.commit() - added += 1 - details.append({"scryfall_id": scryfall_id, "name": card_name, "card_type": card_type, "status": "added"}) - - conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,)) - conn.commit() - - analysis = _compute_deck_analysis(deck_id) - return {"added": added, "skipped": skipped, "details": details, "analysis": analysis} - - -# ── 2.9 Simulation Export ─────────────────────────────────── - -@app.post("/api/decks/{deck_id}/export-to-sim") -async def export_deck_to_sim(deck_id: int): - """ - Export a deck to Forge .dck format and save to the Forge decks directory. - Returns the deck name for use in simulations. - """ - deck = _get_deck_or_404(deck_id) - conn = _get_db_conn() - - card_rows = conn.execute( - "SELECT card_name, quantity, is_commander FROM deck_cards WHERE deck_id = ? ORDER BY is_commander DESC, card_name ASC", - (deck_id,) - ).fetchall() - - if not card_rows: - raise HTTPException(400, "Deck has no cards to export") - - # Build .dck content - lines = ["[metadata]"] - deck_name = deck.get("name", f"Deck {deck_id}") - lines.append(f"Name={deck_name}") - lines.append("") - - commanders = [r for r in card_rows if r["is_commander"]] - mainboard = [r for r in card_rows if not r["is_commander"]] - - lines.append("[Commander]") - for r in commanders: - name = r["card_name"] or "Unknown" - qty = r["quantity"] or 1 - lines.append(f"{qty} {name}") - - lines.append("") - lines.append("[Main]") - for r in mainboard: - name = r["card_name"] or "Unknown" - qty = r["quantity"] or 1 - lines.append(f"{qty} {name}") - - content = "\n".join(lines) - - # Determine save directory - safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", deck_name).replace(" ", "_").strip() - if not safe_name: - safe_name = f"deck_{deck_id}" - - save_dir = CFG.forge_decks_dir - if not save_dir or not os.path.isdir(save_dir): - save_dir = os.path.join(os.path.dirname(__file__), "exported-decks") - os.makedirs(save_dir, exist_ok=True) - - out_path = Path(save_dir) / f"{safe_name}.dck" - out_path.write_text(content, encoding="utf-8") - log_deckgen.info(f" Exported deck {deck_id} to {out_path}") - - return { - "success": True, - "deckName": safe_name, - "dckFile": str(out_path), - "totalCards": sum(r["quantity"] for r in card_rows), - "commanderCount": len(commanders), - "mainboardCount": len(mainboard), - } - - -# ══════════════════════════════════════════════════════════════ -# Deck Import Endpoint -# ══════════════════════════════════════════════════════════════ - - -def _parse_decklist_text(text: str) -> dict: - """ - Parse a decklist from text. Supports: - - Forge .dck format with [Commander], [Deck], [Main] sections - - MTGA format: 1 Card Name - - Plain: Card Name (assumed qty 1) - - Lines starting with // or # are comments - Returns { 'commanders': [{'name': str, 'qty': int}], 'cards': [{'name': str, 'qty': int}] } - """ - commanders = [] - cards = [] - section = 'main' # default section - - for raw_line in text.splitlines(): - line = raw_line.strip() - if not line or line.startswith('//') or line.startswith('#'): - continue - - # Section headers - low = line.lower() - if low.startswith('[commander'): - section = 'commander' - continue - if low.startswith('[deck') or low.startswith('[main') or low.startswith('[mainboard'): - section = 'main' - continue - if low.startswith('[sideboard') or low.startswith('[side') or low.startswith('[metadata'): - section = 'skip' - continue - if low.startswith('['): - # Unknown section, treat as main - section = 'main' - continue - - if section == 'skip': - continue - - # Parse quantity + name - m = re.match(r'^(\d+)\s*[xX]?\s+(.+)$', line) - if m: - qty = int(m.group(1)) - name = m.group(2).strip() - else: - qty = 1 - name = line - - # Strip set codes like "(M21)" or "(M21) 123" from MTGA exports - name = re.sub(r'\s*\([A-Z0-9]+\)\s*\d*\s*$', '', name).strip() - - if not name: - continue - - if section == 'commander': - commanders.append({'name': name, 'qty': qty}) - else: - cards.append({'name': name, 'qty': qty}) - - return {'commanders': commanders, 'cards': cards} - - -@app.post('/api/decks/{deck_id}/import') -async def import_decklist(deck_id: int, request: FastAPIRequest): - """ - Import a decklist into an existing deck. Parses text, looks up each card - on Scryfall, and adds them to the deck. - - Body: { "text": "1 Sol Ring\n1 Cultivate..." , "clearFirst": false } - """ - deck = _get_deck_or_404(deck_id) - body = await request.json() - text = body.get('text', '') - clear_first = body.get('clearFirst', False) - - if not text.strip(): - return JSONResponse({'error': 'No decklist text provided'}, status_code=400) - - parsed = _parse_decklist_text(text) - all_entries = [] - for c in parsed['commanders']: - all_entries.append({**c, 'is_commander': True}) - for c in parsed['cards']: - all_entries.append({**c, 'is_commander': False}) - - if not all_entries: - return JSONResponse({'error': 'No cards found in decklist'}, status_code=400) - - conn = _get_db_conn() - - # Optionally clear existing cards - if clear_first: - conn.execute('DELETE FROM deck_cards WHERE deck_id = ?', (deck_id,)) - conn.commit() - - added = 0 - failed = [] - results = [] - - for entry in all_entries: - name = entry['name'] - qty = entry['qty'] - is_cmd = entry['is_commander'] - - # Look up on Scryfall - sf = _scryfall_fuzzy_lookup(name) - if not sf: - failed.append(name) - results.append({'name': name, 'status': 'not_found'}) - continue - - scryfall_id = sf.get('id', '') - resolved_name = sf.get('name', name) - - # Check if already in deck - existing = conn.execute( - 'SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?', - (deck_id, scryfall_id) - ).fetchone() - - if existing: - conn.execute( - 'UPDATE deck_cards SET quantity = ?, is_commander = ? WHERE id = ?', - (existing['quantity'] + qty, 1 if is_cmd else existing.get('is_commander', 0), existing['id']) - ) - else: - conn.execute( - 'INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander) VALUES (?, ?, ?, ?, ?)', - (deck_id, scryfall_id, resolved_name, qty, 1 if is_cmd else 0) - ) - conn.commit() - added += 1 - results.append({'name': resolved_name, 'qty': qty, 'status': 'added', 'isCommander': is_cmd}) - - # Update commander on deck record if we found one - cmd_entries = [r for r in results if r.get('isCommander')] - if cmd_entries: - cmd_name = cmd_entries[0]['name'] - # Look up scryfall_id from the deck_cards we just inserted - cmd_row = conn.execute( - 'SELECT scryfall_id FROM deck_cards WHERE deck_id = ? AND is_commander = 1 LIMIT 1', - (deck_id,) - ).fetchone() - cmd_sf_id = cmd_row['scryfall_id'] if cmd_row else '' - conn.execute( - 'UPDATE decks SET commander_name = ?, commander_scryfall_id = ?, updated_at = datetime(\'now\') WHERE id = ?', - (cmd_name, cmd_sf_id, deck_id) - ) - else: - conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,)) - conn.commit() - - return { - 'added': added, - 'failed': len(failed), - 'failedNames': failed, - 'total': len(all_entries), - 'results': results, - } - - -@app.post('/api/decks/import-new') -async def import_decklist_new(request: FastAPIRequest): - """ - Import a decklist as a brand-new deck. Creates the deck, then imports cards. - - Body: { "text": "...", "name": "My Deck" } - """ - body = await request.json() - text = body.get('text', '') - deck_name = body.get('name', '').strip() - - if not text.strip(): - return JSONResponse({'error': 'No decklist text provided'}, status_code=400) - - parsed = _parse_decklist_text(text) - all_entries = [] - for c in parsed['commanders']: - all_entries.append({**c, 'is_commander': True}) - for c in parsed['cards']: - all_entries.append({**c, 'is_commander': False}) - - if not all_entries: - return JSONResponse({'error': 'No cards found in decklist'}, status_code=400) - - # Auto-name from first commander if no name given - if not deck_name: - cmd = parsed['commanders'][0]['name'] if parsed['commanders'] else None - deck_name = cmd or 'Imported Deck' - - conn = _get_db_conn() - cur = conn.execute( - "INSERT INTO decks (name, created_at, updated_at) VALUES (?, datetime('now'), datetime('now'))", - (deck_name,) - ) - conn.commit() - new_deck_id = cur.lastrowid - - # Set commander on the deck if we have one - first_cmd_scryfall = None - first_cmd_name = None - - added = 0 - failed = [] - - for entry in all_entries: - name = entry['name'] - qty = entry['qty'] - is_cmd = entry['is_commander'] - - sf = _scryfall_fuzzy_lookup(name) - if not sf: - failed.append(name) - continue - - scryfall_id = sf.get('id', '') - resolved_name = sf.get('name', name) - - if is_cmd and not first_cmd_scryfall: - first_cmd_scryfall = scryfall_id - first_cmd_name = resolved_name - - conn.execute( - 'INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander) VALUES (?, ?, ?, ?, ?)', - (new_deck_id, scryfall_id, resolved_name, qty, 1 if is_cmd else 0) - ) - conn.commit() - added += 1 - - # Update commander on deck record - if first_cmd_scryfall: - conn.execute( - 'UPDATE decks SET commander_name = ?, commander_scryfall_id = ? WHERE id = ?', - (first_cmd_name, first_cmd_scryfall, new_deck_id) - ) - conn.commit() - - return { - 'deckId': new_deck_id, - 'deckName': deck_name, - 'added': added, - 'failed': len(failed), - 'failedNames': failed, - 'total': len(all_entries), - } - - -# ══════════════════════════════════════════════════════════════ -# Card Scanner Endpoint -# ══════════════════════════════════════════════════════════════ - -def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]: - """ - Fuzzy-search Scryfall for a card name (used by the scanner pipeline). - Returns the raw Scryfall JSON dict, or None on failure. - """ - from urllib.parse import quote - _scryfall_rate_limit() - try: - encoded = quote(name) - url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}" - req = Request(url, headers=_API_HEADERS) - with urlopen(req, timeout=10) as resp: - data = json.loads(resp.read().decode("utf-8")) - if data.get("object") == "error": - return None - return data - except Exception: - return None - - @app.post("/api/collection/scan") async def scan_card_image(request: FastAPIRequest): """ diff --git a/routes/__init__.py b/routes/__init__.py index 3300ba0..d13d0aa 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1,5 +1,6 @@ -"""Commander AI Lab — API router package. +""" +routes package — API routers for Commander AI Lab. -Each module in this package exposes an `APIRouter` that is -registered in `lab_api.py` via `app.include_router()`. +Each module defines a FastAPI APIRouter with real endpoint implementations. +All shared helpers, models, and state live in routes.shared. """ diff --git a/routes/_autoload.py b/routes/_autoload.py deleted file mode 100644 index 3815590..0000000 --- a/routes/_autoload.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -routes/_autoload.py -=================== -Optional zero-touch bootstrap for the routes package. - -If you don't want to modify lab_api.py at all, add one line -anywhere after `app = FastAPI(...)` in lab_api.py:: - - from routes import _autoload # noqa — registers routers on startup - -This module registers a FastAPI startup event that imports routes._wire, -which in turn calls _register_shared() and app.include_router() for all -five routers. -""" -from __future__ import annotations - - -def register_startup(app): - """Attach the router-wiring startup event to a FastAPI app instance.""" - - @app.on_event("startup") - async def _wire_routers(): - import routes._wire # noqa — side-effect: registers shared + mounts routers diff --git a/routes/_wire.py b/routes/_wire.py deleted file mode 100644 index a953788..0000000 --- a/routes/_wire.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -routes/_wire.py -=============== -Wires the routes package into lab_api.py. - -This module is imported ONCE at the bottom of lab_api.py after all -symbols (CFG, app, helper functions, endpoint functions) are defined. - -It does two things: - 1. Calls routes.shared._register_shared() so every router can access - shared state via routes.shared.get(). - 2. Calls app.include_router() for every APIRouter defined in the - routes/ package. - -Keeping wiring here means lab_api.py only needs one new line: - - import routes._wire # noqa — side-effect import - -""" -from __future__ import annotations - -import lab_api as _api # import the main module to grab its symbols - -from routes.shared import _register_shared -from routes import ( - collection, - deckbuilder, - import_routes, - lab, - precon, -) - -# ------------------------------------------------------------------ -# 1. Register shared symbols so routers can call routes.shared.get() -# ------------------------------------------------------------------ -_register_shared( - # Config + app - CFG=_api.CFG, - app=_api.app, - - # Loggers - log=_api.log, - log_batch=_api.log_batch, - log_sim=_api.log_sim, - log_coach=_api.log_coach, - log_deckgen=_api.log_deckgen, - log_collect=_api.log_collect, - log_scan=_api.log_scan, - log_ml=_api.log_ml, - log_cache=_api.log_cache, - log_pplx=_api.log_pplx, - - # In-memory state - active_batches=_api.active_batches, - COMMANDER_META=_api.COMMANDER_META, - PRECON_INDEX=_api.PRECON_INDEX, - PRECON_DIR=_api.PRECON_DIR, - AI_PROFILES=_api.AI_PROFILES, - - # DB - COLLECTION_DB_PATH=_api.COLLECTION_DB_PATH, - init_collection_db=_api.init_collection_db, - - # Precon helpers - download_precon_database=_api.download_precon_database, - - # Scryfall helpers - _enrich_from_scryfall=_api._enrich_from_scryfall, - _fetch_scryfall_api=_api._fetch_scryfall_api, - - # Utility helpers - _row_to_dict=_api._row_to_dict, - _add_image_url=_api._add_image_url, - _snake_to_camel=_api._snake_to_camel, - _classify_card_type=_api._classify_card_type, - _detect_card_roles=_api._detect_card_roles, - _build_collection_filters=_api._build_collection_filters, - _get_deck_or_404=_api._get_deck_or_404, - _compute_deck_analysis=_api._compute_deck_analysis, - _to_edhrec_slug=_api._to_edhrec_slug, - _save_profile_to_dck=_api._save_profile_to_dck, - _parse_finish=_api._parse_finish, - _parse_text_line=_api._parse_text_line, - _parse_csv_content=_api._parse_csv_content, - _auto_infer_mapping=_api._auto_infer_mapping, - get_java17=_api.get_java17, - build_java_command=_api.build_java_command, - - # Deck import helpers - _import_from_url=_api._import_from_url, - _parse_text_decklist=_api._parse_text_decklist, - _fetch_edhrec_average=_api._fetch_edhrec_average, - - # Endpoint functions (delegated by thin router wrappers) - start_batch=_api.start_batch, - start_batch_deepseek=_api.start_batch_deepseek, - get_status=_api.get_status, - get_result=_api.get_result, - list_decks=_api.list_decks, - list_history=_api.list_history, - list_profiles=_api.list_profiles, - get_profile=_api.get_profile, - analyze_deck=_api.analyze_deck, - get_deck_trends=_api.get_deck_trends, - get_log=_api.get_log, - get_debug_log=_api.get_debug_log, - list_collection=_api.list_collection, - export_collection=_api.export_collection, - collection_sets=_api.collection_sets, - collection_keywords=_api.collection_keywords, - import_collection=_api.import_collection, - get_collection_card=_api.get_collection_card, - update_collection_card=_api.update_collection_card, - scryfall_cache_stats=_api.scryfall_cache_stats, - scryfall_cache_clear=_api.scryfall_cache_clear, - scryfall_cache_evict_expired=_api.scryfall_cache_evict_expired, - create_deck=_api.create_deck, - list_decks_db=_api.list_decks_db, - get_deck=_api.get_deck, - update_deck=_api.update_deck, - delete_deck=_api.delete_deck, - delete_all_decks=_api.delete_all_decks, - get_deck_cards=_api.get_deck_cards, - add_deck_card=_api.add_deck_card, - remove_deck_card=_api.remove_deck_card, - patch_deck_card=_api.patch_deck_card, - deck_analysis=_api.deck_analysis, - recommend_from_collection=_api.recommend_from_collection, - deck_edh_recs=_api.deck_edh_recs, - bulk_add_cards=_api.bulk_add_cards, - bulk_add_recommended=_api.bulk_add_recommended, - export_deck_to_sim=_api.export_deck_to_sim, -) - -# ------------------------------------------------------------------ -# 2. Mount all routers onto the FastAPI app -# ------------------------------------------------------------------ -_api.app.include_router(lab.router) -_api.app.include_router(import_routes.router) -_api.app.include_router(precon.router) -_api.app.include_router(collection.router) -_api.app.include_router(deckbuilder.router) diff --git a/routes/collection.py b/routes/collection.py index 2a28c8f..5df6c30 100644 --- a/routes/collection.py +++ b/routes/collection.py @@ -13,22 +13,182 @@ Collection management endpoints: DELETE /api/cache/scryfall POST /api/cache/scryfall/evict """ - from __future__ import annotations +import csv +import io +import json +import re +import sqlite3 +import time +from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, HTTPException -from fastapi.responses import StreamingResponse +from fastapi.responses import JSONResponse, StreamingResponse -from routes.shared import get as _get +from routes.shared import ( + CFG, + _get_db_conn, + _row_to_dict, + _add_image_url, + _build_collection_filters, + _detect_card_roles, + _classify_card_type, + _enrich_from_scryfall, + _fetch_scryfall_api, + _parse_finish, + _parse_text_line, + _parse_csv_content, + _auto_infer_mapping, + _scryfall_cache, + _scryfall_rate_limit, + _snake_to_camel, + VALID_SORT_FIELDS, + _JSON_FIELDS, + SCRYFALL_CACHE_DB_PATH, + SCRYFALL_CACHE_TTL_SECONDS, + _API_HEADERS, + log_collect, + log_cache, +) router = APIRouter(tags=["collection"]) -# --------------------------------------------------------------------------- -# Collection list / search -# --------------------------------------------------------------------------- +@router.get("/api/collection/export") +async def export_collection( + format: str = "INTERNAL_CSV", + q: Optional[str] = None, + colors: Optional[str] = None, + types: Optional[str] = None, + isLegendary: Optional[bool] = None, + isBasic: Optional[bool] = None, + isGameChanger: Optional[bool] = None, + highSalt: Optional[bool] = None, + finish: Optional[str] = None, + cmcMin: Optional[float] = None, + cmcMax: Optional[float] = None, + priceMin: Optional[float] = None, + priceMax: Optional[float] = None, + category: Optional[str] = None, +): + """Export collection in various formats with optional filters.""" + where_str, params = _build_collection_filters( + q=q, colors=colors, types=types, isLegendary=isLegendary, + isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt, + finish=finish, cmcMin=cmcMin, cmcMax=cmcMax, + priceMin=priceMin, priceMax=priceMax, category=category, + ) + + sql = f"SELECT * FROM collection_entries {where_str} ORDER BY name ASC" + conn = _get_db_conn() + rows = [_row_to_dict(r) for r in conn.execute(sql, params).fetchall()] + + fmt = format.upper() + output = io.StringIO() + + if fmt == "INTERNAL_CSV": + if rows: + fieldnames = list(rows[0].keys()) + else: + fieldnames = [ + "id", "name", "type_line", "subtypes", "is_legendary", "is_basic", + "color_identity", "cmc", "oracle_text", "keywords", "tcg_price", + "salt_score", "is_game_changer", "category", "scryfall_id", "tcgplayer_id", + "quantity", "finish", "condition", "language", "notes", "tags", + "set_code", "collector_number", "created_at", "updated_at", + ] + writer = csv.DictWriter(output, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + filename = "collection_export.csv" + media_type = "text/csv" + + elif fmt == "MOXFIELD_CSV": + fieldnames = ["Count", "Name", "Edition", "Condition", "Language", "Foil", "Collector Number"] + writer = csv.DictWriter(output, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + finish_val = row.get("finish", "NORMAL").upper() + foil_val = "foil" if finish_val == "FOIL" else ("etched" if finish_val == "ETCHED" else "") + writer.writerow({ + "Count": row.get("quantity", 1), + "Name": row.get("name", ""), + "Edition": row.get("set_code", "").upper(), + "Condition": row.get("condition", ""), + "Language": row.get("language", ""), + "Foil": foil_val, + "Collector Number": row.get("collector_number", ""), + }) + filename = "collection_moxfield.csv" + media_type = "text/csv" + + elif fmt == "TEXT": + lines = [] + for row in rows: + qty = row.get("quantity", 1) + name = row.get("name", "") + set_code = row.get("set_code", "").upper() + coll_num = row.get("collector_number", "") + if set_code and coll_num: + lines.append(f"{qty} {name} ({set_code}) {coll_num}") + elif set_code: + lines.append(f"{qty} {name} ({set_code})") + else: + lines.append(f"{qty} {name}") + output.write("\n".join(lines)) + filename = "collection_export.txt" + media_type = "text/plain" + + else: + raise HTTPException(400, f"Unknown format: {format}. Use INTERNAL_CSV, MOXFIELD_CSV, or TEXT.") + + content = output.getvalue() + + def iter_content(): + yield content.encode("utf-8") + + return StreamingResponse( + iter_content(), + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/api/collection/sets") +async def collection_sets(): + """Return distinct set codes + names in the collection (for filter autocomplete).""" + conn = _get_db_conn() + rows = conn.execute( + "SELECT DISTINCT UPPER(set_code) as code, set_name as name FROM collection_entries WHERE set_code != '' ORDER BY set_name ASC" + ).fetchall() + result = [] + for r in rows: + code = r["code"] if "code" in r.keys() else r[0] + name = r["name"] if "name" in r.keys() else r[1] + if code: + result.append({"code": code, "name": name or code}) + return JSONResponse(result) + + +@router.get("/api/collection/keywords") +async def collection_keywords(): + """Return distinct keywords found in the collection.""" + conn = _get_db_conn() + rows = conn.execute("SELECT keywords FROM collection_entries WHERE keywords != '' AND keywords != '[]'").fetchall() + kw_set = set() + for r in rows: + try: + kws = json.loads(r["keywords"]) if isinstance(r["keywords"], str) else r["keywords"] + if isinstance(kws, list): + for k in kws: + if k: + kw_set.add(k) + except (json.JSONDecodeError, TypeError): + pass + return JSONResponse(sorted(kw_set)) + @router.get("/api/collection") async def list_collection( @@ -62,92 +222,752 @@ async def list_collection( qtyMin: Optional[int] = None, qtyMax: Optional[int] = None, ): - fn = _get("list_collection") - return await fn( - page=page, pageSize=pageSize, q=q, sortField=sortField, sortDir=sortDir, - colors=colors, types=types, isLegendary=isLegendary, isBasic=isBasic, - isGameChanger=isGameChanger, highSalt=highSalt, finish=finish, - cmcMin=cmcMin, cmcMax=cmcMax, priceMin=priceMin, priceMax=priceMax, - category=category, deck_id=deck_id, rarity=rarity, setCode=setCode, - powerMin=powerMin, powerMax=powerMax, toughMin=toughMin, toughMax=toughMax, - keyword=keyword, edhrecMin=edhrecMin, edhrecMax=edhrecMax, - qtyMin=qtyMin, qtyMax=qtyMax, - ) - - -@router.get("/api/collection/export") -async def export_collection( - format: str = "INTERNAL_CSV", - q: Optional[str] = None, - colors: Optional[str] = None, - types: Optional[str] = None, - isLegendary: Optional[bool] = None, - isBasic: Optional[bool] = None, - isGameChanger: Optional[bool] = None, - highSalt: Optional[bool] = None, - finish: Optional[str] = None, - cmcMin: Optional[float] = None, - cmcMax: Optional[float] = None, - priceMin: Optional[float] = None, - priceMax: Optional[float] = None, - category: Optional[str] = None, -): - fn = _get("export_collection") - return await fn( - format=format, q=q, colors=colors, types=types, - isLegendary=isLegendary, isBasic=isBasic, isGameChanger=isGameChanger, - highSalt=highSalt, finish=finish, cmcMin=cmcMin, cmcMax=cmcMax, + """List collection entries with search, sort, and filter. + Optional deck_id: when provided, includes in_deck_quantity for each result. + """ + where_str, params = _build_collection_filters( + q=q, colors=colors, types=types, isLegendary=isLegendary, + isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt, + finish=finish, cmcMin=cmcMin, cmcMax=cmcMax, priceMin=priceMin, priceMax=priceMax, category=category, + rarity=rarity, setCode=setCode, powerMin=powerMin, powerMax=powerMax, + toughMin=toughMin, toughMax=toughMax, keyword=keyword, + edhrecMin=edhrecMin, edhrecMax=edhrecMax, qtyMin=qtyMin, qtyMax=qtyMax, ) + # Validate sort + sort_field = sortField if sortField in VALID_SORT_FIELDS else "name" + sort_dir = "DESC" if (sortDir or "").upper() == "DESC" else "ASC" -@router.get("/api/collection/sets") -async def collection_sets(): - fn = _get("collection_sets") - return await fn() + conn = _get_db_conn() + # Count total + count_sql = f"SELECT COUNT(*) FROM collection_entries {where_str}" + total = conn.execute(count_sql, params).fetchone()[0] -@router.get("/api/collection/keywords") -async def collection_keywords(): - fn = _get("collection_keywords") - return await fn() + # Paginate + offset = (max(1, page) - 1) * pageSize + data_sql = f""" + SELECT * FROM collection_entries {where_str} + ORDER BY {sort_field} {sort_dir} + LIMIT ? OFFSET ? + """ + rows = conn.execute(data_sql, params + [pageSize, offset]).fetchall() + items = [_add_image_url(_row_to_dict(r)) for r in rows] + # If deck_id provided, annotate each item with how many copies are already in that deck + if deck_id is not None: + # Build a map of scryfall_id -> quantity in deck + deck_card_rows = conn.execute( + "SELECT scryfall_id, SUM(quantity) as qty FROM deck_cards WHERE deck_id = ? GROUP BY scryfall_id", + (deck_id,) + ).fetchall() + in_deck_map = {row["scryfall_id"]: row["qty"] for row in deck_card_rows} + for item in items: + item["in_deck_quantity"] = in_deck_map.get(item.get("scryfall_id", ""), 0) -@router.post("/api/collection/import") -async def import_collection(body: dict): - fn = _get("import_collection") - return await fn(body) + return {"items": items, "page": page, "pageSize": pageSize, "total": total} @router.get("/api/collection/{cardId}") async def get_collection_card(cardId: int): - fn = _get("get_collection_card") - return await fn(cardId) + """Get a single collection entry by ID.""" + conn = _get_db_conn() + row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone() + if not row: + raise HTTPException(404, f"Card with id {cardId} not found") + return _add_image_url(_row_to_dict(row)) @router.patch("/api/collection/{cardId}") async def update_collection_card(cardId: int, body: dict): - fn = _get("update_collection_card") - return await fn(cardId, body) + """Update mutable fields of a collection entry.""" + conn = _get_db_conn() + row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone() + if not row: + raise HTTPException(404, f"Card with id {cardId} not found") + + updates = {} + if "category" in body: + cat = body["category"] + if isinstance(cat, list): + updates["category"] = json.dumps(cat) + else: + updates["category"] = cat + if "tags" in body: + updates["tags"] = str(body["tags"]) + if "notes" in body: + updates["notes"] = str(body["notes"]) + if "finish" in body: + finish_val = str(body["finish"]).upper() + if finish_val not in ("NORMAL", "FOIL", "ETCHED"): + raise HTTPException(400, "finish must be NORMAL, FOIL, or ETCHED") + updates["finish"] = finish_val + + if not updates: + return _add_image_url(_row_to_dict(row)) + + updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + set_clause = ", ".join(f"{k} = ?" for k in updates) + values = list(updates.values()) + [cardId] + conn.execute(f"UPDATE collection_entries SET {set_clause} WHERE id = ?", values) + conn.commit() + + updated_row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone() + return _add_image_url(_row_to_dict(updated_row)) -# --------------------------------------------------------------------------- -# Scryfall cache management -# --------------------------------------------------------------------------- +# ══════════════════════════════════════════════════════════════ +# Collection Import Helpers +# ══════════════════════════════════════════════════════════════ + +# ── Scryfall Response Cache ───────────────────────────────── +# SQLite-backed cache for Scryfall API responses. +# Keyed by (name, set_code, collector_number). Default TTL: 7 days. +# Scryfall data changes infrequently (set releases ~every 3 months). +# Eliminates redundant HTTP calls across imports and re-enrichments. + +SCRYFALL_CACHE_DB_PATH = Path(__file__).parent / "scryfall_cache.db" +SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600)) # 7 days + + +class _ScryfallCache: + """ + Thread-safe SQLite cache for raw Scryfall JSON responses. + + Schema: + cache_key TEXT PRIMARY KEY — "name|set|cn" normalised lowercase + json_blob TEXT — raw Scryfall API JSON response + fetched_at TEXT — ISO-8601 UTC timestamp + """ + + def __init__(self, db_path: Path = None, ttl_seconds: int = None): + self._db_path = db_path or SCRYFALL_CACHE_DB_PATH + self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS + self._lock = threading.Lock() + self._conn: Optional[sqlite3.Connection] = None + self._hits = 0 + self._misses = 0 + self._init_db() + + # ── internal ──────────────────────────────────────────── + + def _init_db(self): + self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False) + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute(""" + CREATE TABLE IF NOT EXISTS scryfall_cache ( + cache_key TEXT PRIMARY KEY, + json_blob TEXT NOT NULL, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + self._conn.commit() + + @staticmethod + def _make_key(name: str, set_code: str, collector_number: str) -> str: + return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}" + + # ── public API ────────────────────────────────────────── + + def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]: + """Return cached Scryfall JSON dict, or None if missing / expired.""" + key = self._make_key(name, set_code, collector_number) + with self._lock: + row = self._conn.execute( + "SELECT json_blob, fetched_at FROM scryfall_cache WHERE cache_key = ?", + (key,), + ).fetchone() + if not row: + self._misses += 1 + return None + # TTL check + try: + fetched = datetime.fromisoformat(row[1]) + age = (datetime.utcnow() - fetched).total_seconds() + if age > self._ttl: + self._misses += 1 + return None + except (ValueError, TypeError): + self._misses += 1 + return None + self._hits += 1 + try: + return json.loads(row[0]) + except json.JSONDecodeError: + self._misses += 1 + return None + + def put(self, name: str, set_code: str, collector_number: str, card_data: dict): + """Store a Scryfall response in the cache.""" + key = self._make_key(name, set_code, collector_number) + blob = json.dumps(card_data, separators=(",", ":")) + with self._lock: + self._conn.execute( + "INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob, fetched_at) " + "VALUES (?, ?, datetime('now'))", + (key, blob), + ) + self._conn.commit() + + def stats(self) -> dict: + """Return cache statistics.""" + with self._lock: + total = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0] + oldest_row = self._conn.execute( + "SELECT MIN(fetched_at) FROM scryfall_cache" + ).fetchone() + newest_row = self._conn.execute( + "SELECT MAX(fetched_at) FROM scryfall_cache" + ).fetchone() + db_size_bytes = os.path.getsize(str(self._db_path)) if self._db_path.exists() else 0 + return { + "total_entries": total, + "hits": self._hits, + "misses": self._misses, + "hit_rate": round(self._hits / max(self._hits + self._misses, 1) * 100, 1), + "ttl_seconds": self._ttl, + "ttl_days": round(self._ttl / 86400, 1), + "oldest_entry": oldest_row[0] if oldest_row else None, + "newest_entry": newest_row[0] if newest_row else None, + "db_size_kb": round(db_size_bytes / 1024, 1), + "db_path": str(self._db_path), + } + + def clear(self) -> int: + """Delete all cached entries. Returns count of deleted rows.""" + with self._lock: + count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0] + self._conn.execute("DELETE FROM scryfall_cache") + self._conn.commit() + self._conn.execute("VACUUM") # must run outside transaction + self._hits = 0 + self._misses = 0 + return count + + def evict_expired(self) -> int: + """Remove entries older than TTL. Returns count of evicted rows.""" + cutoff = datetime.utcnow().isoformat() + with self._lock: + self._conn.execute( + "DELETE FROM scryfall_cache WHERE " + "(julianday(?) - julianday(fetched_at)) * 86400 > ?", + (cutoff, self._ttl), + ) + deleted = self._conn.total_changes + self._conn.commit() + return deleted + + +_scryfall_cache = _ScryfallCache() + + +# Scryfall rate-limit semaphore (max 10 req/sec) +_scryfall_lock = threading.Lock() +_scryfall_last_call = 0.0 + + +def _scryfall_rate_limit(): + """Enforce at most 10 Scryfall requests per second via simple sleep.""" + global _scryfall_last_call + with _scryfall_lock: + now = time.monotonic() + elapsed = now - _scryfall_last_call + if elapsed < 0.1: + time.sleep(0.1 - elapsed) + _scryfall_last_call = time.monotonic() + + +def _parse_finish(raw: str) -> str: + """Normalize finish string to NORMAL|FOIL|ETCHED.""" + if not raw: + return "NORMAL" + r = raw.strip().lower() + if r in ("etched", "foil etched", "foil_etched", "etched foil"): + return "ETCHED" + if r in ("yes", "foil", "true", "1"): + return "FOIL" + return "NORMAL" + + +def _parse_text_line(line: str) -> dict: + """ + Parse a text import line into {name, quantity, set_code, collector_number}. + Supports formats: + - "3 Sol Ring (CMR) 123" + - "1x Sol Ring (CMR)" + - "Sol Ring [CMR:123]" + - "1 Sol Ring" + """ + line = line.strip() + if not line or line.startswith("#") or line.startswith("//"): + return {} + + result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""} + + # Strip quantity prefix + qty_match = re.match(r"^(\d+)x?\s+", line) + if qty_match: + result["quantity"] = int(qty_match.group(1)) + line = line[qty_match.end():] + + # Format: "Name [SET:CollNum]" + bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line) + if bracket_match: + result["set_code"] = bracket_match.group(1).lower() + result["collector_number"] = bracket_match.group(2) + line = line[:bracket_match.start()].strip() + result["name"] = line + return result + + # Format: "Name (SET) CollNum" + paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line) + if paren_coll_match: + result["set_code"] = paren_coll_match.group(1).lower() + result["collector_number"] = paren_coll_match.group(2) + line = line[:paren_coll_match.start()].strip() + result["name"] = line + return result + + # Format: "Name (SET)" + paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line) + if paren_match: + result["set_code"] = paren_match.group(1).lower() + line = line[:paren_match.start()].strip() + result["name"] = line + return result + + result["name"] = line.strip() + return result + + +def _auto_infer_mapping(headers: list) -> dict: + """ + Auto-infer column → field mapping from header names. + Returns dict of {header: field_key}. + Field keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags + """ + mapping = {} + header_lower = {h: h.lower().strip() for h in headers} + field_patterns = { + "name": ["name", "card name", "card_name", "cardname", "title"], + "quantity": ["quantity", "qty", "count", "amount", "number", "#"], + "set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"], + "collector_number": [ + "collector number", "collector_number", "collectornumber", + "col #", "col#", "number", "card number", + ], + "finish": ["finish", "foil", "printing", "treatment"], + "condition": ["condition", "cond", "grade"], + "language": ["language", "lang", "locale"], + "notes": ["notes", "note", "comments", "comment"], + "tags": ["tags", "tag", "labels", "label"], + } + for header, lower_h in header_lower.items(): + for field, patterns in field_patterns.items(): + if lower_h in patterns: + mapping[header] = field + break + return mapping + + +def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list: + """ + Parse CSV/text content into a list of RawImportRow dicts. + Returns list of dicts with keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags + """ + rows = [] + source_upper = (source or "").upper() + + if source_upper == "TEXT": + for line in content.splitlines(): + parsed = _parse_text_line(line) + if parsed.get("name"): + rows.append(parsed) + return rows + + # CSV-based sources + reader = csv.DictReader(io.StringIO(content)) + headers = reader.fieldnames or [] + + # Build effective mapping + if mapping: + col_map = {h: mapping.get(h, "") for h in headers} + else: + col_map = _auto_infer_mapping(headers) + + # Normalize col_map field values to lowercase so both "NAME" (UI) and "name" (auto-infer) work + col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()} + + for csv_row in reader: + row = { + "name": "", + "quantity": 1, + "set_code": "", + "collector_number": "", + "finish": "NORMAL", + "condition": "", + "language": "", + "notes": "", + "tags": "", + } + for header, field in col_map.items(): + val = csv_row.get(header, "").strip() + if not val or field == "ignore" or not field: + continue + if field == "name": + # Strip finish suffix like "(Foil Etched)" + val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip() + row["name"] = val + elif field == "quantity": + try: + row["quantity"] = int(float(val)) + except ValueError: + row["quantity"] = 1 + elif field in ("set_code", "set_code_secondary"): + row["set_code"] = val.lower() + elif field in ("collector_number", "collector_number_secondary"): + row["collector_number"] = val + elif field == "finish": + row["finish"] = _parse_finish(val) + elif field == "condition": + row["condition"] = val + elif field == "language": + row["language"] = val + elif field == "notes": + row["notes"] = val + elif field == "tags": + row["tags"] = val + + if row["name"]: + rows.append(row) + + return rows + + +def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict: + """ + Fetch raw card data from Scryfall API (with caching). + Returns the raw Scryfall JSON dict, or a dict with '_error' on failure. + Cache is checked first; live HTTP call only on miss. + """ + from urllib.parse import quote + + # ── Cache check ────────────────────────────────────── + cached = _scryfall_cache.get(name, set_code, collector_number) + if cached is not None: + return cached + + # ── Live fetch ─────────────────────────────────────── + card_data = None + last_error = "" + + # Try set + collector number first + if set_code and collector_number: + _scryfall_rate_limit() + try: + url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}" + req = Request(url, headers=_API_HEADERS) + with urlopen(req, timeout=10) as resp: + card_data = json.loads(resp.read().decode("utf-8")) + except Exception as e: + last_error = str(e) + card_data = None + + # Fall back to exact name lookup + if not card_data: + _scryfall_rate_limit() + try: + encoded_name = quote(name) + url = f"https://api.scryfall.com/cards/named?exact={encoded_name}" + req = Request(url, headers=_API_HEADERS) + with urlopen(req, timeout=10) as resp: + card_data = json.loads(resp.read().decode("utf-8")) + except Exception as e: + last_error = str(e) + return {"_error": f"Scryfall lookup failed for '{name}': {last_error}"} + + if not card_data or card_data.get("object") == "error": + err_detail = card_data.get("details", "unknown error") if card_data else last_error + return {"_error": f"Scryfall returned error for '{name}': {err_detail}"} + + # ── Cache the successful response ──────────────────── + _scryfall_cache.put(name, set_code, collector_number, card_data) + # Also cache under the Scryfall-resolved name (handles spelling normalisation) + resolved_name = card_data.get("name", "") + if resolved_name and resolved_name.lower() != name.strip().lower(): + _scryfall_cache.put(resolved_name, set_code, collector_number, card_data) + + return card_data + + +def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict: + """ + Fetch card data from Scryfall (cached) and return enriched fields dict. + Tries set+collectorNumber first, then exact name lookup. + Returns empty dict on failure. + """ + card_data = _fetch_scryfall_api(name, set_code, collector_number) + + if not card_data or "_error" in card_data: + return card_data or {} + + # Extract fields + type_line = card_data.get("type_line", "") + color_identity = card_data.get("color_identity", []) + keywords = card_data.get("keywords", []) + + # Parse subtypes (after em-dash or hyphen) + subtypes = [] + for sep in [" \u2014 ", " - "]: + if sep in type_line: + parts = type_line.split(sep, 1) + subtypes = [s.strip() for s in parts[1].split() if s.strip()] + break + + is_legendary = 1 if "Legendary" in type_line else 0 + is_basic = 1 if "Basic" in type_line else 0 + + # Get price + prices = card_data.get("prices", {}) + tcg_price = 0.0 + try: + price_val = prices.get("usd") or prices.get("usd_foil") or "0" + tcg_price = float(price_val) if price_val else 0.0 + except (ValueError, TypeError): + tcg_price = 0.0 + + # Handle double-faced cards: oracle_text may be on card_faces + oracle_text = card_data.get("oracle_text", "") + if not oracle_text and card_data.get("card_faces"): + face_texts = [f.get("oracle_text", "") for f in card_data["card_faces"]] + oracle_text = "\n//\n".join(t for t in face_texts if t) + + # Power/toughness (may be on card_faces for DFCs) + power = card_data.get("power", "") + toughness = card_data.get("toughness", "") + if not power and card_data.get("card_faces"): + power = card_data["card_faces"][0].get("power", "") + toughness = card_data["card_faces"][0].get("toughness", "") + + # Mana cost (may be on card_faces for DFCs) + mana_cost = card_data.get("mana_cost", "") + if not mana_cost and card_data.get("card_faces"): + mana_cost = card_data["card_faces"][0].get("mana_cost", "") + + # Auto-detect functional categories + auto_roles = _detect_card_roles(oracle_text, type_line, keywords) + + return { + "name": card_data.get("name", name), + "type_line": type_line, + "subtypes": json.dumps(subtypes), + "is_legendary": is_legendary, + "is_basic": is_basic, + "color_identity": json.dumps(color_identity), + "cmc": card_data.get("cmc", 0.0), + "mana_cost": mana_cost, + "oracle_text": oracle_text, + "keywords": json.dumps(keywords), + "power": power or "", + "toughness": toughness or "", + "rarity": card_data.get("rarity", ""), + "set_name": card_data.get("set_name", ""), + "edhrec_rank": card_data.get("edhrec_rank", 0) or 0, + "tcg_price": tcg_price, + "salt_score": 0.0, + "is_game_changer": 0, + "category": json.dumps(auto_roles), + "scryfall_id": card_data.get("id", ""), + "tcgplayer_id": str(card_data.get("tcgplayer_id", "")), + } + + +# ══════════════════════════════════════════════════════════════ +# Collection Import Endpoint +# ══════════════════════════════════════════════════════════════ + +@router.post("/api/collection/import") +async def import_collection(body: dict): + """ + Import cards into the collection from CSV, Moxfield, Archidekt, or plain text. + + Body: + { + "source": "CSV" | "MOXFIELD" | "ARCHIDEKT" | "TEXT", + "mode": "MERGE" | "REPLACE", + "content": "...csv or text content...", + "mapping": { ... optional column mapping ... } + } + """ + source = str(body.get("source", "CSV")).upper() + mode = str(body.get("mode", "MERGE")).upper() + content = str(body.get("content", "")) + mapping = body.get("mapping", {}) + + if not content.strip(): + raise HTTPException(400, "content is required") + + imported_count = 0 + updated_count = 0 + failed_count = 0 + errors = [] + + conn = _get_db_conn() + + # REPLACE mode: clear existing entries + if mode == "REPLACE": + conn.execute("DELETE FROM collection_entries") + conn.commit() + + # Parse the content into raw rows + try: + raw_rows = _parse_csv_content(content, source, mapping if mapping else None) + except Exception as e: + raise HTTPException(400, f"Failed to parse content: {str(e)}") + + for raw in raw_rows: + name = raw.get("name", "").strip() + if not name: + continue + + quantity = int(raw.get("quantity", 1)) + set_code = raw.get("set_code", "").lower() + collector_number = raw.get("collector_number", "") + finish = _parse_finish(raw.get("finish", "")) + condition = raw.get("condition", "") + language = raw.get("language", "") + notes = raw.get("notes", "") + tags = raw.get("tags", "") + + # Scryfall enrichment + try: + enriched = _enrich_from_scryfall(name, set_code, collector_number) + except Exception as e: + errors.append(f"Scryfall error for '{name}': {str(e)}") + failed_count += 1 + continue + + if not enriched or "_error" in enriched: + err_msg = enriched.get("_error", f"Card not found on Scryfall: '{name}'") if enriched else f"Card not found on Scryfall: '{name}'" + errors.append(err_msg) + failed_count += 1 + continue + + # Use Scryfall-resolved name if available + resolved_name = enriched.get("name", name) + + # Check for existing entry (identity key) + existing = conn.execute( + """SELECT id, quantity FROM collection_entries + WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""", + (resolved_name, set_code, collector_number, finish), + ).fetchone() + + if existing and mode == "MERGE": + # Update quantity + new_qty = existing["quantity"] + quantity + conn.execute( + "UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?", + (new_qty, existing["id"]), + ) + conn.commit() + updated_count += 1 + else: + # Insert new row + conn.execute( + """INSERT INTO collection_entries + (name, type_line, subtypes, is_legendary, is_basic, color_identity, + cmc, mana_cost, oracle_text, keywords, + power, toughness, rarity, set_name, edhrec_rank, + tcg_price, salt_score, is_game_changer, + category, scryfall_id, tcgplayer_id, + quantity, finish, condition, language, notes, tags, + set_code, collector_number) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + resolved_name, + enriched.get("type_line", ""), + enriched.get("subtypes", "[]"), + enriched.get("is_legendary", 0), + enriched.get("is_basic", 0), + enriched.get("color_identity", "[]"), + enriched.get("cmc", 0.0), + enriched.get("mana_cost", ""), + enriched.get("oracle_text", ""), + enriched.get("keywords", "[]"), + enriched.get("power", ""), + enriched.get("toughness", ""), + enriched.get("rarity", ""), + enriched.get("set_name", ""), + enriched.get("edhrec_rank", 0), + enriched.get("tcg_price", 0.0), + enriched.get("salt_score", 0.0), + enriched.get("is_game_changer", 0), + enriched.get("category", "[]"), + enriched.get("scryfall_id", ""), + enriched.get("tcgplayer_id", ""), + quantity, + finish, + condition, + language, + notes, + tags, + set_code, + collector_number, + ), + ) + conn.commit() + imported_count += 1 + + return { + "importedCount": imported_count, + "updatedCount": updated_count, + "failedCount": failed_count, + "errors": errors, + } + + +# ══════════════════════════════════════════════════════════════ +# Scryfall Cache Management Endpoints +# ══════════════════════════════════════════════════════════════ + @router.get("/api/cache/scryfall") async def scryfall_cache_stats(): - fn = _get("scryfall_cache_stats") - return await fn() + """Return Scryfall response cache statistics.""" + return _scryfall_cache.stats() @router.delete("/api/cache/scryfall") async def scryfall_cache_clear(): - fn = _get("scryfall_cache_clear") - return await fn() + """Clear all cached Scryfall responses.""" + deleted = _scryfall_cache.clear() + return {"cleared": deleted, "message": f"Deleted {deleted} cached entries"} @router.post("/api/cache/scryfall/evict") async def scryfall_cache_evict_expired(): - fn = _get("scryfall_cache_evict_expired") - return await fn() + """Remove only expired entries (older than TTL) from the cache.""" + evicted = _scryfall_cache.evict_expired() + return {"evicted": evicted, "message": f"Evicted {evicted} expired entries"} + + +# ══════════════════════════════════════════════════════════════ +# Deck Builder Helpers +# ══════════════════════════════════════════════════════════════ + +# Type classification priority order +_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"] + +# Target ranges: {type: [min, max]} +_TYPE_TARGETS = { + "Land": [36, 38], + "Instant": [9, 11], + "Sorcery": [7, 9], + "Artifact": [9, 11], + "Creature": [20, 30], + "Enchantment": [5, 10], + "Planeswalker": [0, 5], +} + diff --git a/routes/deckbuilder.py b/routes/deckbuilder.py index 04a91db..aded3d6 100644 --- a/routes/deckbuilder.py +++ b/routes/deckbuilder.py @@ -1,7 +1,7 @@ """ routes/deckbuilder.py ===================== -Deck Builder endpoints: +Deck builder endpoints: POST /api/decks GET /api/decks GET /api/decks/{deck_id} @@ -18,138 +18,1084 @@ Deck Builder endpoints: POST /api/decks/{deck_id}/bulk-add POST /api/decks/{deck_id}/bulk-add-recommended POST /api/decks/{deck_id}/export-to-sim + POST /api/decks/{deck_id}/import + POST /api/decks/import-new """ - from __future__ import annotations +import json +import os +import re +import time +from datetime import datetime +from pathlib import Path from typing import Optional -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException, Request as FastAPIRequest +from fastapi.responses import JSONResponse -from routes.shared import get as _get +from routes.shared import ( + CFG, + _get_db_conn, + _row_to_dict, + _add_image_url, + _get_deck_or_404, + _compute_deck_analysis, + _check_ratio_limit, + _classify_card_type, + _detect_card_roles, + _enrich_from_scryfall, + _fetch_scryfall_api, + _scryfall_rate_limit, + _to_edhrec_slug, + _edhrec_cache_get, + _edhrec_cache_set, + _fetch_edhrec_average, + _save_profile_to_dck, + _parse_text_decklist, + _API_HEADERS, + _TYPE_TARGETS, + CreateDeckRequest, + UpdateDeckRequest, + AddDeckCardRequest, + PatchDeckCardRequest, + BulkAddRequest, + BulkAddRecommendedRequest, + log_collect, + log_deckgen, +) -router = APIRouter(prefix="/api/decks", tags=["deckbuilder"]) +router = APIRouter(tags=["deckbuilder"]) -# --------------------------------------------------------------------------- -# Deck CRUD -# --------------------------------------------------------------------------- - -@router.post("") -async def create_deck(body: dict): - fn = _get("create_deck") - return await fn(body) +@router.post("/api/decks") +async def create_deck(req: CreateDeckRequest): + """Create a new deck.""" + conn = _get_db_conn() + color_identity_json = json.dumps(req.color_identity or []) + cur = conn.execute( + """ + INSERT INTO decks (name, commander_scryfall_id, commander_name, color_identity, strategy_tag) + VALUES (?, ?, ?, ?, ?) + """, + ( + req.name, + req.commander_scryfall_id or "", + req.commander_name or "", + color_identity_json, + req.strategy_tag or "", + ) + ) + conn.commit() + deck_id = cur.lastrowid + return _get_deck_or_404(deck_id) -@router.get("") +@router.get("/api/decks") async def list_decks_db(): - fn = _get("list_decks_db") - return await fn() + """List all decks with card counts.""" + conn = _get_db_conn() + rows = conn.execute( + """ + SELECT d.*, + COALESCE(SUM(dc.quantity), 0) AS total_cards, + COUNT(dc.id) AS card_slots + FROM decks d + LEFT JOIN deck_cards dc ON dc.deck_id = d.id + GROUP BY d.id + ORDER BY d.updated_at DESC + """ + ).fetchall() + decks = [] + for row in rows: + d = dict(row) + try: + d["color_identity"] = json.loads(d.get("color_identity", "[]")) + except Exception: + d["color_identity"] = [] + decks.append(d) + return {"decks": decks} -@router.get("/{deck_id}") +@router.get("/api/decks/{deck_id}") async def get_deck(deck_id: int): - fn = _get("get_deck") - return await fn(deck_id) + """Get full deck info with composition summary.""" + deck = _get_deck_or_404(deck_id) + conn = _get_db_conn() + card_rows = conn.execute( + "SELECT * FROM deck_cards WHERE deck_id = ? ORDER BY is_commander DESC, card_name ASC", + (deck_id,) + ).fetchall() + deck["cards"] = [dict(r) for r in card_rows] + deck["total_cards"] = sum(r["quantity"] for r in card_rows) + deck["card_slots"] = len(card_rows) + # Composition summary by type + analysis = _compute_deck_analysis(deck_id) + deck["composition"] = analysis["counts_by_type"] + return deck -@router.put("/{deck_id}") -async def update_deck(deck_id: int, body: dict): - fn = _get("update_deck") - return await fn(deck_id, body) +@router.put("/api/decks/{deck_id}") +async def update_deck(deck_id: int, req: UpdateDeckRequest): + """Update deck metadata.""" + _get_deck_or_404(deck_id) # ensure exists + conn = _get_db_conn() + + updates = {} + if req.name is not None: + updates["name"] = req.name + if req.commander_scryfall_id is not None: + updates["commander_scryfall_id"] = req.commander_scryfall_id + if req.commander_name is not None: + updates["commander_name"] = req.commander_name + if req.color_identity is not None: + updates["color_identity"] = json.dumps(req.color_identity) + if req.strategy_tag is not None: + updates["strategy_tag"] = req.strategy_tag + + if not updates: + return _get_deck_or_404(deck_id) + + updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + set_clause = ", ".join(f"{k} = ?" for k in updates) + conn.execute(f"UPDATE decks SET {set_clause} WHERE id = ?", list(updates.values()) + [deck_id]) + conn.commit() + return _get_deck_or_404(deck_id) -@router.delete("/{deck_id}") +@router.delete("/api/decks/{deck_id}") async def delete_deck(deck_id: int): - fn = _get("delete_deck") - return await fn(deck_id) + """Delete a deck and all its cards (cascade).""" + _get_deck_or_404(deck_id) # ensure exists + conn = _get_db_conn() + conn.execute("DELETE FROM deck_cards WHERE deck_id = ?", (deck_id,)) + conn.execute("DELETE FROM decks WHERE id = ?", (deck_id,)) + conn.commit() + return {"deleted": True, "deck_id": deck_id} -@router.delete("") +@router.delete("/api/decks") async def delete_all_decks(): - fn = _get("delete_all_decks") - return await fn() + """Delete ALL decks and their cards.""" + conn = _get_db_conn() + conn.execute("DELETE FROM deck_cards") + count = conn.execute("SELECT COUNT(*) FROM decks").fetchone()[0] + conn.execute("DELETE FROM decks") + conn.commit() + return {"deleted": True, "count": count} -# --------------------------------------------------------------------------- -# Deck card manipulation -# --------------------------------------------------------------------------- +# ── 2.2 Deck Card Manipulation ─────────────────────────────── -@router.get("/{deck_id}/cards") +@router.get("/api/decks/{deck_id}/cards") async def get_deck_cards(deck_id: int): - fn = _get("get_deck_cards") - return await fn(deck_id) + """Get all cards in a deck with joined collection data.""" + _get_deck_or_404(deck_id) + conn = _get_db_conn() + rows = conn.execute( + """ + SELECT + dc.id, dc.deck_id, dc.scryfall_id, dc.card_name, dc.quantity, + dc.is_commander, dc.role_tag, + ce.type_line, ce.cmc, ce.mana_cost, ce.color_identity, ce.oracle_text, ce.keywords, + ce.tcg_price, ce.quantity AS owned_qty, ce.is_legendary, + ce.salt_score, ce.is_game_changer + FROM deck_cards dc + LEFT JOIN ( + SELECT scryfall_id, type_line, cmc, mana_cost, color_identity, + oracle_text, keywords, tcg_price, quantity, is_legendary, + salt_score, is_game_changer + 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 + """, + (deck_id,) + ).fetchall() + cards = [] + for row in rows: + d = dict(row) + # Parse JSON fields + for f in ("color_identity", "keywords"): + if isinstance(d.get(f), str): + try: + d[f] = json.loads(d[f]) + except Exception: + d[f] = [] + d["image_url"] = ( + f"https://api.scryfall.com/cards/{d['scryfall_id']}?format=image&version=normal" + if d.get("scryfall_id") else None + ) + cards.append(d) + return {"cards": cards, "total": len(cards)} -@router.post("/{deck_id}/cards") -async def add_deck_card(deck_id: int, body: dict): - fn = _get("add_deck_card") - return await fn(deck_id, body) +@router.post("/api/decks/{deck_id}/cards") +async def add_deck_card(deck_id: int, req: AddDeckCardRequest): + """Add or update a card in the deck.""" + _get_deck_or_404(deck_id) + conn = _get_db_conn() + + # Look up card_name from collection_entries by scryfall_id, fall back to request + ce_row = conn.execute( + "SELECT name FROM collection_entries WHERE scryfall_id = ? LIMIT 1", + (req.scryfall_id,) + ).fetchone() + card_name = ce_row["name"] if ce_row else (req.card_name or "") + + # Check if this scryfall_id is already in the deck + existing = conn.execute( + "SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?", + (deck_id, req.scryfall_id) + ).fetchone() + + if existing: + new_qty = existing["quantity"] + (req.quantity or 1) + conn.execute( + "UPDATE deck_cards SET quantity = ?, role_tag = ?, is_commander = ? WHERE id = ?", + (new_qty, req.role_tag or "", req.is_commander or 0, existing["id"]) + ) + conn.commit() + row = conn.execute("SELECT * FROM deck_cards WHERE id = ?", (existing["id"],)).fetchone() + else: + cur = conn.execute( + """ + INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + deck_id, req.scryfall_id, card_name, + req.quantity or 1, + req.is_commander or 0, + req.role_tag or "" + ) + ) + conn.commit() + row = conn.execute("SELECT * FROM deck_cards WHERE id = ?", (cur.lastrowid,)).fetchone() + + # Update deck updated_at + conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,)) + conn.commit() + return dict(row) -@router.delete("/{deck_id}/cards/{card_id}") +@router.delete("/api/decks/{deck_id}/cards/{card_id}") async def remove_deck_card(deck_id: int, card_id: int): - fn = _get("remove_deck_card") - return await fn(deck_id, card_id) + """Remove a card from a deck.""" + _get_deck_or_404(deck_id) + conn = _get_db_conn() + row = conn.execute( + "SELECT id FROM deck_cards WHERE id = ? AND deck_id = ?", (card_id, deck_id) + ).fetchone() + if not row: + raise HTTPException(404, f"Card slot {card_id} not found in deck {deck_id}") + conn.execute("DELETE FROM deck_cards WHERE id = ?", (card_id,)) + conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,)) + conn.commit() + return {"deleted": True, "card_id": card_id, "deck_id": deck_id} -@router.patch("/{deck_id}/cards/{card_id}") -async def patch_deck_card(deck_id: int, card_id: int, body: dict): - fn = _get("patch_deck_card") - return await fn(deck_id, card_id, body) +@router.patch("/api/decks/{deck_id}/cards/{card_id}") +async def patch_deck_card(deck_id: int, card_id: int, req: PatchDeckCardRequest): + """Update quantity or role_tag for a card in a deck.""" + _get_deck_or_404(deck_id) + conn = _get_db_conn() + row = conn.execute( + "SELECT * FROM deck_cards WHERE id = ? AND deck_id = ?", (card_id, deck_id) + ).fetchone() + if not row: + raise HTTPException(404, f"Card slot {card_id} not found in deck {deck_id}") + + updates = {} + if req.quantity is not None: + if req.quantity < 1: + raise HTTPException(400, "quantity must be >= 1") + updates["quantity"] = req.quantity + if req.role_tag is not None: + updates["role_tag"] = req.role_tag + + if updates: + set_clause = ", ".join(f"{k} = ?" for k in updates) + conn.execute( + f"UPDATE deck_cards SET {set_clause} WHERE id = ?", + list(updates.values()) + [card_id] + ) + conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,)) + conn.commit() + + updated = conn.execute("SELECT * FROM deck_cards WHERE id = ?", (card_id,)).fetchone() + return dict(updated) -# --------------------------------------------------------------------------- -# Analysis + recommendations -# --------------------------------------------------------------------------- +# ── 2.3 Deck Analysis ──────────────────────────────────────────── -@router.get("/{deck_id}/analysis") +@router.get("/api/decks/{deck_id}/analysis") async def deck_analysis(deck_id: int): - fn = _get("deck_analysis") - return await fn(deck_id) + """Analyze deck composition — type counts, mana curve, color pips, roles, targets, deltas.""" + _get_deck_or_404(deck_id) + return _compute_deck_analysis(deck_id) -@router.get("/{deck_id}/recommended-from-collection") -async def recommend_from_collection( - deck_id: int, - max_results: int = 20, - roles: Optional[str] = None, -): - fn = _get("recommend_from_collection") - return await fn(deck_id, max_results=max_results, roles=roles) +# ── 2.5 Recommendations from Collection ─────────────────────── + +@router.get("/api/decks/{deck_id}/recommended-from-collection") +async def recommend_from_collection(deck_id: int, max_results: int = 20, roles: Optional[str] = None): + """ + Recommend cards from the user's collection for a deck. + Finds shortfalls, then scores owned cards by how well they fit. + """ + deck = _get_deck_or_404(deck_id) + deck_color_identity = deck.get("color_identity", []) + conn = _get_db_conn() + + # Run analysis to find shortfalls + analysis = _compute_deck_analysis(deck_id) + deltas = analysis["deltas"] + counts_by_type = analysis["counts_by_type"] + + # Find types that are below their target midpoint (shortfall) + shortfall_types = set() + for t, delta in deltas.items(): + lo, hi = _TYPE_TARGETS.get(t, [0, 0]) + if counts_by_type.get(t, 0) < lo: + shortfall_types.add(t) + + # Get scryfall_ids already in deck to exclude + in_deck_ids = set( + r["scryfall_id"] for r in + conn.execute("SELECT scryfall_id FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall() + ) + + # Parse role filter + role_filter = [r.strip() for r in roles.split(",") if r.strip()] if roles else [] + + # Query collection for candidate cards + coll_rows = conn.execute( + "SELECT * FROM collection_entries WHERE quantity > 0" + ).fetchall() + + scored = [] + for row in coll_rows: + card = _row_to_dict(row) + card_id = card.get("scryfall_id", "") + + # Skip cards already in deck + if card_id in in_deck_ids: + continue + + # Color identity check: all card colors must be in deck colors + card_ci = card.get("color_identity", []) + if isinstance(card_ci, str): + try: + card_ci = json.loads(card_ci) + except Exception: + card_ci = [] + if deck_color_identity and card_ci: + if not all(c in deck_color_identity for c in card_ci): + continue + + type_line = card.get("type_line", "") + oracle_text = card.get("oracle_text", "") + keywords = card.get("keywords", []) + card_type = _classify_card_type(type_line) + card_roles = _detect_card_roles(oracle_text, type_line, keywords) + + # Role filter + if role_filter and not any(r in card_roles for r in role_filter): + continue + + # Scoring + score = 0 + # +3 if card type is in shortfall + if card_type in shortfall_types: + score += 3 + # +2 per matching role + for r in card_roles: + if r in ("Ramp", "Draw", "Removal", "BoardWipe"): + score += 2 + else: + score += 1 + # +1 for lower CMC (curve fit) + cmc = float(card.get("cmc", 0)) + if cmc <= 3: + score += 1 + + scored.append({ + "id": card.get("id"), + "scryfall_id": card_id, + "name": card.get("name"), + "type_line": type_line, + "card_type": card_type, + "cmc": cmc, + "color_identity": card_ci, + "owned_qty": card.get("quantity", 0), + "roles": card_roles, + "score": score, + "image_url": f"https://api.scryfall.com/cards/{card_id}?format=image&version=normal" if card_id else None, + }) + + scored.sort(key=lambda x: x["score"], reverse=True) + + # Group by type + grouped = {} + for card in scored[:max_results]: + ct = card["card_type"] + grouped.setdefault(ct, []).append(card) + + return { + "shortfall_types": list(shortfall_types), + "role_filter": role_filter, + "grouped": grouped, + "total": len(scored[:max_results]), + } -@router.get("/{deck_id}/edh-recs") -async def deck_edh_recs( - deck_id: int, - only_owned: bool = False, - max_results: int = 30, -): - fn = _get("deck_edh_recs") - return await fn(deck_id, only_owned=only_owned, max_results=max_results) +# ── 2.6 EDHREC-style Recommendations ──────────────────────── + +@router.get("/api/decks/{deck_id}/edh-recs") +async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int = 30): + """ + Fetch EDHREC average deck recommendations for this deck's commander. + Cross-references with collection for owned status. + """ + deck = _get_deck_or_404(deck_id) + commander_name = deck.get("commander_name", "") + if not commander_name: + raise HTTPException(400, "Deck has no commander_name set. Update the deck first.") + + # Cache check + cache_key = f"edhrec:avg:{_to_edhrec_slug(commander_name)}" + cached_profile = _edhrec_cache_get(cache_key) + if cached_profile is None: + try: + cached_profile = _fetch_edhrec_average(commander_name) + _edhrec_cache_set(cache_key, cached_profile) + except Exception as e: + raise HTTPException(502, f"Failed to fetch EDHREC data for '{commander_name}': {str(e)}") + + edhrec_profile = cached_profile + mainboard = edhrec_profile.get("mainboard", {}) + + conn = _get_db_conn() + + # Get cards already in deck + in_deck_names = set( + r["card_name"].lower() for r in + conn.execute("SELECT card_name FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall() + ) + + # Build collection lookup: name (lower) -> {owned, qty, scryfall_id} + coll_rows = conn.execute( + "SELECT name, quantity, scryfall_id FROM collection_entries" + ).fetchall() + coll_map = {} + for r in coll_rows: + key = r["name"].lower() + existing = coll_map.get(key) + if not existing or r["quantity"] > existing["qty"]: + coll_map[key] = {"owned": True, "qty": r["quantity"], "scryfall_id": r["scryfall_id"]} + + # Also look up card details from collection_entries for type/role + coll_details = {} + for r in conn.execute("SELECT name, type_line, oracle_text, keywords, scryfall_id FROM collection_entries").fetchall(): + key = r["name"].lower() + if key not in coll_details: + coll_details[key] = dict(r) + + results = [] + for card_name in mainboard: + name_lower = card_name.lower() + # Skip cards already in deck + if name_lower in in_deck_names: + continue + + owned_info = coll_map.get(name_lower, {"owned": False, "qty": 0, "scryfall_id": ""}) + + if only_owned and not owned_info["owned"]: + continue + + # Get type/role from collection if available + details = coll_details.get(name_lower, {}) + type_line = details.get("type_line", "") + oracle_text = details.get("oracle_text", "") + keywords = details.get("keywords", "[]") + card_roles = _detect_card_roles(oracle_text, type_line, keywords) + scryfall_id = owned_info.get("scryfall_id") or details.get("scryfall_id", "") + + results.append({ + "name": card_name, + "type_line": type_line, + "role": card_roles[0] if card_roles else "Other", + "roles": card_roles, + "inclusion_pct": None, # EDHREC average doesn't expose % directly in this path + "synergy_score": None, + "owned": owned_info["owned"], + "owned_qty": owned_info["qty"], + "scryfall_id": scryfall_id, + "image_url": f"https://api.scryfall.com/cards/{scryfall_id}?format=image&version=normal" if scryfall_id else None, + }) + + return { + "commander": commander_name, + "source": "EDHREC Average", + "total": len(results[:max_results]), + "recommendations": results[:max_results], + } -# --------------------------------------------------------------------------- -# Bulk add -# --------------------------------------------------------------------------- +# ── 2.7 Bulk Add Operations ──────────────────────────────── -@router.post("/{deck_id}/bulk-add") -async def bulk_add_cards(deck_id: int, body: dict): - fn = _get("bulk_add_cards") - return await fn(deck_id, body) +def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool: + """ + Return True if adding count_to_add cards of card_type stays within target max. + """ + analysis = _compute_deck_analysis(deck_id) + current = analysis["counts_by_type"].get(card_type, 0) + target_max = _TYPE_TARGETS.get(card_type, [0, 9999])[1] + return (current + count_to_add) <= target_max -@router.post("/{deck_id}/bulk-add-recommended") -async def bulk_add_recommended(deck_id: int, body: dict): - fn = _get("bulk_add_recommended") - return await fn(deck_id, body) +@router.post("/api/decks/{deck_id}/bulk-add") +async def bulk_add_cards(deck_id: int, req: BulkAddRequest): + """ + Bulk-add cards to a deck by scryfall_id. + If respect_ratios, skips cards of a type when the target max is already reached. + """ + _get_deck_or_404(deck_id) + conn = _get_db_conn() + + added = 0 + skipped = 0 + details = [] + + for card_entry in req.cards: + scryfall_id = str(card_entry.get("scryfall_id", "")).strip() + quantity = int(card_entry.get("quantity", 1)) + if not scryfall_id: + skipped += 1 + details.append({"scryfall_id": scryfall_id, "status": "skipped", "reason": "missing scryfall_id"}) + continue + + # Look up card name and type from collection + ce_row = conn.execute( + "SELECT name, type_line, oracle_text, keywords FROM collection_entries WHERE scryfall_id = ? LIMIT 1", + (scryfall_id,) + ).fetchone() + card_name = ce_row["name"] if ce_row else "" + type_line = ce_row["type_line"] if ce_row else "" + card_type = _classify_card_type(type_line) + + if req.respect_ratios and not _check_ratio_limit(deck_id, card_type, quantity): + skipped += 1 + details.append({ + "scryfall_id": scryfall_id, + "name": card_name, + "card_type": card_type, + "status": "skipped", + "reason": f"Type '{card_type}' at or above target max", + }) + continue + + # Upsert + existing = conn.execute( + "SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?", + (deck_id, scryfall_id) + ).fetchone() + + if existing: + conn.execute( + "UPDATE deck_cards SET quantity = ? WHERE id = ?", + (existing["quantity"] + quantity, existing["id"]) + ) + else: + conn.execute( + "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, ?)", + (deck_id, scryfall_id, card_name, quantity) + ) + conn.commit() + added += 1 + details.append({"scryfall_id": scryfall_id, "name": card_name, "card_type": card_type, "status": "added", "quantity": quantity}) + + conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,)) + conn.commit() + + analysis = _compute_deck_analysis(deck_id) + return {"added": added, "skipped": skipped, "details": details, "analysis": analysis} -# --------------------------------------------------------------------------- -# Simulation export -# --------------------------------------------------------------------------- +@router.post("/api/decks/{deck_id}/bulk-add-recommended") +async def bulk_add_recommended(deck_id: int, req: BulkAddRecommendedRequest): + """ + Fetch recommendations from 'collection' or 'edhrec', filter, and bulk-add to deck. + """ + _get_deck_or_404(deck_id) + conn = _get_db_conn() -@router.post("/{deck_id}/export-to-sim") + source = (req.source or "collection").lower() + type_filter = [t.strip() for t in req.types] if req.types else [] + role_filter = [r.strip() for r in req.roles] if req.roles else [] + + candidates = [] + + if source == "collection": + # Reuse the recommend_from_collection logic + deck = _get_deck_or_404(deck_id) + deck_color_identity = deck.get("color_identity", []) + analysis = _compute_deck_analysis(deck_id) + counts_by_type = analysis["counts_by_type"] + shortfall_types = {t for t, (lo, _) in _TYPE_TARGETS.items() if counts_by_type.get(t, 0) < lo} + + in_deck_ids = set( + r["scryfall_id"] for r in + conn.execute("SELECT scryfall_id FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall() + ) + coll_rows = conn.execute("SELECT * FROM collection_entries WHERE quantity > 0").fetchall() + for row in coll_rows: + card = _row_to_dict(row) + cid = card.get("scryfall_id", "") + if cid in in_deck_ids: + continue + card_ci = card.get("color_identity", []) + if isinstance(card_ci, str): + try: + card_ci = json.loads(card_ci) + except Exception: + card_ci = [] + if deck_color_identity and card_ci: + if not all(c in deck_color_identity for c in card_ci): + continue + tl = card.get("type_line", "") + ct = _classify_card_type(tl) + card_roles = _detect_card_roles(card.get("oracle_text", ""), tl, card.get("keywords", [])) + if type_filter and ct not in type_filter: + continue + if role_filter and not any(r in card_roles for r in role_filter): + continue + candidates.append({"scryfall_id": cid, "card_type": ct, "quantity": 1}) + + elif source == "edhrec": + deck = _get_deck_or_404(deck_id) + commander_name = deck.get("commander_name", "") + if not commander_name: + raise HTTPException(400, "Deck has no commander_name set") + try: + edhrec_profile = _fetch_edhrec_average(commander_name) + except Exception as e: + raise HTTPException(502, f"EDHREC fetch failed: {e}") + mainboard = edhrec_profile.get("mainboard", {}) + in_deck_names = set( + r["card_name"].lower() for r in + conn.execute("SELECT card_name FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall() + ) + coll_rows = conn.execute("SELECT name, scryfall_id, type_line, oracle_text, keywords, quantity FROM collection_entries").fetchall() + coll_map = {r["name"].lower(): dict(r) for r in coll_rows} + + for card_name in mainboard: + name_lower = card_name.lower() + if name_lower in in_deck_names: + continue + coll_info = coll_map.get(name_lower) + if req.only_owned and not coll_info: + continue + if not coll_info: + continue # can't add without scryfall_id + scryfall_id = coll_info.get("scryfall_id", "") + if not scryfall_id: + continue + tl = coll_info.get("type_line", "") + ct = _classify_card_type(tl) + card_roles = _detect_card_roles(coll_info.get("oracle_text", ""), tl, coll_info.get("keywords", [])) + if type_filter and ct not in type_filter: + continue + if role_filter and not any(r in card_roles for r in role_filter): + continue + candidates.append({"scryfall_id": scryfall_id, "card_type": ct, "quantity": 1}) + else: + raise HTTPException(400, f"Unknown source '{source}'. Use 'collection' or 'edhrec'.") + + # Now bulk-add candidates + added = 0 + skipped = 0 + details = [] + + for c in candidates: + scryfall_id = c["scryfall_id"] + card_type = c["card_type"] + quantity = c["quantity"] + + if req.respect_ratios and not _check_ratio_limit(deck_id, card_type, quantity): + skipped += 1 + details.append({"scryfall_id": scryfall_id, "card_type": card_type, "status": "skipped", "reason": "ratio limit"}) + continue + + ce_row = conn.execute( + "SELECT name FROM collection_entries WHERE scryfall_id = ? LIMIT 1", (scryfall_id,) + ).fetchone() + card_name = ce_row["name"] if ce_row else "" + + existing = conn.execute( + "SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?", + (deck_id, scryfall_id) + ).fetchone() + if existing: + conn.execute( + "UPDATE deck_cards SET quantity = ? WHERE id = ?", + (existing["quantity"] + quantity, existing["id"]) + ) + else: + conn.execute( + "INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, ?)", + (deck_id, scryfall_id, card_name, quantity) + ) + conn.commit() + added += 1 + details.append({"scryfall_id": scryfall_id, "name": card_name, "card_type": card_type, "status": "added"}) + + conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,)) + conn.commit() + + analysis = _compute_deck_analysis(deck_id) + return {"added": added, "skipped": skipped, "details": details, "analysis": analysis} + + +# ── 2.9 Simulation Export ─────────────────────────────────── + +@router.post("/api/decks/{deck_id}/export-to-sim") async def export_deck_to_sim(deck_id: int): - fn = _get("export_deck_to_sim") - return await fn(deck_id) + """ + Export a deck to Forge .dck format and save to the Forge decks directory. + Returns the deck name for use in simulations. + """ + deck = _get_deck_or_404(deck_id) + conn = _get_db_conn() + + card_rows = conn.execute( + "SELECT card_name, quantity, is_commander FROM deck_cards WHERE deck_id = ? ORDER BY is_commander DESC, card_name ASC", + (deck_id,) + ).fetchall() + + if not card_rows: + raise HTTPException(400, "Deck has no cards to export") + + # Build .dck content + lines = ["[metadata]"] + deck_name = deck.get("name", f"Deck {deck_id}") + lines.append(f"Name={deck_name}") + lines.append("") + + commanders = [r for r in card_rows if r["is_commander"]] + mainboard = [r for r in card_rows if not r["is_commander"]] + + lines.append("[Commander]") + for r in commanders: + name = r["card_name"] or "Unknown" + qty = r["quantity"] or 1 + lines.append(f"{qty} {name}") + + lines.append("") + lines.append("[Main]") + for r in mainboard: + name = r["card_name"] or "Unknown" + qty = r["quantity"] or 1 + lines.append(f"{qty} {name}") + + content = "\n".join(lines) + + # Determine save directory + safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", deck_name).replace(" ", "_").strip() + if not safe_name: + safe_name = f"deck_{deck_id}" + + save_dir = CFG.forge_decks_dir + if not save_dir or not os.path.isdir(save_dir): + save_dir = os.path.join(os.path.dirname(__file__), "exported-decks") + os.makedirs(save_dir, exist_ok=True) + + out_path = Path(save_dir) / f"{safe_name}.dck" + out_path.write_text(content, encoding="utf-8") + log_deckgen.info(f" Exported deck {deck_id} to {out_path}") + + return { + "success": True, + "deckName": safe_name, + "dckFile": str(out_path), + "totalCards": sum(r["quantity"] for r in card_rows), + "commanderCount": len(commanders), + "mainboardCount": len(mainboard), + } + + +# ══════════════════════════════════════════════════════════════ +# Deck Import Endpoint +# ══════════════════════════════════════════════════════════════ + + +def _parse_decklist_text(text: str) -> dict: + """ + Parse a decklist from text. Supports: + - Forge .dck format with [Commander], [Deck], [Main] sections + - MTGA format: 1 Card Name + - Plain: Card Name (assumed qty 1) + - Lines starting with // or # are comments + Returns { 'commanders': [{'name': str, 'qty': int}], 'cards': [{'name': str, 'qty': int}] } + """ + commanders = [] + cards = [] + section = 'main' # default section + + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith('//') or line.startswith('#'): + continue + + # Section headers + low = line.lower() + if low.startswith('[commander'): + section = 'commander' + continue + if low.startswith('[deck') or low.startswith('[main') or low.startswith('[mainboard'): + section = 'main' + continue + if low.startswith('[sideboard') or low.startswith('[side') or low.startswith('[metadata'): + section = 'skip' + continue + if low.startswith('['): + # Unknown section, treat as main + section = 'main' + continue + + if section == 'skip': + continue + + # Parse quantity + name + m = re.match(r'^(\d+)\s*[xX]?\s+(.+)$', line) + if m: + qty = int(m.group(1)) + name = m.group(2).strip() + else: + qty = 1 + name = line + + # Strip set codes like "(M21)" or "(M21) 123" from MTGA exports + name = re.sub(r'\s*\([A-Z0-9]+\)\s*\d*\s*$', '', name).strip() + + if not name: + continue + + if section == 'commander': + commanders.append({'name': name, 'qty': qty}) + else: + cards.append({'name': name, 'qty': qty}) + + return {'commanders': commanders, 'cards': cards} + + +@router.post('/api/decks/{deck_id}/import') +async def import_decklist(deck_id: int, request: FastAPIRequest): + """ + Import a decklist into an existing deck. Parses text, looks up each card + on Scryfall, and adds them to the deck. + + Body: { "text": "1 Sol Ring\n1 Cultivate..." , "clearFirst": false } + """ + deck = _get_deck_or_404(deck_id) + body = await request.json() + text = body.get('text', '') + clear_first = body.get('clearFirst', False) + + if not text.strip(): + return JSONResponse({'error': 'No decklist text provided'}, status_code=400) + + parsed = _parse_decklist_text(text) + all_entries = [] + for c in parsed['commanders']: + all_entries.append({**c, 'is_commander': True}) + for c in parsed['cards']: + all_entries.append({**c, 'is_commander': False}) + + if not all_entries: + return JSONResponse({'error': 'No cards found in decklist'}, status_code=400) + + conn = _get_db_conn() + + # Optionally clear existing cards + if clear_first: + conn.execute('DELETE FROM deck_cards WHERE deck_id = ?', (deck_id,)) + conn.commit() + + added = 0 + failed = [] + results = [] + + for entry in all_entries: + name = entry['name'] + qty = entry['qty'] + is_cmd = entry['is_commander'] + + # Look up on Scryfall + sf = _scryfall_fuzzy_lookup(name) + if not sf: + failed.append(name) + results.append({'name': name, 'status': 'not_found'}) + continue + + scryfall_id = sf.get('id', '') + resolved_name = sf.get('name', name) + + # Check if already in deck + existing = conn.execute( + 'SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?', + (deck_id, scryfall_id) + ).fetchone() + + if existing: + conn.execute( + 'UPDATE deck_cards SET quantity = ?, is_commander = ? WHERE id = ?', + (existing['quantity'] + qty, 1 if is_cmd else existing.get('is_commander', 0), existing['id']) + ) + else: + conn.execute( + 'INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander) VALUES (?, ?, ?, ?, ?)', + (deck_id, scryfall_id, resolved_name, qty, 1 if is_cmd else 0) + ) + conn.commit() + added += 1 + results.append({'name': resolved_name, 'qty': qty, 'status': 'added', 'isCommander': is_cmd}) + + # Update commander on deck record if we found one + cmd_entries = [r for r in results if r.get('isCommander')] + if cmd_entries: + cmd_name = cmd_entries[0]['name'] + # Look up scryfall_id from the deck_cards we just inserted + cmd_row = conn.execute( + 'SELECT scryfall_id FROM deck_cards WHERE deck_id = ? AND is_commander = 1 LIMIT 1', + (deck_id,) + ).fetchone() + cmd_sf_id = cmd_row['scryfall_id'] if cmd_row else '' + conn.execute( + 'UPDATE decks SET commander_name = ?, commander_scryfall_id = ?, updated_at = datetime(\'now\') WHERE id = ?', + (cmd_name, cmd_sf_id, deck_id) + ) + else: + conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,)) + conn.commit() + + return { + 'added': added, + 'failed': len(failed), + 'failedNames': failed, + 'total': len(all_entries), + 'results': results, + } + + +@router.post('/api/decks/import-new') +async def import_decklist_new(request: FastAPIRequest): + """ + Import a decklist as a brand-new deck. Creates the deck, then imports cards. + + Body: { "text": "...", "name": "My Deck" } + """ + body = await request.json() + text = body.get('text', '') + deck_name = body.get('name', '').strip() + + if not text.strip(): + return JSONResponse({'error': 'No decklist text provided'}, status_code=400) + + parsed = _parse_decklist_text(text) + all_entries = [] + for c in parsed['commanders']: + all_entries.append({**c, 'is_commander': True}) + for c in parsed['cards']: + all_entries.append({**c, 'is_commander': False}) + + if not all_entries: + return JSONResponse({'error': 'No cards found in decklist'}, status_code=400) + + # Auto-name from first commander if no name given + if not deck_name: + cmd = parsed['commanders'][0]['name'] if parsed['commanders'] else None + deck_name = cmd or 'Imported Deck' + + conn = _get_db_conn() + cur = conn.execute( + "INSERT INTO decks (name, created_at, updated_at) VALUES (?, datetime('now'), datetime('now'))", + (deck_name,) + ) + conn.commit() + new_deck_id = cur.lastrowid + + # Set commander on the deck if we have one + first_cmd_scryfall = None + first_cmd_name = None + + added = 0 + failed = [] + + for entry in all_entries: + name = entry['name'] + qty = entry['qty'] + is_cmd = entry['is_commander'] + + sf = _scryfall_fuzzy_lookup(name) + if not sf: + failed.append(name) + continue + + scryfall_id = sf.get('id', '') + resolved_name = sf.get('name', name) + + if is_cmd and not first_cmd_scryfall: + first_cmd_scryfall = scryfall_id + first_cmd_name = resolved_name + + conn.execute( + 'INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander) VALUES (?, ?, ?, ?, ?)', + (new_deck_id, scryfall_id, resolved_name, qty, 1 if is_cmd else 0) + ) + conn.commit() + added += 1 + + # Update commander on deck record + if first_cmd_scryfall: + conn.execute( + 'UPDATE decks SET commander_name = ?, commander_scryfall_id = ? WHERE id = ?', + (first_cmd_name, first_cmd_scryfall, new_deck_id) + ) + conn.commit() + + return { + 'deckId': new_deck_id, + 'deckName': deck_name, + 'added': added, + 'failed': len(failed), + 'failedNames': failed, + 'total': len(all_entries), + } + + +# ══════════════════════════════════════════════════════════════ +# Card Scanner Endpoint +# ══════════════════════════════════════════════════════════════ + +def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]: + """ + Fuzzy-search Scryfall for a card name (used by the scanner pipeline). + Returns the raw Scryfall JSON dict, or None on failure. + """ + from urllib.parse import quote + _scryfall_rate_limit() + try: + encoded = quote(name) + url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}" + req = Request(url, headers=_API_HEADERS) + with urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode("utf-8")) + if data.get("object") == "error": + return None + return data + except Exception: + return None + diff --git a/routes/import_routes.py b/routes/import_routes.py index c55f641..8479868 100644 --- a/routes/import_routes.py +++ b/routes/import_routes.py @@ -1,43 +1,38 @@ """ routes/import_routes.py ======================= -Deck import + commander meta endpoints: +Deck import & commander meta endpoints: POST /api/lab/import/url POST /api/lab/import/text GET /api/lab/meta/commanders GET /api/lab/meta/search POST /api/lab/meta/fetch """ - from __future__ import annotations -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel from typing import Optional -from routes.shared import get as _get +from fastapi import APIRouter, HTTPException, BackgroundTasks +from fastapi.responses import JSONResponse -router = APIRouter(prefix="/api/lab", tags=["import"]) +from routes.shared import ( + COMMANDER_META, + ImportUrlRequest, + ImportTextRequest, + MetaFetchRequest, + _import_from_url, + _parse_text_decklist, + _save_profile_to_dck, + _fetch_edhrec_average, + log, +) + +router = APIRouter(tags=["import"]) -class ImportUrlRequest(BaseModel): - url: str - - -class ImportTextRequest(BaseModel): - text: str - commander: Optional[str] = None - - -class MetaFetchRequest(BaseModel): - commander: str - - -@router.post("/import/url") +@router.post("/api/lab/import/url") async def import_from_url(req: ImportUrlRequest): - """Import a deck from Archidekt or EDHREC URL.""" - _import_from_url = _get("_import_from_url") - _save_profile_to_dck = _get("_save_profile_to_dck") + """Import a deck from Archidekt or EDHREC URL. Returns parsed DeckProfile + saves .dck file.""" url = req.url.strip() try: profile = _import_from_url(url) @@ -57,11 +52,9 @@ async def import_from_url(req: ImportUrlRequest): raise HTTPException(400, str(e)) -@router.post("/import/text") +@router.post("/api/lab/import/text") async def import_from_text(req: ImportTextRequest): """Import a deck from plain text card list.""" - _parse_text_decklist = _get("_parse_text_decklist") - _save_profile_to_dck = _get("_save_profile_to_dck") try: profile = _parse_text_decklist(req.text, req.commander) dck_path = _save_profile_to_dck(profile) @@ -77,10 +70,9 @@ async def import_from_text(req: ImportTextRequest): raise HTTPException(400, str(e)) -@router.get("/meta/commanders") +@router.get("/api/lab/meta/commanders") async def list_meta_commanders(): """List available commanders in the meta mapping.""" - COMMANDER_META = _get("COMMANDER_META") commanders = [] for name, entries in COMMANDER_META.items(): entry = entries[0] if entries else {} @@ -93,10 +85,9 @@ async def list_meta_commanders(): return {"commanders": commanders} -@router.get("/meta/search") +@router.get("/api/lab/meta/search") async def search_meta_commanders(q: str = ""): """Search commanders by partial name match.""" - COMMANDER_META = _get("COMMANDER_META") query = q.lower() matches = [] for name, entries in COMMANDER_META.items(): @@ -110,11 +101,9 @@ async def search_meta_commanders(q: str = ""): return {"results": matches} -@router.post("/meta/fetch") +@router.post("/api/lab/meta/fetch") async def fetch_meta_deck(req: MetaFetchRequest): - """Fetch EDHREC average deck for a commander.""" - _fetch_edhrec_average = _get("_fetch_edhrec_average") - _save_profile_to_dck = _get("_save_profile_to_dck") + """Fetch EDHREC average deck for a commander and save as .dck file.""" try: profile = _fetch_edhrec_average(req.commander) dck_path = _save_profile_to_dck(profile) @@ -131,3 +120,8 @@ async def fetch_meta_deck(req: MetaFetchRequest): } except Exception as e: raise HTTPException(400, f"Failed to fetch meta deck for '{req.commander}': {str(e)}") + + +# ══════════════════════════════════════════════════════════════ +# Existing Endpoints (v1/v2) +# ══════════════════════════════════════════════════════════════ diff --git a/routes/lab.py b/routes/lab.py index 73180fa..71084e9 100644 --- a/routes/lab.py +++ b/routes/lab.py @@ -1,7 +1,7 @@ """ routes/lab.py ============= -Simulator endpoints: +Batch simulation & lab management endpoints: POST /api/lab/start POST /api/lab/start-deepseek GET /api/lab/status @@ -14,126 +14,431 @@ Simulator endpoints: GET /api/lab/trends/{deck_name} GET /api/lab/log GET /api/lab/debug-log - -All heavy logic (run_batch_subprocess, _run_deepseek_batch_thread, -build_java_command, parse_dck_file, AI_PROFILES dict) lives in -lab_api.py until the full extraction is complete; this router -delegate-calls those functions via routes.shared.get(). """ - from __future__ import annotations +import glob import json import os +import threading +import uuid +from datetime import datetime from pathlib import Path from typing import Optional -from fastapi import APIRouter, BackgroundTasks, HTTPException, Request as FARequest +from fastapi import APIRouter, HTTPException, BackgroundTasks, Request as FastAPIRequest from fastapi.responses import JSONResponse -from pydantic import BaseModel -from routes.shared import get as _get +from routes.shared import ( + CFG, + active_batches, + BatchState, + StartRequest, + StartResponse, + StatusResponse, + AI_PROFILES, + PRECON_DIR, + PRECON_INDEX, + build_java_command, + run_batch_subprocess, + _run_deepseek_batch_thread, + parse_dck_file, + _get_db_conn, + _load_deck_cards_by_name, + _ml_logging_enabled, + log, + log_batch, +) -router = APIRouter(prefix="/api/lab", tags=["simulator"]) +router = APIRouter(tags=["lab"]) -# --------------------------------------------------------------------------- -# Local lightweight models (don't need shared state to define) -# --------------------------------------------------------------------------- +@router.post("/api/lab/start", response_model=StartResponse) +async def start_batch(req: StartRequest, background_tasks: BackgroundTasks): + """Start a new batch simulation run.""" + if len(req.decks) < 2 or len(req.decks) > 4: + raise HTTPException(400, "2-4 decks required") + if not all(req.decks): + raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled") + if req.numGames < 1 or req.numGames > 10000: + raise HTTPException(400, "numGames must be 1-10000") + if req.threads < 1 or req.threads > 16: + raise HTTPException(400, "threads must be 1-16") -class StartResponse(BaseModel): - batchId: str - status: str = "started" - message: str = "" + if not os.path.exists(CFG.forge_jar): + raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}") + if not os.path.isdir(CFG.forge_dir): + raise HTTPException(500, f"Forge dir not found: {CFG.forge_dir}") + + batch_id = str(uuid.uuid4())[:12] + state = BatchState(batch_id, req.numGames, req.threads) + active_batches[batch_id] = state + + os.makedirs(CFG.results_dir, exist_ok=True) + output_path = os.path.join(CFG.results_dir, f"batch-{batch_id}.json") + state.result_path = output_path + + background_tasks.add_task( + run_batch_subprocess, + state, + req.decks, + req.numGames, + req.threads, + req.seed, + req.clock, + output_path, + req.useLearnedPolicy, + req.policyStyle, + req.policyGreedy, + req.aiSimplified, + req.aiThinkTimeMs, + req.maxQueueDepth, + ) + + policy_msg = "" + if req.useLearnedPolicy: + policy_msg = f" [Learned Policy: {req.policyStyle}]" + return StartResponse( + batchId=batch_id, + status="started", + message=f"Running {req.numGames} games with {req.threads} threads{policy_msg}", + ) -class StatusResponse(BaseModel): - batchId: str = "" - running: bool = False - completed: int = 0 - total: int = 0 - threads: int = 0 - elapsedMs: int = 0 - error: Optional[str] = None - simsPerSec: float = 0.0 - run_id: str = "" - games_completed: int = 0 - total_games: int = 0 - current_decks: list = [] - - -# --------------------------------------------------------------------------- -# Simulator endpoints -# --------------------------------------------------------------------------- - -@router.post("/start", response_model=StartResponse) -async def start_batch(req: FARequest, background_tasks: BackgroundTasks): - """Delegate to lab_api.start_batch (registered via shared registry).""" - fn = _get("start_batch") - return await fn(req, background_tasks) - - -@router.post("/start-deepseek") -async def start_batch_deepseek(request: FARequest, background_tasks: BackgroundTasks): - fn = _get("start_batch_deepseek") - return await fn(request, background_tasks) - - -@router.get("/status", response_model=StatusResponse) +@router.get("/api/lab/status", response_model=StatusResponse) async def get_status(batchId: Optional[str] = None): - fn = _get("get_status") - return await fn(batchId=batchId) + # If no batchId given, find the most recent active (or last) batch + state = None + if batchId: + state = active_batches.get(batchId) + else: + # Return the most recently active batch, or any batch + for s in active_batches.values(): + if s.running: + state = s + break + if state is None and active_batches: + state = list(active_batches.values())[-1] + if not state: + return StatusResponse( + batchId="", running=False, completed=0, total=0, + threads=0, elapsedMs=0, error=None, simsPerSec=0.0, + run_id="", games_completed=0, total_games=0, current_decks=[], + ) + elapsed = int((datetime.now() - state.start_time).total_seconds() * 1000) + deck_names = getattr(state, 'deck_names', []) if hasattr(state, 'deck_names') else [] + return StatusResponse( + batchId=state.batch_id, + running=state.running, + completed=state.completed_games, + total=state.total_games, + threads=state.threads, + elapsedMs=elapsed if state.running else state.elapsed_ms, + error=state.error, + simsPerSec=state.sims_per_sec, + # React SPA fields + run_id=state.batch_id, + games_completed=state.completed_games, + total_games=state.total_games, + current_decks=deck_names, + ) -@router.get("/result") +@router.get("/api/lab/result") async def get_result(batchId: Optional[str] = None): - fn = _get("get_result") - return await fn(batchId=batchId) + state = None + if batchId: + state = active_batches.get(batchId) + else: + # Find most recent completed batch + for s in reversed(list(active_batches.values())): + if not s.running and s.result_path: + state = s + break + if not state: + raise HTTPException(404, "No batch result available") + if state.running: + raise HTTPException(409, "Batch still running") + if state.error: + raise HTTPException(500, state.error) + if not state.result_path or not os.path.exists(state.result_path): + raise HTTPException(500, "Result file not found") + with open(state.result_path, "r", encoding="utf-8") as f: + data = json.load(f) + return JSONResponse(content=data) -@router.get("/decks") +@router.get("/api/lab/decks") async def list_decks(): - fn = _get("list_decks") - return await fn() + decks = [] + seen_names = set() + + # 1. Forge .dck files from decks directory + decks_dir = CFG.forge_decks_dir + if decks_dir and os.path.isdir(decks_dir): + for f in sorted(Path(decks_dir).glob("*.dck")): + deck_name = f.stem + decks.append({"name": deck_name, "filename": f.name, "source": "forge"}) + seen_names.add(deck_name.lower()) + + # 2. Decks from the Deck Builder database + try: + conn = _get_db_conn() + rows = conn.execute( + "SELECT id, name, commander_name FROM decks ORDER BY id DESC" + ).fetchall() + for row in rows: + db_name = row["name"] + if db_name.lower() not in seen_names: + decks.append({ + "name": db_name, + "filename": "", + "source": "deckbuilder", + "deck_id": row["id"], + "commander": row["commander_name"] or "", + }) + seen_names.add(db_name.lower()) + except Exception as e: + log.warning(f" WARNING: Failed to load DB decks for /api/lab/decks: {e}") + + return {"decks": decks} -@router.get("/history") +@router.get("/api/lab/history") async def list_history(): - fn = _get("list_history") - return await fn() + results_dir = Path(CFG.results_dir) + if not results_dir.exists(): + return {"results": []} + results = [] + for f in sorted(results_dir.glob("batch-*.json"), reverse=True): + try: + with open(f, "r", encoding="utf-8") as fh: + data = json.load(fh) + meta = data.get("metadata", {}) + decks = data.get("decks", []) + results.append({ + "filename": f.name, + "batchId": meta.get("batchId", ""), + "timestamp": meta.get("timestamp", ""), + "totalGames": meta.get("completedGames", 0), + "threads": meta.get("threads", 1), + "elapsedMs": meta.get("elapsedMs", 0), + "decks": [{"name": d.get("deckName", ""), "source": d.get("source", "")} for d in decks], + }) + except Exception: + continue + return {"results": results[:50]} -@router.get("/profiles") +# ════════════════════════════════════════════════════════════ +# AI Profiles +# ════════════════════════════════════════════════════════════ + +AI_PROFILES = { + "default": { + "name": "default", + "description": "Balanced \u2014 Forge's default AI behavior", + "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, + "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5, + }, + "aggro": { + "name": "aggro", + "description": "Aggressive \u2014 attacks early, prioritizes damage", + "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, + "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1, + }, + "control": { + "name": "control", + "description": "Control \u2014 defensive, removal-heavy, card advantage", + "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, + "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9, + }, + "combo": { + "name": "combo", + "description": "Combo \u2014 ramps, digs for pieces, assembles combos", + "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, + "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7, + }, + "midrange": { + "name": "midrange", + "description": "Midrange \u2014 flexible, strong board presence, value-oriented", + "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, + "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5, + }, +} + +@router.get("/api/lab/profiles") async def list_profiles(): - fn = _get("list_profiles") - return await fn() + return {"profiles": list(AI_PROFILES.values())} - -@router.get("/profiles/{name}") +@router.get("/api/lab/profiles/{name}") async def get_profile(name: str): - fn = _get("get_profile") - return await fn(name) + profile = AI_PROFILES.get(name.lower()) + if not profile: + raise HTTPException(404, f"Profile '{name}' not found. Available: {list(AI_PROFILES.keys())}") + return profile -@router.get("/analytics/{deck_name}") +@router.get("/api/lab/analytics/{deck_name}") async def analyze_deck(deck_name: str): - fn = _get("analyze_deck") - return await fn(deck_name) + decks_dir = CFG.forge_decks_dir + if not decks_dir or not os.path.isdir(decks_dir): + raise HTTPException(500, f"Decks directory not found: {decks_dir}") + deck_path = os.path.join(decks_dir, deck_name + ".dck") + if not os.path.exists(deck_path): + for f in os.listdir(decks_dir): + if f.lower() == deck_name.lower() + ".dck": + deck_path = os.path.join(decks_dir, f) + break + else: + raise HTTPException(404, f"Deck not found: {deck_name}") + try: + analysis = parse_dck_file(deck_path) + return analysis + except Exception as e: + raise HTTPException(500, f"Analysis failed: {str(e)}") -@router.get("/trends/{deck_name}") +def parse_dck_file(deck_path: str) -> dict: + cards = [] + commander = None + section = "Main" + deck_name = Path(deck_path).stem + with open(deck_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("//"): + continue + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + continue + if line.startswith("Name="): + deck_name = line[5:].strip() + continue + m = re.match(r"^(\d+)\s+(.+?)(?:\|(.+))?$", line) + if m: + qty = int(m.group(1)) + name = m.group(2).strip() + set_code = m.group(3).strip() if m.group(3) else "" + cards.append({ + "quantity": qty, "name": name, "set": set_code, + "section": section, + "is_commander": 1 if section == "Commander" else 0, + }) + if section == "Commander": + commander = name + total = sum(c["quantity"] for c in cards) + return { + "deckName": deck_name, + "commanderName": commander, + "totalCards": total, + "cardCount": len(cards), + "cards": cards[:200], + } + + +@router.get("/api/lab/trends/{deck_name}") async def get_deck_trends(deck_name: str): - fn = _get("get_deck_trends") - return await fn(deck_name) + results_dir = Path(CFG.results_dir) + if not results_dir.exists(): + return {"deckName": deck_name, "history": []} + history = [] + for f in sorted(results_dir.glob("batch-*.json")): + try: + with open(f, "r", encoding="utf-8") as fh: + data = json.load(fh) + summary = data.get("summary", {}) + meta = data.get("metadata", {}) + per_deck = summary.get("perDeck", []) + for ds in per_deck: + if ds.get("deckName", "").lower() == deck_name.lower(): + history.append({ + "batchId": meta.get("batchId"), + "timestamp": meta.get("timestamp"), + "winRate": ds.get("winRate", 0), + "wins": ds.get("wins", 0), + "losses": ds.get("losses", 0), + "draws": ds.get("draws", 0), + "totalGames": meta.get("completedGames", 0), + }) + break + except Exception: + continue + return {"deckName": deck_name, "history": history} -@router.get("/log") +@router.get("/api/lab/log") async def get_log(batchId: str): - fn = _get("get_log") - return await fn(batchId=batchId) + state = active_batches.get(batchId) + if not state: + raise HTTPException(404, f"Batch {batchId} not found") + return {"lines": state.log_lines[-200:]} -@router.get("/debug-log") +@router.get("/api/lab/debug-log") async def get_debug_log(): - fn = _get("get_debug_log") - return await fn() + """Return the raw Forge subprocess debug log for diagnosing simulation issues.""" + # Check multiple possible locations for the debug log + candidates = [] + if CFG.forge_dir: + candidates.append(Path(CFG.forge_dir).parent / "forge-sim-debug.log") + candidates.append(Path(CFG.forge_dir) / "forge-sim-debug.log") + candidates.append(Path("forge-sim-debug.log")) + + for log_path in candidates: + if log_path.exists(): + text = log_path.read_text(encoding="utf-8", errors="replace") + # Return last 50KB to avoid huge responses + if len(text) > 50000: + text = "... (truncated) ...\n" + text[-50000:] + return {"path": str(log_path), "content": text} + + return {"path": None, "content": "No debug log found. Run a simulation first."} + + + +@router.post('/api/lab/start-deepseek') +async def start_batch_deepseek(request: FastAPIRequest, background_tasks: BackgroundTasks): + """Start a batch simulation using the Python sim engine + DeepSeek LLM opponent.""" + body = await request.json() + decks = body.get('decks', []) + num_games = body.get('numGames', 30) + # Note: threads not applicable for Python sim (single-threaded LLM calls) + + if not decks or len(decks) < 1: + raise HTTPException(400, 'At least 1 deck required') + if num_games < 1 or num_games > 500: + raise HTTPException(400, 'numGames must be 1-500') + + # Filter empty deck slots + decks = [d for d in decks if d] + if not decks: + raise HTTPException(400, 'No valid decks provided') + + batch_id = str(uuid.uuid4())[:12] + state = BatchState(batch_id, num_games, 1) + active_batches[batch_id] = state + + os.makedirs(CFG.results_dir, exist_ok=True) + output_path = os.path.join(CFG.results_dir, f'batch-{batch_id}.json') + state.result_path = output_path + + # Use threading instead of BackgroundTasks to avoid async issues with DB + t = threading.Thread( + target=_run_deepseek_batch_thread, + args=(state, decks, num_games, output_path), + daemon=True, + ) + t.start() + + return JSONResponse({ + 'batchId': batch_id, + 'status': 'started', + 'message': f'Running {num_games} games across {len(decks)} decks with DeepSeek AI', + 'engine': 'deepseek', + }) + + +# Global ML logging toggle (can be enabled via API) +_ml_logging_enabled = False + diff --git a/routes/precon.py b/routes/precon.py index 2e66d7b..86cedf3 100644 --- a/routes/precon.py +++ b/routes/precon.py @@ -7,43 +7,51 @@ Precon deck management endpoints: POST /api/lab/precons/install-batch POST /api/lab/precons/refresh """ - from __future__ import annotations +import os import shutil from pathlib import Path from fastapi import APIRouter, HTTPException -from routes.shared import get as _get +from routes.shared import ( + CFG, + PRECON_INDEX, + PRECON_DIR, + download_precon_database, + log, +) -router = APIRouter(prefix="/api/lab", tags=["precon"]) +router = APIRouter(tags=["precon"]) -@router.get("/precons") +@router.get("/api/lab/precons") async def list_precons(): """List all available precon decks.""" - PRECON_INDEX = _get("PRECON_INDEX") return {"precons": PRECON_INDEX} -@router.post("/precons/install") +@router.post("/api/lab/precons/install") async def install_precon(req: dict): - """Install a precon deck to the Forge decks directory.""" - PRECON_DIR = _get("PRECON_DIR") - CFG = _get("CFG") - import os + """Install a precon deck to the Forge decks directory. + Body: {"fileName": "Elven_Empire.dck"} + """ file_name = req.get("fileName", "") if not file_name: raise HTTPException(400, "fileName is required") + src = PRECON_DIR / file_name if not src.exists(): raise HTTPException(404, f"Precon not found: {file_name}") + decks_dir = CFG.forge_decks_dir if not decks_dir or not os.path.isdir(decks_dir): raise HTTPException(500, f"Forge decks directory not found: {decks_dir}") + dst = Path(decks_dir) / file_name shutil.copy2(str(src), str(dst)) + deck_name = file_name.replace(".dck", "") return { "installed": True, @@ -53,18 +61,19 @@ async def install_precon(req: dict): } -@router.post("/precons/install-batch") +@router.post("/api/lab/precons/install-batch") async def install_precons_batch(req: dict): - """Install multiple precon decks at once.""" - PRECON_DIR = _get("PRECON_DIR") - CFG = _get("CFG") - import os + """Install multiple precon decks at once. + Body: {"fileNames": ["Elven_Empire.dck", "Necron_Dynasties.dck"]} + """ file_names = req.get("fileNames", []) if not file_names: raise HTTPException(400, "fileNames list is required") + decks_dir = CFG.forge_decks_dir if not decks_dir or not os.path.isdir(decks_dir): raise HTTPException(500, f"Forge decks directory not found: {decks_dir}") + results = [] for file_name in file_names: src = PRECON_DIR / file_name @@ -75,13 +84,13 @@ async def install_precons_batch(req: dict): shutil.copy2(str(src), str(dst)) deck_name = file_name.replace(".dck", "") results.append({"fileName": file_name, "installed": True, "deckName": deck_name}) + return {"results": results} -@router.post("/precons/refresh") +@router.post("/api/lab/precons/refresh") async def refresh_precons(): """Force re-download of all Commander precon decks from GitHub.""" - download_precon_database = _get("download_precon_database") result = download_precon_database(force=True) if result.get("error"): raise HTTPException(502, result["error"]) diff --git a/routes/shared.py b/routes/shared.py index ceeb3f6..832177e 100644 --- a/routes/shared.py +++ b/routes/shared.py @@ -951,6 +951,26 @@ def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = } +def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]: + """ + Fuzzy-search Scryfall for a card name (used by the scanner pipeline). + Returns the raw Scryfall JSON dict, or None on failure. + """ + from urllib.parse import quote + _scryfall_rate_limit() + try: + encoded = quote(name) + url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}" + req = Request(url, headers=_API_HEADERS) + with urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode("utf-8")) + if data.get("object") == "error": + return None + return data + except Exception: + return None + + # ══════════════════════════════════════════════════════════════ # Import Helpers # ══════════════════════════════════════════════════════════════