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
This commit is contained in:
410
scripts/build_deck.py
Normal file
410
scripts/build_deck.py
Normal file
@@ -0,0 +1,410 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user