Files
rgriffithandClaude Opus 4.6 9eaa41a4ae feat: deck dropdown, user-decks pipeline, deckgen fixes, gpt-oss removal
- Add user-decks/ directory and USER_DECKS_DIR config for exported decks
- Update export-to-sim to write to user-decks/ with static mount in lab_api
- Replace import/paste UI with API-backed "My Decks" dropdown in LAN client
  and AI Lab, fetching from GET /api/lab/decks with source grouping
- Fix GET /api/lab/decks to JOIN deck_cards for totalCards count
- Fix deckgenerator: add missing DOM elements (dg-strategy-bar, dg-stats-bar,
  dg-card-groups) inside #dg-results, null guards on render functions,
  fix setLoading always hiding results
- Replace gpt-oss:20b with claude-opus-4-20250514 across all files
- Add user-decks/ to _BUILTIN_DECK_DIRS in deck_service.py
- Enrich missing type_line from Forge card cache in DB deck path
- Collapse import bar behind <details> toggle in AI Lab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 01:17:30 -04:00

445 lines
17 KiB
Python

"""Deck service: .dck parsing, deck profile saving, deck loading."""
import json
import logging
import os
import re
from pathlib import Path
from typing import Optional
from fastapi import HTTPException
from models.state import CFG
from services.database import _get_db_conn, _row_to_dict
log_collect = logging.getLogger("lab.collection")
# Repo root = two levels up from this file (services/deck_service.py)
_REPO_ROOT = Path(__file__).parent.parent
# Repo-relative deck dirs always searched as fallbacks (zero-config support)
_BUILTIN_DECK_DIRS = [
_REPO_ROOT / "precon-decks",
_REPO_ROOT / "sample-decks",
_REPO_ROOT / "imported-decks",
_REPO_ROOT / "user-decks",
]
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
_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:
tl = type_line or ""
for t in _TYPE_PRIORITY:
if t in tl:
return t
return "Other"
_CATEGORY_MAP = {
"Ramp": ["search your library for a", "add one mana", "add {c}", "{t}: add"],
"Removal": ["destroy target", "exile target", "counter target spell"],
"Draw": ["draw a card", "draw two", "draw three", "draw cards"],
"Board Wipe": ["destroy all", "exile all", "all creatures get -"],
"Protection": ["hexproof", "indestructible", "shroud", "ward"],
"Tutor": ["search your library"],
}
def _classify_card_category(type_line: str, oracle_text: str) -> str:
"""Assign a functional category based on type_line and oracle text."""
card_type = _classify_card_type(type_line)
if card_type == "Land":
return "Land"
text = (oracle_text or "").lower()
for cat, phrases in _CATEGORY_MAP.items():
if any(p in text for p in phrases):
return cat
if card_type == "Creature":
return "Creature"
return card_type
def _get_deck_or_404(deck_id: int):
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:
conn = _get_db_conn()
rows = conn.execute("""
SELECT dc.id, dc.scryfall_id, dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.keywords, cr.keywords, '[]') as keywords,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, keywords, cmc, color_identity
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, oracle_text, keywords, cmc, color_identity
FROM card_records GROUP BY name
) cr ON cr.name = dc.card_name
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, "C": 0}
total_cards = 0
roles_count = {}
for row in rows:
r = dict(row)
qty = r.get("quantity", 1)
total_cards += qty
card_type = _classify_card_type(r.get("type_line", ""))
counts_by_type[card_type] = counts_by_type.get(card_type, 0) + qty
cmc = int(r.get("cmc", 0) or 0)
if card_type != "Land":
key = str(min(cmc, 6)) if cmc < 6 else "6+"
mana_curve[key] = mana_curve.get(key, 0) + qty
ci_raw = r.get("color_identity", "[]")
if isinstance(ci_raw, str):
try:
ci_raw = json.loads(ci_raw)
except Exception:
ci_raw = []
for c in (ci_raw or []):
if c in color_pips:
color_pips[c] += qty
role = r.get("role_tag") or "Unknown"
roles_count[role] = roles_count.get(role, 0) + qty
return {
"total_cards": total_cards,
"counts_by_type": counts_by_type,
"mana_curve": mana_curve,
"color_pips": color_pips,
"roles": roles_count,
}
def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool:
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
def _to_edhrec_slug(name: str) -> str:
slug = name.lower()
slug = re.sub(r"[',.]" , "", slug)
slug = re.sub(r"[^a-z0-9]+", "-", slug)
return slug.strip("-")
def parse_dck_file(deck_path: str) -> dict:
"""Parse a Forge .dck file into {name, commanders, mainboard, colorIdentity, totalCards}."""
path = Path(deck_path)
if not path.exists():
return {"error": f"File not found: {deck_path}"}
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
name = path.stem
section = "main"
commanders: dict = {}
mainboard: dict = {}
commander_name = None
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
if line == "[metadata]":
section = "metadata"; continue
if line.startswith("Name="):
name = line.split("=", 1)[1].strip(); continue
if line == "[Commander]":
section = "commander"; continue
if line == "[Main]":
section = "main"; continue
if line.lower().startswith("sideboard") or line == "[Sideboard]":
section = "sideboard"; continue
clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip()
clean = re.sub(r"\s*\*\.\*$", "", clean).strip()
m = re.match(r"^(\d+)x?\s+(.+)$", clean)
qty, card_name = (int(m.group(1)), m.group(2).strip()) if m else (1, clean)
if not card_name:
continue
if section == "commander":
commanders[card_name] = qty
if not commander_name:
commander_name = card_name
elif section != "sideboard":
mainboard[card_name] = qty
if commander_name:
name = f"{commander_name} - Text Import"
total = sum(commanders.values()) + sum(mainboard.values())
return {"name": name, "commander": commander_name, "commanders": commanders, "mainboard": mainboard, "colorIdentity": [], "totalCards": total}
def _save_profile_to_dck(profile: dict) -> Path:
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
for name, qty in profile.get("commanders", {}).items():
lines.append(f"{qty} {name}")
lines += ["", "[Main]"]
for name, qty in profile.get("mainboard", {}).items():
lines.append(f"{qty} {name}")
content = "\n".join(lines)
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 = str(_REPO_ROOT / "imported-decks")
os.makedirs(save_dir, exist_ok=True)
out_path = Path(save_dir) / f"{safe_name}.dck"
out_path.write_text(content, encoding="utf-8")
log_collect.info(f" Saved .dck: {out_path}")
return out_path
def _find_dck_file(deck_name: str) -> Optional[Path]:
"""Search known deck directories for a .dck file matching deck_name.
Resolution order:
1. CFG.precon_dir (explicit CLI/env config)
2. CFG.forge_decks_dir (Forge user decks)
3. FORGE_DIR/res/decks/commander (Forge bundled commander decks)
4. Repo-relative fallbacks: precon-decks/, sample-decks/, imported-decks/
(always searched so precon decks work with zero configuration)
Within each directory tries:
a. Exact stem match (Elven_Empire.dck)
b. Case-insensitive fuzzy match stripping underscores/spaces
"""
search_dirs: list[Path] = []
# CFG-configured paths (set via CLI args)
if CFG.precon_dir and os.path.isdir(CFG.precon_dir):
search_dirs.append(Path(CFG.precon_dir))
if CFG.forge_decks_dir and os.path.isdir(CFG.forge_decks_dir):
search_dirs.append(Path(CFG.forge_decks_dir))
if CFG.forge_dir:
forge_cmdr = Path(CFG.forge_dir) / "res" / "decks" / "commander"
if forge_cmdr.is_dir():
search_dirs.append(forge_cmdr)
# Repo-relative fallbacks -- always active so precon decks need no config
for builtin_dir in _BUILTIN_DECK_DIRS:
if builtin_dir.is_dir() and builtin_dir not in search_dirs:
search_dirs.append(builtin_dir)
target_norm = deck_name.lower().replace("_", "").replace(" ", "")
for d in search_dirs:
exact = d / f"{deck_name}.dck"
if exact.exists():
return exact
try:
for f in d.iterdir():
if f.suffix.lower() == ".dck":
if f.stem.lower().replace("_", "").replace(" ", "") == target_norm:
log_collect.info(f"[DeckService] Fuzzy .dck match: '{deck_name}' -> '{f}'")
return f
except OSError:
pass
return None
def _load_deck_cards_from_dck(dck_path: Path) -> list:
"""Parse a .dck file and return card dicts enriched with Forge card data.
Calls lookup_forge_card() for each card so that type_line, oracle_text,
cmc, mana_cost, power, toughness, and keywords are populated from the
Forge cardsfolder cache. This ensures Card.is_land() / is_creature()
work correctly when a deck is loaded via the .dck fallback path (i.e.
the deck is not yet in the SQLite DB).
The Forge cache is lazy-loaded and module-level, so there is no
per-game overhead after the first deck load.
"""
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card # lazy to avoid circular import
parsed = parse_dck_file(str(dck_path))
cards = []
def _make_card(card_name: str, qty: int, is_commander: int) -> dict:
forge = lookup_forge_card(card_name)
power = ""
toughness = ""
if forge and "/" in forge.pt:
parts = forge.pt.split("/", 1)
power = parts[0].strip()
toughness = parts[1].strip()
return {
"card_name": card_name,
"name": card_name,
"quantity": qty,
"is_commander": is_commander,
"type_line": forge.type_line if forge else "",
"oracle_text": forge.oracle_text if forge else "",
"cmc": forge.cmc if forge else 0,
"mana_cost": forge.mana_cost if forge else "",
"power": power,
"toughness": toughness,
"keywords": json.dumps(forge.keywords) if forge else "[]",
"color_identity": "[]",
}
for card_name, qty in parsed.get("commanders", {}).items():
cards.append(_make_card(card_name, qty, 1))
for card_name, qty in parsed.get("mainboard", {}).items():
cards.append(_make_card(card_name, qty, 0))
enriched = sum(1 for c in cards if c["type_line"])
log_collect.info(
"[DeckService] .dck load: %d cards, %d enriched from Forge cache (%s)",
len(cards), enriched, dck_path.name,
)
return cards
def _load_deck_cards_by_name(deck_name: str) -> list:
"""Load cards from a deck by name, returning list of card dicts.
Resolution order:
1. SQLite DB (decks built/imported via the Deck Builder UI)
2. .dck file fallback -- searches precon_dir, forge_decks_dir,
FORGE_DIR/res/decks/commander, and repo-relative precon-decks/
sample-decks/ so precon decks work without any configuration.
"""
# -- 1. DB lookup --
conn = _get_db_conn()
deck_row = conn.execute("SELECT id FROM decks WHERE name = ?", (deck_name,)).fetchone()
if deck_row:
deck_id = deck_row[0]
rows = conn.execute("""
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
COALESCE(ce.mana_cost, '', '') as mana_cost,
COALESCE(ce.power, '', '') as power,
COALESCE(ce.toughness, '', '') as toughness,
COALESCE(ce.keywords, cr.keywords, '[]') as keywords
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost, power, toughness, keywords
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, oracle_text, cmc, color_identity, keywords
FROM card_records GROUP BY name
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
result = [_row_to_dict(r) for r in rows]
# Enrich cards missing type_line from Forge card cache
try:
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card
for card in result:
if not card.get("type_line"):
forge = lookup_forge_card(card.get("card_name", ""))
if forge:
card["type_line"] = forge.type_line
card["oracle_text"] = forge.oracle_text
card["cmc"] = forge.cmc
card["mana_cost"] = forge.mana_cost
except ImportError:
pass # Forge card loader not available
return result
# -- 2. .dck file fallback --
dck_path = _find_dck_file(deck_name)
if dck_path:
log_collect.info(
f"[DeckService] '{deck_name}' not in DB -- loading from .dck file: {dck_path}"
)
return _load_deck_cards_from_dck(dck_path)
log_collect.warning(f"[DeckService] Deck '{deck_name}' not found in DB or any .dck search path.")
return []
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
def _build_dck_lines(
deck_name: str,
commander_name: str,
cards: list[dict],
*,
resolve_substitutes: bool = False,
) -> list[str]:
"""Build Forge .dck file lines from deck name, commander, and card list."""
lines: list[str] = [
"[metadata]",
f"Name={deck_name}",
"",
"[Commander]",
f"1 {commander_name}",
"",
"[Main]",
]
for card in cards:
cname = card.get("name", "")
if resolve_substitutes:
if card.get("status") == "substituted" and card.get("selected_substitute"):
cname = card["selected_substitute"]
if cname and cname != commander_name:
lines.append(f"{card.get('count', card.get('quantity', 1))} {cname}")
return lines
def _write_dck_file(
deck_name: str,
commander_name: str,
cards: list[dict],
decks_dir: str | None = None,
*,
fallback_id: int | str = 0,
resolve_substitutes: bool = False,
) -> str | None:
"""Write a Forge .dck file to decks_dir. Returns path on success, None otherwise."""
if not decks_dir:
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
return None
safe_name = re.sub(r"[^a-zA-Z0-9_\-\s]", "", deck_name).strip().replace(" ", "_")
if not safe_name:
safe_name = f"Deck_{fallback_id}"
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
lines = _build_dck_lines(
deck_name, commander_name, cards,
resolve_substitutes=resolve_substitutes,
)
with open(dck_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
log_deckgen.info(f" Exported .dck: {dck_path}")
return dck_path