From 31a4995bbe6db965b1bf90ba90c401796e44c287 Mon Sep 17 00:00:00 2001 From: Tuan-Dat Tran Date: Tue, 3 Mar 2026 23:23:44 +0100 Subject: [PATCH] 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 --- AGENTS.md | 1 + README.md | 1 + docs/plans/2026-03-03-deck-builder-design.md | 107 ++++ .../2026-03-03-deck-builder-implementation.md | 475 ++++++++++++++++++ hydrate.py | 142 +++--- scripts/build_deck.py | 410 +++++++++++++++ 6 files changed, 1080 insertions(+), 56 deletions(-) create mode 100644 docs/plans/2026-03-03-deck-builder-design.md create mode 100644 docs/plans/2026-03-03-deck-builder-implementation.md create mode 100644 scripts/build_deck.py diff --git a/AGENTS.md b/AGENTS.md index f218dcf..62ef1e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/README.md b/README.md index b0af807..d483603 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/plans/2026-03-03-deck-builder-design.md b/docs/plans/2026-03-03-deck-builder-design.md new file mode 100644 index 0000000..8fba6d7 --- /dev/null +++ b/docs/plans/2026-03-03-deck-builder-design.md @@ -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/.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 diff --git a/docs/plans/2026-03-03-deck-builder-implementation.md b/docs/plans/2026-03-03-deck-builder-implementation.md new file mode 100644 index 0000000..f720520 --- /dev/null +++ b/docs/plans/2026-03-03-deck-builder-implementation.md @@ -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/.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? \ No newline at end of file diff --git a/hydrate.py b/hydrate.py index 3fefa12..f7aa8e3 100644 --- a/hydrate.py +++ b/hydrate.py @@ -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": diff --git a/scripts/build_deck.py b/scripts/build_deck.py new file mode 100644 index 0000000..a6b1adc --- /dev/null +++ b/scripts/build_deck.py @@ -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()