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:
142
hydrate.py
142
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":
|
||||
|
||||
Reference in New Issue
Block a user