From a8a181c4af58edea356ba38fdd1ab2ef5590aeec Mon Sep 17 00:00:00 2001 From: mwisnowski Date: Mon, 18 Aug 2025 11:58:25 -0700 Subject: [PATCH] Started remaking the builder module, Currently up through adding the standard/staple lands --- code/deck_builder/builder.py | 1353 +++++++++++++- code/deck_builder/builder_constants.py | 34 + code/deck_builder/builder_utils.py | 1642 ----------------- .../{builder.py => builder_old.py} | 0 code/file_setup/setup_constants.py | 10 +- code/non_interactive_test.py | 39 + requirements.txt | 1 + 7 files changed, 1337 insertions(+), 1742 deletions(-) rename code/deck_builder_old/{builder.py => builder_old.py} (100%) create mode 100644 code/non_interactive_test.py diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index f01b22a..40870e8 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -1,103 +1,1266 @@ from __future__ import annotations -import math -import numpy as np -import os -import random -import time -from functools import lru_cache -from typing import Dict, List, Optional, Union - -import inquirer.prompt -import keyboard +from dataclasses import dataclass, field +from typing import Optional, List, Dict, Any, Callable, Tuple import pandas as pd -import pprint -from fuzzywuzzy import process -from tqdm import tqdm -from settings import CSV_DIRECTORY, MULTIPLE_COPY_CARDS -from .builder_constants import ( - BASIC_LANDS, CARD_TYPES, DEFAULT_NON_BASIC_LAND_SLOTS, - COMMANDER_CSV_PATH, FUZZY_MATCH_THRESHOLD, MAX_FUZZY_CHOICES, FETCH_LAND_DEFAULT_COUNT, - COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT, - COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT, - THEME_PRIORITY_BONUS, THEME_POOL_SIZE_MULTIPLIER, DECK_DIRECTORY, - COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_DEFAULT, - COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, DUAL_LAND_TYPE_MAP, - CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS, - STAPLE_LAND_CONDITIONS, TRIPLE_LAND_TYPE_MAP, MISC_LAND_MAX_COUNT, MISC_LAND_MIN_COUNT, - MISC_LAND_POOL_SIZE, LAND_REMOVAL_MAX_ATTEMPTS, PROTECTED_LANDS, - MANA_COLORS, MANA_PIP_PATTERNS, THEME_WEIGHT_MULTIPLIER -) -from . import builder_utils -from file_setup import setup_utils -from input_handler import InputHandler -from exceptions import ( - BasicLandCountError, - BasicLandError, - CommanderMoveError, - CardTypeCountError, - CommanderColorError, - CommanderSelectionError, - CommanderValidationError, - CSVError, - CSVReadError, - CSVTimeoutError, - CSVValidationError, - DataFrameValidationError, - DuplicateCardError, - DeckBuilderError, - EmptyDataFrameError, - FetchLandSelectionError, - FetchLandValidationError, - IdealDeterminationError, - LandRemovalError, - LibraryOrganizationError, - LibrarySortError, - PriceAPIError, - PriceConfigurationError, - PriceLimitError, - PriceTimeoutError, - PriceValidationError, - ThemeSelectionError, - ThemeWeightError, - StapleLandError, - ManaPipError, - ThemeTagError, - ThemeWeightingError, - ThemePoolError -) -from type_definitions import ( - CommanderDict, - CardLibraryDF, - CommanderDF, - LandDF, - ArtifactDF, - CreatureDF, - NonCreatureDF, - PlaneswalkerDF, - NonPlaneswalkerDF) +from . import builder_constants as bc -import logging_util - -# Create logger for this module -logger = logging_util.logging.getLogger(__name__) -logger.setLevel(logging_util.LOG_LEVEL) -logger.addHandler(logging_util.file_handler) -logger.addHandler(logging_util.stream_handler) - -# Try to import scrython and price_checker +# Attempt to use a fast fuzzy library; fall back gracefully try: - import scrython - from price_check import PriceChecker - use_scrython = True + from rapidfuzz import process as rf_process, fuzz as rf_fuzz + _FUZZ_BACKEND = "rapidfuzz" except ImportError: - scrython = None - PriceChecker = None - use_scrython = False - logger.warning("Scrython is not installed. Price checking features will be unavailable." - ) + try: + from fuzzywuzzy import process as fw_process, fuzz as fw_fuzz + _FUZZ_BACKEND = "fuzzywuzzy" + except ImportError: + _FUZZ_BACKEND = "difflib" -pd.set_option('display.max_columns', None) -pd.set_option('display.max_rows', None) -pd.set_option('display.max_colwidth', 50) \ No newline at end of file +if _FUZZ_BACKEND == "rapidfuzz": + def _full_ratio(a: str, b: str) -> float: + return rf_fuzz.ratio(a, b) + def _top_matches(query: str, choices: List[str], limit: int): + return [(name, int(score)) for name, score, _ in rf_process.extract(query, choices, limit=limit)] +elif _FUZZ_BACKEND == "fuzzywuzzy": + def _full_ratio(a: str, b: str) -> float: + return fw_fuzz.ratio(a, b) + def _top_matches(query: str, choices: List[str], limit: int): + return fw_process.extract(query, choices, limit=limit) +else: + # Very basic fallback (difflib) + from difflib import SequenceMatcher, get_close_matches + def _full_ratio(a: str, b: str) -> float: + return SequenceMatcher(None, a.lower(), b.lower()).ratio() * 100 + def _top_matches(query: str, choices: List[str], limit: int): + close = get_close_matches(query, choices, n=limit, cutoff=0.0) + scored = [(c, int(_full_ratio(query, c))) for c in close] + if len(scored) < limit: + remaining = [c for c in choices if c not in close] + extra = sorted( + ((c, int(_full_ratio(query, c))) for c in remaining), + key=lambda x: x[1], + reverse=True + )[: limit - len(scored)] + scored.extend(extra) + return scored + +EXACT_NAME_THRESHOLD = 80 +FIRST_WORD_THRESHOLD = 75 +MAX_PRESENTED_CHOICES = 5 + +# --------------------------- +# Deck Power Bracket (Deck Building Step 1) +# --------------------------- + +@dataclass(frozen=True) +class BracketDefinition: + level: int + name: str + short_desc: str + long_desc: str + limits: Dict[str, Optional[int]] # None = unlimited + +BRACKET_DEFINITIONS: List[BracketDefinition] = [ + BracketDefinition( + 1, + "Exhibition", + "Ultra-casual / novelty; long games; focus on fun.", + ("Throw down with your ultra‑casual deck. Winning isn't primary—show off something unusual. " + "Games go long and end slowly."), + { + "game_changers": 0, + "mass_land_denial": 0, + "extra_turns": 0, + "tutors_nonland": 3, + "two_card_combos": 0 + } + ), + BracketDefinition( + 2, + "Core", + "Precon baseline; splashy turns; 9+ turn games.", + ("Average modern precon: tuned engines & splashy turns, some pet/theme cards, usually longer games."), + { + "game_changers": 0, + "mass_land_denial": 0, + "extra_turns": 3, + "tutors_nonland": 3, + "two_card_combos": 0 + } + ), + BracketDefinition( + 3, + "Upgraded", + "Refined beyond precon; faster; selective power.", + ("Carefully selected cards; may include up to three Game Changers. Avoids cheap fast infinite two‑card combos."), + { + "game_changers": 3, + "mass_land_denial": 0, + "extra_turns": 3, + "tutors_nonland": None, + "two_card_combos": 0 + } + ), + BracketDefinition( + 4, + "Optimized", + "High power, explosive, not meta-focused.", + ("Strong, explosive builds; any number of powerful effects, tutors, combos, and denial."), + { + "game_changers": None, + "mass_land_denial": None, + "extra_turns": None, + "tutors_nonland": None, + "two_card_combos": None + } + ), + BracketDefinition( + 5, + "cEDH", + "Competitive, meta-driven mindset.", + ("Metagame/tournament mindset; precision choices; winning prioritized over expression."), + { + "game_changers": None, + "mass_land_denial": None, + "extra_turns": None, + "tutors_nonland": None, + "two_card_combos": None + } + ), +] + + +@dataclass +class DeckBuilder: + # Commander core selection state + commander_name: str = "" + commander_row: Optional[pd.Series] = None + commander_tags: List[str] = field(default_factory=list) + + # Tag prioritization + primary_tag: Optional[str] = None + secondary_tag: Optional[str] = None + tertiary_tag: Optional[str] = None + selected_tags: List[str] = field(default_factory=list) + + # Future deck config placeholders + color_identity: List[str] = field(default_factory=list) # raw list of color letters e.g. ['B','G'] + color_identity_key: Optional[str] = None # canonical key form e.g. 'B, G' + color_identity_full: Optional[str] = None # human readable e.g. 'Golgari: Black/Green' + files_to_load: List[str] = field(default_factory=list) # csv file stems to load + synergy_profile: Dict[str, Any] = field(default_factory=dict) + deck_goal: Optional[str] = None + + # Aggregated commander info (scalar fields) + commander_dict: Dict[str, Any] = field(default_factory=dict) + + # Power bracket state (Deck Building Step 1) + bracket_level: Optional[int] = None + bracket_name: Optional[str] = None + bracket_limits: Dict[str, Optional[int]] = field(default_factory=dict) + bracket_definition: Optional[BracketDefinition] = None + + # Cached data + _commander_df: Optional[pd.DataFrame] = None + _combined_cards_df: Optional[pd.DataFrame] = None + _full_cards_df: Optional[pd.DataFrame] = None # immutable snapshot of original combined pool + + # Deck library (cards added so far) mapping name->record + card_library: Dict[str, Dict[str, Any]] = field(default_factory=dict) + + # IO injection for testing + input_func: Callable[[str], str] = field(default=lambda prompt: input(prompt)) + output_func: Callable[[str], None] = field(default=lambda msg: print(msg)) + + # --------------------------- + # Data Loading + # --------------------------- + def load_commander_data(self) -> pd.DataFrame: + if self._commander_df is not None: + return self._commander_df + df = pd.read_csv( + bc.COMMANDER_CSV_PATH, + converters=getattr(bc, "COMMANDER_CONVERTERS", None) + ) + if "themeTags" not in df.columns: + df["themeTags"] = [[] for _ in range(len(df))] + if "creatureTypes" not in df.columns: + df["creatureTypes"] = [[] for _ in range(len(df))] + self._commander_df = df + return df + + # --------------------------- + # Fuzzy Search Helpers + # --------------------------- + def _auto_accept(self, query: str, candidate: str) -> bool: + full = _full_ratio(query, candidate) + if full >= EXACT_NAME_THRESHOLD: + return True + q_first = query.strip().split()[0].lower() if query.strip() else "" + c_first = candidate.split()[0].lower() + if q_first and _full_ratio(q_first, c_first) >= FIRST_WORD_THRESHOLD: + return True + return False + + def _gather_candidates(self, query: str, names: List[str]) -> List[tuple]: + scored = _top_matches(query, names, MAX_PRESENTED_CHOICES) + uniq: Dict[str, int] = {} + for n, s in scored: + uniq[n] = max(uniq.get(n, 0), s) + return sorted(uniq.items(), key=lambda x: x[1], reverse=True) + + # --------------------------- + # Commander Dict Initialization + # --------------------------- + def _initialize_commander_dict(self, row: pd.Series): + def get(field: str, default=""): + return row.get(field, default) if isinstance(row, pd.Series) else default + + mana_cost = get("manaCost", "") + mana_value = get("manaValue", get("cmc", None)) + try: + if mana_value is None and isinstance(mana_cost, str): + mana_value = mana_cost.count("}") if "}" in mana_cost else None + except Exception: + pass + + color_identity_raw = get("colorIdentity", get("colors", [])) + if isinstance(color_identity_raw, str): + stripped = color_identity_raw.strip("[] ") + if "," in stripped: + color_identity = [c.strip(" '\"") for c in stripped.split(",")] + else: + color_identity = list(stripped) + else: + color_identity = color_identity_raw if isinstance(color_identity_raw, list) else [] + + colors_field = get("colors", color_identity) + if isinstance(colors_field, str): + colors = list(colors_field) + else: + colors = colors_field if isinstance(colors_field, list) else [] + + type_line = get("type", get("type_line", "")) + creature_types = get("creatureTypes", []) + if isinstance(creature_types, str): + creature_types = [s.strip() for s in creature_types.split(",") if s.strip()] + + text_field = get("text", get("oracleText", "")) + if isinstance(text_field, str): + text_field = text_field.replace("\\n", "\n") + + power = get("power", "") + toughness = get("toughness", "") + themes = get("themeTags", []) + if isinstance(themes, str): + themes = [t.strip() for t in themes.split(",") if t.strip()] + + cmc = get("cmc", mana_value if mana_value is not None else 0.0) + try: + cmc = float(cmc) if cmc not in ("", None) else 0.0 + except Exception: + cmc = 0.0 + + self.commander_dict = { + "Commander Name": self.commander_name, + "Mana Cost": mana_cost, + "Mana Value": mana_value, + "Color Identity": color_identity, + "Colors": colors, + "Type": type_line, + "Creature Types": creature_types, + "Text": text_field, + "Power": power, + "Toughness": toughness, + "Themes": themes, + "CMC": cmc, + } + # Ensure commander added to card library + try: + self.add_card( + card_name=self.commander_name, + card_type=type_line, + mana_cost=mana_cost, + mana_value=cmc, + creature_types=creature_types if isinstance(creature_types, list) else [], + tags=themes if isinstance(themes, list) else [], + is_commander=True + ) + except Exception: + pass + + # --------------------------- + # Pretty Display + # --------------------------- + def _format_commander_pretty(self, row: pd.Series) -> str: + import math + + def norm(val): + if isinstance(val, list) and len(val) == 1: + val = val[0] + if val is None or (isinstance(val, float) and math.isnan(val)): + return "-" + return val + + def join_list(val, sep=", "): + val = norm(val) + if isinstance(val, list): + return sep.join(str(x) for x in val) if val else "-" + return str(val) + + name = norm(row.get("name", "")) + face_name = norm(row.get("faceName", name)) + edhrec = norm(row.get("edhrecRank", "-")) + color_identity = join_list(row.get("colorIdentity", row.get("colors", [])), "") + colors = join_list(row.get("colors", []), "") + mana_cost = norm(row.get("manaCost", "")) + mana_value = norm(row.get("manaValue", row.get("cmc", "-"))) + type_line = norm(row.get("type", row.get("type_line", ""))) + creature_types = join_list(row.get("creatureTypes", [])) + text_field = norm(row.get("text", row.get("oracleText", ""))) + text_field = str(text_field).replace("\\n", "\n") + power = norm(row.get("power", "-")) + toughness = norm(row.get("toughness", "-")) + keywords = join_list(row.get("keywords", [])) + raw_tags = row.get("themeTags", []) + if isinstance(raw_tags, str): + tags_list = [t.strip() for t in raw_tags.split(",") if t.strip()] + elif isinstance(raw_tags, list): + if len(raw_tags) == 1 and isinstance(raw_tags[0], list): + tags_list = raw_tags[0] + else: + tags_list = raw_tags + else: + tags_list = [] + layout = norm(row.get("layout", "-")) + side = norm(row.get("side", "-")) + + lines = [ + "Selected Commander:", + f"Name: {name}", + f"Face Name: {face_name}", + f"EDHREC Rank: {edhrec}", + f"Color Identity: {color_identity}", + f"Colors: {colors}", + f"Mana Cost: {mana_cost}", + f"Mana Value: {mana_value}", + f"Type: {type_line}", + f"Creature Types: {creature_types}", + f"Power/Toughness: {power}/{toughness}", + f"Keywords: {keywords}", + f"Layout: {layout}", + f"Side: {side}", + ] + if tags_list: + lines.append("Theme Tags:") + for t in tags_list: + lines.append(f" - {t}") + else: + lines.append("Theme Tags: -") + lines.extend([ + "Text:", + text_field, + "" + ]) + return "\n".join(lines) + + def _present_commander_and_confirm(self, df: pd.DataFrame, name: str) -> bool: + row = df[df["name"] == name].iloc[0] + pretty = self._format_commander_pretty(row) + self.output_func("\n" + pretty) + while True: + resp = self.input_func("Is this the commander you want? (y/n): ").strip().lower() + if resp in ("y", "yes"): + self._apply_commander_selection(row) + return True + if resp in ("n", "no"): + return False + self.output_func("Please enter y or n.") + + # --------------------------- + # Commander Selection + # --------------------------- + def choose_commander(self) -> str: + df = self.load_commander_data() + names = df["name"].tolist() + while True: + query = self.input_func("Enter commander name: ").strip() + if not query: + self.output_func("No input provided. Try again.") + continue + + direct_hits = [n for n in names if self._auto_accept(query, n)] + if len(direct_hits) == 1: + candidate = direct_hits[0] + self.output_func(f"(Auto match candidate) {candidate}") + if self._present_commander_and_confirm(df, candidate): + self.output_func(f"Confirmed: {candidate}") + return candidate + else: + self.output_func("Not confirmed. Starting over.\n") + continue + + candidates = self._gather_candidates(query, names) + if not candidates: + self.output_func("No close matches found. Try again.") + continue + + self.output_func("\nTop matches:") + for idx, (n, score) in enumerate(candidates, start=1): + self.output_func(f" {idx}. {n} (score {score})") + self.output_func("Enter number to inspect, 'r' to retry, or type a new name:") + + choice = self.input_func("Selection: ").strip() + if choice.lower() == 'r': + continue + if choice.isdigit(): + i = int(choice) + if 1 <= i <= len(candidates): + nm = candidates[i - 1][0] + if self._present_commander_and_confirm(df, nm): + self.output_func(f"Confirmed: {nm}") + return nm + else: + self.output_func("Not confirmed. Search again.\n") + continue + else: + self.output_func("Invalid index.") + continue + # Treat as new query + query = choice + + def _apply_commander_selection(self, row: pd.Series): + self.commander_name = row["name"] + self.commander_row = row + self.commander_tags = list(row.get("themeTags", []) or []) + self._initialize_commander_dict(row) + + # --------------------------- + # Color Identity & Card Pool Loading (New Step) + # --------------------------- + def _canonical_color_key(self, colors: List[str]) -> str: + """Return canonical key like 'B, G, W' or 'COLORLESS'. Uses alphabetical ordering. + + The legacy constants expect a specific ordering (alphabetical seems consistent in provided maps). + """ + if not colors: + return 'COLORLESS' + # Deduplicate & sort + uniq = sorted({c.strip().upper() for c in colors if c.strip()}) + return ', '.join(uniq) + + def determine_color_identity(self) -> Tuple[str, List[str]]: + """Determine color identity key/full name and derive csv file list. + + Returns (color_identity_full, files_to_load). + """ + if self.commander_row is None: + raise RuntimeError("Commander must be selected before determining color identity.") + + raw_ci = self.commander_row.get('colorIdentity') + if isinstance(raw_ci, list): + colors_list = raw_ci + elif isinstance(raw_ci, str) and raw_ci.strip(): + # Could be formatted like "['B','G']" or 'BG'; attempt simple parsing + if ',' in raw_ci: + colors_list = [c.strip().strip("'[] ") for c in raw_ci.split(',') if c.strip().strip("'[] ")] + else: + colors_list = [c for c in raw_ci if c.isalpha()] + else: + # Fallback to 'colors' field or treat as colorless + alt = self.commander_row.get('colors') + if isinstance(alt, list): + colors_list = alt + elif isinstance(alt, str) and alt.strip(): + colors_list = [c for c in alt if c.isalpha()] + else: + colors_list = [] + + self.color_identity = [c.upper() for c in colors_list] + self.color_identity_key = self._canonical_color_key(self.color_identity) + + # Match against maps + full = None + load_files: List[str] = [] + key = self.color_identity_key + if key in bc.MONO_COLOR_MAP: + full, load_files = bc.MONO_COLOR_MAP[key] + elif key in bc.DUAL_COLOR_MAP: + info = bc.DUAL_COLOR_MAP[key] + full, load_files = info[0], info[2] + elif key in bc.TRI_COLOR_MAP: + info = bc.TRI_COLOR_MAP[key] + full, load_files = info[0], info[2] + elif key in bc.OTHER_COLOR_MAP: + info = bc.OTHER_COLOR_MAP[key] + full, load_files = info[0], info[2] + else: + # Unknown / treat as colorless fallback + full, load_files = 'Unknown', ['colorless'] + + self.color_identity_full = full + self.files_to_load = load_files + return full, load_files + + def setup_dataframes(self) -> pd.DataFrame: + """Load all csv files for current color identity into one combined DataFrame. + + Each file stem in files_to_load corresponds to csv_files/{stem}_cards.csv. + The result is cached and returned. Minimal validation only (non-empty, required columns exist if known). + """ + if self._combined_cards_df is not None: + return self._combined_cards_df + if not self.files_to_load: + # Attempt to determine if not yet done + self.determine_color_identity() + dfs = [] + required = getattr(bc, 'CSV_REQUIRED_COLUMNS', []) + for stem in self.files_to_load: + path = f'csv_files/{stem}_cards.csv' + try: + df = pd.read_csv(path) + if required: + missing = [c for c in required if c not in df.columns] + if missing: + # Skip or still keep with warning; choose to warn + self.output_func(f"Warning: {path} missing columns: {missing}") + dfs.append(df) + except FileNotFoundError: + self.output_func(f"Warning: CSV file not found: {path}") + continue + if not dfs: + raise RuntimeError("No CSV files loaded for color identity.") + combined = pd.concat(dfs, axis=0, ignore_index=True) + # Drop duplicate rows by 'name' if column exists + if 'name' in combined.columns: + combined = combined.drop_duplicates(subset='name', keep='first') + self._combined_cards_df = combined + # Preserve original snapshot for enrichment across subsequent removals + if self._full_cards_df is None: + self._full_cards_df = combined.copy() + return combined + + # --------------------------- + # Card Library Management + # --------------------------- + def add_card(self, + card_name: str, + card_type: str = '', + mana_cost: str = '', + mana_value: Optional[float] = None, + creature_types: Optional[List[str]] = None, + tags: Optional[List[str]] = None, + is_commander: bool = False) -> None: + """Add (or increment) a card in the deck library. + + Stores minimal metadata; duplicates increment Count. Basic lands allowed unlimited. + """ + if creature_types is None: + creature_types = [] + if tags is None: + tags = [] + # Compute mana value if missing from cost (simple heuristic: count symbols between braces) + if mana_value is None and mana_cost: + try: + if '{' in mana_cost and '}' in mana_cost: + # naive parse: digits add numeric value; individual colored symbols count as 1 + import re + symbols = re.findall(r'\{([^}]+)\}', mana_cost) + total = 0 + for sym in symbols: + if sym.isdigit(): + total += int(sym) + else: + total += 1 + mana_value = total + except Exception: + mana_value = None + entry = self.card_library.get(card_name) + if entry: + entry['Count'] += 1 + else: + self.card_library[card_name] = { + 'Card Name': card_name, + 'Card Type': card_type, + 'Mana Cost': mana_cost, + 'Mana Value': mana_value, + 'Creature Types': creature_types, + 'Tags': tags, + 'Commander': is_commander, + 'Count': 1 + } + # Keep commander dict CMC up to date if adding commander + if is_commander and self.commander_dict: + if mana_value is not None: + self.commander_dict['CMC'] = mana_value + # Remove this card from combined pool if present + self._remove_from_pool(card_name) + + def _remove_from_pool(self, card_name: str): + if self._combined_cards_df is None: + return + df = self._combined_cards_df + if 'name' in df.columns: + self._combined_cards_df = df[df['name'] != card_name] + elif 'Card Name' in df.columns: + self._combined_cards_df = df[df['Card Name'] != card_name] + + # --------------------------- + # Land Building Step 1: Basic Lands + # --------------------------- + def add_basic_lands(self): + """Add basic (or snow basic) lands based on color identity. + + Logic: + - Determine target basics = ceil(1.3 * ideal_basic_min) (rounded) but capped by total land target + - Evenly distribute among colored identity letters (W,U,B,R,G) + - If commander/selected tags include 'Snow' (case-insensitive) use snow basics mapping + - Colorless commander: use Wastes for the entire basic allocation + """ + # Ensure color identity determined + if not self.files_to_load: + try: + self.determine_color_identity() + self.setup_dataframes() + except Exception as e: + self.output_func(f"Cannot add basics until color identity resolved: {e}") + return + + # Ensure ideal counts (for min basics & total lands) + basic_min = None + land_total = None + if hasattr(self, 'ideal_counts') and self.ideal_counts: + basic_min = self.ideal_counts.get('basic_lands') + land_total = self.ideal_counts.get('lands') + if basic_min is None: + basic_min = getattr(bc, 'DEFAULT_BASIC_LAND_COUNT', 20) + if land_total is None: + land_total = getattr(bc, 'DEFAULT_LAND_COUNT', 35) + + # Target basics = 1.3 * minimum (rounded) but not exceeding total lands + target_basics = int(round(1.3 * basic_min)) + if target_basics > land_total: + target_basics = land_total + if target_basics <= 0: + self.output_func("Target basic land count is zero; skipping basics.") + return + + colors = [c for c in self.color_identity if c in ['W','U','B','R','G']] + if not colors: + # Colorless + colors = [] # special case: use Wastes only + + # Determine if snow preferred + tag_pool = (self.selected_tags or []) + (self.commander_tags if hasattr(self, 'commander_tags') else []) + use_snow = any('snow' in str(t).lower() for t in tag_pool) + snow_map = getattr(bc, 'SNOW_BASIC_LAND_MAPPING', {}) + basic_map = getattr(bc, 'COLOR_TO_BASIC_LAND', {}) + + allocation: Dict[str, int] = {} + if not colors: # colorless + allocation_name = snow_map.get('C', 'Wastes') if use_snow else 'Wastes' + allocation[allocation_name] = target_basics + else: + n = len(colors) + base = target_basics // n + rem = target_basics % n + for idx, c in enumerate(sorted(colors)): # sorted for deterministic distribution + count = base + (1 if idx < rem else 0) + land_name = snow_map.get(c) if use_snow else basic_map.get(c) + if not land_name: + continue + allocation[land_name] = allocation.get(land_name, 0) + count + + # Add to library + for land_name, count in allocation.items(): + for _ in range(count): + self.add_card(land_name, card_type='Land') + + # Summary output + self.output_func("\nBasic Lands Added:") + width = max(len(n) for n in allocation.keys()) if allocation else 0 + for name, cnt in allocation.items(): + self.output_func(f" {name.ljust(width)} : {cnt}") + self.output_func(f" Total Basics : {sum(allocation.values())} (Target {target_basics}, Min {basic_min})") + + def run_land_step1(self): + """Public wrapper to execute land building step 1 (basics).""" + self.add_basic_lands() + + # --------------------------- + # Land Building Step 2: Staple Nonbasic Lands (NO Kindred yet) + # --------------------------- + def _current_land_count(self) -> int: + """Return total number of land cards currently in the library (counts duplicates).""" + total = 0 + for name, entry in self.card_library.items(): + # If we recorded type when adding basics or staples, use that + ctype = entry.get('Card Type', '') + if ctype and 'land' in ctype.lower(): + total += entry.get('Count', 1) + continue + # Else attempt enrichment from combined pool + if self._combined_cards_df is not None and 'name' in self._combined_cards_df.columns: + row = self._combined_cards_df[self._combined_cards_df['name'] == name] + if not row.empty: + type_field = str(row.iloc[0].get('type', '')).lower() + if 'land' in type_field: + total += entry.get('Count', 1) + return total + + def add_staple_lands(self): + """Add generic staple lands defined in STAPLE_LAND_CONDITIONS (excluding kindred lands). + + Respects total land target (ideal_counts['lands']). Skips additions once target reached. + Conditions may use commander tags (all available, not just selected), color identity, and commander power. + """ + # Ensure color identity and card pool loaded + if not self.files_to_load: + try: + self.determine_color_identity() + self.setup_dataframes() + except Exception as e: + self.output_func(f"Cannot add staple lands until color identity resolved: {e}") + return + + # Determine land target + land_target = None + if hasattr(self, 'ideal_counts') and self.ideal_counts: + land_target = self.ideal_counts.get('lands') + if land_target is None: + land_target = getattr(bc, 'DEFAULT_LAND_COUNT', 35) + + # Early exit if already at or above target + if self._current_land_count() >= land_target: + self.output_func("Staple Lands: Land target already met; skipping step 2.") + return + + commander_tags_all = set(getattr(self, 'commander_tags', []) or []) | set(getattr(self, 'selected_tags', []) or []) + colors = self.color_identity or [] + # Commander power for conditions + commander_power = 0 + try: + if self.commander_row is not None: + raw_power = self.commander_row.get('power') + if isinstance(raw_power, (int, float)): + commander_power = int(raw_power) + elif isinstance(raw_power, str) and raw_power.isdigit(): + commander_power = int(raw_power) + except Exception: + commander_power = 0 + + added: List[str] = [] + reasons: Dict[str, str] = {} + for land_name, cond in getattr(bc, 'STAPLE_LAND_CONDITIONS', {}).items(): + # Stop if land target reached + if self._current_land_count() >= land_target: + break + # Skip if already in library + if land_name in self.card_library: + continue + try: + include = cond(list(commander_tags_all), colors, commander_power) + except Exception: + include = False + if include: + self.add_card(land_name, card_type='Land') + added.append(land_name) + # Basic reason heuristics for transparency + if land_name == 'Command Tower': + reasons[land_name] = f"multi-color ({len(colors)} colors)" + elif land_name == 'Exotic Orchard': + reasons[land_name] = f"multi-color ({len(colors)} colors)" + elif land_name == 'War Room': + reasons[land_name] = f"<=2 colors ({len(colors)})" + elif land_name == 'Reliquary Tower': + reasons[land_name] = 'always include' + elif land_name == 'Ash Barrens': + reasons[land_name] = 'no Landfall tag' + elif land_name == "Rogue's Passage": + reasons[land_name] = f"commander power {commander_power} >=5" + + self.output_func("\nStaple Lands Added (Step 2):") + if not added: + self.output_func(" (None added)") + else: + width = max(len(n) for n in added) + for n in added: + reason = reasons.get(n, '') + self.output_func(f" {n.ljust(width)} : 1 {('(' + reason + ')') if reason else ''}") + self.output_func(f" Land Count Now : {self._current_land_count()} / {land_target}") + + def run_land_step2(self): + """Public wrapper for adding generic staple nonbasic lands (excluding kindred).""" + self.add_staple_lands() + + # --------------------------- + # Tag Prioritization + # --------------------------- + def select_commander_tags(self) -> List[str]: + if not self.commander_name: + self.output_func("No commander chosen yet. Selecting commander first...") + self.choose_commander() + + tags = list(dict.fromkeys(self.commander_tags)) + if not tags: + self.output_func("Commander has no theme tags available.") + self.selected_tags = [] + self.primary_tag = self.secondary_tag = self.tertiary_tag = None + self._update_commander_dict_with_selected_tags() + return self.selected_tags + + self.output_func("\nAvailable Theme Tags:") + for i, t in enumerate(tags, 1): + self.output_func(f" {i}. {t}") + + self.selected_tags = [] + # Primary (required) + self.primary_tag = self._prompt_tag_choice(tags, "Select PRIMARY tag (required):", allow_stop=False) + self.selected_tags.append(self.primary_tag) + + remaining = [t for t in tags if t not in self.selected_tags] + + # Secondary (optional) + if remaining: + self.secondary_tag = self._prompt_tag_choice( + remaining, + "Select SECONDARY tag (or 0 to stop here):", + allow_stop=True + ) + if self.secondary_tag: + self.selected_tags.append(self.secondary_tag) + remaining = [t for t in remaining if t != self.secondary_tag] + + # Tertiary (optional) + if remaining and self.secondary_tag: + self.tertiary_tag = self._prompt_tag_choice( + remaining, + "Select TERTIARY tag (or 0 to stop here):", + allow_stop=True + ) + if self.tertiary_tag: + self.selected_tags.append(self.tertiary_tag) + + self.output_func("\nChosen Tags (in priority order):") + if not self.selected_tags: + self.output_func(" (None)") + else: + for idx, tag in enumerate(self.selected_tags, 1): + label = ["Primary", "Secondary", "Tertiary"][idx - 1] if idx <= 3 else f"Tag {idx}" + self.output_func(f" {idx}. {tag} ({label})") + + self._update_commander_dict_with_selected_tags() + return self.selected_tags + + def _prompt_tag_choice(self, available: List[str], prompt_text: str, allow_stop: bool) -> Optional[str]: + while True: + self.output_func("\nCurrent options:") + for i, t in enumerate(available, 1): + self.output_func(f" {i}. {t}") + if allow_stop: + self.output_func(" 0. Stop (no further tags)") + raw = self.input_func(f"{prompt_text} ").strip() + if allow_stop and raw == "0": + return None + if raw.isdigit(): + idx = int(raw) + if 1 <= idx <= len(available): + return available[idx - 1] + matches = [t for t in available if t.lower() == raw.lower()] + if matches: + return matches[0] + self.output_func("Invalid selection. Try again.") + + def _update_commander_dict_with_selected_tags(self): + if not self.commander_dict and self.commander_row is not None: + self._initialize_commander_dict(self.commander_row) + if not self.commander_dict: + return + self.commander_dict["Primary Tag"] = self.primary_tag + self.commander_dict["Secondary Tag"] = self.secondary_tag + self.commander_dict["Tertiary Tag"] = self.tertiary_tag + self.commander_dict["Selected Tags"] = self.selected_tags.copy() + + # --------------------------- + # Power Bracket Selection (Deck Building Step 1) + # --------------------------- + def select_power_bracket(self) -> BracketDefinition: + if self.bracket_definition: + return self.bracket_definition + + self.output_func("\nChoose Deck Power Bracket:") + for bd in BRACKET_DEFINITIONS: + self.output_func(f" {bd.level}. {bd.name} - {bd.short_desc}") + + while True: + raw = self.input_func("Enter bracket number (1-5) or 'info' for details: ").strip().lower() + if raw == "info": + self._print_bracket_details() + continue + if raw.isdigit(): + num = int(raw) + match = next((bd for bd in BRACKET_DEFINITIONS if bd.level == num), None) + if match: + self.bracket_definition = match + self.bracket_level = match.level + self.bracket_name = match.name + self.bracket_limits = match.limits.copy() + self.output_func(f"\nSelected Bracket {match.level}: {match.name}") + self._print_selected_bracket_summary() + return match + self.output_func("Invalid input. Type 1-5 or 'info'.") + + def _print_bracket_details(self): + self.output_func("\nBracket Details:") + for bd in BRACKET_DEFINITIONS: + self.output_func(f"\n[{bd.level}] {bd.name}") + self.output_func(bd.long_desc) + self.output_func(self._format_limits(bd.limits)) + + def _print_selected_bracket_summary(self): + if not self.bracket_definition: + return + self.output_func("\nBracket Constraints:") + self.output_func(self._format_limits(self.bracket_limits)) + + @staticmethod + def _format_limits(limits: Dict[str, Optional[int]]) -> str: + labels = { + "game_changers": "Game Changers", + "mass_land_denial": "Mass Land Denial", + "extra_turns": "Extra Turn Cards", + "tutors_nonland": "Nonland Tutors", + "two_card_combos": "Two-Card Combos" + } + lines = [] + for key, label in labels.items(): + val = limits.get(key, None) + if val is None: + lines.append(f" {label}: Unlimited") + else: + lines.append(f" {label}: {val}") + return "\n".join(lines) + + def run_deck_build_step1(self): + self.select_power_bracket() + + # --------------------------- + # Reporting Helper + # --------------------------- + def print_commander_dict_table(self): + if self.commander_row is None: + self.output_func("No commander selected.") + return + block = self._format_commander_pretty(self.commander_row) + self.output_func("\n" + block) + # New: show which CSV files (stems) were loaded for this color identity + if self.files_to_load: + file_list = ", ".join(f"{stem}_cards.csv" for stem in self.files_to_load) + self.output_func(f"Card Pool Files: {file_list}") + if self.selected_tags: + self.output_func("Chosen Tags:") + if self.primary_tag: + self.output_func(f" Primary : {self.primary_tag}") + if self.secondary_tag: + self.output_func(f" Secondary: {self.secondary_tag}") + if self.tertiary_tag: + self.output_func(f" Tertiary : {self.tertiary_tag}") + self.output_func("") + if self.bracket_definition: + self.output_func(f"Power Bracket: {self.bracket_level} - {self.bracket_name}") + self.output_func(self._format_limits(self.bracket_limits)) + self.output_func("") + + # --------------------------- + # Orchestration + # --------------------------- + def run_initial_setup(self): + self.choose_commander() + self.select_commander_tags() + # New: color identity & card pool loading + try: + self.determine_color_identity() + self.setup_dataframes() + except Exception as e: + self.output_func(f"Failed to load color-identity card pool: {e}") + self.print_commander_dict_table() + + def run_full_initial_with_bracket(self): + self.run_initial_setup() + self.run_deck_build_step1() + # (Further steps can be chained here) + self.print_commander_dict_table() + + # =========================== + # Deck Building Step 2: Ideal Composition Counts + # =========================== + ideal_counts: Dict[str, int] = field(default_factory=dict) + + def run_deck_build_step2(self) -> Dict[str, int]: + """Determine ideal counts for general card categories (bracket‑agnostic baseline). + + Prompts the user (Enter to keep default). Stores results in ideal_counts and returns it. + Categories: + ramp, lands, basic_lands, creatures, removal, wipes, card_advantage, protection + """ + # Initialize defaults from constants if not already present + defaults = { + 'ramp': bc.DEFAULT_RAMP_COUNT, + 'lands': bc.DEFAULT_LAND_COUNT, + 'basic_lands': bc.DEFAULT_BASIC_LAND_COUNT, + 'creatures': bc.DEFAULT_CREATURE_COUNT, + 'removal': bc.DEFAULT_REMOVAL_COUNT, + 'wipes': bc.DEFAULT_WIPES_COUNT, + 'card_advantage': bc.DEFAULT_CARD_ADVANTAGE_COUNT, + 'protection': bc.DEFAULT_PROTECTION_COUNT, + } + + # Seed existing values if already set (allow re-run keeping previous choices) + for k, v in defaults.items(): + if k not in self.ideal_counts: + self.ideal_counts[k] = v + + self.output_func("\nSet Ideal Deck Composition Counts (press Enter to accept default/current):") + for key, prompt in bc.DECK_COMPOSITION_PROMPTS.items(): + if key not in defaults: # skip price prompts & others for this step + continue + current_default = self.ideal_counts[key] + value = self._prompt_int_with_default(f"{prompt} ", current_default, minimum=0, maximum=200) + self.ideal_counts[key] = value + + # Basic validation adjustments + # Ensure basic_lands <= lands + if self.ideal_counts['basic_lands'] > self.ideal_counts['lands']: + self.output_func("Adjusting basic lands to not exceed total lands.") + self.ideal_counts['basic_lands'] = self.ideal_counts['lands'] + + self._print_ideal_counts_summary() + return self.ideal_counts + + # Helper to prompt integer values with default + def _prompt_int_with_default(self, prompt: str, default: int, minimum: int = 0, maximum: int = 999) -> int: + while True: + raw = self.input_func(f"{prompt}[{default}] ").strip() + if raw == "": + return default + if raw.isdigit(): + val = int(raw) + if minimum <= val <= maximum: + return val + self.output_func(f"Enter a number between {minimum} and {maximum}, or press Enter for {default}.") + + def _print_ideal_counts_summary(self): + self.output_func("\nIdeal Composition Targets:") + order = [ + ('ramp', 'Ramp Pieces'), + ('lands', 'Total Lands'), + ('basic_lands', 'Minimum Basic Lands'), + ('creatures', 'Creatures'), + ('removal', 'Spot Removal'), + ('wipes', 'Board Wipes'), + ('card_advantage', 'Card Advantage'), + ('protection', 'Protection') + ] + width = max(len(label) for _, label in order) + for key, label in order: + if key in self.ideal_counts: + self.output_func(f" {label.ljust(width)} : {self.ideal_counts[key]}") + + # Public wrapper for external callers / tests + def print_ideal_counts(self): + if not self.ideal_counts: + self.output_func("Ideal counts not set. Run run_deck_build_step2() first.") + return + # Reuse formatting but with a simpler heading per user request + self.output_func("\nIdeal Counts:") + order = [ + ('ramp', 'Ramp'), + ('lands', 'Total Lands'), + ('basic_lands', 'Basic Lands (Min)'), + ('creatures', 'Creatures'), + ('removal', 'Spot Removal'), + ('wipes', 'Board Wipes'), + ('card_advantage', 'Card Advantage'), + ('protection', 'Protection') + ] + width = max(len(label) for _, label in order) + for key, label in order: + if key in self.ideal_counts: + self.output_func(f" {label.ljust(width)} : {self.ideal_counts[key]}") + + # --------------------------- + # Card Library Reporting + # --------------------------- + def print_card_library(self, truncate_text: bool = True, text_limit: int = 80): + """Pretty print the current card library using PrettyTable. + + Columns: Name | Color Identity | Colors | Mana Cost | Mana Value | Type | Creature Types | Power | Toughness | Keywords | Theme Tags | Text + Commander appears first, then cards in insertion order. Shows total & remaining slots (to 100). + """ + total_cards = sum(entry.get('Count', 1) for entry in self.card_library.values()) + remaining = max(0, 100 - total_cards) + self.output_func(f"\nCard Library: {total_cards} cards (Commander included). Remaining slots: {remaining}") + + try: + from prettytable import PrettyTable + except ImportError: + self.output_func("PrettyTable not installed. Run 'pip install prettytable' to enable formatted library output.") + for name, entry in self.card_library.items(): + self.output_func(f"- {name} x{entry.get('Count',1)}") + return + + cols = [ + 'Name', 'Color Identity', 'Colors', 'Mana Cost', 'Mana Value', 'Type', + 'Creature Types', 'Power', 'Toughness', 'Keywords', 'Theme Tags', 'Text' + ] # Name will include duplicate count suffix (e.g., "Plains x13") if Count>1 + table = PrettyTable(field_names=cols) + table.align = 'l' + + # Build lookup from combined df for enrichment (prefer full snapshot so removed rows still enrich) + combined = self._full_cards_df if self._full_cards_df is not None else self._combined_cards_df + combined_lookup: Dict[str, pd.Series] = {} + if combined is not None and 'name' in combined.columns: + for _, r in combined.iterrows(): + nm = str(r.get('name')) + if nm not in combined_lookup: + combined_lookup[nm] = r + + def limit(txt: str): + if not truncate_text or txt is None: + return txt + if len(txt) <= text_limit: + return txt + return txt[: text_limit - 3] + '...' + + # Commander first + ordered_items = list(self.card_library.items()) + ordered_items.sort(key=lambda kv: (0 if kv[1].get('Commander') else 1)) + + basic_names = set(getattr(bc, 'BASIC_LANDS', [])) + snow_basic_names = set(getattr(bc, 'SNOW_BASIC_LAND_MAPPING', {}).values()) + rev_basic = {v: k for k, v in getattr(bc, 'COLOR_TO_BASIC_LAND', {}).items()} + rev_snow = {v: k for k, v in getattr(bc, 'SNOW_BASIC_LAND_MAPPING', {}).items()} + + for name, entry in ordered_items: + row_source = combined_lookup.get(name) + count = entry.get('Count', 1) + display_name = f"{name} x{count}" if count > 1 else name + + if entry.get('Commander') and self.commander_dict: + ci_list = self.commander_dict.get('Color Identity', []) + ci = ''.join(ci_list) if isinstance(ci_list, list) else str(ci_list) + colors_list = self.commander_dict.get('Colors', []) + colors = ''.join(colors_list) if isinstance(colors_list, list) else str(colors_list) + mana_cost = self.commander_dict.get('Mana Cost', '') + mana_value = self.commander_dict.get('Mana Value', '') + type_line = self.commander_dict.get('Type', '') + creature_types_val = self.commander_dict.get('Creature Types', []) + creature_types = ', '.join(creature_types_val) if isinstance(creature_types_val, list) else str(creature_types_val) + power = self.commander_dict.get('Power', '') + toughness = self.commander_dict.get('Toughness', '') + # Enrich keywords from snapshot if present + if row_source is not None: + kw_val = row_source.get('keywords', []) + if isinstance(kw_val, list): + keywords = ', '.join(str(x) for x in kw_val) + else: + keywords = '' if kw_val in (None, '') else str(kw_val) + else: + keywords = '' + theme_tags_val = self.commander_dict.get('Themes', []) + theme_tags = ', '.join(theme_tags_val) if isinstance(theme_tags_val, list) else str(theme_tags_val) + text_field = limit(self.commander_dict.get('Text', '')) + else: + # Default blanks + ci = colors = mana_cost = '' + mana_value = '' + type_line = entry.get('Card Type', '') + creature_types = power = toughness = keywords = theme_tags = text_field = '' + if row_source is not None: + # Basic enrichment fields + mana_cost = row_source.get('manaCost', '') + mana_value = row_source.get('manaValue', row_source.get('cmc', '')) + type_line = row_source.get('type', row_source.get('type_line', type_line or '')) + ct_raw = row_source.get('creatureTypes', []) + if isinstance(ct_raw, list): + creature_types = ', '.join(ct_raw) + else: + creature_types = str(ct_raw) if ct_raw not in (None, '') else '' + power = row_source.get('power', '') + toughness = row_source.get('toughness', '') + kw_raw = row_source.get('keywords', []) + if isinstance(kw_raw, list): + keywords = ', '.join(kw_raw) + elif kw_raw not in (None, ''): + keywords = str(kw_raw) + tg_raw = row_source.get('themeTags', []) + if isinstance(tg_raw, list): + theme_tags = ', '.join(tg_raw) + text_field = limit(str(row_source.get('text', row_source.get('oracleText', ''))).replace('\n', ' ')) + + # Only apply color identity/colors if NOT a land or is a basic/snow basic + type_lower = str(type_line).lower() + if 'land' in type_lower: + if name in basic_names: + letter = rev_basic.get(name, '') + ci = letter + colors = letter + elif name in snow_basic_names: + letter = rev_snow.get(name, '') + ci = letter + colors = letter + else: + ci = '' + colors = '' + else: + ci_raw = row_source.get('colorIdentity', row_source.get('colors', [])) + if isinstance(ci_raw, list): + ci = ''.join(ci_raw) + else: + ci = str(ci_raw) if ci_raw not in (None, '') else '' + colors_raw = row_source.get('colors', []) + if isinstance(colors_raw, list): + colors = ''.join(colors_raw) + elif colors_raw not in (None, ''): + colors = str(colors_raw) + else: + # No row source (likely a basic we added or manual staple missing from CSV) + type_line = type_line or 'Land' + if name in basic_names: + letter = rev_basic.get(name, '') + ci = letter + colors = letter + elif name in snow_basic_names: + letter = rev_snow.get(name, '') + ci = letter + colors = letter + elif 'land' in str(type_line).lower(): + ci = colors = '' # nonbasic land => blank + + # Ensure nonbasic land override even if CSV has color identity + if 'land' in str(type_line).lower() and name not in basic_names and name not in snow_basic_names: + ci = '' + colors = '' + + table.add_row([ + display_name, + ci, + colors, + mana_cost, + mana_value, + type_line, + creature_types, + power, + toughness, + keywords, + theme_tags, + text_field + ]) + + self.output_func(table.get_string()) + + # Convenience to run Step 1 & 2 sequentially (future orchestrator) + def run_deck_build_steps_1_2(self): + self.run_deck_build_step1() + self.run_deck_build_step2() \ No newline at end of file diff --git a/code/deck_builder/builder_constants.py b/code/deck_builder/builder_constants.py index 9761812..c06bb43 100644 --- a/code/deck_builder/builder_constants.py +++ b/code/deck_builder/builder_constants.py @@ -122,6 +122,23 @@ OTHER_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = { 'bant', 'jeskai', 'glint', 'dune', 'witch', 'yore', 'ink', 'wubrg']) } +# Card category validation rules +CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { + 'power': {'type': ('str', 'int', 'float'), 'required': True}, + 'toughness': {'type': ('str', 'int', 'float'), 'required': True}, + 'creatureTypes': {'type': 'list', 'required': True} +} + +SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { + 'manaCost': {'type': 'str', 'required': True}, + 'text': {'type': 'str', 'required': True} +} + +LAND_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { + 'type': {'type': ('str', 'object'), 'required': True}, + 'text': {'type': ('str', 'object'), 'required': False} +} + # Price checking configuration DEFAULT_PRICE_DELAY: Final[float] = 0.1 # Delay between price checks in seconds MAX_PRICE_CHECK_ATTEMPTS: Final[int] = 3 # Maximum attempts for price checking @@ -434,4 +451,21 @@ DATAFRAME_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, boo CARD_TYPE_SORT_ORDER: Final[List[str]] = [ 'Planeswalker', 'Battle', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land' +] + +# Game changer cards, used to help determine bracket +GAME_CHANGERS: Final[List[str]] = [ + 'Ad Nauseum', 'Ancient Tomb', 'Aura Shards', 'Bolas\'s Citadel', 'Braids, Cabal Minion', + 'Chrome Mox', 'Coalition Victory', 'Consecrated Sphinx', 'Crop Rotation', 'Cyclonic Rift', + 'Deflecting Swat', 'Demonic Tutor', 'Drannith Magistrate', 'Enlightened Tutor', 'Expropriate', + 'Field of the Dead', 'Fierce Guardianship', 'Food Chain', 'Force of Will', 'Gaea\'s Cradle', + 'Gamble', 'Gifts Ungiven', 'Glacial Chasm', 'Grand Arbiter Augustin IV', 'Grim Monolith', 'Humility', + 'Imperial Seal', 'Intuition', 'Jeska\'s Will', 'Jin-Gitaxias, Core Augur', 'Kinnan, Bonder Prodigy', + 'Lion\'s Eye Diamond', 'Mana Vault', 'Mishra\'s Workshop', 'Mox Diamond', 'Mystical Tutor', + 'Narset, Parter of Veils', 'Natural Order', 'Necropotence', 'Notion Thief', 'Opposition Agent', + 'Orcish Bowmasters', 'Panoptic Mirror', 'Rhystic Study', 'Seedborn Muse', 'Serra\'s Sanctum', + 'Smother Tithe', 'Survival of the Fittest', 'Sway of the Stars', 'Teferi\'s Protection', + 'Tergrid, God of Fright', 'Thassa\'s Oracle', 'The One Ring', 'The Tabernacle at Pendrell Vale', + 'Underworld Breach', 'Urza, Lord High Artificer', 'Vampiric Tutor', 'Vorinclex, Voice of Hunger', + 'Winota, Joiner of Forces', 'Worldly Tutor', 'Yuriko, the Tiger\'s Shadow' ] \ No newline at end of file diff --git a/code/deck_builder/builder_utils.py b/code/deck_builder/builder_utils.py index 87345bc..e69de29 100644 --- a/code/deck_builder/builder_utils.py +++ b/code/deck_builder/builder_utils.py @@ -1,1642 +0,0 @@ -"""Utility module for MTG deck building operations. - -This module provides utility functions for various deck building operations including: -- DataFrame validation and processing -- Card type counting and validation -- Land selection and management -- Theme processing and weighting -- Price checking integration -- Mana pip analysis - -The module serves as a central collection of helper functions used throughout the -deck building process, handling data validation, card selection, and various -deck composition calculations. - -Key Features: -- DataFrame validation with timeout handling -- Card type counting and categorization -- Land type validation and selection (basic, fetch, dual, etc.) -- Theme tag processing and weighting calculations -- Mana pip counting and color distribution analysis - -Typical usage example: - >>> df = load_commander_data() - >>> validate_dataframe(df, DATAFRAME_VALIDATION_RULES) - >>> process_dataframe_batch(df) - >>> count_cards_by_type(df, ['Creature', 'Instant', 'Sorcery']) -""" - -# Standard library imports -import functools -import time -from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union, cast - -# Third-party imports -import pandas as pd -from fuzzywuzzy import process - -# Local application imports -from exceptions import ( - CSVValidationError, - DataFrameTimeoutError, - DataFrameValidationError, - DeckBuilderError, - DuplicateCardError, - EmptyDataFrameError, - FetchLandSelectionError, - FetchLandValidationError, - KindredLandSelectionError, - KindredLandValidationError, - LandRemovalError, - ThemeSelectionError, - ThemeWeightError, - CardTypeCountError -) -from input_handler import InputHandler -from price_check import PriceChecker -from .builder_constants import ( - CARD_TYPE_SORT_ORDER, COLOR_TO_BASIC_LAND, COMMANDER_CONVERTERS, - COMMANDER_CSV_PATH, DATAFRAME_BATCH_SIZE, - DATAFRAME_REQUIRED_COLUMNS, DATAFRAME_TRANSFORM_TIMEOUT, - DATAFRAME_VALIDATION_RULES, DATAFRAME_VALIDATION_TIMEOUT, - DECK_COMPOSITION_PROMPTS, DEFAULT_BASIC_LAND_COUNT, - DEFAULT_CARD_ADVANTAGE_COUNT, DEFAULT_CREATURE_COUNT, - DEFAULT_LAND_COUNT, DEFAULT_MAX_CARD_PRICE, DEFAULT_MAX_DECK_PRICE, - DEFAULT_PROTECTION_COUNT, DEFAULT_RAMP_COUNT, - DEFAULT_REMOVAL_COUNT, DEFAULT_WIPES_COUNT, DUAL_LAND_TYPE_MAP, - DUPLICATE_CARD_FORMAT, FUZZY_MATCH_THRESHOLD, KINDRED_STAPLE_LANDS, - MANA_COLORS, MANA_PIP_PATTERNS, MAX_FUZZY_CHOICES, - SNOW_BASIC_LAND_MAPPING, THEME_POOL_SIZE_MULTIPLIER, - WEIGHT_ADJUSTMENT_FACTORS -) -from type_definitions import CardLibraryDF, CommanderDF, LandDF -import logging_util - -# Create logger for this module -logger = logging_util.logging.getLogger(__name__) -logger.setLevel(logging_util.LOG_LEVEL) -logger.addHandler(logging_util.file_handler) -logger.addHandler(logging_util.stream_handler) - -# Type variables for generic functions -T = TypeVar('T') -DataFrame = TypeVar('DataFrame', bound=pd.DataFrame) - -def timeout_wrapper(timeout: float) -> Callable: - """Decorator to add timeout to functions. - - Args: - timeout: Maximum execution time in seconds - - Returns: - Decorated function with timeout - - Raises: - DataFrameTimeoutError: If operation exceeds timeout - """ - def decorator(func: Callable[..., T]) -> Callable[..., T]: - @functools.wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> T: - start_time = time.time() - result = func(*args, **kwargs) - elapsed = time.time() - start_time - - if elapsed > timeout: - raise DataFrameTimeoutError( - func.__name__, - timeout, - elapsed, - {'args': args, 'kwargs': kwargs} - ) - return result - return wrapper - return decorator - -def get_validation_rules(data_type: str) -> Dict[str, Dict[str, Any]]: - """Get validation rules for specific data type. - - Args: - data_type: Type of data to get rules for - - Returns: - Dictionary of validation rules - """ - from .builder_constants import ( - CREATURE_VALIDATION_RULES, - SPELL_VALIDATION_RULES, - LAND_VALIDATION_RULES - ) - - rules_map = { - 'creature': CREATURE_VALIDATION_RULES, - 'spell': SPELL_VALIDATION_RULES, - 'land': LAND_VALIDATION_RULES - } - - return rules_map.get(data_type, DATAFRAME_VALIDATION_RULES) - -@timeout_wrapper(DATAFRAME_VALIDATION_TIMEOUT) -def validate_dataframe(df: pd.DataFrame, rules: Dict[str, Dict[str, Any]]) -> bool: - """Validate DataFrame against provided rules. - - Args: - df: DataFrame to validate - rules: Validation rules to apply - - Returns: - True if validation passes - - Raises: - DataFrameValidationError: If validation fails - """ - #print(df.columns) - if df.empty: - raise EmptyDataFrameError("validate_dataframe") - - try: - validate_required_columns(df) - validate_column_types(df, rules) - return True - except Exception as e: - raise DataFrameValidationError( - "DataFrame validation failed", - {'rules': rules, 'error': str(e)} - ) - -def validate_column_types(df: pd.DataFrame, rules: Dict[str, Dict[str, Any]]) -> bool: - """Validate column types against rules. - - Args: - df: DataFrame to validate - rules: Type validation rules - - Returns: - True if validation passes - - Raises: - DataFrameValidationError: If type validation fails - """ - for col, rule in rules.items(): - if col not in df.columns: - continue - - expected_type = rule.get('type') - if not expected_type: - continue - - if isinstance(expected_type, tuple): - valid = any(df[col].dtype.name.startswith(t) for t in expected_type) - else: - valid = df[col].dtype.name.startswith(expected_type) - - if not valid: - raise DataFrameValidationError( - col, - rule, - {'actual_type': df[col].dtype.name} - ) - - return True - -def validate_required_columns(df: pd.DataFrame) -> bool: - """Validate presence of required columns. - - Args: - df: DataFrame to validate - - Returns: - True if validation passes - - Raises: - DataFrameValidationError: If required columns are missing - """ - #print(df.columns) - missing = set(DATAFRAME_REQUIRED_COLUMNS) - set(df.columns) - if missing: - raise DataFrameValidationError( - "missing_columns", - {'required': DATAFRAME_REQUIRED_COLUMNS}, - {'missing': list(missing)} - ) - return True - -@timeout_wrapper(DATAFRAME_TRANSFORM_TIMEOUT) -def process_dataframe_batch(df: pd.DataFrame, batch_size: int = DATAFRAME_BATCH_SIZE) -> pd.DataFrame: - """Process DataFrame in batches. - - Args: - df: DataFrame to process - batch_size: Size of each batch - - Returns: - Processed DataFrame - - Raises: - DataFrameTimeoutError: If processing exceeds timeout - """ - processed_dfs = [] - - for i in range(0, len(df), batch_size): - batch = df.iloc[i:i + batch_size].copy() - processed = transform_dataframe(batch) - processed_dfs.append(processed) - - return pd.concat(processed_dfs, ignore_index=True) - -def transform_dataframe(df: pd.DataFrame) -> pd.DataFrame: - """Apply transformations to DataFrame. - - Args: - df: DataFrame to transform - - Returns: - Transformed DataFrame - """ - df = df.copy() - - # Fill missing values - df['colorIdentity'] = df['colorIdentity'].fillna('COLORLESS') - df['colors'] = df['colors'].fillna('COLORLESS') - - # Convert types - numeric_cols = ['manaValue', 'edhrecRank'] - for col in numeric_cols: - if col in df.columns: - df[col] = pd.to_numeric(df[col], errors='coerce') - - return df - -def combine_dataframes(dfs: List[pd.DataFrame]) -> pd.DataFrame: - """Combine multiple DataFrames with validation. - - Args: - dfs: List of DataFrames to combine - - Returns: - Combined DataFrame - - Raises: - EmptyDataFrameError: If no valid DataFrames to combine - """ - if not dfs: - raise EmptyDataFrameError("No DataFrames to combine") - - valid_dfs = [] - for df in dfs: - try: - if validate_dataframe(df, DATAFRAME_VALIDATION_RULES): - valid_dfs.append(df) - except DataFrameValidationError as e: - logger.warning(f"Skipping invalid DataFrame: {e}") - - if not valid_dfs: - raise EmptyDataFrameError("No valid DataFrames to combine") - - return pd.concat(valid_dfs, ignore_index=True) - -def load_commander_data(csv_path: str = COMMANDER_CSV_PATH, - converters: Dict = COMMANDER_CONVERTERS) -> pd.DataFrame: - """Load and prepare commander data from CSV file. - - Args: - csv_path (str): Path to commander CSV file. Defaults to COMMANDER_CSV_PATH. - converters (Dict): Column converters for CSV loading. Defaults to COMMANDER_CONVERTERS. - - Returns: - pd.DataFrame: Processed commander dataframe - - Raises: - DeckBuilderError: If CSV file cannot be loaded or processed - """ - try: - df = pd.read_csv(csv_path, converters=converters) - df['colorIdentity'] = df['colorIdentity'].fillna('COLORLESS') - df['colors'] = df['colors'].fillna('COLORLESS') - return df - except FileNotFoundError: - logger.error(f"Commander CSV file not found at {csv_path}") - raise DeckBuilderError(f"Commander data file not found: {csv_path}") - except Exception as e: - logger.error(f"Error loading commander data: {e}") - raise DeckBuilderError(f"Failed to load commander data: {str(e)}") - -def process_fuzzy_matches(card_name: str, - df: pd.DataFrame, - threshold: int = FUZZY_MATCH_THRESHOLD, - max_choices: int = MAX_FUZZY_CHOICES) -> Tuple[str, List[Tuple[str, int]], bool]: - """Process fuzzy matching for commander name selection. - - Args: - card_name (str): Input card name to match - df (pd.DataFrame): Commander dataframe to search - threshold (int): Minimum score for direct match. Defaults to FUZZY_MATCH_THRESHOLD. - max_choices (int): Maximum number of choices to return. Defaults to MAX_FUZZY_CHOICES. - - Returns: - Tuple[str, List[Tuple[str, int]], bool]: Selected card name, list of matches with scores, and match status - """ - try: - match, score, _ = process.extractOne(card_name, df['name']) - if score >= threshold: - return match, [], True - - fuzzy_choices = process.extract(card_name, df['name'], limit=max_choices) - fuzzy_choices = [(name, score) for name, score in fuzzy_choices] - return "", fuzzy_choices, False - except Exception as e: - logger.error(f"Error in fuzzy matching: {e}") - raise DeckBuilderError(f"Failed to process fuzzy matches: {str(e)}") - -def validate_commander_selection(df: pd.DataFrame, commander_name: str) -> Dict: - """Validate and format commander data from selection. - - Args: - df (pd.DataFrame): Commander dataframe - commander_name (str): Selected commander name - - Returns: - Dict: Formatted commander data dictionary - - Raises: - DeckBuilderError: If commander data is invalid or missing - """ - try: - filtered_df = df[df['name'] == commander_name] - if filtered_df.empty: - raise DeckBuilderError(f"No commander found with name: {commander_name}") - - commander_dict = filtered_df.to_dict('list') - - # Validate required fields - required_fields = ['name', 'type', 'colorIdentity', 'colors', 'manaCost', 'manaValue'] - for field in required_fields: - if field not in commander_dict or not commander_dict[field]: - raise DeckBuilderError(f"Missing required commander data: {field}") - - return commander_dict - except Exception as e: - logger.error(f"Error validating commander selection: {e}") - raise DeckBuilderError(f"Failed to validate commander selection: {str(e)}") - -def select_theme(themes_list: List[str], prompt: str, optional=False) -> str: - """Handle the selection of a theme from a list with user interaction. - - Args: - themes_list: List of available themes to choose from - prompt: Message to display when prompting for theme selection - - Returns: - str: Selected theme name - - Raises: - ThemeSelectionError: If user chooses to stop without selecting a theme - """ - try: - if not themes_list: - raise ThemeSelectionError("No themes available for selection") - - print(prompt) - for idx, theme in enumerate(themes_list, 1): - print(f"{idx}. {theme}") - print("0. Stop selection") - - while True: - try: - choice = int(input("Enter the number of your choice: ")) - if choice == 0: - return 'Stop Here' - if 1 <= choice <= len(themes_list): - return themes_list[choice - 1] - print("Invalid choice. Please try again.") - except ValueError: - print("Please enter a valid number.") - - except Exception as e: - logger.error(f"Error in theme selection: {e}") - raise ThemeSelectionError(f"Theme selection failed: {str(e)}") - -def adjust_theme_weights(primary_theme: str, - secondary_theme: str, - tertiary_theme: str, - weights: Dict[str, float]) -> Dict[str, float]: - """Calculate adjusted theme weights based on theme combinations. - - Args: - primary_theme: The main theme selected - secondary_theme: The second theme selected - tertiary_theme: The third theme selected - weights: Initial theme weights dictionary - - Returns: - Dict[str, float]: Adjusted theme weights - - Raises: - ThemeWeightError: If weight calculations fail - """ - try: - adjusted_weights = weights.copy() - - for theme, factors in WEIGHT_ADJUSTMENT_FACTORS.items(): - if theme in [primary_theme, secondary_theme, tertiary_theme]: - for target_theme, factor in factors.items(): - if target_theme in adjusted_weights: - adjusted_weights[target_theme] = round(adjusted_weights[target_theme] * factor, 2) - - # Normalize weights to ensure they sum to 1.0 - total_weight = sum(adjusted_weights.values()) - if total_weight > 0: - adjusted_weights = {k: round(v/total_weight, 2) for k, v in adjusted_weights.items()} - - return adjusted_weights - - except Exception as e: - logger.error(f"Error adjusting theme weights: {e}") - raise ThemeWeightError(f"Failed to adjust theme weights: {str(e)}") -def configure_price_settings(price_checker: Optional[PriceChecker], input_handler: InputHandler) -> None: - """Handle configuration of price settings if price checking is enabled. - - Args: - price_checker: Optional PriceChecker instance for price validation - input_handler: InputHandler instance for user input - - Returns: - None - - Raises: - ValueError: If invalid price values are provided - """ - if not price_checker: - return - - try: - # Configure max deck price - print('Would you like to set an intended max price of the deck?\n' - 'There will be some leeway of ~10%, with a couple alternative options provided.') - if input_handler.questionnaire('Confirm', message='', default_value=False): - print('What would you like the max price to be?') - max_deck_price = float(input_handler.questionnaire('Number', default_value=DEFAULT_MAX_DECK_PRICE)) - price_checker.max_deck_price = max_deck_price - print() - - # Configure max card price - print('Would you like to set a max price per card?\n' - 'There will be some leeway of ~10% when choosing cards and you can choose to keep it or not.') - if input_handler.questionnaire('Confirm', message='', default_value=False): - print('What would you like the max price to be?') - max_card_price = float(input_handler.questionnaire('Number', default_value=DEFAULT_MAX_CARD_PRICE)) - price_checker.max_card_price = max_card_price - print() - - except ValueError as e: - logger.error(f"Error configuring price settings: {e}") - raise - -def get_deck_composition_values(input_handler: InputHandler) -> Dict[str, int]: - """Collect deck composition values from the user. - - Args: - input_handler: InputHandler instance for user input - - Returns: - Dict[str, int]: Mapping of component names to their values - - Raises: - ValueError: If invalid numeric values are provided - """ - try: - composition = {} - for component, prompt in DECK_COMPOSITION_PROMPTS.items(): - if component not in ['max_deck_price', 'max_card_price']: - default_map = { - 'ramp': DEFAULT_RAMP_COUNT, - 'lands': DEFAULT_LAND_COUNT, - 'basic_lands': DEFAULT_BASIC_LAND_COUNT, - 'creatures': DEFAULT_CREATURE_COUNT, - 'removal': DEFAULT_REMOVAL_COUNT, - 'wipes': DEFAULT_WIPES_COUNT, - 'card_advantage': DEFAULT_CARD_ADVANTAGE_COUNT, - 'protection': DEFAULT_PROTECTION_COUNT - } - default_value = default_map.get(component, 0) - - print(prompt) - composition[component] = int(input_handler.questionnaire('Number', message='Default', default_value=default_value)) - print() - - return composition - - except ValueError as e: - logger.error(f"Error getting deck composition values: {e}") - raise - -def assign_sort_order(df: pd.DataFrame) -> pd.DataFrame: - """Assign sort order to cards based on their types. - - This function adds a 'Sort Order' column to the DataFrame based on the - CARD_TYPE_SORT_ORDER constant from settings. Cards are sorted according to - their primary type, with the order specified in CARD_TYPE_SORT_ORDER. - - Args: - df: DataFrame containing card information with a 'Card Type' column - - Returns: - DataFrame with an additional 'Sort Order' column - - Example: - >>> df = pd.DataFrame({ - ... 'Card Type': ['Creature', 'Instant', 'Land'] - ... }) - >>> sorted_df = assign_sort_order(df) - >>> sorted_df['Sort Order'].tolist() - ['Creature', 'Instant', 'Land'] - """ - # Create a copy of the input DataFrame - df = df.copy() - - # Initialize Sort Order column with default value - df['Sort Order'] = 'Other' - - # Assign sort order based on card types - for card_type in CARD_TYPE_SORT_ORDER: - mask = df['Card Type'].str.contains(card_type, case=False, na=False) - df.loc[mask, 'Sort Order'] = card_type - - # Convert Sort Order to categorical for proper sorting - df['Sort Order'] = pd.Categorical( - df['Sort Order'], - categories=CARD_TYPE_SORT_ORDER + ['Other'], - ordered=True - ) - return df - -def process_duplicate_cards(card_library: pd.DataFrame, duplicate_lists: List[str]) -> pd.DataFrame: - """Process duplicate cards in the library and consolidate them with updated counts. - - This function identifies duplicate cards that are allowed to have multiple copies - (like basic lands and certain special cards), consolidates them into single entries, - and updates their counts. Card names are formatted using DUPLICATE_CARD_FORMAT. - - Args: - card_library: DataFrame containing the deck's card library - duplicate_lists: List of card names allowed to have multiple copies - - Returns: - DataFrame with processed duplicate cards and updated counts - - Raises: - DuplicateCardError: If there are issues processing duplicate cards - - Example: - >>> card_library = pd.DataFrame({ - ... 'name': ['Forest', 'Forest', 'Mountain', 'Mountain', 'Sol Ring'], - ... 'type': ['Basic Land', 'Basic Land', 'Basic Land', 'Basic Land', 'Artifact'] - ... }) - >>> duplicate_lists = ['Forest', 'Mountain'] - >>> result = process_duplicate_cards(card_library, duplicate_lists) - >>> print(result['name'].tolist()) - ['Forest x 2', 'Mountain x 2', 'Sol Ring'] - """ - try: - # Create a copy of the input DataFrame - processed_library = card_library.copy() - - # Process each allowed duplicate card - for card_name in duplicate_lists: - # Find all instances of the card - card_mask = processed_library['Card Name'] == card_name - card_count = card_mask.sum() - - if card_count > 1: - # Keep only the first instance and update its name with count - first_instance = processed_library[card_mask].iloc[0] - processed_library = processed_library[~card_mask] - - first_instance['Card Name'] = DUPLICATE_CARD_FORMAT.format( - card_name=card_name, - count=card_count - ) - processed_library = pd.concat([processed_library, pd.DataFrame([first_instance])]) - - return processed_library.reset_index(drop=True) - - except Exception as e: - raise DuplicateCardError( - f"Failed to process duplicate cards: {str(e)}", - details={'error': str(e)} - ) - -def count_cards_by_type(card_library: pd.DataFrame, card_types: List[str]) -> Dict[str, int]: - """Count the number of cards for each specified card type in the library. - - Args: - card_library: DataFrame containing the card library - card_types: List of card types to count - - Returns: - Dictionary mapping card types to their counts - - Raises: - CardTypeCountError: If counting fails for any card type - """ - try: - type_counts = {} - for card_type in card_types: - # Use pandas str.contains() for efficient type matching - # Case-insensitive matching with na=False to handle missing values - type_mask = card_library['Card Type'].str.contains( - card_type, - case=False, - na=False - ) - type_counts[card_type] = int(type_mask.sum()) - - return type_counts - except Exception as e: - print(card_type) - logger.error(f"Error counting cards by type: {e}") - raise CardTypeCountError(f"Failed to count cards by type: {str(e)}") - -def calculate_basics_per_color(total_basics: int, num_colors: int) -> Tuple[int, int]: - """Calculate the number of basic lands per color and remaining basics. - - Args: - total_basics: Total number of basic lands to distribute - num_colors: Number of colors in the deck - - Returns: - Tuple containing (basics per color, remaining basics) - - Example: - >>> calculate_basics_per_color(20, 3) - (6, 2) # 6 basics per color with 2 remaining - """ - if num_colors == 0: - return 0, total_basics - - basics_per_color = total_basics // num_colors - remaining_basics = total_basics % num_colors - - return basics_per_color, remaining_basics - -def get_basic_land_mapping(use_snow_covered: bool = False) -> Dict[str, str]: - """Get the appropriate basic land mapping based on snow-covered preference. - - Args: - use_snow_covered: Whether to use snow-covered basic lands - - Returns: - Dictionary mapping colors to their corresponding basic land names - - Example: - >>> get_basic_land_mapping(False) - {'W': 'Plains', 'U': 'Island', ...} - >>> get_basic_land_mapping(True) - {'W': 'Snow-Covered Plains', 'U': 'Snow-Covered Island', ...} - """ - return SNOW_BASIC_LAND_MAPPING if use_snow_covered else COLOR_TO_BASIC_LAND - -def distribute_remaining_basics( - basics_per_color: Dict[str, int], - remaining_basics: int, - colors: List[str] -) -> Dict[str, int]: - """Distribute remaining basic lands across colors. - - This function takes the initial distribution of basic lands and distributes - any remaining basics across the colors. The distribution prioritizes colors - based on their position in the color list (typically WUBRG order). - - Args: - basics_per_color: Initial distribution of basics per color - remaining_basics: Number of remaining basics to distribute - colors: List of colors to distribute basics across - - Returns: - Updated dictionary with final basic land counts per color - - Example: - >>> distribute_remaining_basics( - ... {'W': 6, 'U': 6, 'B': 6}, - ... 2, - ... ['W', 'U', 'B'] - ... ) - {'W': 7, 'U': 7, 'B': 6} - """ - if not colors: - return basics_per_color - - # Create a copy to avoid modifying the input dictionary - final_distribution = basics_per_color.copy() - - # Distribute remaining basics - color_index = 0 - while remaining_basics > 0 and color_index < len(colors): - color = colors[color_index] - if color in final_distribution: - final_distribution[color] += 1 - remaining_basics -= 1 - color_index = (color_index + 1) % len(colors) - - return final_distribution - -def validate_staple_land_conditions( - land_name: str, - conditions: dict, - commander_tags: List[str], - colors: List[str], - commander_power: int -) -> bool: - """Validate if a staple land meets its inclusion conditions. - - Args: - land_name: Name of the staple land to validate - conditions: Dictionary mapping land names to their condition functions - commander_tags: List of tags associated with the commander - colors: List of colors in the deck - commander_power: Power level of the commander - - Returns: - bool: True if the land meets its conditions, False otherwise - - Example: - >>> conditions = {'Command Tower': lambda tags, colors, power: len(colors) > 1} - >>> validate_staple_land_conditions('Command Tower', conditions, [], ['W', 'U'], 7) - True - """ - condition = conditions.get(land_name) - if not condition: - return False - return condition(commander_tags, colors, commander_power) - -def process_staple_lands( - lands_to_add: List[str], - card_library: pd.DataFrame, - land_df: pd.DataFrame -) -> pd.DataFrame: - """Update the land DataFrame by removing added staple lands. - - Args: - lands_to_add: List of staple land names to be added - card_library: DataFrame containing all available cards - land_df: DataFrame containing available lands - - Returns: - Updated land DataFrame with staple lands removed - - Example: - >>> process_staple_lands(['Command Tower'], card_library, land_df) - DataFrame without 'Command Tower' in the available lands - """ - updated_land_df = land_df[~land_df['name'].isin(lands_to_add)] - return updated_land_df - -def validate_fetch_land_count(count: int, min_count: int = 0, max_count: int = 9) -> int: - """Validate the requested number of fetch lands. - - Args: - count: Number of fetch lands requested - min_count: Minimum allowed fetch lands (default: 0) - max_count: Maximum allowed fetch lands (default: 9) - - Returns: - Validated fetch land count - - Raises: - FetchLandValidationError: If count is invalid - - Example: - >>> validate_fetch_land_count(5) - 5 - >>> validate_fetch_land_count(-1) # raises FetchLandValidationError - """ - try: - fetch_count = int(count) - if fetch_count < min_count or fetch_count > max_count: - raise FetchLandValidationError( - f"Fetch land count must be between {min_count} and {max_count}", - {"requested": fetch_count, "min": min_count, "max": max_count} - ) - return fetch_count - except ValueError: - raise FetchLandValidationError( - f"Invalid fetch land count: {count}", - {"value": count} - ) - -def get_available_fetch_lands(colors: List[str], price_checker: Optional[Any] = None, - max_price: Optional[float] = None) -> List[str]: - """Get list of fetch lands available for the deck's colors and budget. - - Args: - colors: List of deck colors - price_checker: Optional price checker instance - max_price: Maximum allowed price per card - - Returns: - List of available fetch land names - - Example: - >>> get_available_fetch_lands(['U', 'R']) - ['Scalding Tarn', 'Flooded Strand', ...] - """ - from settings import GENERIC_FETCH_LANDS, COLOR_TO_FETCH_LANDS - - # Start with generic fetches that work in any deck - available_fetches = GENERIC_FETCH_LANDS.copy() - - # Add color-specific fetches - for color in colors: - if color in COLOR_TO_FETCH_LANDS: - available_fetches.extend(COLOR_TO_FETCH_LANDS[color]) - - # Remove duplicates while preserving order - available_fetches = list(dict.fromkeys(available_fetches)) - - # Filter by price if price checking is enabled - if price_checker and max_price: - available_fetches = [ - fetch for fetch in available_fetches - if price_checker.get_card_price(fetch) <= max_price * 1.1 - ] - return available_fetches - -def select_fetch_lands(available_fetches: List[str], count: int, - allow_duplicates: bool = False) -> List[str]: - """Randomly select fetch lands from the available pool. - - Args: - available_fetches: List of available fetch lands - count: Number of fetch lands to select - allow_duplicates: Whether to allow duplicate selections - - Returns: - List of selected fetch land names - - Raises: - FetchLandSelectionError: If unable to select required number of fetches - - Example: - >>> select_fetch_lands(['Flooded Strand', 'Polluted Delta'], 2) - ['Polluted Delta', 'Flooded Strand'] - """ - import random - - if not available_fetches: - raise FetchLandSelectionError( - "No fetch lands available to select from", - {"requested": count} - ) - - if not allow_duplicates and count > len(available_fetches): - raise FetchLandSelectionError( - f"Not enough unique fetch lands available (requested {count}, have {len(available_fetches)})", - {"requested": count, "available": len(available_fetches)} - ) - - if allow_duplicates: - return random.choices(available_fetches, k=count) - else: - return random.sample(available_fetches, k=count) - -def validate_kindred_lands(land_name: str, commander_tags: List[str], colors: List[str]) -> bool: - """Validate if a Kindred land meets inclusion criteria. - - Args: - land_name: Name of the Kindred land to validate - commander_tags: List of tags associated with the commander - colors: List of colors in the deck - - Returns: - bool: True if the land meets criteria, False otherwise - - Raises: - KindredLandValidationError: If validation fails - - Example: - >>> validate_kindred_lands('Cavern of Souls', ['Elf Kindred'], ['G']) - True - """ - try: - # Check if any commander tags are Kindred-related - has_kindred_theme = any('Kindred' in tag for tag in commander_tags) - if not has_kindred_theme: - return False - - # Validate color requirements - if land_name in KINDRED_STAPLE_LANDS: - return True - - # Additional validation logic can be added here - return True - - except Exception as e: - raise KindredLandValidationError( - f"Failed to validate Kindred land {land_name}", - {"error": str(e), "tags": commander_tags, "colors": colors} - ) -def get_available_kindred_lands(land_df: pd.DataFrame, colors: List[str], commander_tags: List[str], - price_checker: Optional[Any] = None, - max_price: Optional[float] = None) -> List[str]: - """Get list of Kindred lands available for the deck's colors and themes. - - Args: - colors: List of deck colors - commander_tags: List of commander theme tags - price_checker: Optional price checker instance - max_price: Maximum allowed price per card - - Returns: - List of available Kindred land names - - Example: - >>> get_available_kindred_lands(['G'], ['Elf Kindred']) - ['Cavern of Souls', 'Path of Ancestry', ...] - """ - # Only proceed if deck has tribal themes - if not any('Kindred' in tag for tag in commander_tags): - return [] - - available_lands = [] - - # Add staple Kindred lands first - available_lands.extend([land['name'] for land in KINDRED_STAPLE_LANDS - if validate_kindred_lands(land['name'], commander_tags, colors)]) - - # Extract creature types from Kindred themes - creature_types = [tag.replace(' Kindred', '') - for tag in commander_tags - if 'Kindred' in tag] - - # Find lands specific to each creature type - for creature_type in creature_types: - logger.info(f'Searching for {creature_type}-specific lands') - - # Filter lands by creature type mentions in text or type - type_specific = land_df[ - land_df['text'].notna() & - (land_df['text'].str.contains(creature_type, case=False) | - land_df['type'].str.contains(creature_type, case=False)) - ] - - # Add any found type-specific lands - if not type_specific.empty: - available_lands.extend(type_specific['name'].tolist()) - - # Filter by price if price checking is enabled - if price_checker and max_price: - available_lands = [ - land for land in available_lands - if price_checker.get_card_price(land) <= max_price * 1.1 - ] - - return available_lands - -def select_kindred_lands(available_lands: List[str], count: int = None, - allow_duplicates: bool = False) -> List[str]: - """Select Kindred lands from the available pool. - - Args: - available_lands: List of available Kindred lands - - Returns: - List of selected Kindred land names - - Raises: - KindredLandSelectionError: If unable to select required number of lands - - Example: - >>> select_kindred_lands(['Cavern of Souls', 'Path of Ancestry']) - ['Cavern of Souls', 'Path of Ancestry'] - """ - import random - if not available_lands: - raise KindredLandSelectionError( - "No Kindred lands available to select from", - {"requested": count} - ) - - if not allow_duplicates and count > len(available_lands): - raise KindredLandSelectionError( - f"Not enough unique Kindred lands available (requested {count}, have {len(available_lands)})", - {"requested": count, "available": len(available_lands)} - ) - - if allow_duplicates: - return random.choices(available_lands, k=count) - else: - return random.sample(available_lands, k=count) - -def process_kindred_lands(lands_to_add: List[str], card_library: pd.DataFrame, - land_df: pd.DataFrame) -> pd.DataFrame: - """Update the land DataFrame by removing added Kindred lands. - - Args: - lands_to_add: List of Kindred land names to be added - card_library: DataFrame containing all available cards - land_df: DataFrame containing available lands - - Returns: - Updated land DataFrame with Kindred lands removed - - Example: - >>> process_kindred_lands(['Cavern of Souls'], card_library, land_df) - DataFrame without 'Cavern of Souls' in the available lands - """ - updated_land_df = land_df[~land_df['name'].isin(lands_to_add)] - return updated_land_df - -def validate_dual_lands(color_pairs: List[str], use_snow: bool = False) -> bool: - """Validate if dual lands should be added based on deck configuration. - - Args: - color_pairs: List of color pair combinations (e.g., ['azorius', 'orzhov']) - use_snow: Whether to use snow-covered lands - - Returns: - bool: True if dual lands should be added, False otherwise - - Example: - >>> validate_dual_lands(['azorius', 'orzhov'], False) - True - """ - if not color_pairs: - return False - - # Validate color pairs against DUAL_LAND_TYPE_MAP - return len(color_pairs) > 0 - -def get_available_dual_lands(land_df: pd.DataFrame, color_pairs: List[str], - use_snow: bool = False) -> pd.DataFrame: - """Get available dual lands based on color pairs and snow preference. - - Args: - land_df: DataFrame containing available lands - color_pairs: List of color pair combinations - use_snow: Whether to use snow-covered lands - - Returns: - DataFrame containing available dual lands - - Example: - >>> get_available_dual_lands(land_df, ['azorius'], False) - DataFrame with azorius dual lands - """ - # Create type filters based on color pairs - type_filters = color_pairs - - # Filter lands - if type_filters: - return land_df[land_df['type'].isin(type_filters)].copy() - return pd.DataFrame() - -def select_dual_lands(dual_df: pd.DataFrame, price_checker: Optional[Any] = None, - max_price: Optional[float] = None) -> List[Dict[str, Any]]: - """Select appropriate dual lands from available pool. - - Args: - dual_df: DataFrame of available dual lands - price_checker: Optional price checker instance - max_price: Maximum allowed price per card - - Returns: - List of selected dual land dictionaries - - Example: - >>> select_dual_lands(dual_df, price_checker, 20.0) - [{'name': 'Hallowed Fountain', 'type': 'Land — Plains Island', ...}] - """ - if dual_df.empty: - return [] - - # Sort by EDHREC rank - dual_df.sort_values(by='edhrecRank', inplace=True) - - # Convert to list of card dictionaries - selected_lands = [] - for _, row in dual_df.iterrows(): - card = { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - } - - # Check price if enabled - if price_checker and max_price: - try: - price = price_checker.get_card_price(card['name']) - if price > max_price * 1.1: - continue - except Exception as e: - logger.warning(f"Price check failed for {card['name']}: {e}") - continue - - selected_lands.append(card) - - return selected_lands - -def process_dual_lands(lands_to_add: List[Dict[str, Any]], card_library: pd.DataFrame, - land_df: pd.DataFrame) -> pd.DataFrame: - """Update land DataFrame after adding dual lands. - - Args: - lands_to_add: List of dual lands to be added - card_library: Current deck library - land_df: DataFrame of available lands - - Returns: - Updated land DataFrame - - Example: - >>> process_dual_lands(dual_lands, card_library, land_df) - Updated DataFrame without added dual lands - """ - lands_to_remove = set(land['name'] for land in lands_to_add) - return land_df[~land_df['name'].isin(lands_to_remove)] - -def validate_triple_lands(color_triplets: List[str], use_snow: bool = False) -> bool: - """Validate if triple lands should be added based on deck configuration. - - Args: - color_triplets: List of color triplet combinations (e.g., ['esper', 'bant']) - use_snow: Whether to use snow-covered lands - - Returns: - bool: True if triple lands should be added, False otherwise - - Example: - >>> validate_triple_lands(['esper', 'bant'], False) - True - """ - if not color_triplets: - return False - - # Validate color triplets - return len(color_triplets) > 0 - -def get_available_triple_lands(land_df: pd.DataFrame, color_triplets: List[str], - use_snow: bool = False) -> pd.DataFrame: - """Get available triple lands based on color triplets and snow preference. - - Args: - land_df: DataFrame containing available lands - color_triplets: List of color triplet combinations - use_snow: Whether to use snow-covered lands - - Returns: - DataFrame containing available triple lands - - Example: - >>> get_available_triple_lands(land_df, ['esper'], False) - DataFrame with esper triple lands - """ - # Create type filters based on color triplets - type_filters = color_triplets - - # Filter lands - if type_filters: - return land_df[land_df['type'].isin(type_filters)].copy() - return pd.DataFrame() - -def select_triple_lands(triple_df: pd.DataFrame, price_checker: Optional[Any] = None, - max_price: Optional[float] = None) -> List[Dict[str, Any]]: - """Select appropriate triple lands from available pool. - - Args: - triple_df: DataFrame of available triple lands - price_checker: Optional price checker instance - max_price: Maximum allowed price per card - - Returns: - List of selected triple land dictionaries - - Example: - >>> select_triple_lands(triple_df, price_checker, 20.0) - [{'name': 'Raffine's Tower', 'type': 'Land — Plains Island Swamp', ...}] - """ - if triple_df.empty: - return [] - - # Sort by EDHREC rank - triple_df.sort_values(by='edhrecRank', inplace=True) - - # Convert to list of card dictionaries - selected_lands = [] - for _, row in triple_df.iterrows(): - card = { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - } - - # Check price if enabled - if price_checker and max_price: - try: - price = price_checker.get_card_price(card['name']) - if price > max_price * 1.1: - continue - except Exception as e: - logger.warning(f"Price check failed for {card['name']}: {e}") - continue - - selected_lands.append(card) - - return selected_lands - -def process_triple_lands(lands_to_add: List[Dict[str, Any]], card_library: pd.DataFrame, - land_df: pd.DataFrame) -> pd.DataFrame: - """Update land DataFrame after adding triple lands. - - Args: - lands_to_add: List of triple lands to be added - card_library: Current deck library - land_df: DataFrame of available lands - - Returns: - Updated land DataFrame - - Example: - >>> process_triple_lands(triple_lands, card_library, land_df) - Updated DataFrame without added triple lands - """ - lands_to_remove = set(land['name'] for land in lands_to_add) - return land_df[~land_df['name'].isin(lands_to_remove)] - -def get_available_misc_lands(land_df: pd.DataFrame, max_pool_size: int) -> List[Dict[str, Any]]: - """Retrieve the top N lands from land_df for miscellaneous land selection. - - Args: - land_df: DataFrame containing available lands - max_pool_size: Maximum number of lands to include in the pool - - Returns: - List of dictionaries containing land information - - Example: - >>> get_available_misc_lands(land_df, 100) - [{'name': 'Command Tower', 'type': 'Land', ...}, ...] - """ - try: - # Take top N lands by EDHREC rank - top_lands = land_df.head(max_pool_size).copy() - - # Convert to list of dictionaries - available_lands = [ - { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - } - for _, row in top_lands.iterrows() - ] - - return available_lands - - except Exception as e: - logger.error(f"Error getting available misc lands: {e}") - return [] - -def select_misc_lands(available_lands: List[Dict[str, Any]], min_count: int, max_count: int, - price_checker: Optional[PriceChecker] = None, - max_price: Optional[float] = None) -> List[Dict[str, Any]]: - """Randomly select a number of lands between min_count and max_count. - - Args: - available_lands: List of available lands to select from - min_count: Minimum number of lands to select - max_count: Maximum number of lands to select - price_checker: Optional price checker instance - max_price: Maximum allowed price per card - - Returns: - List of selected land dictionaries - - Example: - >>> select_misc_lands(available_lands, 5, 10) - [{'name': 'Command Tower', 'type': 'Land', ...}, ...] - """ - import random - - if not available_lands: - return [] - - # Randomly determine number of lands to select - target_count = random.randint(min_count, max_count) - selected_lands = [] - - # Create a copy of available lands to avoid modifying the original - land_pool = available_lands.copy() - - while land_pool and len(selected_lands) < target_count: - # Randomly select a land - land = random.choice(land_pool) - land_pool.remove(land) - - # Check price if enabled - if price_checker and max_price: - try: - price = price_checker.get_card_price(land['name']) - if price > max_price * 1.1: - continue - except Exception as e: - logger.warning(f"Price check failed for {land['name']}: {e}") - continue - - selected_lands.append(land) - - return selected_lands - - -def filter_removable_lands(card_library: pd.DataFrame, protected_lands: List[str]) -> pd.DataFrame: - """Filter the card library to get lands that can be removed. - - Args: - card_library: DataFrame containing all cards in the deck - protected_lands: List of land names that cannot be removed - - Returns: - DataFrame containing only removable lands - - Raises: - LandRemovalError: If no removable lands are found - DataFrameValidationError: If card_library validation fails - """ - try: - # Validate input DataFrame - if card_library.empty: - raise EmptyDataFrameError("filter_removable_lands") - - # Filter for lands only - lands_df = card_library[card_library['Card Type'].str.contains('Land', case=False, na=False)].copy() - - # Remove protected lands - removable_lands = lands_df[~lands_df['Card Name'].isin(protected_lands)] - - if removable_lands.empty: - raise LandRemovalError( - "No removable lands found in deck", - {"protected_lands": protected_lands} - ) - - logger.debug(f"Found {len(removable_lands)} removable lands") - return removable_lands - - except Exception as e: - logger.error(f"Error filtering removable lands: {e}") - raise - -def select_land_for_removal(filtered_lands: pd.DataFrame) -> Tuple[int, str]: - """Randomly select a land for removal from filtered lands. - - Args: - filtered_lands: DataFrame containing only removable lands - - Returns: - Tuple containing (index in original DataFrame, name of selected land) - - Raises: - LandRemovalError: If filtered_lands is empty - DataFrameValidationError: If filtered_lands validation fails - """ - try: - if filtered_lands.empty: - raise LandRemovalError( - "No lands available for removal", - {"filtered_lands_size": len(filtered_lands)} - ) - - # Randomly select a land - selected_land = filtered_lands.sample(n=1).iloc[0] - index = selected_land.name - land_name = selected_land['Card Name'] - - logger.info(f"Selected land for removal: {land_name}") - return index, land_name - - except Exception as e: - logger.error(f"Error selecting land for removal: {e}") - raise - -def get_card_theme_overlap(card_tags: List[str], deck_themes: List[str]) -> int: - """Count how many deck themes a given card matches. - - Args: - card_tags: List of tags associated with the card - deck_themes: List of themes in the deck - - Returns: - Number of deck themes that match the card's tags - - Example: - >>> card_tags = ['Artifacts Matter', 'Token Creation', 'Sacrifice'] - >>> deck_themes = ['Artifacts Matter', 'Sacrifice Matters'] - >>> get_card_theme_overlap(card_tags, deck_themes) - 2 - """ - if not card_tags or not deck_themes: - return 0 - - # Convert to sets for efficient intersection - card_tag_set = set(card_tags) - deck_theme_set = set(deck_themes) - - # Count overlapping themes - return len(card_tag_set.intersection(deck_theme_set)) - -def calculate_theme_priority(card_tags: List[str], deck_themes: List[str], THEME_PRIORITY_BONUS: float) -> float: - """Calculate priority score for a card based on theme overlap. - - Args: - card_tags: List of tags associated with the card - deck_themes: List of themes in the deck - THEME_PRIORITY_BONUS: Bonus multiplier for each additional theme match - - Returns: - Priority score for the card (higher means more theme overlap) - - Example: - >>> card_tags = ['Artifacts Matter', 'Token Creation', 'Sacrifice'] - >>> deck_themes = ['Artifacts Matter', 'Sacrifice Matters'] - >>> calculate_theme_priority(card_tags, deck_themes, 1.2) - 1.44 # Base score of 1.0 * (1.2 ^ 2) for two theme matches - """ - overlap_count = get_card_theme_overlap(card_tags, deck_themes) - if overlap_count == 0: - return 0.0 - - # Calculate priority score with exponential bonus for multiple matches - return pow(THEME_PRIORITY_BONUS, overlap_count) - -def calculate_weighted_pool_size(ideal_count: int, weight: float, multiplier: float = THEME_POOL_SIZE_MULTIPLIER) -> int: - """Calculate the size of the initial card pool based on ideal count and weight. - - Args: - ideal_count: Target number of cards to select - weight: Theme weight factor (0.0-1.0) - multiplier: Pool size multiplier (default from settings) - - Returns: - Calculated pool size - - Example: - >>> calculate_weighted_pool_size(10, 0.8, 2.0) - 16 - """ - return int(ideal_count * weight * multiplier) - -def filter_theme_cards(df: pd.DataFrame, themes: List[str], pool_size: int) -> pd.DataFrame: - """Filter cards by theme and return top cards by EDHREC rank. - - Args: - df: Source DataFrame to filter - themes: List of theme tags to filter by - pool_size: Number of cards to return - - Returns: - Filtered DataFrame with top cards - - Raises: - ValueError: If themes is None or contains invalid values - TypeError: If themes is not a list - - Example: - >>> filtered_df = filter_theme_cards(cards_df, ['Artifacts Matter', 'Token Creation'], 20) - """ - # Input validation - if themes is None: - raise ValueError("themes parameter cannot be None") - - if not isinstance(themes, list): - raise TypeError("themes must be a list of strings") - - if not all(isinstance(theme, str) for theme in themes): - raise ValueError("all themes must be strings") - - if not themes: - return pd.DataFrame() # Return empty DataFrame for empty themes list - - # Create copy to avoid modifying original - filtered_df = df.copy() - - # Filter by theme - filtered_df = filtered_df[filtered_df['themeTags'].apply( - lambda x: any(theme in x for theme in themes) if isinstance(x, list) else False - )] - - # Sort by EDHREC rank and take top cards - filtered_df.sort_values('edhrecRank', inplace=True) - return filtered_df.head(pool_size) - -def select_weighted_cards( - card_pool: pd.DataFrame, - target_count: int, - price_checker: Optional[Any] = None, - max_price: Optional[float] = None -) -> List[Dict[str, Any]]: - """Select cards from pool considering price constraints. - - Args: - card_pool: DataFrame of candidate cards - target_count: Number of cards to select - price_checker: Optional price checker instance - max_price: Maximum allowed price per card - - Returns: - List of selected card dictionaries - - Example: - >>> selected = select_weighted_cards(pool_df, 5, price_checker, 10.0) - """ - selected_cards = [] - - for _, card in card_pool.iterrows(): - if len(selected_cards) >= target_count: - break - - # Check price if enabled - if price_checker and max_price: - try: - price = price_checker.get_card_price(card['name']) - if price > max_price * 1.1: - continue - except Exception as e: - logger.warning(f"Price check failed for {card['name']}: {e}") - continue - - selected_cards.append({ - 'name': card['name'], - 'type': card['type'], - 'manaCost': card['manaCost'], - 'manaValue': card['manaValue'], - 'themeTags': card['themeTags'] - }) - - return selected_cards - -def count_color_pips(mana_costs: pd.Series, color: str) -> int: - """Count the number of colored mana pips of a specific color in mana costs. - - Args: - mana_costs: Series of mana cost strings to analyze - color: Color to count pips for (W, U, B, R, or G) - - Returns: - Total number of pips of the specified color - - Example: - >>> mana_costs = pd.Series(['{2}{W}{W}', '{W}{U}', '{B}{R}']) - >>> count_color_pips(mana_costs, 'W') - 3 - """ - if not isinstance(mana_costs, pd.Series): - raise TypeError("mana_costs must be a pandas Series") - - if color not in MANA_COLORS: - raise ValueError(f"Invalid color: {color}. Must be one of {MANA_COLORS}") - - pattern = MANA_PIP_PATTERNS[color] - - # Count occurrences of the pattern in non-null mana costs - pip_counts = mana_costs.fillna('').str.count(pattern) - - return int(pip_counts.sum()) - -def calculate_pip_percentages(pip_counts: Dict[str, int]) -> Dict[str, float]: - """Calculate the percentage distribution of mana pips for each color. - - Args: - pip_counts: Dictionary mapping colors to their pip counts - - Returns: - Dictionary mapping colors to their percentage of total pips (0-100) - - Example: - >>> pip_counts = {'W': 10, 'U': 5, 'B': 5, 'R': 0, 'G': 0} - >>> calculate_pip_percentages(pip_counts) - {'W': 50.0, 'U': 25.0, 'B': 25.0, 'R': 0.0, 'G': 0.0} - - Note: - If total pip count is 0, returns 0% for all colors to avoid division by zero. - """ - if not isinstance(pip_counts, dict): - raise TypeError("pip_counts must be a dictionary") - - # Validate colors - invalid_colors = set(pip_counts.keys()) - set(MANA_COLORS) - if invalid_colors: - raise ValueError(f"Invalid colors in pip_counts: {invalid_colors}") - - total_pips = sum(pip_counts.values()) - - if total_pips == 0: - return {color: 0.0 for color in MANA_COLORS} - - percentages = {} - for color in MANA_COLORS: - count = pip_counts.get(color, 0) - percentage = (count / total_pips) * 100 - percentages[color] = round(percentage, 1) - - return percentages diff --git a/code/deck_builder_old/builder.py b/code/deck_builder_old/builder_old.py similarity index 100% rename from code/deck_builder_old/builder.py rename to code/deck_builder_old/builder_old.py diff --git a/code/file_setup/setup_constants.py b/code/file_setup/setup_constants.py index 962f710..5226b9d 100644 --- a/code/file_setup/setup_constants.py +++ b/code/file_setup/setup_constants.py @@ -17,18 +17,18 @@ __all__ = [ BANNED_CARDS: List[str] = [ # Commander banned list 'Ancestral Recall', 'Balance', 'Biorhythm', 'Black Lotus', - 'Braids, Cabal Minion', 'Chaos Orb', 'Coalition Victory', - 'Channel', 'Dockside Extortionist', 'Emrakul, the Aeons Torn', + 'Chaos Orb', 'Channel', 'Dockside Extortionist', + 'Emrakul, the Aeons Torn', 'Erayo, Soratami Ascendant', 'Falling Star', 'Fastbond', - 'Flash', 'Gifts Ungiven', 'Golos, Tireless Pilgrim', + 'Flash', 'Golos, Tireless Pilgrim', 'Griselbrand', 'Hullbreacher', 'Iona, Shield of Emeria', 'Karakas', 'Jeweled Lotus', 'Leovold, Emissary of Trest', 'Library of Alexandria', 'Limited Resources', 'Lutri, the Spellchaser', 'Mana Crypt', 'Mox Emerald', 'Mox Jet', 'Mox Pearl', 'Mox Ruby', - 'Mox Sapphire', 'Nadu, Winged Wisdom', 'Panoptic Mirror', + 'Mox Sapphire', 'Nadu, Winged Wisdom', 'Paradox Engine', 'Primeval Titan', 'Prophet of Kruphix', 'Recurring Nightmare', 'Rofellos, Llanowar Emissary', 'Shahrazad', - 'Sundering Titan', 'Sway of the Stars', 'Sylvan Primordial', + 'Sundering Titan', 'Sylvan Primordial', 'Time Vault', 'Time Walk', 'Tinker', 'Tolarian Academy', 'Trade Secrets', 'Upheaval', "Yawgmoth's Bargain", # Problematic / culturally sensitive or banned in other formats diff --git a/code/non_interactive_test.py b/code/non_interactive_test.py new file mode 100644 index 0000000..cfae6e9 --- /dev/null +++ b/code/non_interactive_test.py @@ -0,0 +1,39 @@ +from deck_builder.builder import DeckBuilder + +# Non-interactive harness: chooses specified commander, first tag, first bracket, accepts defaults + +def run(command_name: str = "Finneas, Ace Archer"): + scripted_inputs = [] + # Commander query + scripted_inputs.append(command_name) # initial query + # After showing matches, choose first candidate (index 1) + scripted_inputs.append("1") # select first candidate to inspect + scripted_inputs.append("y") # confirm commander + # Tag selection: choose eleventh tag as primary + scripted_inputs.append("11") + # Stop after primary (secondary prompt enters 1) + scripted_inputs.append("1") + # Stop after primary (tertiary prompt enters 0) + scripted_inputs.append("0") + # Bracket selection: choose 3 (Typical Casual mid default) else 2 maybe; pick 3 + scripted_inputs.append("3") + # Ideal counts prompts (8 prompts) -> press Enter (empty) to accept defaults + for _ in range(8): + scripted_inputs.append("") + + def scripted_input(prompt: str) -> str: + if scripted_inputs: + return scripted_inputs.pop(0) + raise RuntimeError("Ran out of scripted inputs for prompt: " + prompt) + + b = DeckBuilder(input_func=scripted_input) + b.run_initial_setup() + b.run_deck_build_step1() + b.run_deck_build_step2() + b.run_land_step1() + b.run_land_step2() + b.print_card_library() + return b + +if __name__ == "__main__": + run() diff --git a/requirements.txt b/requirements.txt index ca651e1..91a21b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ tqdm>=4.66.0 scrython>=1.10.0 numpy>=1.24.0 requests>=2.31.0 +prettytable>=3.9.0 # Development dependencies mypy>=1.3.0