- 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
411 lines
13 KiB
Python
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()
|