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:
Tuan-Dat Tran
2026-03-03 23:23:44 +01:00
parent c19ae87e4c
commit 31a4995bbe
6 changed files with 1080 additions and 56 deletions

View File

@@ -102,6 +102,7 @@ Array of card objects with Scryfall fields:
| Deck | Colors | Commander | Archetype |
|------|--------|-----------|-----------|
| my_choco_deck | BUW | G'raha Tia, Scion Reborn | Auto-generated |
| Choco | UGW | Choco, Seeker of Paradise | Bird Tribal Landfall |
| Hazel | BG | Hazel of the Rootbloom | Golgari Aristocrats |
| Palamecia | UR | The Emperor of Palamecia // The Lord Master of Hell | Izzet Self-Mill Storm |

View File

@@ -64,6 +64,7 @@ python scripts/deck_report.py --collection output/hydrated/deck.json --decks-dir
| Deck | Colors | Commander | Archetype |
|------|--------|-----------|-----------|
| my_choco_deck | BUW | G'raha Tia, Scion Reborn | Auto-generated |
| Choco | UGW | Choco, Seeker of Paradise | Bird Tribal Landfall |
| Hazel | BG | Hazel of the Rootbloom | Golgari Aristocrats |
| Palamecia | UR | The Emperor of Palamecia // The Lord Master of Hell | Izzet Self-Mill Storm |

View File

@@ -0,0 +1,107 @@
# Deck Builder Design
## Overview
Semi-automated tool to build Commander decks from existing card collections.
## Architecture
- Script: `scripts/build_deck.py`
- Input: Hydrated collection JSON (`output/hydrated/deck.json`)
- Output: Deck JSON file in `data/decks/`
## Components
### 1. Collection Analyzer
- Analyzes color distribution across collection
- Identifies card type distribution (creatures, spells, lands)
- Calculates CMC distribution
- Finds potential synergies (keywords, creature types)
### 2. Commander Suggester
- Identifies legendary creatures and planeswalkers in collection
- Scores commanders based on:
- Color support in collection
- Number of cards matching color identity
- Synergistic mechanics present
- Provides top 3-5 commander recommendations
### 3. Deck Generator
- Creates starter decklist based on chosen commander
- Includes:
- Commander (1 card)
- Lands (35-40 cards matching color identity)
- Creatures, spells, and artifacts matching color identity
- Balanced mana curve
- Ensures deck meets Commander format requirements
### 4. Refinement Tools
- Functions to add/remove specific cards
- Balance mana curve
- Adjust land count
- Filter by card type or CMC
## Data Flow
1. Load hydrated collection from `output/hydrated/deck.json`
2. Analyze collection statistics
3. Identify potential commanders
4. User selects commander
5. Generate starter decklist
6. Save deck JSON file
7. Provide analysis report
## Error Handling
- Validate input file exists and is properly formatted
- Handle cases where no suitable commanders are found
- Provide fallback options for incomplete collections
- Validate generated deck meets format requirements
## Testing Strategy
1. Test with existing collection files
2. Verify generated decks meet Commander format requirements
3. Check color identity matching works correctly
4. Validate mana curve balancing
5. Test edge cases (small collections, mono-color, multi-color)
## Usage Documentation
Add to README.md:
```markdown
### Build a Deck From Your Collection
```bash
# Analyze your collection and build a deck
python scripts/build_deck.py --collection output/hydrated/deck.json --name my_new_deck
# View suggested commanders
python scripts/build_deck.py --collection output/hydrated/deck.json --list-commanders
# Build deck with specific commander
python scripts/build_deck.py --collection output/hydrated/deck.json --name my_deck --commander "Choco, Seeker of Paradise"
```
The script will:
1. Analyze your collection for color distribution and card types
2. Suggest potential commanders from your collection
3. Generate a starter decklist matching the commander's color identity
4. Save the deck to `data/decks/<name>.json`
5. Provide a summary report with suggestions for improvements
After building, you can:
- Manually edit the deck file
- Run analysis to find upgrades: `python scripts/analyze_decks.py --collection output/hydrated/deck.json --deck-dir data/decks/`
- Generate a report: `python scripts/deck_report.py --collection output/hydrated/deck.json --decks-dir data/decks/`
```
## Implementation Plan
1. Create `scripts/build_deck.py` with argparse CLI
2. Implement collection analysis functions
3. Build commander suggestion algorithm
4. Create deck generation logic
5. Add refinement tools
6. Write comprehensive tests
7. Update README documentation
8. Add to pre-commit hook for auto-docs

View File

@@ -0,0 +1,475 @@
# Deck Builder Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a semi-automated deck builder that creates Commander decks from existing card collections
**Architecture:** Python script that analyzes collection data, suggests commanders, and generates starter decklists
**Tech Stack:** Python 3 standard library (argparse, json, collections)
---
### Task 1: Create Deck Builder Script
**Files:**
- Create: `scripts/build_deck.py`
**Step 1: Write script skeleton with CLI interface**
```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 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 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)
# Sort by CMC and add cards
valid_cards.sort(key=lambda x: x.get('cmc', 0))
# Add lands (target ~38 lands)
land_count = 0
lands_added = []
for card in valid_cards:
if 'Land' in card.get('type_line', '') and land_count < 38:
count = min(card.get('count', 1), 38 - land_count)
if count > 0:
deck_cards.append({
'name': card['name'],
'count': count,
'data': card
})
land_count += count
lands_added.append(card['name'])
# Add non-land cards (target 99 total)
non_land_cards = [c for c in valid_cards if c['name'] not in lands_added]
non_land_count = 0
for card in non_land_cards:
if non_land_count >= 61: # 99 - 38 lands - 1 commander
break
count = min(card.get('count', 1), 61 - non_land_count)
if count > 0:
deck_cards.append({
'name': card['name'],
'count': count,
'data': card
})
non_land_count += count
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', required=True, help='Path to hydrated collection JSON')
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
cards = load_collection(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()
```
**Step 2: Test basic functionality**
Run: `python scripts/build_deck.py --help`
Expected: Help message with all arguments
**Step 3: Test with existing collection**
Run: `python scripts/build_deck.py --collection output/hydrated/deck.json --list-commanders`
Expected: List of potential commanders from collection
**Step 4: Generate a test deck**
Run: `python scripts/build_deck.py --collection output/hydrated/deck.json --name test_deck`
Expected: Deck file created in data/decks/
**Step 5: Commit initial implementation**
```bash
git add scripts/build_deck.py
git commit -m "feat: add deck builder script"
```
---
### Task 2: Add Documentation to README
**Files:**
- Modify: `README.md` (add usage section)
**Step 1: Add deck builder usage to README**
Add to README.md after the "Find Synergies" section:
```markdown
### Build a Deck From Your Collection
```bash
# List suggested commanders from your collection
python scripts/build_deck.py --collection output/hydrated/deck.json --list-commanders
# Build deck with suggested commander
python scripts/build_deck.py --collection output/hydrated/deck.json --name my_new_deck
# Build deck with specific commander
python scripts/build_deck.py --collection output/hydrated/deck.json --name my_deck --commander "Choco, Seeker of Paradise"
```
The deck builder will:
1. Analyze your collection for color distribution and card types
2. Suggest potential commanders from your collection
3. Generate a starter decklist matching the commander's color identity
4. Save the deck to `data/decks/<name>.json`
5. Provide a summary report
After building, refine your deck:
- Edit the deck file manually
- Find upgrades: `python scripts/analyze_decks.py --collection output/hydrated/deck.json --deck-dir data/decks/`
- Generate reports: `python scripts/deck_report.py --collection output/hydrated/deck.json --decks-dir data/decks/`
```
**Step 2: Update README**
Run: Edit README.md to add the new section
**Step 3: Commit documentation**
```bash
git add README.md
git commit -m "docs: add deck builder usage to README"
```
---
### Task 3: Enhance Deck Generation Logic
**Files:**
- Modify: `scripts/build_deck.py` (improve generate_deck function)
**Step 1: Improve land selection**
Update generate_deck to better handle land selection:
- Prioritize basic lands
- Include color-fixing lands when available
- Balance land types for multi-color decks
**Step 2: Add mana curve balancing**
Add function to balance mana curve:
- Target distribution: 10-12 cards at 2 CMC, 8-10 at 3, 6-8 at 4, etc.
- Adjust card selection to match curve
**Step 3: Test enhanced generation**
Run: `python scripts/build_deck.py --collection output/hydrated/deck.json --name enhanced_deck`
Expected: Better balanced deck with improved mana curve
**Step 4: Commit improvements**
```bash
git add scripts/build_deck.py
git commit -m "feat: improve deck generation logic"
```
---
### Task 4: Add Testing and Validation
**Files:**
- Create: `tests/test_build_deck.py`
**Step 1: Write unit tests**
```python
import json
import tempfile
import os
from scripts.build_deck import load_collection, analyze_collection, find_commanders
def test_load_collection():
"""Test loading collection from JSON."""
test_data = [
{"name": "Test Card", "count": 1, "color_identity": ["U"], "type_line": "Creature"}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(test_data, f)
filepath = f.name
try:
result = load_collection(filepath)
assert len(result) == 1
assert result[0]['name'] == "Test Card"
finally:
os.unlink(filepath)
def test_analyze_collection():
"""Test collection analysis."""
test_cards = [
{"name": "Blue Card", "count": 2, "color_identity": ["U"], "type_line": "Creature", "cmc": 3},
{"name": "Red Card", "count": 1, "color_identity": ["R"], "type_line": "Instant", "cmc": 2},
]
result = analyze_collection(test_cards)
assert result['total_cards'] == 3
assert result['colors']['U'] == 2
assert result['types']['creatures'] == 2
assert result['cmc_distribution'][2] == 1
def test_find_commanders():
"""Test commander finding."""
test_cards = [
{"name": "Legendary Creature", "type_line": "Legendary Creature", "color_identity": ["G"]},
{"name": "Regular Creature", "type_line": "Creature"},
]
commanders = find_commanders(test_cards)
assert len(commanders) == 1
assert commanders[0]['name'] == "Legendary Creature"
```
**Step 2: Run tests**
Run: `python -m pytest tests/test_build_deck.py -v`
Expected: All tests pass
**Step 3: Commit tests**
```bash
git add tests/test_build_deck.py
git commit -m "test: add unit tests for deck builder"
```
---
### Task 5: Final Integration and Testing
**Files:**
- Modify: `scripts/update_docs.py` (add deck builder to auto-docs)
**Step 1: Update documentation generator**
Add deck builder to the auto-generated documentation
**Step 2: Test complete workflow**
```bash
# Test complete workflow
python hydrate.py hydrate data/collection/Box1\ 2026-01-30.txt -o output/hydrated/ -c cache/card_cache.json
python scripts/build_deck.py --collection output/hydrated/deck.json --list-commanders
python scripts/build_deck.py --collection output/hydrated/deck.json --name my_test_deck
python scripts/analyze_decks.py --collection output/hydrated/deck.json --deck-dir data/decks/
```
**Step 3: Verify all tests pass**
Run: `python -m pytest tests/ -v`
Expected: All tests pass
**Step 4: Final commit**
```bash
git add .
git commit -m "feat: complete deck builder implementation"
```
---
## Plan Complete
**Execution Options:**
1. **Subagent-Driven (this session)** - I'll implement task-by-task with your review
2. **Parallel Session** - You can run this plan in a separate session
Which approach would you prefer?

View File

@@ -21,8 +21,16 @@ SCRYFALL_API = "https://api.scryfall.com"
RATE_LIMIT_DELAY = 0.1 # 100ms between requests
FIELDS = [
"name", "mana_cost", "cmc", "colors", "color_identity",
"type_line", "oracle_text", "power", "toughness", "loyalty"
"name",
"mana_cost",
"cmc",
"colors",
"color_identity",
"type_line",
"oracle_text",
"power",
"toughness",
"loyalty",
]
@@ -36,17 +44,19 @@ def parse_decklist(filepath: str) -> list[dict]:
continue
match = re.match(r"^(\d+)x?\s+(.+)$", line, re.IGNORECASE)
if match:
cards.append({"count": int(match.group(1)), "name": match.group(2).strip()})
cards.append(
{"count": int(match.group(1)), "name": match.group(2).strip()}
)
return cards
def strip_set_code(name: str) -> str:
"""Remove set code and collector number from card name.
E.g., "Keep Out (ECL) 19" -> "Keep Out"
"""
name = re.sub(r'\s*\([^)]+\)\s*\d+ *$', '', name)
name = re.sub(r'\s*\*F\*$', '', name)
name = re.sub(r"\s*\([^)]+\)\s*\d+ *$", "", name)
name = re.sub(r"\s*\*F\*$", "", name)
return name.strip()
@@ -54,16 +64,15 @@ def fetch_card(name: str, retry_count: int = 3) -> Optional[dict]:
"""Fetch card data from Scryfall API using fuzzy search."""
original_name = name
name = strip_set_code(name)
encoded = urllib.parse.quote(name)
url = f"{SCRYFALL_API}/cards/named?fuzzy={encoded}"
for attempt in range(retry_count):
try:
req = urllib.request.Request(url, headers={
"User-Agent": "EDHDeckBuilder/1.0",
"Accept": "*/*"
})
req = urllib.request.Request(
url, headers={"User-Agent": "EDHDeckBuilder/1.0", "Accept": "*/*"}
)
with urllib.request.urlopen(req, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as e:
@@ -73,17 +82,25 @@ def fetch_card(name: str, retry_count: int = 3) -> Optional[dict]:
time.sleep(retry_after)
elif e.code == 404:
if name != original_name:
print(f" Not found with set code, retrying: '{name}'", file=sys.stderr)
print(
f" Not found with set code, retrying: '{name}'",
file=sys.stderr,
)
return fetch_card(name, retry_count=1)
print(f" Error fetching '{original_name}': Not found", file=sys.stderr)
return None
else:
if attempt < retry_count - 1:
wait_time = 2 ** attempt
print(f" HTTP {e.code}, retrying in {wait_time}s...", file=sys.stderr)
wait_time = 2**attempt
print(
f" HTTP {e.code}, retrying in {wait_time}s...", file=sys.stderr
)
time.sleep(wait_time)
else:
print(f" Error fetching '{original_name}': HTTP {e.code}", file=sys.stderr)
print(
f" Error fetching '{original_name}': HTTP {e.code}",
file=sys.stderr,
)
return None
except urllib.error.URLError as e:
print(f" Error fetching '{original_name}': {e.reason}", file=sys.stderr)
@@ -91,26 +108,26 @@ def fetch_card(name: str, retry_count: int = 3) -> Optional[dict]:
except json.JSONDecodeError:
print(f" Error parsing response for '{original_name}'", file=sys.stderr)
return None
return None
def extract_card_info(card_data: dict) -> dict:
"""Extract relevant fields from Scryfall card data."""
result = {"scryfall_uri": card_data.get("scryfall_uri", "")}
for field in FIELDS:
result[field] = card_data.get(field)
if card_data.get("card_faces"):
face = card_data["card_faces"][0]
for field in ["mana_cost", "type_line", "oracle_text", "power", "toughness"]:
if result.get(field) is None:
result[field] = face.get(field)
result["colors"] = card_data.get("colors", [])
result["color_identity"] = card_data.get("color_identity", [])
return result
@@ -127,15 +144,17 @@ def categorize_by_type(cards: list[dict]) -> dict[str, list[dict]]:
"lands": [],
"other": [],
}
for card in cards:
type_line = card.get("type_line", "").lower()
if "legendary" in type_line and ("creature" in type_line or "planeswalker" in type_line):
if "legendary" in type_line and (
"creature" in type_line or "planeswalker" in type_line
):
if not categories["commander"]:
categories["commander"].append(card)
continue
if "creature" in type_line:
categories["creatures"].append(card)
elif "instant" in type_line:
@@ -152,27 +171,29 @@ def categorize_by_type(cards: list[dict]) -> dict[str, list[dict]]:
categories["lands"].append(card)
else:
categories["other"].append(card)
return {k: v for k, v in categories.items() if v}
def hydrate_decklist(input_file: str, output_dir: str, cache_file: Optional[str] = None) -> None:
def hydrate_decklist(
input_file: str, output_dir: str, cache_file: Optional[str] = None
) -> None:
"""Main hydration function."""
cache = {}
if cache_file and os.path.exists(cache_file):
with open(cache_file, "r", encoding="utf-8") as f:
cache = json.load(f)
print(f"Loaded {len(cache)} cached cards")
print(f"Parsing decklist: {input_file}")
entries = parse_decklist(input_file)
print(f"Found {len(entries)} unique card entries")
hydrated = []
for i, entry in enumerate(entries, 1):
name = entry["name"]
count = entry["count"]
if name in cache:
card_info = cache[name]
print(f"[{i}/{len(entries)}] {name} (cached)")
@@ -185,30 +206,30 @@ def hydrate_decklist(input_file: str, output_dir: str, cache_file: Optional[str]
time.sleep(RATE_LIMIT_DELAY)
else:
card_info = {"name": name, "error": "not found"}
card_info["count"] = count
hydrated.append(card_info)
if cache_file:
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(cache, f, indent=2)
print(f"Cached {len(cache)} cards to {cache_file}")
categories = categorize_by_type(hydrated)
os.makedirs(output_dir, exist_ok=True)
all_cards_path = os.path.join(output_dir, "deck.json")
all_cards_path = os.path.join(output_dir, "full_collection.json")
with open(all_cards_path, "w", encoding="utf-8") as f:
json.dump(hydrated, f, indent=2)
print(f"Wrote full decklist to {all_cards_path}")
print(f"Wrote full collection to {all_cards_path}")
for category, cards in categories.items():
cat_path = os.path.join(output_dir, f"{category}.json")
with open(cat_path, "w", encoding="utf-8") as f:
json.dump(cards, f, indent=2)
print(f" {category}: {len(cards)} cards -> {cat_path}")
print(f"\nDeck summary:")
print(f" Total cards: {sum(c.get('count', 1) for c in hydrated)}")
print(f" Unique cards: {len(hydrated)}")
@@ -218,35 +239,44 @@ def create_deck(deck_name: str, base_dir: str = "decks") -> str:
"""Create a new deck folder structure."""
deck_path = os.path.join(base_dir, deck_name)
os.makedirs(deck_path, exist_ok=True)
template = {
"name": deck_name,
"commander": None,
"cards": []
}
template = {"name": deck_name, "commander": None, "cards": []}
with open(os.path.join(deck_path, "deck.json"), "w", encoding="utf-8") as f:
json.dump(template, f, indent=2)
print(f"Created deck: {deck_path}")
return deck_path
def main():
parser = argparse.ArgumentParser(description="Hydrate MTG decklists with Scryfall data")
parser = argparse.ArgumentParser(
description="Hydrate MTG decklists with Scryfall data"
)
subparsers = parser.add_subparsers(dest="command", help="Commands")
hydrate_parser = subparsers.add_parser("hydrate", help="Hydrate a decklist with Scryfall data")
hydrate_parser = subparsers.add_parser(
"hydrate", help="Hydrate a decklist with Scryfall data"
)
hydrate_parser.add_argument("input", help="Input decklist file")
hydrate_parser.add_argument("-o", "--output", default="output/hydrated", help="Output directory")
hydrate_parser.add_argument("-c", "--cache", default="cache/card_cache.json", help="Cache file for card data")
hydrate_parser.add_argument(
"-o", "--output", default="output/hydrated", help="Output directory"
)
hydrate_parser.add_argument(
"-c",
"--cache",
default="cache/card_cache.json",
help="Cache file for card data",
)
new_parser = subparsers.add_parser("new", help="Create a new deck folder")
new_parser.add_argument("name", help="Deck name")
new_parser.add_argument("-d", "--dir", default="data/decks", help="Base directory for decks")
new_parser.add_argument(
"-d", "--dir", default="data/decks", help="Base directory for decks"
)
args = parser.parse_args()
if args.command == "hydrate":
hydrate_decklist(args.input, args.output, args.cache)
elif args.command == "new":

410
scripts/build_deck.py Normal file
View 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()