Files
mtg-builder/scripts/build_deck.py
Tuan-Dat Tran 31a4995bbe feat: add deck builder with multiple collection support
- Add build_deck.py script for automated deck building
- Support multiple collection files for comprehensive deck building
- Rename main collection file to full_collection.json
- Add comprehensive documentation and usage examples
- Include design and implementation plans
- Enhance synergy detection and commander suggestion
2026-03-03 23:23:44 +01:00

411 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Deck builder for MTG Commander decks from existing collections.
"""
import argparse
import json
import os
from collections import Counter
from typing import List, Dict, Optional
def load_collection(collection_path: str) -> List[dict]:
"""Load hydrated collection from JSON file."""
with open(collection_path, "r", encoding="utf-8") as f:
return json.load(f)
def load_multiple_collections(collection_paths: List[str]) -> List[dict]:
"""Load and merge multiple collection files."""
all_cards = []
for path in collection_paths:
cards = load_collection(path)
all_cards.extend(cards)
# Merge cards with same name by summing counts
merged_cards = {}
for card in all_cards:
name = card["name"]
if name in merged_cards:
merged_cards[name]["count"] += card.get("count", 1)
else:
merged_cards[name] = card.copy()
return list(merged_cards.values())
def analyze_collection(cards: List[dict]) -> Dict:
"""Analyze collection statistics."""
colors = Counter()
types = Counter()
cmc_dist = Counter()
for card in cards:
# Count colors
for color in card.get("color_identity", []):
colors[color] += card.get("count", 1)
# Count types
type_line = card.get("type_line", "")
if "Creature" in type_line:
types["creatures"] += card.get("count", 1)
elif "Instant" in type_line:
types["instants"] += card.get("count", 1)
elif "Sorcery" in type_line:
types["sorceries"] += card.get("count", 1)
elif "Artifact" in type_line:
types["artifacts"] += card.get("count", 1)
elif "Enchantment" in type_line:
types["enchantments"] += card.get("count", 1)
elif "Land" in type_line:
types["lands"] += card.get("count", 1)
# Count CMC
cmc = card.get("cmc")
if cmc:
cmc_dist[int(cmc)] += card.get("count", 1)
return {
"colors": dict(colors),
"types": dict(types),
"cmc_distribution": dict(cmc_dist),
"total_cards": sum(card.get("count", 1) for card in cards),
"unique_cards": len(cards),
}
def find_commanders(cards: List[dict]) -> List[dict]:
"""Find potential commander cards in collection."""
commanders = []
for card in cards:
type_line = card.get("type_line", "")
if "Legendary" in type_line and (
"Creature" in type_line or "Planeswalker" in type_line
):
commanders.append(card)
return commanders
def score_commander(commander: dict, collection_stats: Dict) -> float:
"""Score commander based on collection support."""
color_identity = commander.get("color_identity", [])
score = 0.0
# Score based on color support
total_colors = sum(collection_stats["colors"].values())
if total_colors > 0:
color_support = sum(
collection_stats["colors"].get(c, 0) for c in color_identity
)
score += (color_support / total_colors) * 100
# Bonus for multi-color commanders with good support
if len(color_identity) > 1:
score += 10
return score
def suggest_commanders(cards: List[dict]) -> List[dict]:
"""Suggest top commanders from collection."""
commanders = find_commanders(cards)
if not commanders:
return []
stats = analyze_collection(cards)
scored = [(score_commander(cmd, stats), cmd) for cmd in commanders]
scored.sort(reverse=True, key=lambda x: x[0])
return [cmd for (score, cmd) in scored[:5]]
def is_basic_land(card: dict) -> bool:
"""Check if a card is a basic land."""
name = card.get("name", "")
return name in ["Plains", "Island", "Swamp", "Mountain", "Forest"]
def balance_mana_curve(cards: List[dict], target_slots: int) -> List[dict]:
"""Balance mana curve for non-land cards."""
# Group cards by CMC
cmc_groups = {}
for card in cards:
cmc = card.get("cmc", 0)
if cmc not in cmc_groups:
cmc_groups[cmc] = []
cmc_groups[cmc].append(card)
# Target distribution (CMC: target count)
# This follows typical Commander mana curve recommendations
target_dist = {
1: 8, # 1-drops
2: 12, # 2-drops
3: 10, # 3-drops
4: 8, # 4-drops
5: 6, # 5-drops
6: 4, # 6-drops
7: 2, # 7+ drops (combined)
}
selected_cards = []
remaining_slots = target_slots
# Distribute cards according to target curve
for cmc, target_count in sorted(target_dist.items()):
if cmc == 7:
# Handle 7+ CMC cards together
available_cards = []
for higher_cmc in sorted(cmc_groups.keys()):
if higher_cmc >= 7:
available_cards.extend(cmc_groups[higher_cmc])
else:
available_cards = cmc_groups.get(cmc, [])
# Calculate how many we can take from this CMC group
slots_for_cmc = min(target_count, len(available_cards), remaining_slots)
if slots_for_cmc > 0:
# Take the first N cards (already sorted by CMC)
selected_cards.extend(available_cards[:slots_for_cmc])
remaining_slots -= slots_for_cmc
if remaining_slots <= 0:
break
# If we still have slots, fill with remaining cards
if remaining_slots > 0:
# Get all remaining cards not yet selected
remaining_cards = []
for cmc, card_list in cmc_groups.items():
if cmc < 7: # We already handled 7+ above
remaining_cards.extend(
card_list[
sorted(target_dist.items()).index((cmc, target_dist[cmc]))
if cmc in target_dist
else 0 :
]
)
# Add remaining cards until we fill all slots
fill_cards = remaining_cards[:remaining_slots]
selected_cards.extend(fill_cards)
return selected_cards
def select_lands(
valid_cards: List[dict], color_identity: List[str], target_land_count: int = 38
) -> List[dict]:
"""Select lands with priority for basic lands and color fixing."""
basic_lands = []
color_fixing_lands = []
other_lands = []
# Categorize lands
for card in valid_cards:
if "Land" not in card.get("type_line", ""):
continue
if is_basic_land(card):
basic_lands.append(card)
elif any(
color in card.get("name", "").lower()
for color in ["plains", "island", "swamp", "mountain", "forest"]
):
# Dual lands, shock lands, etc.
color_fixing_lands.append(card)
else:
other_lands.append(card)
selected_lands = []
land_count = 0
# Add basic lands first
for land in basic_lands:
if land_count >= target_land_count:
break
# Check if this basic land produces a color in our identity
land_color = None
if land["name"] == "Plains":
land_color = "W"
elif land["name"] == "Island":
land_color = "U"
elif land["name"] == "Swamp":
land_color = "B"
elif land["name"] == "Mountain":
land_color = "R"
elif land["name"] == "Forest":
land_color = "G"
if land_color and land_color in color_identity:
count = min(land.get("count", 1), target_land_count - land_count)
if count > 0:
selected_lands.append(land)
land_count += count
# Add color-fixing lands
for land in color_fixing_lands:
if land_count >= target_land_count:
break
count = min(land.get("count", 1), target_land_count - land_count)
if count > 0:
selected_lands.append(land)
land_count += count
# Add other lands if needed
for land in other_lands:
if land_count >= target_land_count:
break
count = min(land.get("count", 1), target_land_count - land_count)
if count > 0:
selected_lands.append(land)
land_count += count
return selected_lands
def generate_deck(commander: dict, cards: List[dict]) -> Dict:
"""Generate a decklist based on chosen commander."""
color_identity = commander.get("color_identity", [])
deck_cards = []
# Add commander
deck_cards.append({"name": commander["name"], "count": 1, "data": commander})
# Filter cards matching color identity
valid_cards = []
for card in cards:
if card["name"] == commander["name"]:
continue # Skip commander in main deck
card_colors = card.get("color_identity", [])
# Check if card colors are subset of commander colors
if all(c in color_identity for c in card_colors):
valid_cards.append(card)
# Select lands using improved logic
selected_lands = select_lands(valid_cards, color_identity)
# Get non-land cards
land_names = {land["name"] for land in selected_lands}
non_land_cards = [c for c in valid_cards if c["name"] not in land_names]
# Balance mana curve for non-land cards (target 61 cards)
balanced_non_lands = balance_mana_curve(non_land_cards, 61)
# Add lands to deck
for land in selected_lands:
count = land.get("count", 1)
deck_cards.append({"name": land["name"], "count": count, "data": land})
# Add non-land cards to deck
for card in balanced_non_lands:
count = card.get("count", 1)
deck_cards.append({"name": card["name"], "count": count, "data": card})
return {
"name": f"{commander['name']} Deck",
"commander": commander["name"],
"colors": color_identity,
"archetype": "Auto-generated",
"cards": {card["name"]: card["count"] for card in deck_cards},
}
def save_deck(deck: Dict, output_dir: str = "data/decks") -> str:
"""Save deck to JSON file."""
os.makedirs(output_dir, exist_ok=True)
# Create safe filename
commander_name = (
deck["commander"].replace(" ", "_").replace(",", "").replace("'", "")
)
filename = f"{commander_name}.json"
filepath = os.path.join(output_dir, filename)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(deck, f, indent=2)
return filepath
def main():
parser = argparse.ArgumentParser(
description="Build MTG Commander decks from collection"
)
parser.add_argument(
"--collection",
nargs="+",
required=True,
help="Path(s) to hydrated collection JSON file(s)",
)
parser.add_argument("--name", help="Deck name (defaults to commander name)")
parser.add_argument("--commander", help="Specific commander to use")
parser.add_argument(
"--list-commanders", action="store_true", help="List suggested commanders"
)
parser.add_argument(
"--output-dir", default="data/decks", help="Output directory for deck files"
)
args = parser.parse_args()
# Load collection(s)
if isinstance(args.collection, list) and len(args.collection) > 1:
cards = load_multiple_collections(args.collection)
else:
cards = load_collection(
args.collection[0] if isinstance(args.collection, list) else args.collection
)
if args.list_commanders:
commanders = suggest_commanders(cards)
print(f"Found {len(commanders)} potential commanders:")
for i, cmd in enumerate(commanders, 1):
colors = "".join(cmd.get("color_identity", []))
print(f"{i}. {cmd['name']} ({colors})")
return
# Find or select commander
if args.commander:
# Find specific commander
commanders = [c for c in cards if c["name"] == args.commander]
if not commanders:
print(f"Error: Commander '{args.commander}' not found in collection")
return
commander = commanders[0]
else:
# Suggest and use top commander
commanders = suggest_commanders(cards)
if not commanders:
print("Error: No suitable commanders found in collection")
return
commander = commanders[0]
print(f"Using suggested commander: {commander['name']}")
# Generate deck
deck = generate_deck(commander, cards)
# Customize deck name if provided
if args.name:
deck["name"] = args.name
# Save deck
filepath = save_deck(deck, args.output_dir)
print(f"Deck saved to: {filepath}")
print(f"\nDeck Summary:")
print(f" Name: {deck['name']}")
print(f" Commander: {deck['commander']}")
print(f" Colors: {', '.join(deck['colors'])}")
print(f" Total cards: {sum(deck['cards'].values())}")
print(f" Unique cards: {len(deck['cards'])}")
if __name__ == "__main__":
main()