Files
commander-ai-lab/lab_api.py
T
KoalaTrapLord f639703fb4 v24: Resolve GitHub Issues #1-#5
Issue #1 — Winner's life total now captured:
  - Parse verbose Forge log for life-total-change events
  - Track last-known life per seat via 3 regex patterns
  - Winner's final life set from log data (not left at default 40)
  - Losers who died to mill/poison retain their actual life total

Issue #2 — Win condition classification formalized:
  - New WinCondition enum (schema/WinCondition.java) with 8 categories
  - Centralized Forge loss-reason-string → enum mapping (regex-based)
  - UNKNOWN fallback with logging for unrecognized loss reasons
  - All classification now goes through WinCondition.classify()

Issue #3 — JVM startup overhead reduced:
  - Added -XX:+UseSerialGC (lighter GC for batch processes)
  - Added -XX:TieredStopAtLevel=1 (faster JIT, less compilation)
  - ForgeWorker stub class for future persistent batch mode
  - Estimated reduction: ~2-3s → ~1-1.5s per game startup

Issue #4 — Single-threaded throughput optimization:
  - New CLI flags: --ai-simplified, --ai-think-time, --benchmark
  - JVM flags passed through to Forge subprocess
  - Benchmark mode: run 1 game before batch, report wall/Forge/overhead times
  - API support: aiSimplified, aiThinkTimeMs in StartRequest

Issue #5 — Threading, progress, and backpressure:
  - --threads 0 or -1 auto-detects optimal count from CPU topology
  - detectOptimalThreads() estimates physical cores (excludes HT)
  - ProgressCallback interface for real-time sims/sec reporting
  - Per-game progress emitted as [PROGRESS] lines, parsed by lab_api.py
  - --max-queue flag for graceful backpressure (limits concurrent JVMs)
  - Final throughput summary printed at batch completion
  - StatusResponse now includes simsPerSec field

Also: .gitignore restored, lab_api.py updated with all new API fields
2026-03-12 01:26:36 +00:00

8039 lines
302 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Commander AI Lab — FastAPI Backend v3
═════════════════════════════════════
v3 adds:
POST /api/lab/import/url — Import deck from Archidekt/EDHREC URL
POST /api/lab/import/text — Import deck from card list text
GET /api/lab/meta/commanders — List available commanders in meta mapping
GET /api/lab/meta/search — Search commanders by name
POST /api/lab/meta/fetch — Fetch EDHREC average deck for a commander
POST /api/lab/start — Extended: accepts imported deck profiles
Runs on port 8080 by default. Serves the web UI static files at /.
"""
import argparse
import asyncio
import csv
import io
import json
import os
import re
import sqlite3
import subprocess
import sys
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.request import urlopen, Request
from urllib.error import URLError
try:
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request as FastAPIRequest
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
except ImportError:
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
sys.exit(1)
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
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
CFG = Config()
# ══════════════════════════════════════════════════════════════
# App Setup
# ══════════════════════════════════════════════════════════════
app = FastAPI(title="Commander AI Lab API", version="3.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
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
completed: int
total: int
threads: int
elapsedMs: int
error: Optional[str] = None
simsPerSec: float = 0.0 # v24 (Issue #5): Real-time throughput
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) != 3:
raise HTTPException(400, "Exactly 3 decks required")
if not all(req.decks):
raise HTTPException(400, "All 3 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: str):
state = active_batches.get(batchId)
if not state:
raise HTTPException(404, f"Batch {batchId} not found")
elapsed = int((datetime.now() - state.start_time).total_seconds() * 1000)
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,
)
@app.get("/api/lab/result")
async def get_result(batchId: str):
state = active_batches.get(batchId)
if not state:
raise HTTPException(404, f"Batch {batchId} not found")
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:
print(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})
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)
print(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
else:
PRECON_INDEX = []
print(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:
print(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 ─────────────────────────────────────────────
print(" 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}"
print(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'
]
print(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
print(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")
print(f" [Import] 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,
)
print(f"[Lab API] 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
print(f"[Lab API] 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")
print(f"[Lab API] Debug log: {debug_log_path}")
except Exception as e:
print(f"[Lab API] 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', '')
print(f'[Lab API] 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:
print(f'[Lab API] 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
print(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:
print(f"[Lab API] 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
print(f"[Lab API] 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:
print(f"[Lab API] Deck reports updated: {', '.join(updated)}")
except Exception as rpt_err:
print(f"[Lab API] 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 collection_entries 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:
print(f"[DeepSeek Batch] 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 = []
in_cards = False # True when inside [Main], [Deck], or [Commander]
with open(dck_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
low = line.lower()
if low in ('[main]', '[deck]', '[commander]'):
in_cards = True
continue
if line.startswith('['):
in_cards = False
continue
if in_cards and line:
parts = line.split(' ', 1)
if len(parts) == 2:
try:
qty = int(parts[0])
name = parts[1].strip()
for _ in range(qty):
cards.append({'name': name, 'type_line': '', 'cmc': 0})
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)')
print(f'[DeepSeek Batch] 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,
"--deck1", decks[0],
"--deck2", decks[1],
"--deck3", decks[2],
"--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}")
print(f" Migration: added column '{col_name}' to collection_entries")
conn.commit()
conn.close()
print(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 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 _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
"""
Fetch card data from Scryfall and return enriched fields dict.
Tries set+collectorNumber first, then exact name lookup.
Returns empty dict on failure.
"""
from urllib.parse import quote
_scryfall_rate_limit()
card_data = None
last_error = ""
# Try set + collector number first
if set_code and collector_number:
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}"}
# 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,
}
# ══════════════════════════════════════════════════════════════
# 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 collection_entries 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 collection_entries 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")
print(f" [DeckBuilder] 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):
"""
Scan a card image and return recognized card name(s).
Uses Ximilar Visual AI for card recognition.
Accepts multipart/form-data with:
- file: image file (JPEG/PNG)
- mode: "single" (default) or "multi"
Returns:
{ "results": [ { raw_ocr, matched_name, set_code, scryfall_id, confidence, image_uri, error, collector_number, rarity, tcgplayer_url }, ... ] }
"""
try:
from scanner.pipeline import scan_single, scan_multi
except ImportError as e:
raise HTTPException(500, f"Scanner module not available: {e}")
# Try CFG first, then fall back to env var
api_key = CFG.ximilar_api_key or os.environ.get("XIMILAR_API_KEY", "")
if not api_key:
raise HTTPException(500, "Card scanner not configured. Start the server with --ximilar-key YOUR_KEY or set XIMILAR_API_KEY env var.")
# Parse multipart form
form = await request.form()
upload = form.get("file")
if upload is None:
raise HTTPException(400, "No file uploaded. Send an image as 'file' in multipart/form-data.")
mode = str(form.get("mode", "single")).lower()
image_bytes = await upload.read()
if len(image_bytes) == 0:
raise HTTPException(400, "Uploaded file is empty")
# Limit file size (20 MB)
if len(image_bytes) > 20 * 1024 * 1024:
raise HTTPException(400, "Image file too large (max 20 MB)")
print(f" [SCAN] Processing {len(image_bytes)} bytes, mode={mode} (Ximilar AI)")
if mode == "multi":
results = scan_multi(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
else:
result = scan_single(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
results = [result]
return {
"results": [r.to_dict() for r in results]
}
@app.post("/api/collection/scan/add")
async def scan_add_cards(body: dict):
"""
Add scanned cards to the collection.
Body:
{
"cards": [
{ "name": "Lightning Bolt", "set_code": "m11", "quantity": 1 },
...
]
}
"""
cards = body.get("cards", [])
if not cards:
raise HTTPException(400, "No cards provided")
conn = _get_db_conn()
imported = 0
updated = 0
errors = []
for card_req in cards:
name = str(card_req.get("name", "")).strip()
if not name:
continue
quantity = int(card_req.get("quantity", 1))
set_code = str(card_req.get("set_code", "")).lower()
collector_number = str(card_req.get("collector_number", ""))
finish = "NORMAL"
try:
enriched = _enrich_from_scryfall(name, set_code, collector_number)
except Exception as e:
errors.append(f"Scryfall error for '{name}': {e}")
continue
if not enriched or "_error" in enriched:
err_msg = enriched.get("_error", f"Not found: '{name}'") if enriched else f"Not found: '{name}'"
errors.append(err_msg)
continue
resolved_name = enriched.get("name", name)
# Check for existing entry
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:
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 += 1
else:
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 += 1
return {
"importedCount": imported,
"updatedCount": updated,
"failedCount": len(errors),
"errors": errors,
}
# ══════════════════════════════════════════════════════════════
# Re-enrich Collection from Scryfall
# ══════════════════════════════════════════════════════════════
@app.post("/api/collection/re-enrich")
async def re_enrich_collection():
"""
Re-fetch Scryfall data for all collection entries to backfill
missing fields (mana_cost, power, toughness, rarity, set_name, edhrec_rank).
Also refreshes: type_line, oracle_text, keywords, cmc, color_identity, prices.
Runs synchronously — may take a while for large collections.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, name, set_code, collector_number FROM collection_entries ORDER BY id"
).fetchall()
total = len(rows)
enriched_count = 0
skipped_count = 0
errors = []
for row in rows:
card_id = row["id"]
name = row["name"]
set_code = row["set_code"] or ""
collector_number = row["collector_number"] or ""
try:
data = _enrich_from_scryfall(name, set_code, collector_number)
except Exception as e:
errors.append(f"Error for '{name}': {e}")
skipped_count += 1
continue
if not data or "_error" in data:
err_msg = data.get("_error", f"Not found: '{name}'") if data else f"Not found: '{name}'"
errors.append(err_msg)
skipped_count += 1
continue
conn.execute(
"""UPDATE collection_entries SET
type_line = ?, subtypes = ?, is_legendary = ?, is_basic = ?,
color_identity = ?, cmc = ?, mana_cost = ?,
oracle_text = ?, keywords = ?,
power = ?, toughness = ?,
rarity = ?, set_name = ?, edhrec_rank = ?,
tcg_price = ?, scryfall_id = ?, tcgplayer_id = ?,
category = ?,
updated_at = datetime('now')
WHERE id = ?""",
(
data.get("type_line", ""),
data.get("subtypes", "[]"),
data.get("is_legendary", 0),
data.get("is_basic", 0),
data.get("color_identity", "[]"),
data.get("cmc", 0.0),
data.get("mana_cost", ""),
data.get("oracle_text", ""),
data.get("keywords", "[]"),
data.get("power", ""),
data.get("toughness", ""),
data.get("rarity", ""),
data.get("set_name", ""),
data.get("edhrec_rank", 0),
data.get("tcg_price", 0.0),
data.get("scryfall_id", ""),
data.get("tcgplayer_id", ""),
data.get("category", "[]"),
card_id,
),
)
conn.commit()
enriched_count += 1
return {
"total": total,
"enrichedCount": enriched_count,
"skippedCount": skipped_count,
"errors": errors[:50], # cap error list
}
# ══════════════════════════════════════════════════════════════
# Auto-Classify Collection
# ══════════════════════════════════════════════════════════════
@app.post("/api/collection/auto-classify")
async def auto_classify_collection():
"""
Run auto-classification on all collection entries.
Uses oracle_text, type_line, and keywords to detect functional roles
(Ramp, Draw, Removal, Board Wipe, Anthem, Stax, etc.).
Only updates cards whose current category is empty or '[]'.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, oracle_text, type_line, keywords, category FROM collection_entries ORDER BY id"
).fetchall()
total = len(rows)
classified_count = 0
skipped_count = 0
for row in rows:
card_id = row["id"]
existing_cats = row["category"] or "[]"
# Parse existing categories
try:
cats = json.loads(existing_cats)
except Exception:
cats = []
# Skip cards that already have manually-set categories
if cats:
skipped_count += 1
continue
oracle_text = row["oracle_text"] or ""
type_line = row["type_line"] or ""
keywords = row["keywords"] or "[]"
roles = _detect_card_roles(oracle_text, type_line, keywords)
if roles:
conn.execute(
"UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?",
(json.dumps(roles), card_id),
)
classified_count += 1
conn.commit()
return {
"total": total,
"classifiedCount": classified_count,
"skippedCount": skipped_count,
"message": f"Classified {classified_count} cards, skipped {skipped_count} (already had categories)",
}
@app.post("/api/collection/auto-classify-all")
async def auto_classify_all_collection():
"""
Force re-classify ALL collection entries, overwriting existing categories.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, oracle_text, type_line, keywords FROM collection_entries ORDER BY id"
).fetchall()
total = len(rows)
classified_count = 0
for row in rows:
card_id = row["id"]
oracle_text = row["oracle_text"] or ""
type_line = row["type_line"] or ""
keywords = row["keywords"] or "[]"
roles = _detect_card_roles(oracle_text, type_line, keywords)
conn.execute(
"UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?",
(json.dumps(roles), card_id),
)
classified_count += 1
conn.commit()
return {
"total": total,
"classifiedCount": classified_count,
"message": f"Re-classified all {classified_count} cards",
}
# ══════════════════════════════════════════════════════════════
# EDHREC Recommendations Cache + Endpoint
# ══════════════════════════════════════════════════════════════
# Simple in-memory cache: {cache_key: {"data": ..., "expires": float}}
_edhrec_cache: dict = {}
_EDHREC_TTL = 3600 # 1 hour
def _edhrec_cache_get(key: str):
entry = _edhrec_cache.get(key)
if entry and time.monotonic() < entry["expires"]:
return entry["data"]
return None
def _edhrec_cache_set(key: str, data):
_edhrec_cache[key] = {"data": data, "expires": time.monotonic() + _EDHREC_TTL}
@app.get("/api/collection/{cardId}/edhrec")
async def get_card_edhrec(cardId: int):
"""
Get EDHREC recommendations for a collection card.
For legendary creatures (potential commanders): fetch commander synergy data.
For other cards: fetch 'also played with' data.
"""
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")
card = _row_to_dict(row)
name = card["name"]
type_line = card.get("type_line", "")
is_legendary = card.get("is_legendary", 0)
# Determine if it's a legendary creature (potential commander)
is_commander = bool(
is_legendary and
re.search(r"\bCreature\b", type_line, re.IGNORECASE)
)
slug = _to_edhrec_slug(name)
cache_key = f"edhrec:{'cmd' if is_commander else 'card'}:{slug}"
cached = _edhrec_cache_get(cache_key)
if cached:
return cached
recommendations = []
links = {
"edhrecPage": (
f"https://edhrec.com/commanders/{slug}"
if is_commander
else f"https://edhrec.com/cards/{slug}"
),
"archidektSearch": f"https://archidekt.com/search/cards?q={name.replace(' ', '+')}",
"moxfieldSearch": f"https://www.moxfield.com/search?q={name.replace(' ', '+')}",
}
try:
if is_commander:
url = f"https://json.edhrec.com/pages/commanders/{slug}.json"
else:
url = f"https://json.edhrec.com/pages/cards/{slug}.json"
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
if is_commander:
# Extract top cards from cardlists
container = data.get("container", {})
json_dict = container.get("json_dict", {})
for cardlist in json_dict.get("cardlists", []):
tag = cardlist.get("tag", "")
for cv in cardlist.get("cardviews", [])[:10]:
rec_name = cv.get("name", "")
if not rec_name:
continue
synergy = cv.get("synergy", 0.0)
recommendations.append({
"name": rec_name,
"synergy": synergy,
"role": tag or "Recommended",
})
else:
# Extract "also played with" cards
container = data.get("container", {})
json_dict = container.get("json_dict", {})
for cardlist in json_dict.get("cardlists", []):
tag = cardlist.get("tag", "")
for cv in cardlist.get("cardviews", [])[:10]:
rec_name = cv.get("name", "")
if not rec_name:
continue
synergy = cv.get("synergy", 0.0)
recommendations.append({
"name": rec_name,
"synergy": synergy,
"role": tag or "Also Played With",
})
except Exception as e:
print(f" [EDHREC] Error fetching data for '{name}': {e}")
# Graceful degradation — return empty results
result = {
"recommendations": recommendations[:30],
"links": links,
}
_edhrec_cache_set(cache_key, result)
return result
# ══════════════════════════════════════════════════════════════
# Commander Meta Mapping (loaded at startup)
# ══════════════════════════════════════════════════════════════
BUILTIN_COMMANDERS = {
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
}
# ══════════════════════════════════════════════════════════════
# Python Simulator API
# ══════════════════════════════════════════════════════════════
import threading as _threading
import uuid as _uuid
# In-memory store for simulation runs
_sim_runs = {} # sim_id -> { status, result, error }
_sim_lock = _threading.Lock()
def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int, deck_name: str, record_logs: bool):
"""Background thread for simulations with full card data from the DB."""
try:
import sys, os
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src')
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.lab.experiments import _generate_training_deck
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
# Build deck with real card data from DB
deck_a = []
for cd in card_data:
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:
import json as _json
kw = _json.loads(kw)
except Exception:
kw = []
if isinstance(kw, list):
c.keywords = kw
# Enrich fills in flags like is_removal, is_ramp, is_board_wipe
enrich_card(c)
deck_a.append(c)
deck_b = _generate_training_deck()
engine = GameEngine(max_turns=25, record_log=record_logs)
import time
start = time.time()
wins = 0
losses = 0
total_turns = 0
total_damage_dealt = 0
total_damage_received = 0
total_spells_cast = 0
total_creatures_played = 0
total_removal_used = 0
total_ramp_played = 0
total_cards_drawn = 0
total_max_board = 0
game_results = []
for i in range(num_games):
result = engine.run(deck_a, deck_b, name_a=deck_name, name_b="Training Deck")
game_data = result.to_dict()
game_data['gameNumber'] = i + 1
game_results.append(game_data)
if result.winner == 0:
wins += 1
else:
losses += 1
total_turns += result.turns
if result.player_a_stats:
total_damage_dealt += result.player_a_stats.damage_dealt
total_damage_received += result.player_a_stats.damage_received
total_spells_cast += result.player_a_stats.spells_cast
total_creatures_played += result.player_a_stats.creatures_played
total_removal_used += result.player_a_stats.removal_used
total_ramp_played += result.player_a_stats.ramp_played
total_cards_drawn += result.player_a_stats.cards_drawn
total_max_board += result.player_a_stats.max_board_size
with _sim_lock:
_sim_runs[sim_id]['completed'] = i + 1
elapsed = time.time() - start
n = num_games
summary = {
'deckName': deck_name,
'opponentName': 'Training Deck',
'totalGames': n,
'wins': wins,
'losses': losses,
'winRate': round(wins / n * 100, 1) if n > 0 else 0.0,
'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0,
'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0,
'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0,
'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0,
'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0,
'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0,
'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0,
'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0,
'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0,
'elapsedSeconds': round(elapsed, 3),
}
with _sim_lock:
_sim_runs[sim_id]['status'] = 'complete'
_sim_runs[sim_id]['result'] = {
'summary': summary,
'games': game_results,
}
except Exception as e:
import traceback
with _sim_lock:
_sim_runs[sim_id]['status'] = 'error'
_sim_runs[sim_id]['error'] = str(e)
traceback.print_exc()
def _run_sim_thread(sim_id: str, decklist: list, num_games: int, deck_name: str, record_logs: bool):
"""Background thread for running Monte Carlo simulations."""
try:
import sys, os
# Add src/ to path if needed for the simulator package
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src')
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.rules import enrich_card, parse_decklist
from commander_ai_lab.lab.experiments import build_deck, _generate_training_deck
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
deck_a = build_deck(decklist)
deck_b = _generate_training_deck()
engine = GameEngine(max_turns=25, record_log=record_logs)
import time
start = time.time()
wins = 0
losses = 0
total_turns = 0
total_damage_dealt = 0
total_damage_received = 0
total_spells_cast = 0
total_creatures_played = 0
total_removal_used = 0
total_ramp_played = 0
total_cards_drawn = 0
total_max_board = 0
game_results = []
for i in range(num_games):
result = engine.run(deck_a, deck_b, name_a=deck_name, name_b="Training Deck")
game_data = result.to_dict()
game_data['gameNumber'] = i + 1
game_results.append(game_data)
if result.winner == 0:
wins += 1
else:
losses += 1
total_turns += result.turns
if result.player_a_stats:
total_damage_dealt += result.player_a_stats.damage_dealt
total_damage_received += result.player_a_stats.damage_received
total_spells_cast += result.player_a_stats.spells_cast
total_creatures_played += result.player_a_stats.creatures_played
total_removal_used += result.player_a_stats.removal_used
total_ramp_played += result.player_a_stats.ramp_played
total_cards_drawn += result.player_a_stats.cards_drawn
total_max_board += result.player_a_stats.max_board_size
# Update progress
with _sim_lock:
_sim_runs[sim_id]['completed'] = i + 1
elapsed = time.time() - start
n = num_games
summary = {
'deckName': deck_name,
'opponentName': 'Training Deck',
'totalGames': n,
'wins': wins,
'losses': losses,
'winRate': round(wins / n * 100, 1) if n > 0 else 0.0,
'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0,
'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0,
'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0,
'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0,
'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0,
'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0,
'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0,
'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0,
'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0,
'elapsedSeconds': round(elapsed, 3),
}
with _sim_lock:
_sim_runs[sim_id]['status'] = 'complete'
_sim_runs[sim_id]['result'] = {
'summary': summary,
'games': game_results,
}
except Exception as e:
import traceback
with _sim_lock:
_sim_runs[sim_id]['status'] = 'error'
_sim_runs[sim_id]['error'] = str(e)
traceback.print_exc()
@app.post('/api/sim/run')
async def sim_run(request: FastAPIRequest):
"""Start a Monte Carlo simulation with the Python engine."""
body = await request.json()
decklist = body.get('decklist', [])
num_games = body.get('numGames', 10)
deck_name = body.get('deckName', 'My Deck')
record_logs = body.get('recordLogs', True)
if not decklist:
return JSONResponse({'error': 'decklist is required'}, status_code=400)
if num_games < 1 or num_games > 1000:
return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400)
sim_id = str(_uuid.uuid4())[:8]
with _sim_lock:
_sim_runs[sim_id] = {
'status': 'queued',
'completed': 0,
'total': num_games,
'deckName': deck_name,
'result': None,
'error': None,
}
t = _threading.Thread(target=_run_sim_thread, args=(sim_id, decklist, num_games, deck_name, record_logs), daemon=True)
t.start()
return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games})
@app.get('/api/sim/status')
async def sim_status(simId: str):
"""Poll simulation progress."""
with _sim_lock:
run = _sim_runs.get(simId)
if not run:
return JSONResponse({'error': 'sim not found'}, status_code=404)
return JSONResponse({
'simId': simId,
'status': run['status'],
'completed': run['completed'],
'total': run['total'],
'deckName': run.get('deckName', ''),
'error': run.get('error'),
})
@app.get('/api/sim/result')
async def sim_result(simId: str):
"""Get completed simulation results."""
with _sim_lock:
run = _sim_runs.get(simId)
if not run:
return JSONResponse({'error': 'sim not found'}, status_code=404)
if run['status'] != 'complete':
return JSONResponse({'error': 'sim not complete', 'status': run['status']}, status_code=400)
return JSONResponse(run['result'])
@app.post('/api/sim/run-from-deck')
async def sim_run_from_deck(request: FastAPIRequest):
"""Start simulation using a deck from the Deck Builder (by deck ID)."""
body = await request.json()
deck_id = body.get('deckId')
num_games = body.get('numGames', 10)
record_logs = body.get('recordLogs', True)
if not deck_id:
return JSONResponse({'error': 'deckId required'}, status_code=400)
conn = _get_db_conn()
cur = conn.cursor()
cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,))
row = cur.fetchone()
if not row:
return JSONResponse({'error': 'deck not found'}, status_code=404)
deck_name = row[0]
# Pull full card data from collection join so the sim engine has real types/stats
cur.execute("""
SELECT dc.card_name, dc.quantity,
ce.type_line, ce.cmc, ce.power, ce.toughness,
ce.oracle_text, ce.keywords, ce.mana_cost
FROM deck_cards dc
LEFT JOIN collection_entries ce ON ce.scryfall_id = dc.scryfall_id
WHERE dc.deck_id = ?
""", (deck_id,))
card_data = []
for r in cur.fetchall():
for _ in range(r[1] or 1):
card_data.append({
'name': r[0],
'type_line': r[2] or '',
'cmc': r[3] or 0,
'power': r[4] or '',
'toughness': r[5] or '',
'oracle_text': r[6] or '',
'keywords': r[7] or '',
'mana_cost': r[8] or '',
})
if not card_data:
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
sim_id = str(_uuid.uuid4())[:8]
with _sim_lock:
_sim_runs[sim_id] = {
'status': 'queued',
'completed': 0,
'total': num_games,
'deckName': deck_name,
'result': None,
'error': None,
}
t = _threading.Thread(target=_run_sim_thread_v2, args=(sim_id, card_data, num_games, deck_name, record_logs), daemon=True)
t.start()
return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games, 'deckName': deck_name})
# ══════════════════════════════════════════════════════════════
# DeepSeek AI Opponent Brain
# ══════════════════════════════════════════════════════════════
# Global DeepSeek brain instance (lazy-initialized)
_deepseek_brain = None
_deepseek_lock = _threading.Lock()
def _get_deepseek_brain():
"""Get or create the global DeepSeek brain instance."""
global _deepseek_brain
if _deepseek_brain is None:
with _deepseek_lock:
if _deepseek_brain is None:
try:
import sys as _sys2, os as _os2
src_dir = _os2.path.join(_os2.path.dirname(_os2.path.abspath(__file__)), 'src')
if src_dir not in _sys2.path:
_sys2.path.insert(0, src_dir)
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
cfg = DeepSeekConfig()
# Allow env var overrides
if _os2.environ.get('DEEPSEEK_API_BASE'):
cfg.api_base = _os2.environ['DEEPSEEK_API_BASE']
if _os2.environ.get('DEEPSEEK_MODEL'):
cfg.model = _os2.environ['DEEPSEEK_MODEL']
cfg.log_dir = _os2.path.join(_os2.path.dirname(_os2.path.abspath(__file__)), 'logs', 'decisions')
_deepseek_brain = DeepSeekBrain(cfg)
except Exception as e:
print(f'[DeepSeek] Failed to initialize brain: {e}')
return None
return _deepseek_brain
@app.post('/api/deepseek/connect')
async def deepseek_connect(request: FastAPIRequest):
"""Test connection to the DeepSeek LLM endpoint and auto-detect model."""
body = await request.json() if await request.body() else {}
api_base = body.get('apiBase', None)
model = body.get('model', None)
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'error': 'DeepSeek brain failed to initialize'}, status_code=500)
# Allow runtime reconfiguration
if api_base:
brain.config.api_base = api_base
if model:
brain.config.model = model
connected = brain.check_connection()
return JSONResponse({
'connected': connected,
'apiBase': brain.config.api_base,
'model': brain.config.model,
'stats': brain.get_stats(),
})
@app.get('/api/deepseek/status')
async def deepseek_status():
"""Get DeepSeek brain status and performance stats."""
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'connected': False, 'error': 'Brain not initialized'})
return JSONResponse(brain.get_stats())
@app.post('/api/deepseek/configure')
async def deepseek_configure(request: FastAPIRequest):
"""Update DeepSeek configuration at runtime."""
body = await request.json()
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
if 'apiBase' in body:
brain.config.api_base = body['apiBase']
if 'model' in body:
brain.config.model = body['model']
if 'temperature' in body:
brain.config.temperature = float(body['temperature'])
if 'maxTokens' in body:
brain.config.max_tokens = int(body['maxTokens'])
if 'timeout' in body:
brain.config.request_timeout = float(body['timeout'])
if 'cacheEnabled' in body:
brain.config.cache_enabled = bool(body['cacheEnabled'])
if 'logDecisions' in body:
brain.config.log_decisions = bool(body['logDecisions'])
if 'fallbackOnTimeout' in body:
brain.config.fallback_on_timeout = bool(body['fallbackOnTimeout'])
# Re-test connection with new settings
connected = brain.check_connection()
return JSONResponse({
'connected': connected,
'config': {
'apiBase': brain.config.api_base,
'model': brain.config.model,
'temperature': brain.config.temperature,
'maxTokens': brain.config.max_tokens,
'timeout': brain.config.request_timeout,
'cacheEnabled': brain.config.cache_enabled,
'logDecisions': brain.config.log_decisions,
'fallbackOnTimeout': brain.config.fallback_on_timeout,
},
})
@app.get('/api/deepseek/logs')
async def deepseek_logs():
"""Get decision log stats and flush pending entries."""
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
pending = len(brain._decision_log)
flushed_path = None
if pending > 0:
try:
flushed_path = brain.flush_log()
except Exception as e:
return JSONResponse({'error': f'Failed to flush: {e}'}, status_code=500)
# List existing log files
import glob as _glob
log_dir = brain.config.log_dir
log_files = []
if log_dir and os.path.isdir(log_dir):
for f in sorted(_glob.glob(os.path.join(log_dir, 'decisions_*.jsonl'))):
stat = os.stat(f)
log_files.append({
'filename': os.path.basename(f),
'size_bytes': stat.st_size,
'modified': stat.st_mtime,
})
return JSONResponse({
'flushed': pending,
'flushedPath': flushed_path,
'logFiles': log_files[-20:], # last 20
})
def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict], num_games: int, deck_name: str, record_logs: bool):
"""Background thread for simulations using DeepSeek AI opponent."""
try:
import sys, os
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src')
if src_dir not in sys.path:
sys.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
from commander_ai_lab.lab.experiments import _generate_training_deck
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
# Build player's deck with real card data
deck_a = []
for cd in card_data:
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:
import json as _json
kw = _json.loads(kw)
except Exception:
kw = []
if isinstance(kw, list):
c.keywords = kw
enrich_card(c)
deck_a.append(c)
deck_b = _generate_training_deck()
# Get DeepSeek brain
brain = _get_deepseek_brain()
if brain and not brain._connected:
brain.check_connection()
engine = DeepSeekGameEngine(
brain=brain,
ai_player_index=1, # Training deck is player B (index 1)
max_turns=25,
record_log=record_logs,
)
import time
start = time.time()
wins = 0
losses = 0
total_turns = 0
total_damage_dealt = 0
total_damage_received = 0
total_spells_cast = 0
total_creatures_played = 0
total_removal_used = 0
total_ramp_played = 0
total_cards_drawn = 0
total_max_board = 0
game_results = []
for i in range(num_games):
game_id = f'ds-sim-{sim_id[:8]}-g{i+1}'
result = engine.run(deck_a, deck_b, name_a=deck_name, name_b='DeepSeek AI',
game_id=game_id, archetype='midrange')
game_data = result.to_dict()
game_data['gameNumber'] = i + 1
game_results.append(game_data)
if result.winner == 0:
wins += 1
else:
losses += 1
total_turns += result.turns
if result.player_a_stats:
total_damage_dealt += result.player_a_stats.damage_dealt
total_damage_received += result.player_a_stats.damage_received
total_spells_cast += result.player_a_stats.spells_cast
total_creatures_played += result.player_a_stats.creatures_played
total_removal_used += result.player_a_stats.removal_used
total_ramp_played += result.player_a_stats.ramp_played
total_cards_drawn += result.player_a_stats.cards_drawn
total_max_board += result.player_a_stats.max_board_size
with _sim_lock:
_sim_runs[sim_id]['completed'] = i + 1
elapsed = time.time() - start
n = num_games
# Write ML decision JSONL for training pipeline
ml_decisions = engine.flush_ml_decisions()
if ml_decisions:
ml_jsonl_path = os.path.join('results', f'ml-decisions-sim-{sim_id[:8]}.jsonl')
os.makedirs('results', exist_ok=True)
with open(ml_jsonl_path, 'w', encoding='utf-8') as mf:
import json as _mljson
for dec in ml_decisions:
mf.write(_mljson.dumps(dec) + '\n')
# Get DeepSeek stats for the summary
ds_stats = brain.get_stats() if brain else {}
summary = {
'deckName': deck_name,
'opponentName': 'DeepSeek AI',
'opponentType': 'deepseek',
'totalGames': n,
'wins': wins,
'losses': losses,
'winRate': round(wins / n * 100, 1) if n > 0 else 0.0,
'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0,
'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0,
'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0,
'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0,
'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0,
'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0,
'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0,
'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0,
'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0,
'elapsedSeconds': round(elapsed, 3),
'deepseekStats': ds_stats,
}
with _sim_lock:
_sim_runs[sim_id]['status'] = 'complete'
_sim_runs[sim_id]['result'] = {
'summary': summary,
'games': game_results,
}
except Exception as e:
import traceback
with _sim_lock:
_sim_runs[sim_id]['status'] = 'error'
_sim_runs[sim_id]['error'] = str(e)
traceback.print_exc()
@app.post('/api/sim/run-deepseek')
async def sim_run_deepseek(request: FastAPIRequest):
"""Start simulation using DeepSeek AI as the opponent brain."""
body = await request.json()
deck_id = body.get('deckId')
num_games = body.get('numGames', 5) # Default 5 (LLM is slower)
record_logs = body.get('recordLogs', True)
if not deck_id:
return JSONResponse({'error': 'deckId required'}, status_code=400)
if num_games < 1 or num_games > 50:
return JSONResponse({'error': 'numGames must be 1-50 for DeepSeek mode'}, status_code=400)
conn = _get_db_conn()
cur = conn.cursor()
cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,))
row = cur.fetchone()
if not row:
return JSONResponse({'error': 'deck not found'}, status_code=404)
deck_name = row[0]
cur.execute("""
SELECT dc.card_name, dc.quantity,
ce.type_line, ce.cmc, ce.power, ce.toughness,
ce.oracle_text, ce.keywords, ce.mana_cost
FROM deck_cards dc
LEFT JOIN collection_entries ce ON ce.scryfall_id = dc.scryfall_id
WHERE dc.deck_id = ?
""", (deck_id,))
card_data = []
for r in cur.fetchall():
for _ in range(r[1] or 1):
card_data.append({
'name': r[0],
'type_line': r[2] or '',
'cmc': r[3] or 0,
'power': r[4] or '',
'toughness': r[5] or '',
'oracle_text': r[6] or '',
'keywords': r[7] or '',
'mana_cost': r[8] or '',
})
if not card_data:
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
sim_id = str(_uuid.uuid4())[:8]
with _sim_lock:
_sim_runs[sim_id] = {
'status': 'queued',
'completed': 0,
'total': num_games,
'deckName': deck_name,
'result': None,
'error': None,
}
t = _threading.Thread(
target=_run_sim_thread_deepseek,
args=(sim_id, card_data, num_games, deck_name, record_logs),
daemon=True,
)
t.start()
return JSONResponse({
'simId': sim_id,
'status': 'queued',
'total': num_games,
'deckName': deck_name,
'opponentType': 'deepseek',
})
# ══════════════════════════════════════════════════════════════
# Auto Deck Generator
# ══════════════════════════════════════════════════════════════
def _resolve_commander(req: DeckGenerationRequest) -> dict:
"""
Resolve commander info from a DeckGenerationRequest.
Returns dict with: name, scryfall_id, color_identity, type_line, mana_cost, image_url
"""
conn = _get_db_conn()
# Try scryfall_id first
if req.commander_scryfall_id:
row = conn.execute(
"SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
(req.commander_scryfall_id,)
).fetchone()
if row:
ci = row["color_identity"]
if isinstance(ci, str):
try:
ci = json.loads(ci)
except Exception:
ci = []
return {
"name": row["name"],
"scryfall_id": row["scryfall_id"],
"color_identity": ci,
"type_line": row["type_line"],
"mana_cost": row["mana_cost"] or "",
"image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal",
}
# Fallback to Scryfall API
_scryfall_rate_limit()
try:
url = f"https://api.scryfall.com/cards/{req.commander_scryfall_id}"
rq = Request(url, headers={"User-Agent": "CommanderAILab/1.0", "Accept": "application/json"})
with urlopen(rq, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
return _scryfall_to_commander(data)
except Exception:
pass
# Try name search
if req.commander_name:
name_lower = req.commander_name.strip().lower()
row = conn.execute(
"SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE LOWER(name) = ? LIMIT 1",
(name_lower,)
).fetchone()
if row:
ci = row["color_identity"]
if isinstance(ci, str):
try:
ci = json.loads(ci)
except Exception:
ci = []
return {
"name": row["name"],
"scryfall_id": row["scryfall_id"],
"color_identity": ci,
"type_line": row["type_line"],
"mana_cost": row["mana_cost"] or "",
"image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal",
}
# Fallback to Scryfall fuzzy search
_scryfall_rate_limit()
try:
import urllib.parse
encoded = urllib.parse.quote(req.commander_name)
url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}"
rq = Request(url, headers={"User-Agent": "CommanderAILab/1.0", "Accept": "application/json"})
with urlopen(rq, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
return _scryfall_to_commander(data)
except Exception:
pass
return None
def _scryfall_to_commander(data: dict) -> dict:
"""Convert Scryfall API response to commander info dict."""
ci = data.get("color_identity", [])
image_uris = data.get("image_uris", {})
if not image_uris and data.get("card_faces"):
image_uris = data["card_faces"][0].get("image_uris", {})
return {
"name": data.get("name", ""),
"scryfall_id": data.get("id", ""),
"color_identity": ci,
"type_line": data.get("type_line", ""),
"mana_cost": data.get("mana_cost", ""),
"image_url": image_uris.get("normal", ""),
}
def _get_collection_for_colors(color_identity: list) -> list:
"""
Fetch all collection cards compatible with the given color identity.
Returns list of dicts with card details.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT * FROM collection_entries WHERE quantity > 0"
).fetchall()
valid = []
for row in rows:
card = _row_to_dict(row)
card_ci = card.get("color_identity", [])
if isinstance(card_ci, str):
try:
card_ci = json.loads(card_ci)
except Exception:
card_ci = []
# Color identity check: all card colors must be within commander's identity
if color_identity and card_ci:
if not all(c in color_identity for c in card_ci):
continue
# Skip if no scryfall_id
if not card.get("scryfall_id"):
continue
card["color_identity"] = card_ci
valid.append(card)
return valid
def _generate_deck(req: DeckGenerationRequest) -> dict:
"""
Core deck generation algorithm.
1. Resolve commander
2. Load collection filtered to color identity
3. Call enabled source adapters (stubbed)
4. Build candidate pool, score by role/type fit
5. Fill slots per target ratios, preferring owned cards
6. Return response dict
"""
# 1. Resolve commander
commander = _resolve_commander(req)
if not commander:
return {"error": "Commander not found. Please check the name or Scryfall ID."}
color_identity = req.color_identity or commander.get("color_identity", [])
print(f" [DECKGEN] Commander: {commander['name']}, CI: {color_identity}")
# 2. Load collection
collection = _get_collection_for_colors(color_identity)
print(f" [DECKGEN] {len(collection)} cards in collection match color identity")
# 3. Call source adapters (currently stubbed)
template_cards = [] # list of (name, weight, source_name)
sources = req.sources or DeckGenerationSourceConfig()
try:
if sources.use_archidekt:
from deck_sources.archidekt_adapter import fetch_template_decks as archidekt_fetch
for td in archidekt_fetch(commander["name"], color_identity, {"url": sources.archidekt_url}):
for tc in td.cards:
template_cards.append((tc.name, tc.quantity, "archidekt"))
except Exception as e:
print(f" [DECKGEN] Archidekt adapter error: {e}")
try:
if sources.use_edhrec:
from deck_sources.edhrec_adapter import fetch_template_decks as edhrec_fetch
for td in edhrec_fetch(commander["name"], color_identity):
for tc in td.cards:
template_cards.append((tc.name, tc.quantity, "edhrec"))
except Exception as e:
print(f" [DECKGEN] EDHREC adapter error: {e}")
try:
if sources.use_moxfield:
from deck_sources.moxfield_adapter import fetch_template_decks as moxfield_fetch
for td in moxfield_fetch(commander["name"], color_identity, {"url": sources.moxfield_url}):
for tc in td.cards:
template_cards.append((tc.name, tc.quantity, "moxfield"))
except Exception as e:
print(f" [DECKGEN] Moxfield adapter error: {e}")
try:
if sources.use_mtggoldfish:
from deck_sources.mtggoldfish_adapter import fetch_template_decks as mtggoldfish_fetch
for td in mtggoldfish_fetch(commander["name"], color_identity, {"url": sources.mtggoldfish_url}):
for tc in td.cards:
template_cards.append((tc.name, tc.quantity, "mtggoldfish"))
except Exception as e:
print(f" [DECKGEN] MTGGoldfish adapter error: {e}")
# 4. Build candidate pool from collection, scored by type need
# Build a map: name_lower -> card dict (deduped)
candidate_map = {}
for card in collection:
name = card.get("name", "")
if not name:
continue
key = name.lower()
# Skip the commander itself
if key == commander["name"].lower():
continue
if key not in candidate_map:
type_line = card.get("type_line", "")
card_type = _classify_card_type(type_line)
card_roles = _detect_card_roles(
card.get("oracle_text", ""),
type_line,
card.get("keywords", [])
)
candidate_map[key] = {
"scryfall_id": card.get("scryfall_id", ""),
"name": name,
"type_line": type_line,
"mana_cost": card.get("mana_cost", ""),
"cmc": float(card.get("cmc", 0)),
"card_type": card_type,
"roles": card_roles,
"source": "collection",
"quantity": 1,
"owned_qty": int(card.get("quantity", 0)),
"is_proxy": False,
"edhrec_rank": int(card.get("edhrec_rank", 99999)),
"image_url": f"https://api.scryfall.com/cards/{card.get('scryfall_id', '')}?format=image&version=normal"
if card.get("scryfall_id") else "",
}
else:
# Merge quantities
candidate_map[key]["owned_qty"] += int(card.get("quantity", 0))
# Add template cards that aren't in collection (as potential proxies)
for tname, tqty, tsource in template_cards:
key = tname.lower()
if key == commander["name"].lower():
continue
if key in candidate_map:
# Boost priority for cards that appear in templates
candidate_map[key]["_template_weight"] = candidate_map[key].get("_template_weight", 0) + tqty
if candidate_map[key]["source"] == "collection":
candidate_map[key]["source"] = f"collection+{tsource}"
elif req.allow_proxies:
candidate_map[key] = {
"scryfall_id": "",
"name": tname,
"type_line": "",
"mana_cost": "",
"cmc": 0,
"card_type": "Other",
"roles": [],
"source": tsource,
"quantity": 1,
"owned_qty": 0,
"is_proxy": True,
"edhrec_rank": 99999,
"image_url": "",
"_template_weight": tqty,
}
# 5. Fill slots per target ratios
targets = {
"Land": req.target_land_count,
"Creature": req.target_creature_count,
"Instant": req.target_instant_count,
"Sorcery": req.target_sorcery_count,
"Artifact": req.target_artifact_count,
"Enchantment": req.target_enchantment_count,
"Planeswalker": req.target_planeswalker_count,
}
# Group candidates by type
by_type = {}
for card in candidate_map.values():
ct = card["card_type"]
by_type.setdefault(ct, []).append(card)
# Score and sort candidates within each type
for ct, cards in by_type.items():
for card in cards:
score = 0
# Prefer owned cards heavily
if card["owned_qty"] > 0:
score += 100
# Prefer cards with functional roles
for r in card.get("roles", []):
if r in ("Ramp", "Draw", "Removal", "BoardWipe"):
score += 10
else:
score += 3
# Prefer lower EDHREC rank (more popular cards)
edhrec_rank = card.get("edhrec_rank", 99999)
if edhrec_rank < 500:
score += 15
elif edhrec_rank < 2000:
score += 10
elif edhrec_rank < 5000:
score += 5
# Prefer cards from templates
score += card.get("_template_weight", 0) * 5
# Prefer lower CMC (curve optimization)
cmc = card.get("cmc", 0)
if cmc <= 2:
score += 5
elif cmc <= 4:
score += 3
card["_score"] = score
cards.sort(key=lambda x: x["_score"], reverse=True)
# Pick cards for each slot type
deck_cards = []
used_names = set() # track by name to avoid duplicates
for card_type, target in targets.items():
candidates = by_type.get(card_type, [])
picked = 0
for card in candidates:
if picked >= target:
break
name_key = card["name"].lower()
if name_key in used_names:
continue
# If only collection, skip proxies
if req.only_cards_in_collection and card["owned_qty"] <= 0:
continue
used_names.add(name_key)
deck_cards.append(card)
picked += 1
# Fill remaining slots up to 99 (commander is #100)
total = sum(targets.values())
if total < 99:
remaining = 99 - len(deck_cards)
# Fill with best remaining cards from any type
all_remaining = []
for ct, cards in by_type.items():
for card in cards:
if card["name"].lower() not in used_names:
if not req.only_cards_in_collection or card["owned_qty"] > 0:
all_remaining.append(card)
all_remaining.sort(key=lambda x: x.get("_score", 0), reverse=True)
for card in all_remaining[:remaining]:
used_names.add(card["name"].lower())
deck_cards.append(card)
elif len(deck_cards) > 99:
deck_cards = deck_cards[:99]
# 6. Compute stats
stats = {"total": len(deck_cards) + 1, "land": 0, "nonland": 0, "by_type": {}, "owned": 0, "proxy": 0}
for card in deck_cards:
ct = card.get("card_type", "Other")
stats["by_type"][ct] = stats["by_type"].get(ct, 0) + 1
if ct == "Land":
stats["land"] += 1
else:
stats["nonland"] += 1
if card.get("owned_qty", 0) > 0:
stats["owned"] += 1
else:
stats["proxy"] += 1
# Clean up internal scoring keys
clean_cards = []
for card in deck_cards:
clean_cards.append({
"scryfall_id": card.get("scryfall_id", ""),
"name": card.get("name", ""),
"type_line": card.get("type_line", ""),
"mana_cost": card.get("mana_cost", ""),
"cmc": card.get("cmc", 0),
"card_type": card.get("card_type", "Other"),
"roles": card.get("roles", []),
"source": card.get("source", "collection"),
"quantity": 1,
"image_url": card.get("image_url", ""),
"owned_qty": card.get("owned_qty", 0),
"is_proxy": card.get("is_proxy", False),
})
print(f" [DECKGEN] Generated deck: {len(clean_cards)} cards + commander")
print(f" [DECKGEN] Stats: {stats}")
return {
"commander": commander,
"color_identity": color_identity,
"cards": clean_cards,
"stats": stats,
"targets": targets,
}
@app.get("/api/deck-generator/config")
async def deck_generator_config():
"""
Return default ratios, supported sources, and limits.
Used by the frontend to prefill the generator form.
"""
return {
"defaults": {
"target_land_count": 37,
"target_instant_count": 10,
"target_sorcery_count": 8,
"target_artifact_count": 10,
"target_enchantment_count": 8,
"target_creature_count": 25,
"target_planeswalker_count": 2,
"only_cards_in_collection": False,
"allow_proxies": True,
},
"sources": [
{"id": "archidekt", "name": "Archidekt", "enabled": True, "experimental": False},
{"id": "edhrec", "name": "EDHREC", "enabled": True, "experimental": False},
{"id": "moxfield", "name": "Moxfield", "enabled": False, "experimental": True},
{"id": "mtggoldfish", "name": "MTGGoldfish", "enabled": False, "experimental": True},
],
"limits": {
"deck_size": 100,
"max_external_templates": 5,
},
}
@app.post("/api/deck-generator/preview")
async def deck_generator_preview(req: DeckGenerationRequest):
"""
Generate a deck preview without saving it.
Returns the generated deck data for user review.
"""
result = _generate_deck(req)
if "error" in result:
raise HTTPException(400, result["error"])
return result
@app.post("/api/deck-generator/commit")
async def deck_generator_commit(req: DeckGenerationRequest):
"""
Generate a deck and save it to the Deck Builder.
Returns the generated deck data plus deck_id and deck_name.
"""
result = _generate_deck(req)
if "error" in result:
raise HTTPException(400, result["error"])
commander = result["commander"]
color_identity = result["color_identity"]
cards = result["cards"]
# Determine deck name
deck_name = req.deck_name or f"Auto - {commander['name']} - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
# Create the deck in the database
conn = _get_db_conn()
color_identity_json = json.dumps(color_identity)
cur = conn.execute(
"""
INSERT INTO decks (name, commander_scryfall_id, commander_name, color_identity, strategy_tag)
VALUES (?, ?, ?, ?, ?)
""",
(
deck_name,
commander.get("scryfall_id", ""),
commander.get("name", ""),
color_identity_json,
"auto-generated",
)
)
conn.commit()
deck_id = cur.lastrowid
# Insert commander as a deck card with is_commander=1
conn.execute(
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) VALUES (?, ?, ?, 1, 1, 'Commander')",
(deck_id, commander.get("scryfall_id", ""), commander.get("name", ""))
)
# Insert the 99 cards
for card in cards:
scryfall_id = card.get("scryfall_id", "")
card_name = card.get("name", "")
quantity = card.get("quantity", 1)
if not scryfall_id and not card_name:
continue
conn.execute(
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) VALUES (?, ?, ?, ?, 0, ?)",
(deck_id, scryfall_id, card_name, quantity, card.get("card_type", ""))
)
conn.commit()
# Also export a .dck file to the Forge decks directory so it appears in the Sim Lab
try:
decks_dir = CFG.forge_decks_dir
if decks_dir and os.path.isdir(decks_dir):
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_')
if not safe_name:
safe_name = f"AutoDeck_{deck_id}"
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
dck_lines = []
dck_lines.append("[metadata]")
dck_lines.append(f"Name={deck_name}")
dck_lines.append("")
dck_lines.append("[Commander]")
dck_lines.append(f"1 {commander['name']}")
dck_lines.append("")
dck_lines.append("[Main]")
for card in cards:
card_name = card.get("name", "")
if card_name:
dck_lines.append(f"{card.get('quantity', 1)} {card_name}")
with open(dck_path, "w", encoding="utf-8") as f:
f.write("\n".join(dck_lines))
print(f" [DECKGEN] Exported .dck file: {dck_path}")
except Exception as e:
print(f" [DECKGEN] Warning: Failed to export .dck file: {e}")
print(f" [DECKGEN] Saved deck '{deck_name}' (ID: {deck_id}) with {len(cards)} cards + commander")
result["deck_id"] = deck_id
result["deck_name"] = deck_name
return result
@app.get("/api/deck-generator/commander-search")
async def deck_generator_commander_search(q: str = ""):
"""
Search for legendary creatures/planeswalkers to use as commander.
First checks collection, then falls back to Scryfall.
"""
if not q or len(q) < 2:
return {"results": []}
conn = _get_db_conn()
q_lower = f"%{q.lower()}%"
# Search collection first
rows = conn.execute(
"""
SELECT DISTINCT name, type_line, color_identity, mana_cost, scryfall_id
FROM collection_entries
WHERE LOWER(name) LIKE ? AND is_legendary = 1
AND (type_line LIKE '%Creature%' OR type_line LIKE '%Planeswalker%')
ORDER BY name ASC
LIMIT 20
""",
(q_lower,)
).fetchall()
results = []
for r in rows:
ci = r["color_identity"]
if isinstance(ci, str):
try:
ci = json.loads(ci)
except Exception:
ci = []
results.append({
"name": r["name"],
"type_line": r["type_line"],
"color_identity": ci,
"mana_cost": r["mana_cost"] or "",
"scryfall_id": r["scryfall_id"],
"in_collection": True,
"image_url": f"https://api.scryfall.com/cards/{r['scryfall_id']}?format=image&version=normal"
if r["scryfall_id"] else "",
})
# If few results, supplement with Scryfall
if len(results) < 5:
try:
import urllib.parse
encoded = urllib.parse.quote(q)
_scryfall_rate_limit()
url = f"https://api.scryfall.com/cards/search?q={encoded}+t%3Alegendary+(t%3Acreature+OR+t%3Aplaneswalker)&order=edhrec&unique=cards"
rq = Request(url, headers={"User-Agent": "CommanderAILab/1.0", "Accept": "application/json"})
with urlopen(rq, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
existing_names = {r["name"].lower() for r in results}
for card in data.get("data", [])[:10]:
if card["name"].lower() in existing_names:
continue
image_uris = card.get("image_uris", {})
if not image_uris and card.get("card_faces"):
image_uris = card["card_faces"][0].get("image_uris", {})
results.append({
"name": card["name"],
"type_line": card.get("type_line", ""),
"color_identity": card.get("color_identity", []),
"mana_cost": card.get("mana_cost", ""),
"scryfall_id": card.get("id", ""),
"in_collection": False,
"image_url": image_uris.get("normal", image_uris.get("small", "")),
})
except Exception as e:
print(f" [DECKGEN] Scryfall search error: {e}")
return {"results": results[:20]}
# ══════════════════════════════════════════════════════════════
# Perplexity API — AI Deck Research & Generation
# ══════════════════════════════════════════════════════════════
class DeckResearchRequest(BaseModel):
deck_id: int # Deck to research
goal: Optional[str] = "Identify weaknesses and suggest upgrades"
budget_usd: Optional[float] = None
omit_cards: Optional[list[str]] = []
use_collection: bool = True
class DeckGenerateAIRequest(BaseModel):
commander: str
budget_usd: Optional[float] = None
budget_mode: str = "total" # "total" or "per_card"
omit_cards: Optional[list[str]] = []
use_collection: bool = True
def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
"""Build a compact collection summary for the AI, optionally filtered by color identity."""
conn = _get_db_conn()
# Base query: all collection entries with oracle text
where = "WHERE quantity > 0"
params = []
# Filter by color identity if provided
if color_identity:
# Cards must only contain colors in the commander's identity (+ colorless)
ci_set = set(c.upper() for c in color_identity)
# We'll do a post-filter since color_identity is JSON in the DB
pass # filter in Python after fetch
rows = conn.execute(f"""
SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity,
color_identity, category, is_game_changer, salt_score
FROM collection_entries {where}
ORDER BY edhrec_rank ASC, tcg_price DESC
""", params).fetchall()
# Color identity filter
def card_fits_identity(card_ci_str, ci_set):
if not ci_set:
return True
try:
card_ci = json.loads(card_ci_str) if card_ci_str else []
if not isinstance(card_ci, list):
return True
return all(c.upper() in ci_set for c in card_ci if c.upper() not in ('', 'C'))
except Exception:
return True
# Role detection from type_line, oracle_text, and category
def detect_role(row):
tl = (row['type_line'] or '').lower()
oracle = (row['oracle_text'] or '').lower()
cat = (row['category'] or '').lower()
kw = (row['keywords'] or '').lower()
# Priority order
if 'land' in tl:
return 'lands'
if any(w in cat for w in ['ramp', 'mana']):
return 'ramp'
if any(w in oracle for w in ['add {', 'add one mana', 'search your library for a basic land', 'search your library for a land']):
return 'ramp'
if 'mana' in cat or ('artifact' in tl and ('add' in oracle and '{' in oracle)):
return 'ramp'
if any(w in cat for w in ['draw', 'card advantage']):
return 'card_draw'
if 'draw' in oracle and 'card' in oracle:
return 'card_draw'
if any(w in cat for w in ['removal', 'targeted removal']):
return 'removal'
if 'destroy target' in oracle or 'exile target' in oracle or 'deals' in oracle:
return 'removal'
if any(w in cat for w in ['board wipe', 'boardwipe', 'wrath']):
return 'board_wipes'
if 'destroy all' in oracle or 'exile all' in oracle:
return 'board_wipes'
if any(w in cat for w in ['win', 'finisher', 'combo']):
return 'win_conditions'
if 'you win the game' in oracle or 'extra turn' in oracle or 'infinite' in oracle:
return 'win_conditions'
if 'creature' in tl:
return 'creatures'
return 'utility'
# Group cards by role
groups: dict[str, list] = {
'ramp': [], 'card_draw': [], 'removal': [], 'board_wipes': [],
'lands': [], 'win_conditions': [], 'creatures': [], 'utility': []
}
filtered_count = 0
for r in rows:
if color_identity and not card_fits_identity(r['color_identity'], ci_set):
continue
filtered_count += 1
role = detect_role(r)
groups[role].append({
'name': r['name'],
'count': r['quantity'],
'price': round(r['tcg_price'] or 0, 2),
'cmc': r['cmc'] or 0,
})
# Limit each group to top 30
group_descriptions = {
'ramp': 'Mana rocks and land ramp in deck colors',
'card_draw': 'Card draw and card advantage engines',
'removal': 'Targeted removal spells (destroy, exile, bounce)',
'board_wipes': 'Board wipes and mass removal',
'lands': 'Non-basic lands that fit the color identity',
'win_conditions': 'Win conditions, combo pieces, and finishers',
'creatures': 'Creatures (non-commander)',
'utility': 'Utility spells, enchantments, artifacts, and planeswalkers',
}
result_groups = []
for gid, cards in groups.items():
if not cards:
continue
result_groups.append({
'group_id': gid,
'description': group_descriptions.get(gid, ''),
'cards': cards[:30],
})
return {
'total_cards': len(rows),
'filtered_cards': filtered_count,
'groups': result_groups,
}
def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
"""Call Perplexity API chat/completions endpoint. Returns the assistant message content."""
if not CFG.pplx_api_key:
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
payload = {
'model': 'sonar',
'messages': messages,
'max_tokens': max_tokens,
'temperature': temperature,
'return_related_questions': False,
}
req_data = json.dumps(payload).encode('utf-8')
req = Request('https://api.perplexity.ai/chat/completions', data=req_data, method='POST')
req.add_header('Content-Type', 'application/json')
req.add_header('Authorization', f'Bearer {CFG.pplx_api_key}')
try:
with urlopen(req, timeout=120) as resp:
data = json.loads(resp.read())
choices = data.get('choices', [])
if not choices:
raise ValueError('Empty response from Perplexity API')
content = choices[0].get('message', {}).get('content', '')
usage = data.get('usage', {})
print(f'[pplx-api] tokens: prompt={usage.get("prompt_tokens","?")}, '
f'completion={usage.get("completion_tokens","?")}, '
f'model={data.get("model","?")}')
return content
except URLError as e:
raise HTTPException(502, f'Perplexity API call failed: {e}')
except json.JSONDecodeError as e:
raise HTTPException(502, f'Perplexity API returned invalid JSON: {e}')
def _extract_json_from_response(text: str) -> dict:
"""Extract JSON object from LLM response, handling markdown fences and extra text."""
# Strip markdown code fences
cleaned = re.sub(r'^```(?:json)?\s*', '', text.strip())
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
# Try direct parse
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
# Try to find JSON object in the text
match = re.search(r'\{[\s\S]*\}', text)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
raise ValueError(f'Could not extract JSON from response: {text[:300]}')
def _postprocess_deck_cards(cards: list[dict], color_identity: list[str] | None = None) -> list[dict]:
"""
Post-process AI-suggested cards:
- Validate names against collection DB
- Attach real prices
- Set from_collection flag
"""
conn = _get_db_conn()
processed = []
for card in cards:
name = card.get('name', '')
if not name:
continue
# Look up in collection
row = conn.execute(
"SELECT name, tcg_price, quantity, scryfall_id, type_line, cmc, oracle_text "
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
(name,)
).fetchone()
entry = {
'name': row['name'] if row else name,
'count': card.get('count', 1),
'role': card.get('role', ''),
'from_collection': bool(row and row['quantity'] and row['quantity'] > 0),
'estimated_price_usd': round(row['tcg_price'], 2) if row and row['tcg_price'] else card.get('estimated_price_usd', 0),
'scryfall_id': row['scryfall_id'] if row else '',
}
# Preserve extra fields from AI response
for extra_key in ('reason', 'synergy_with', 'priority', 'severity'):
if extra_key in card:
entry[extra_key] = card[extra_key]
processed.append(entry)
return processed
# ── Research Endpoint ─────────────────────────────────────────
@app.post('/api/deck-research')
async def deck_research(req: DeckResearchRequest):
"""Analyze an existing deck using Perplexity AI and suggest improvements."""
conn = _get_db_conn()
# Load deck info
deck = conn.execute('SELECT * FROM decks WHERE id = ?', (req.deck_id,)).fetchone()
if not deck:
raise HTTPException(404, f'Deck {req.deck_id} not found')
deck_name = deck['name']
commander_name = deck['commander_name'] or 'Unknown'
color_identity_str = deck['color_identity'] or '[]'
try:
color_identity = json.loads(color_identity_str)
except Exception:
color_identity = []
# Load deck cards
card_rows = conn.execute("""
SELECT dc.card_name, dc.quantity, dc.is_commander,
ce.type_line, ce.cmc, ce.oracle_text, ce.tcg_price
FROM deck_cards dc
LEFT JOIN collection_entries ce ON ce.scryfall_id = dc.scryfall_id
WHERE dc.deck_id = ?
ORDER BY dc.is_commander DESC, dc.card_name ASC
""", (req.deck_id,)).fetchall()
if not card_rows:
raise HTTPException(400, 'Deck has no cards')
# Build decklist text
decklist_lines = []
total_price = 0.0
for r in card_rows:
price = r['tcg_price'] or 0
total_price += price * (r['quantity'] or 1)
cmdr_tag = ' [COMMANDER]' if r['is_commander'] else ''
decklist_lines.append(f"{r['quantity'] or 1}x {r['card_name']}{cmdr_tag}")
decklist_text = '\n'.join(decklist_lines)
# Build collection summary if requested
collection_block = ''
if req.use_collection:
summary = _build_collection_summary(color_identity)
if summary['filtered_cards'] > 0:
coll_lines = [f'\nCOLLECTION SUMMARY ({summary["filtered_cards"]} cards in deck colors):']
for grp in summary['groups']:
card_names = [f"{c['name']} (${c['price']})" for c in grp['cards'][:15]]
coll_lines.append(f" {grp['group_id'].upper()} ({grp['description']}): {', '.join(card_names)}")
collection_block = '\n'.join(coll_lines)
omit_block = ''
if req.omit_cards:
omit_block = f'\nDO NOT suggest these cards: {", ".join(req.omit_cards)}'
budget_block = ''
if req.budget_usd:
budget_block = f'\nBUDGET: ${req.budget_usd} total for upgrades. Current deck value: ~${total_price:.0f}'
# Build messages
system_msg = """You are an elite Magic: The Gathering Commander analyst with encyclopedic knowledge of the format, metagame, and every card ever printed. Provide a DEEP, COMPREHENSIVE analysis.
Always respond with ONLY a JSON object, no markdown fences, no extra text.
JSON schema:
{
"overall_rating": "1-10 integer",
"rating_explanation": "2-3 sentence explanation of the rating",
"deck_description": "3-5 sentence overview of what this deck does, its game plan, and how it wins",
"archetype": "aggro|midrange|control|combo|stax|voltron|aristocrats|spellslinger|tokens|tribal|group_hug|lands|reanimator|other",
"bracket_level": {
"level": 1-4,
"reasoning": "why this bracket",
"power_ceiling": "what power level this deck could reach with upgrades"
},
"win_conditions": [
{"name": "Win condition name", "cards_involved": ["card1", "card2"], "description": "How this wins the game", "reliability": "high|medium|low"}
],
"synergy_packages": [
{"package_name": "Package Name (e.g. Sacrifice Engine, Blink Package)", "cards": ["card1", "card2", "card3"], "description": "How these cards work together", "strength": "strong|moderate|weak"}
],
"strengths": ["strength 1", "strength 2"],
"weaknesses": ["weakness 1", "weakness 2"],
"threat_assessment": {
"early_game": "1-2 sentences on turns 1-3 plan",
"mid_game": "1-2 sentences on turns 4-7 plan",
"late_game": "1-2 sentences on turns 8+ plan",
"vulnerability": "What shuts this deck down (e.g. graveyard hate, board wipes)"
},
"mana_analysis": {
"land_count": "current land count assessment",
"color_fixing": "assessment of color fixing quality",
"ramp_package": "assessment of ramp quantity and quality",
"curve_assessment": "is the mana curve appropriate for the strategy",
"problem_cards": ["cards that are hard to cast or mana-inefficient"]
},
"cuts": [{"name": "card to remove", "reason": "why", "severity": "must_cut|should_cut|consider_cutting"}],
"adds": [{"name": "card to add", "count": 1, "role": "ramp|removal|draw|creature|utility|land|combo_piece|protection|finisher", "estimated_price_usd": 2.5, "reason": "why this card", "synergy_with": ["existing card it synergizes with"], "priority": "critical|high|medium|nice_to_have"}],
"role_gaps": {
"ramp": {"current": 8, "recommended": 10, "note": "needs 2 more ramp sources"},
"card_draw": {"current": 5, "recommended": 10, "note": ""},
"removal": {"current": 6, "recommended": 8, "note": ""},
"board_wipes": {"current": 2, "recommended": 3, "note": ""},
"protection": {"current": 1, "recommended": 3, "note": ""},
"lands": {"current": 35, "recommended": 36, "note": ""}
},
"strategy_notes": "detailed strategic advice for piloting this deck"
}"""
user_msg = f"""Provide a DEEP, COMPREHENSIVE analysis of this Commander deck.
DECK NAME: {deck_name}
COMMANDER: {commander_name}
COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'}
CURRENT DECK VALUE: ~${total_price:.0f}
GOAL: {req.goal}
DECKLIST ({len(card_rows)} cards):
{decklist_text}
{budget_block}{omit_block}{collection_block}
Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4 per Commander Rules Committee), ALL synergy packages between cards, ALL win conditions, game plan by phase (early/mid/late), mana base health, every role gap. Suggest specific cuts with severity and specific adds with priority and synergy tags. For adds, prioritize cards from the COLLECTION SUMMARY when available."""
content = _call_pplx_api([
{'role': 'system', 'content': system_msg},
{'role': 'user', 'content': user_msg},
], max_tokens=8192)
try:
analysis = _extract_json_from_response(content)
except ValueError as e:
return JSONResponse({'error': str(e), 'raw_response': content[:500]}, status_code=422)
# Post-process "adds" — validate against DB
if 'adds' in analysis and isinstance(analysis['adds'], list):
analysis['adds'] = _postprocess_deck_cards(analysis['adds'], color_identity)
# Compute real total cost of adds
adds_total = sum(c.get('estimated_price_usd', 0) * c.get('count', 1) for c in analysis.get('adds', []))
analysis['adds_total_usd'] = round(adds_total, 2)
analysis['deck_name'] = deck_name
analysis['commander'] = commander_name
analysis['color_identity'] = color_identity
analysis['card_count'] = len(card_rows)
analysis['deck_value_usd'] = round(total_price, 2)
return analysis
# ── Generate Endpoint ─────────────────────────────────────────
@app.post('/api/deck-generate')
async def deck_generate_ai(req: DeckGenerateAIRequest):
"""Generate a full 100-card Commander deck using Perplexity AI."""
commander = req.commander.strip()
if not commander:
raise HTTPException(400, 'Commander name is required')
# Look up commander on Scryfall for color identity
color_identity = []
commander_type = ''
try:
scry_url = f'https://api.scryfall.com/cards/named?fuzzy={commander.replace(" ", "+")}'
scry_req = Request(scry_url)
scry_req.add_header('User-Agent', 'CommanderAILab/1.0')
with urlopen(scry_req, timeout=10) as resp:
scry_data = json.loads(resp.read())
color_identity = scry_data.get('color_identity', [])
commander = scry_data.get('name', commander) # Use canonical name
commander_type = scry_data.get('type_line', '')
except Exception as e:
print(f'[pplx-gen] Scryfall lookup failed for "{commander}": {e}')
# Build collection summary
collection_block = ''
if req.use_collection:
summary = _build_collection_summary(color_identity or None)
if summary['filtered_cards'] > 0:
coll_lines = [f'\nCOLLECTION SUMMARY ({summary["filtered_cards"]} cards available):']
for grp in summary['groups']:
card_names = [f"{c['name']} (${c['price']})" for c in grp['cards'][:20]]
coll_lines.append(f" {grp['group_id'].upper()}: {', '.join(card_names)}")
collection_block = '\n'.join(coll_lines)
omit_block = ''
if req.omit_cards:
omit_block = f'\nOMIT LIST (do NOT include): {", ".join(req.omit_cards)}'
budget_block = ''
if req.budget_usd:
mode_desc = 'total deck cost' if req.budget_mode == 'total' else 'per card'
budget_block = f'\nBUDGET: ${req.budget_usd} {mode_desc}. Stay within budget.'
system_msg = """You are an expert Magic: The Gathering Commander deck builder.
Build a complete, legal 100-card Commander deck (1 commander + 99 other cards).
Prefer cards from the player's collection when available.
Respect the budget and omit list.
Always respond with ONLY a JSON object, no markdown fences, no extra text.
JSON schema:
{
"commander": "Commander Name",
"strategy": "1-2 sentence strategy description",
"cards": [
{"name": "Card Name", "count": 1, "role": "ramp|removal|draw|creature|land|utility|win_condition", "estimated_price_usd": 2.5}
],
"reasoning": {
"strategy": "detailed strategy explanation",
"mana_curve": "mana curve reasoning",
"key_synergies": "key synergies and combos",
"budget_notes": "how budget was managed",
"collection_usage_notes": "which collection cards were used and why"
},
"estimated_total_usd": 187.5
}
Deck building rules:
- Exactly 100 cards total (commander + 99)
- No more than 1 copy of any card (except basic lands)
- 36-38 lands including commander-colored basics and utility lands
- ~10 ramp sources, ~10 card draw, ~8-10 removal, ~2-3 board wipes
- Include the commander in the cards list with role "commander"
- All cards must be legal in Commander format"""
user_msg = f"""Build a complete 100-card Commander deck for:
COMMANDER: {commander}
TYPE: {commander_type}
COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'}
{budget_block}{omit_block}{collection_block}
Build the deck as JSON. Prioritize collection cards when they fit the strategy."""
content = _call_pplx_api([
{'role': 'system', 'content': system_msg},
{'role': 'user', 'content': user_msg},
], max_tokens=8192, temperature=0.3)
try:
result = _extract_json_from_response(content)
except ValueError as e:
return JSONResponse({'error': str(e), 'raw_response': content[:500]}, status_code=422)
# Post-process cards
if 'cards' in result and isinstance(result['cards'], list):
result['cards'] = _postprocess_deck_cards(result['cards'], color_identity)
# Compute real totals
real_total = sum(c.get('estimated_price_usd', 0) * c.get('count', 1) for c in result.get('cards', []))
from_collection_count = sum(1 for c in result.get('cards', []) if c.get('from_collection'))
result['real_total_usd'] = round(real_total, 2)
result['from_collection_count'] = from_collection_count
result['total_cards'] = sum(c.get('count', 1) for c in result.get('cards', []))
result['color_identity'] = color_identity
return result
@app.get('/api/pplx/status')
async def pplx_status():
"""Check if Perplexity API is configured."""
return {
'configured': bool(CFG.pplx_api_key),
'key_prefix': CFG.pplx_api_key[:8] + '...' if CFG.pplx_api_key else '',
}
# ══════════════════════════════════════════════════════════════
# V3 Deck Generator (Perplexity Structured Output)
# ══════════════════════════════════════════════════════════════
@app.get('/api/deck/v3/status')
async def deck_gen_v3_status():
"""Check V3 deck generator status."""
return {
'initialized': _deck_gen_v3 is not None,
'pplx_configured': bool(CFG.pplx_api_key),
'model': _deck_gen_v3.pplx.model if _deck_gen_v3 else None,
'embeddings_loaded': (
_coach_embeddings.loaded if _coach_embeddings else False
),
'embedding_cards': (
_coach_embeddings.card_count if _coach_embeddings else 0
),
'error': _deck_gen_v3_error,
}
@app.post('/api/deck/v3/generate')
async def deck_gen_v3_generate(req: DeckGenV3Request):
"""
V3 Deck Generation — Perplexity structured output with Smart Substitution.
Pipeline:
1. Resolve commander via Scryfall
2. Build collection summary from DB
3. Call Perplexity Sonar with JSON schema enforcement
4. Cross-reference cards with collection for ownership
5. Run Smart Substitution (embedding + Perplexity fallback)
6. Return complete deck with substitution data
"""
if _deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized. Check PPLX_API_KEY.')
if not req.commander_name or len(req.commander_name.strip()) < 2:
raise HTTPException(400, 'Commander name is required (min 2 chars)')
try:
# Generate deck
result = _deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
# Run substitution if requested
if req.run_substitution and result.get('cards'):
from coach.schemas.substitution_schema import DeckCardWithStatus
cards = [DeckCardWithStatus(**c) for c in result['cards']]
sub_result = _deck_gen_v3.run_substitution(
cards=cards,
commander=result['commander'],
strategy=req.strategy,
)
result['cards'] = [c.model_dump() for c in sub_result.cards]
result['substitution_stats'] = {
'owned': sub_result.owned_count,
'substituted': sub_result.substituted_count,
'missing': sub_result.missing_count,
}
# Recompute stats with updated cards
from coach.services.deck_generator import DeckGeneratorV3
result['stats'] = DeckGeneratorV3._compute_stats(sub_result.cards)
return result
except ValueError as e:
return JSONResponse({'error': str(e)}, status_code=422)
except Exception as e:
print(f'[deck-gen-v3] Error: {e}')
import traceback
traceback.print_exc()
raise HTTPException(500, f'Deck generation failed: {str(e)}')
@app.post('/api/deck/v3/commit')
async def deck_gen_v3_commit(req: DeckGenV3Request):
"""
V3 Generate + Commit — generates the deck and saves it to the Deck Builder DB.
Also exports a .dck file for Forge Sim Lab.
"""
if _deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
if not req.commander_name or len(req.commander_name.strip()) < 2:
raise HTTPException(400, 'Commander name is required')
try:
# Generate deck (same as preview)
result = _deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
# Run substitution
if req.run_substitution and result.get('cards'):
from coach.schemas.substitution_schema import DeckCardWithStatus
cards = [DeckCardWithStatus(**c) for c in result['cards']]
sub_result = _deck_gen_v3.run_substitution(
cards=cards,
commander=result['commander'],
strategy=req.strategy,
)
result['cards'] = [c.model_dump() for c in sub_result.cards]
# Save to DB
commander = result['commander']
color_identity = result.get('color_identity', [])
cards = result.get('cards', [])
strategy = result.get('strategy_summary', '')
bracket = result.get('bracket', {}).get('level', 0)
deck_name = (
req.deck_name
or f"V3 - {commander['name']} - B{bracket} - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
)
conn = _get_db_conn()
cur = conn.execute(
"INSERT INTO decks (name, commander_scryfall_id, commander_name, color_identity, strategy_tag) "
"VALUES (?, ?, ?, ?, ?)",
(deck_name, commander.get('scryfall_id', ''),
commander.get('name', ''), json.dumps(color_identity),
f"v3-auto|B{bracket}|{strategy[:60]}")
)
conn.commit()
deck_id = cur.lastrowid
# Insert commander
conn.execute(
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) "
"VALUES (?, ?, ?, 1, 1, 'Commander')",
(deck_id, commander.get('scryfall_id', ''), commander.get('name', ''))
)
# Insert the 99
for card in cards:
card_name = card.get('name', '')
if not card_name or card_name == commander.get('name', ''):
continue # Skip commander (already inserted)
scryfall_id = card.get('scryfall_id', '')
# Use substitute name if substituted
if card.get('status') == 'substituted' and card.get('selected_substitute'):
card_name = card['selected_substitute']
conn.execute(
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity, is_commander, role_tag) "
"VALUES (?, ?, ?, ?, 0, ?)",
(deck_id, scryfall_id, card_name,
card.get('count', 1), card.get('category', ''))
)
conn.commit()
# Export .dck for Forge
try:
decks_dir = CFG.forge_decks_dir
if decks_dir and os.path.isdir(decks_dir):
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_')
if not safe_name:
safe_name = f"V3Deck_{deck_id}"
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
dck_lines = ["[metadata]", f"Name={deck_name}", "",
"[Commander]", f"1 {commander['name']}", "", "[Main]"]
for card in cards:
cname = card.get('name', '')
if card.get('status') == 'substituted' and card.get('selected_substitute'):
cname = card['selected_substitute']
if cname and cname != commander.get('name', ''):
dck_lines.append(f"{card.get('count', 1)} {cname}")
with open(dck_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(dck_lines))
print(f" [DeckGenV3] Exported .dck: {dck_path}")
except Exception as e:
print(f" [DeckGenV3] .dck export failed: {e}")
result['deck_id'] = deck_id
result['deck_name'] = deck_name
print(f" [DeckGenV3] Committed deck '{deck_name}' (ID: {deck_id})")
return result
except ValueError as e:
return JSONResponse({'error': str(e)}, status_code=422)
except Exception as e:
print(f'[deck-gen-v3] Commit error: {e}')
import traceback
traceback.print_exc()
raise HTTPException(500, f'Deck generation/commit failed: {str(e)}')
@app.post('/api/deck/v3/export/csv')
async def deck_gen_v3_export_csv(req: DeckGenV3Request):
"""Generate a deck and return as CSV."""
if _deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
result = _deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
cards = result.get('cards', [])
commander = result.get('commander', {})
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['Count', 'Name', 'Category', 'Roles', 'Status', 'Price_USD', 'Reason'])
# Commander row
writer.writerow([1, commander.get('name', ''), 'Commander', '', 'owned', '', 'Commander'])
for card in cards:
if card.get('name') == commander.get('name', ''):
continue
writer.writerow([
card.get('count', 1),
card.get('name', ''),
card.get('category', ''),
'; '.join(card.get('role_tags', [])),
card.get('status', 'unknown'),
card.get('estimated_price_usd', 0),
card.get('reason', ''),
])
csv_content = output.getvalue()
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_')
return StreamingResponse(
io.BytesIO(csv_content.encode('utf-8')),
media_type='text/csv',
headers={'Content-Disposition': f'attachment; filename="{safe_name}_deck.csv"'},
)
@app.post('/api/deck/v3/export/dck')
async def deck_gen_v3_export_dck(req: DeckGenV3Request):
"""Generate a deck and return as Forge .dck format."""
if _deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
result = _deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
cards = result.get('cards', [])
commander = result.get('commander', {})
lines = [
'[metadata]',
f'Name={commander.get("name", "Deck")} - V3 Auto',
'',
'[Commander]',
f'1 {commander.get("name", "")}',
'',
'[Main]',
]
for card in cards:
cname = card.get('name', '')
if card.get('status') == 'substituted' and card.get('selected_substitute'):
cname = card['selected_substitute']
if cname and cname != commander.get('name', ''):
lines.append(f"{card.get('count', 1)} {cname}")
dck_content = '\n'.join(lines)
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_')
return StreamingResponse(
io.BytesIO(dck_content.encode('utf-8')),
media_type='text/plain',
headers={'Content-Disposition': f'attachment; filename="{safe_name}.dck"'},
)
@app.post('/api/deck/v3/export/moxfield')
async def deck_gen_v3_export_moxfield(req: DeckGenV3Request):
"""Generate a deck and return in Moxfield paste format."""
if _deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
result = _deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
cards = result.get('cards', [])
commander = result.get('commander', {})
# Moxfield format: card lines in main, commander in dedicated section
lines = []
lines.append('// Commander')
lines.append(f'1 {commander.get("name", "")}')
lines.append('')
lines.append('// Deck')
for card in cards:
cname = card.get('name', '')
if card.get('status') == 'substituted' and card.get('selected_substitute'):
cname = card['selected_substitute']
if cname and cname != commander.get('name', ''):
lines.append(f"{card.get('count', 1)} {cname}")
txt = '\n'.join(lines)
return {'format': 'moxfield', 'content': txt, 'commander': commander.get('name', '')}
@app.post('/api/deck/v3/export/shopping')
async def deck_gen_v3_export_shopping(req: DeckGenV3Request):
"""Generate a deck and return a shopping list of cards not owned."""
if _deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
result = _deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
cards = result.get('cards', [])
shopping = []
total = 0.0
for card in cards:
if not card.get('owned', True) and card.get('status') != 'substituted':
price = card.get('estimated_price_usd', 0)
shopping.append({
'name': card.get('name', ''),
'count': card.get('count', 1),
'category': card.get('category', ''),
'estimated_price_usd': price,
'reason': card.get('reason', ''),
})
total += price * card.get('count', 1)
return {
'commander': result.get('commander', {}).get('name', ''),
'shopping_list': shopping,
'total_missing': len(shopping),
'estimated_cost_usd': round(total, 2),
}
# ══════════════════════════════════════════════════════════════
# Coach Service (LLM-powered deck coaching)
# ══════════════════════════════════════════════════════════════
# Global coach instances (initialized at startup)
_coach_service = None
_coach_embeddings = None
_coach_llm = None
_deck_gen_v3 = None # V3 Deck Generator (Perplexity structured output)
_deck_gen_v3_error = None # Error message if V3 init failed
def init_coach_service():
"""Initialize the coach service with LLM client and embeddings."""
global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
try:
from coach.llm_client import LMStudioClient
from coach.embeddings import MTGEmbeddingIndex
from coach.coach_service import CoachService
from coach.config import ensure_dirs
ensure_dirs()
_coach_llm = LMStudioClient()
_coach_embeddings = MTGEmbeddingIndex()
# Try to load embeddings (non-blocking — download happens on first use)
try:
from coach.config import EMBEDDINGS_NPZ
if EMBEDDINGS_NPZ.exists():
_coach_embeddings.load()
print(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
else:
print(" Coach: Embeddings not yet downloaded (will download on first use)")
except Exception as e:
print(f" Coach: Embeddings load failed: {e}")
_coach_service = CoachService(_coach_llm, _coach_embeddings)
# Check LLM connection
llm_status = _coach_llm.check_connection()
if llm_status.get("connected"):
print(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
else:
print(f" Coach LLM: Not connected (start LM Studio on 192.168.0.122:1234)")
print(" Coach: Service initialized")
# Initialize V3 Deck Generator (Perplexity)
_deck_gen_v3_error = None
if CFG.pplx_api_key:
try:
from coach.clients.perplexity_client import PerplexityClient
from coach.services.deck_generator import DeckGeneratorV3
from coach.config import DECK_GEN_MODEL
pplx_client = PerplexityClient(
api_key=CFG.pplx_api_key,
model=DECK_GEN_MODEL,
)
_deck_gen_v3 = DeckGeneratorV3(
pplx_client=pplx_client,
db_conn_factory=_get_db_conn,
embedding_index=_coach_embeddings,
)
print(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})")
except ImportError as e:
_deck_gen_v3_error = f"Missing dependency: {e}. Run: pip install openai"
print(f" Deck Gen V3: {_deck_gen_v3_error}")
_deck_gen_v3 = None
except Exception as e:
_deck_gen_v3_error = str(e)
print(f" Deck Gen V3: Failed to initialize: {e}")
_deck_gen_v3 = None
else:
_deck_gen_v3_error = "PPLX_API_KEY not set"
print(" Deck Gen V3: Skipped (no PPLX_API_KEY)")
except Exception as e:
print(f" Coach: Failed to initialize: {e}")
_coach_service = None
@app.get("/api/coach/status")
async def coach_status():
"""Check coach subsystem health."""
if _coach_service is None:
return {"llmConnected": False, "embeddingsLoaded": False,
"embeddingCards": 0, "deckReportsAvailable": 0,
"error": "Coach service not initialized"}
status = _coach_service.get_status()
return status.model_dump()
@app.get("/api/coach/decks")
async def coach_list_decks():
"""List all decks with available reports."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
return {"decks": _coach_service.list_deck_reports()}
@app.get("/api/coach/decks/{deck_id}/report")
async def coach_get_report(deck_id: str):
"""Get the latest DeckReport for a deck."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
report = _coach_service.load_deck_report(deck_id)
if report is None:
raise HTTPException(404, f"Deck report not found: {deck_id}")
return report.model_dump()
class CoachRequestBody(BaseModel):
goals: Optional[dict] = None
@app.post("/api/coach/decks/{deck_id}")
async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
"""Trigger a coaching session for a deck."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
# Load embeddings on first use if not loaded
if _coach_embeddings and not _coach_embeddings.loaded:
try:
_coach_embeddings.load(force_download=True)
except Exception as e:
print(f" Coach: Embeddings download failed: {e}")
goals = None
if body and body.goals:
from coach.models import CoachGoals
try:
goals = CoachGoals(**body.goals)
except Exception:
goals = None
try:
session = await _coach_service.run_coaching_session(deck_id, goals)
return session.model_dump()
except ValueError as e:
raise HTTPException(404, str(e))
except ConnectionError as e:
raise HTTPException(503, f"LLM connection failed: {e}")
except Exception as e:
raise HTTPException(500, f"Coach session failed: {e}")
@app.get("/api/coach/sessions")
async def coach_list_sessions(deck_id: str = None):
"""List all coaching sessions, optionally filtered by deck."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
return {"sessions": _coach_service.list_sessions(deck_id)}
@app.get("/api/coach/sessions/{session_id}")
async def coach_get_session(session_id: str):
"""Get a specific coaching session."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
session = _coach_service.load_session(session_id)
if session is None:
raise HTTPException(404, f"Session not found: {session_id}")
return session.model_dump()
@app.post("/api/coach/embeddings/download")
async def coach_download_embeddings():
"""Download and convert MTG card embeddings from HuggingFace."""
if _coach_embeddings is None:
raise HTTPException(500, "Coach service not initialized")
try:
_coach_embeddings.load(force_download=True)
return {
"success": True,
"cards": _coach_embeddings.card_count,
"message": f"Loaded {_coach_embeddings.card_count} card embeddings"
}
except Exception as e:
raise HTTPException(500, f"Failed to download embeddings: {e}")
@app.get("/api/coach/embeddings/search")
async def coach_search_similar(card: str, colors: str = None, top_n: int = 10):
"""Search for similar cards by name."""
if _coach_embeddings is None or not _coach_embeddings.loaded:
raise HTTPException(503, "Embeddings not loaded")
color_filter = list(colors.upper()) if colors else None
matches = _coach_embeddings.search_similar(
query_card=card, color_filter=color_filter, top_n=top_n
)
return {"query": card, "matches": [m.to_dict() for m in matches]}
@app.post("/api/coach/reports/generate")
async def coach_generate_reports():
"""Rebuild all deck reports from batch result JSONs in results/."""
try:
from coach.report_generator import generate_deck_reports
lab_root = Path(__file__).parent
results_dir = str(lab_root / CFG.results_dir)
reports_dir = str(lab_root / "deck-reports")
updated = generate_deck_reports(results_dir, reports_dir)
return {
"status": "ok",
"decksUpdated": updated,
"count": len(updated),
"message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found",
}
except Exception as e:
raise HTTPException(500, f"Report generation failed: {e}")
# ══════════════════════════════════════════════════════════════
# ML Training Data Endpoints
# ══════════════════════════════════════════════════════════════
@app.get("/api/ml/status")
async def ml_status():
"""Get ML decision logging status and available training data."""
global _ml_logging_enabled
lab_root = Path(__file__).parent
results_dir = lab_root / CFG.results_dir
# Find existing ML decision files
ml_files = []
if results_dir.exists():
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
lines = 0
try:
with open(f) as fh:
lines = sum(1 for _ in fh)
except Exception:
pass
ml_files.append({
"file": f.name,
"decisions": lines,
"size_kb": round(f.stat().st_size / 1024, 1),
})
total_decisions = sum(f["decisions"] for f in ml_files)
return {
"ml_logging_enabled": _ml_logging_enabled,
"training_files": ml_files,
"total_decisions": total_decisions,
"total_files": len(ml_files),
}
@app.post("/api/ml/toggle")
async def ml_toggle(enable: bool = True):
"""Enable or disable ML decision logging for future batch runs."""
global _ml_logging_enabled
_ml_logging_enabled = enable
return {
"ml_logging_enabled": _ml_logging_enabled,
"message": f"ML decision logging {'enabled' if enable else 'disabled'} for future batches",
}
@app.get("/api/ml/decisions/{filename}")
async def ml_get_decisions(filename: str, limit: int = 100, offset: int = 0):
"""Read decision snapshots from a training data file."""
lab_root = Path(__file__).parent
filepath = lab_root / CFG.results_dir / filename
if not filepath.exists() or not filename.startswith("ml-decisions-"):
raise HTTPException(404, f"ML decisions file not found: {filename}")
decisions = []
try:
import json as _json
with open(filepath) as f:
for i, line in enumerate(f):
if i < offset:
continue
if len(decisions) >= limit:
break
try:
decisions.append(_json.loads(line.strip()))
except _json.JSONDecodeError:
continue
except Exception as e:
raise HTTPException(500, f"Failed to read decisions: {e}")
return {
"file": filename,
"offset": offset,
"limit": limit,
"count": len(decisions),
"decisions": decisions,
}
# ══════════════════════════════════════════════════════════════
# ML Policy Inference Endpoints
# ══════════════════════════════════════════════════════════════
# Lazy-loaded policy inference service (only loads when first called)
_policy_service = None
_policy_service_init_attempted = False
def _get_policy_service():
"""Get or initialize the policy inference service (lazy loading)."""
global _policy_service, _policy_service_init_attempted
if _policy_service is None and not _policy_service_init_attempted:
_policy_service_init_attempted = True
try:
from ml.serving.policy_server import PolicyInferenceService
_policy_service = PolicyInferenceService()
if _policy_service.load():
print(f"[ML] Policy model loaded on {_policy_service.device}")
else:
print(f"[ML] Policy model not available: {_policy_service._load_error}")
except Exception as e:
print(f"[ML] Policy service init failed: {e}")
return _policy_service
@app.post("/api/ml/predict")
async def ml_predict(request: FastAPIRequest):
"""Predict a macro-action from a game state snapshot.
Accepts DecisionSnapshot-shaped JSON from the Java batch runner.
Returns the learned policy's recommended action.
Request body:
{
"turn": 5,
"phase": "main_1",
"active_seat": 0,
"players": [...],
"archetype": "aggro",
"temperature": 1.0,
"greedy": false
}
Response:
{
"action": "cast_creature",
"action_index": 0,
"confidence": 0.73,
"probabilities": {...},
"inference_ms": 2.3
}
"""
svc = _get_policy_service()
if svc is None or not svc._loaded:
detail = "Policy model not loaded. "
if svc:
detail += svc._load_error or "Train a model first."
else:
detail += "PyTorch may not be installed."
raise HTTPException(status_code=503, detail=detail)
body = await request.json()
playstyle = body.pop("archetype", "midrange")
temperature = body.pop("temperature", 1.0)
greedy = body.pop("greedy", False)
result = svc.predict(
decision_snapshot=body,
playstyle=playstyle,
temperature=temperature,
greedy=greedy,
)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
@app.post("/api/ml/predict/batch")
async def ml_predict_batch(request: FastAPIRequest):
"""Predict actions for multiple snapshots at once.
Request body: {"snapshots": [...], "greedy": true}
"""
svc = _get_policy_service()
if svc is None or not svc._loaded:
raise HTTPException(status_code=503, detail="Policy model not loaded")
body = await request.json()
snapshots = body.get("snapshots", [])
greedy = body.get("greedy", True)
if not snapshots:
return {"results": []}
results = svc.predict_batch(snapshots, greedy=greedy)
return {"results": results, "count": len(results)}
@app.get("/api/ml/model")
async def ml_model_info():
"""Get information about the loaded policy model."""
svc = _get_policy_service()
if svc is None:
return {
"loaded": False,
"error": "Policy service not initialized",
"torch_available": False,
}
return svc.get_status()
@app.post("/api/ml/reload")
async def ml_reload_model(checkpoint: str = None):
"""Hot-reload a policy model checkpoint."""
svc = _get_policy_service()
if svc is None:
raise HTTPException(status_code=503, detail="Policy service not available")
# If service hasn't loaded yet, try full load
if not svc._loaded:
ok = svc.load()
else:
ok = svc.reload(checkpoint)
return {
"success": ok,
"status": svc.get_status(),
}
# ══════════════════════════════════════════════════════════════
# ML Training Management Endpoints
# ══════════════════════════════════════════════════════════════
# Training state tracking
_training_state = {
"running": False,
"progress": 0,
"total_epochs": 0,
"current_epoch": 0,
"phase": "idle", # idle | building | training | evaluating | done | error
"message": "",
"metrics": None, # latest epoch metrics
"result": None, # final training result
"error": None,
"started_at": None,
}
def _run_training_pipeline(
results_dir: str,
epochs: int,
lr: float,
batch_size: int,
patience: int,
rebuild_dataset: bool,
):
"""Run the full ML training pipeline in a background thread."""
global _training_state
try:
import sys
project_root = str(Path(__file__).parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
_training_state["running"] = True
_training_state["started_at"] = datetime.now().isoformat()
_training_state["error"] = None
_training_state["result"] = None
data_dir = os.path.join(project_root, "ml", "models")
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
os.makedirs(data_dir, exist_ok=True)
os.makedirs(ckpt_dir, exist_ok=True)
# --- Phase 1: Build Dataset ---
train_path = os.path.join(data_dir, "train.npz")
if rebuild_dataset or not os.path.exists(train_path):
_training_state["phase"] = "building"
_training_state["message"] = "Loading card embeddings & building dataset..."
print("[ML Train] Building dataset (loading embeddings, may auto-download)...")
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
dataset = build_dataset(results_dir=results_dir)
if not dataset:
raise RuntimeError("No training data produced. Check server log for details.")
train_ds, val_ds, test_ds = split_dataset(dataset)
save_dataset(train_ds, os.path.join(data_dir, "train.npz"))
save_dataset(val_ds, os.path.join(data_dir, "val.npz"))
save_dataset(test_ds, os.path.join(data_dir, "test.npz"))
total_samples = len(dataset["states"])
_training_state["message"] = f"Dataset built: {total_samples} samples"
print(f"[ML Train] Dataset built: {total_samples} samples")
# --- Phase 2: Train ---
_training_state["phase"] = "training"
_training_state["total_epochs"] = epochs
_training_state["current_epoch"] = 0
_training_state["message"] = f"Training policy network ({epochs} epochs)..."
print(f"[ML Train] Starting training: {epochs} epochs, lr={lr}, bs={batch_size}")
import numpy as np
import torch
from ml.training.policy_network import PolicyNetwork
from ml.training.trainer import SupervisedTrainer
def load_npz_split(path):
data = np.load(path)
return data["states"].astype(np.float32), data["labels"].astype(np.int64)
train_states, train_labels = load_npz_split(train_path)
val_path = os.path.join(data_dir, "val.npz")
val_states, val_labels = load_npz_split(val_path)
actual_dim = train_states.shape[1]
model = PolicyNetwork(input_dim=actual_dim)
device = "cpu"
if torch.cuda.is_available():
device = "cuda"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
device = "mps"
trainer = SupervisedTrainer(
model=model,
device=device,
learning_rate=lr,
batch_size=batch_size,
epochs=epochs,
patience=patience,
checkpoint_dir=ckpt_dir,
)
# Hook into trainer to report progress
original_train = trainer.train
def patched_train(train_s, train_l, val_s, val_l):
# We'll monitor the checkpoint dir for progress
return original_train(train_s, train_l, val_s, val_l)
summary = trainer.train(train_states, train_labels, val_states, val_labels)
_training_state["current_epoch"] = summary.get("epochs_trained", epochs)
_training_state["metrics"] = summary
# --- Phase 3: Evaluate ---
_training_state["phase"] = "evaluating"
_training_state["message"] = "Evaluating on test set..."
test_path = os.path.join(data_dir, "test.npz")
eval_results = None
if os.path.exists(test_path):
from ml.training.trainer import load_checkpoint, evaluate_model
test_states, test_labels = load_npz_split(test_path)
best_model, _ = load_checkpoint(summary["checkpoint_path"], device)
eval_results = evaluate_model(best_model, test_states, test_labels, device)
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
json.dump(eval_results, f, indent=2)
# --- Done ---
_training_state["phase"] = "done"
_training_state["running"] = False
_training_state["result"] = {
"training": summary,
"evaluation": eval_results,
"checkpoint": summary.get("checkpoint_path", ""),
"device": device,
}
_training_state["message"] = f"Training complete! Best val acc: {summary.get('best_val_accuracy', 0):.1%}"
print(f"[ML Train] Complete: {summary.get('best_val_accuracy', 0):.1%} val accuracy")
# Auto-reload policy server with new checkpoint
global _policy_service, _policy_service_init_attempted
if _policy_service and _policy_service._loaded:
_policy_service.reload(summary.get("checkpoint_path"))
print("[ML Train] Policy server reloaded with new checkpoint")
elif not _policy_service_init_attempted:
_policy_service_init_attempted = False # Allow re-init with new model
except Exception as e:
import traceback
_training_state["phase"] = "error"
_training_state["running"] = False
_training_state["error"] = str(e)
_training_state["message"] = f"Training failed: {e}"
print(f"[ML Train] ERROR: {e}")
traceback.print_exc()
@app.post("/api/ml/train")
async def ml_start_training(request: FastAPIRequest):
"""Trigger ML training pipeline from the web UI.
Request body (all optional):
{
"epochs": 50,
"lr": 0.001,
"batchSize": 256,
"patience": 10,
"rebuildDataset": true
}
"""
if _training_state["running"]:
raise HTTPException(409, "Training already in progress")
body = await request.json() if await request.body() else {}
epochs = body.get("epochs", 50)
lr = body.get("lr", 0.001)
batch_size = body.get("batchSize", 256)
patience = body.get("patience", 10)
rebuild = body.get("rebuildDataset", True)
results_dir = os.path.join(str(Path(__file__).parent), "results")
# Reset state
_training_state.update({
"running": True,
"progress": 0,
"total_epochs": epochs,
"current_epoch": 0,
"phase": "starting",
"message": "Initializing training pipeline...",
"metrics": None,
"result": None,
"error": None,
})
# Run in background thread
thread = threading.Thread(
target=_run_training_pipeline,
args=(results_dir, epochs, lr, batch_size, patience, rebuild),
daemon=True,
)
thread.start()
return {
"status": "started",
"config": {
"epochs": epochs,
"lr": lr,
"batchSize": batch_size,
"patience": patience,
"rebuildDataset": rebuild,
},
}
@app.get("/api/ml/train/status")
async def ml_training_status():
"""Get current training pipeline status."""
return dict(_training_state)
@app.get("/api/ml/data/status")
async def ml_data_status():
"""Get status of available training data and model checkpoints."""
project_root = Path(__file__).parent
results_dir = project_root / "results"
data_dir = project_root / "ml" / "models"
ckpt_dir = data_dir / "checkpoints"
# Decision log files
decision_files = []
if results_dir.exists():
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
line_count = 0
try:
with open(f, "r") as fh:
line_count = sum(1 for _ in fh)
except Exception:
pass
decision_files.append({
"name": f.name,
"size": f.stat().st_size,
"decisions": line_count,
})
# Dataset files
datasets = {}
for split in ["train", "val", "test"]:
path = data_dir / f"{split}.npz"
if path.exists():
try:
import numpy as np
data = np.load(str(path))
datasets[split] = {
"samples": int(data["states"].shape[0]),
"features": int(data["states"].shape[1]),
"size": path.stat().st_size,
}
except Exception:
datasets[split] = {"size": path.stat().st_size}
# Checkpoints
checkpoints = []
if ckpt_dir.exists():
for f in sorted(ckpt_dir.glob("*.pt")):
checkpoints.append({
"name": f.name,
"size": f.stat().st_size,
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
})
# Eval results
eval_path = ckpt_dir / "eval_results.json"
eval_results = None
if eval_path.exists():
try:
with open(eval_path, "r") as f:
eval_results = json.load(f)
except Exception:
pass
return {
"decisionFiles": decision_files,
"totalDecisions": sum(d["decisions"] for d in decision_files),
"datasets": datasets,
"checkpoints": checkpoints,
"evalResults": eval_results,
"policyLoaded": _policy_service is not None and _policy_service._loaded if _policy_service else False,
}
# ══════════════════════════════════════════════════════════════
# PPO Training + Tournament Endpoints
# ══════════════════════════════════════════════════════════════
_ppo_state = {
"running": False,
"iteration": 0,
"total_iterations": 0,
"phase": "idle",
"message": "",
"metrics": None,
"result": None,
"error": None,
}
_tournament_state = {
"running": False,
"phase": "idle",
"message": "",
"result": None,
"error": None,
}
def _run_ppo_pipeline(
iterations: int, episodes_per_iter: int, ppo_epochs: int, batch_size: int,
lr: float, clip_epsilon: float, entropy_coeff: float,
opponent: str, playstyle: str, load_supervised: str,
):
"""Run PPO training in a background thread."""
global _ppo_state
try:
import sys
project_root = str(Path(__file__).parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
_ppo_state["running"] = True
_ppo_state["error"] = None
_ppo_state["result"] = None
from ml.training.ppo_trainer import PPOTrainer, PPOConfig
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
config = PPOConfig(
iterations=iterations,
episodes_per_iter=episodes_per_iter,
ppo_epochs=ppo_epochs,
batch_size=batch_size,
learning_rate=lr,
clip_epsilon=clip_epsilon,
entropy_coeff=entropy_coeff,
opponent=opponent,
playstyle=playstyle,
checkpoint_dir=ckpt_dir,
load_supervised=load_supervised if load_supervised else None,
)
trainer = PPOTrainer(config)
def progress_cb(iteration, metrics):
_ppo_state["iteration"] = iteration
_ppo_state["phase"] = "training"
_ppo_state["message"] = f"Iteration {iteration}/{iterations} | WR: {metrics.get('win_rate', 0):.0%}"
_ppo_state["metrics"] = metrics
summary = trainer.train(progress_callback=progress_cb)
_ppo_state["phase"] = "done"
_ppo_state["running"] = False
_ppo_state["result"] = summary
_ppo_state["message"] = f"PPO complete! Best win rate: {summary.get('best_win_rate', 0):.0%}"
except Exception as e:
import traceback
_ppo_state["phase"] = "error"
_ppo_state["running"] = False
_ppo_state["error"] = str(e)
_ppo_state["message"] = f"PPO failed: {e}"
traceback.print_exc()
@app.post("/api/ml/train/ppo")
async def ml_start_ppo(request: FastAPIRequest):
"""Start PPO training pipeline."""
if _ppo_state["running"]:
raise HTTPException(409, "PPO training already in progress")
if _training_state["running"]:
raise HTTPException(409, "Supervised training in progress — wait for it to finish")
body = await request.json() if await request.body() else {}
iterations = body.get("iterations", 100)
episodes = body.get("episodesPerIter", 64)
ppo_epochs = body.get("ppoEpochs", 4)
batch_size = body.get("batchSize", 256)
lr = body.get("lr", 3e-4)
clip_eps = body.get("clipEpsilon", 0.2)
entropy = body.get("entropyCoeff", 0.01)
opponent = body.get("opponent", "heuristic")
playstyle = body.get("playstyle", "midrange")
load_sup = body.get("loadSupervised", "")
_ppo_state.update({
"running": True, "iteration": 0, "total_iterations": iterations,
"phase": "starting", "message": "Initializing PPO...",
"metrics": None, "result": None, "error": None,
})
thread = threading.Thread(
target=_run_ppo_pipeline,
args=(iterations, episodes, ppo_epochs, batch_size, lr, clip_eps, entropy, opponent, playstyle, load_sup),
daemon=True,
)
thread.start()
return {"status": "started", "iterations": iterations}
@app.get("/api/ml/train/ppo/status")
async def ml_ppo_status():
"""Get PPO training status."""
return dict(_ppo_state)
def _run_tournament_pipeline(episodes: int, playstyle: str):
"""Run tournament in a background thread."""
global _tournament_state
try:
import sys
project_root = str(Path(__file__).parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
_tournament_state["running"] = True
_tournament_state["error"] = None
_tournament_state["result"] = None
_tournament_state["phase"] = "running"
_tournament_state["message"] = "Running tournament..."
from ml.eval.tournament import (
run_tournament, HeuristicPolicy, RandomPolicy, LearnedPolicy,
)
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
policies = {
"heuristic": HeuristicPolicy(),
"random": RandomPolicy(),
}
sup_path = os.path.join(ckpt_dir, "best_policy.pt")
if os.path.exists(sup_path):
policies["supervised"] = LearnedPolicy(sup_path)
ppo_path = os.path.join(ckpt_dir, "best_ppo.pt")
if os.path.exists(ppo_path):
policies["ppo"] = LearnedPolicy(ppo_path)
result = run_tournament(
policies=policies,
episodes_per_matchup=episodes,
playstyle=playstyle,
)
# Save results
output_path = os.path.join(ckpt_dir, "tournament_results.json")
with open(output_path, "w") as f:
json.dump(result.to_dict(), f, indent=2)
_tournament_state["phase"] = "done"
_tournament_state["running"] = False
_tournament_state["result"] = result.to_dict()
_tournament_state["message"] = f"Tournament complete! {result.total_matches} matches"
except Exception as e:
import traceback
_tournament_state["phase"] = "error"
_tournament_state["running"] = False
_tournament_state["error"] = str(e)
_tournament_state["message"] = f"Tournament failed: {e}"
traceback.print_exc()
@app.post("/api/ml/tournament")
async def ml_start_tournament(request: FastAPIRequest):
"""Start a tournament evaluation."""
if _tournament_state["running"]:
raise HTTPException(409, "Tournament already in progress")
body = await request.json() if await request.body() else {}
episodes = body.get("episodes", 50)
playstyle = body.get("playstyle", "midrange")
_tournament_state.update({
"running": True, "phase": "starting",
"message": "Initializing tournament...",
"result": None, "error": None,
})
thread = threading.Thread(
target=_run_tournament_pipeline,
args=(episodes, playstyle),
daemon=True,
)
thread.start()
return {"status": "started", "episodes": episodes}
@app.get("/api/ml/tournament/status")
async def ml_tournament_status():
"""Get tournament status."""
return dict(_tournament_state)
@app.get("/api/ml/tournament/results")
async def ml_tournament_results():
"""Get latest tournament results."""
project_root = Path(__file__).parent
results_path = project_root / "ml" / "models" / "checkpoints" / "tournament_results.json"
if results_path.exists():
with open(results_path, "r") as f:
return json.load(f)
return {"error": "No tournament results found. Run a tournament first."}
# ══════════════════════════════════════════════════════════════
# Static File Serving (React SPA) — MUST be after all API routes
# ══════════════════════════════════════════════════════════════
# React SPA: serve from frontend/commander-ai-lab-ui/dist (built with Vite)
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
_legacy_ui_dir = Path(__file__).parent / "ui"
if _spa_dir.exists():
# Serve static assets (JS, CSS, images) from the SPA dist folder
_spa_assets = _spa_dir / "assets"
if _spa_assets.exists():
app.mount("/assets", StaticFiles(directory=str(_spa_assets)), name="spa-assets")
# SPA catch-all: serve index.html for all non-API routes (client-side routing)
@app.get("/{full_path:path}")
async def _spa_catchall(full_path: str):
from fastapi.responses import FileResponse
# If the exact file exists in dist, serve it
requested_file = _spa_dir / full_path
if full_path and requested_file.exists() and requested_file.is_file():
return FileResponse(str(requested_file))
# Otherwise serve index.html (let React Router handle it)
return FileResponse(str(_spa_dir / "index.html"))
elif _legacy_ui_dir.exists():
# Fallback: legacy HTML files
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
# ══════════════════════════════════════════════════════════════
# Startup
# ══════════════════════════════════════════════════════════════
def parse_args():
parser = argparse.ArgumentParser(description="Commander AI Lab API Server v3")
parser.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""),
help="Path to forge-gui-desktop jar-with-dependencies")
parser.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", ""),
help="Forge working directory (contains res/)")
parser.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", ""),
help="Path to Commander deck files (default: %%APPDATA%%/Forge/decks/commander)")
parser.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""),
help="Path to commander-ai-lab.jar (default: auto-detect from target/)")
parser.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
parser.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", "96c7dab35ddbd8829b04c0f5bcea57f5ede20496"),
help="Ximilar API key for card scanner (visual AI recognition)")
parser.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", "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"),
help="Perplexity API key for AI deck research/generation (env: PPLX_API_KEY)")
return parser.parse_args()
def resolve_lab_jar() -> str:
target_dir = Path(__file__).parent / "target"
if target_dir.exists():
for pattern in [
"commander-ai-lab-*-jar-with-dependencies.jar",
"commander-ai-lab-*-shaded.jar",
"commander-ai-lab-*.jar",
]:
jars = sorted(target_dir.glob(pattern))
jars = [j for j in jars if not j.name.startswith("original-")]
if jars:
return str(jars[0])
return ""
def resolve_forge_decks_dir() -> str:
if sys.platform == "win32":
appdata = os.environ.get("APPDATA", "")
if appdata:
candidate = os.path.join(appdata, "Forge", "decks", "commander")
if os.path.isdir(candidate):
return candidate
home = Path.home()
for candidate in [
home / ".forge" / "decks" / "commander",
home / "Forge" / "decks" / "commander",
]:
if candidate.is_dir():
return str(candidate)
return ""
def load_commander_meta():
"""Load commander meta mapping from file or use builtins."""
global COMMANDER_META
meta_path = Path(__file__).parent / "commander-meta.json"
if meta_path.exists():
try:
with open(meta_path, "r", encoding="utf-8") as f:
COMMANDER_META = json.load(f)
print(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
return
except Exception as e:
print(f" WARNING: Failed to load commander-meta.json: {e}")
COMMANDER_META = BUILTIN_COMMANDERS
print(f" Meta: {len(COMMANDER_META)} built-in commanders")
def main():
args = parse_args()
CFG.forge_jar = args.forge_jar
CFG.forge_dir = args.forge_dir
CFG.forge_decks_dir = args.forge_decks_dir or resolve_forge_decks_dir()
CFG.lab_jar = args.lab_jar or resolve_lab_jar()
CFG.port = args.port
CFG.ximilar_api_key = args.ximilar_key
CFG.pplx_api_key = args.pplx_key
print("╔══════════════════════════════════════════════════╗")
print("║ Commander AI Lab — API Server v3.0.0 ║")
print("╚══════════════════════════════════════════════════╝")
print()
print(f" Forge JAR: {CFG.forge_jar}")
print(f" Forge Dir: {CFG.forge_dir}")
print(f" Decks Dir: {CFG.forge_decks_dir}")
print(f" Lab JAR: {CFG.lab_jar}")
print(f" Results Dir: {CFG.results_dir}")
print(f" Port: {CFG.port}")
print(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET (scanner will fail)'}")
print(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET (AI research/gen disabled)'}")
j17 = get_java17()
print(f" Java 17: {j17 if j17 != 'java' else 'NOT FOUND (batch sim may fail on Java 25+)'}")
print(f" LM Studio: http://192.168.0.122:1234")
load_commander_meta()
download_precon_database() # Auto-downloads all 163+ Commander precons on first run
init_collection_db()
init_coach_service()
# Ximilar API key check
if not CFG.ximilar_api_key:
print(" WARNING: --ximilar-key not set. Card scanner will not work.")
print(" Set via CLI: --ximilar-key YOUR_KEY")
print(" Or env var: XIMILAR_API_KEY=YOUR_KEY")
if not CFG.forge_jar:
print("WARNING: --forge-jar not set. /api/lab/start will fail.")
if not CFG.lab_jar:
print("WARNING: Lab JAR not found. Build with: mvn package -DskipTests")
if not CFG.forge_decks_dir:
print("WARNING: Commander decks dir not found. /api/lab/decks will return empty.")
print()
print(f" Starting server on http://localhost:{CFG.port}")
if _spa_dir.exists():
print(f" Web UI: http://localhost:{CFG.port}/ (React SPA)")
print(f" Routes: / (Batch Sim), /collection, /decks, /autogen, /simulator, /coach, /training")
else:
print(f" Web UI: http://localhost:{CFG.port}/index.html (legacy HTML)")
print(f" NOTE: React SPA not built. Run: cd frontend/commander-ai-lab-ui && npm install && npm run build")
print(f" API docs: http://localhost:{CFG.port}/docs")
print()
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
if __name__ == "__main__":
main()