diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index e0eb0d8..f01b22a 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -100,2398 +100,4 @@ except ImportError: pd.set_option('display.max_columns', None) pd.set_option('display.max_rows', None) -pd.set_option('display.max_colwidth', 50) - -def new_line(num_lines: int = 1) -> None: - """Print specified number of newlines for formatting output. - - Args: - num_lines (int): Number of newlines to print. Defaults to 1. - - Returns: - None - """ - if num_lines < 0: - raise ValueError("Number of lines cannot be negative") - print('\n' * num_lines) - -class DeckBuilder: - - def __init__(self) -> None: - """Initialize DeckBuilder with empty dataframes and default attributes.""" - # Initialize dataframes with type hints - self.card_library: CardLibraryDF = pd.DataFrame({ - 'Card Name': pd.Series(dtype='str'), - 'Card Type': pd.Series(dtype='str'), - 'Mana Cost': pd.Series(dtype='str'), - 'Mana Value': pd.Series(dtype='int'), - 'Creature Types': pd.Series(dtype='object'), - 'Themes': pd.Series(dtype='object'), - 'Commander': pd.Series(dtype='bool'), - }) - - # Initialize component dataframes - self.commander_df: CommanderDF = pd.DataFrame() - self.land_df: LandDF = pd.DataFrame() - self.artifact_df: ArtifactDF = pd.DataFrame() - self.creature_df: CreatureDF = pd.DataFrame() - self.noncreature_df: NonCreatureDF = pd.DataFrame() - self.nonplaneswalker_df: NonPlaneswalkerDF = pd.DataFrame() - # Initialize other attributes with type hints - self.commander_info: Dict = {} - self.max_card_price: Optional[float] = None - self.commander_dict: CommanderDict = {} - self.commander: str = '' - self.commander_type: str = '' - self.commander_text: str = '' - self.commander_power: int = 0 - self.commander_toughness: int = 0 - self.commander_mana_cost: str = '' - self.commander_mana_value: int = 0 - self.color_identity: Union[str, List[str]] = '' - self.color_identity_full: str = '' - self.colors: List[str] = [] - self.creature_types: str = '' - self.commander_tags: List[str] = [] - self.themes: List[str] = [] - - # Initialize handlers - self.price_checker = PriceChecker() if PriceChecker else None - self.input_handler = InputHandler() - - def pause_with_message(self, message: str = "Press Enter to continue...") -> None: - """Display a message and wait for user input. - - Args: - message: Message to display before pausing - """ - """Helper function to pause execution with a message.""" - print(f"\n{message}") - input() - - # Determine and Validate commander - def determine_commander(self) -> None: - """Main orchestrator method for commander selection and initialization process. - - This method coordinates the commander selection workflow by: - 1. Loading commander data - 2. Facilitating commander selection - 3. Confirming the selection - 4. Initializing commander attributes - - Raises: - CommanderLoadError: If commander data cannot be loaded - CommanderSelectionError: If commander selection fails - CommanderValidationError: If commander data is invalid - """ - logger.info("Starting commander selection process") - - try: - # Load commander data using builder_utils - df = builder_utils.load_commander_data() - logger.debug("Commander data loaded successfully") - - # Select commander - commander_name = self._select_commander(df) - logger.info(f"Commander selected: {commander_name}") - - # Confirm selection - commander_data = self._confirm_commander(df, commander_name) - logger.info("Commander selection confirmed") - - # Initialize commander - self._initialize_commander(commander_data) - logger.info("Commander initialization complete") - - except DeckBuilderError as e: - logger.error(f"Commander selection failed: {e}") - raise - except Exception as e: - logger.error(f"Unexpected error in commander selection: {e}") - raise DeckBuilderError(f"Commander selection failed: {str(e)}") - - def _select_commander(self, df: pd.DataFrame) -> str: - """Handle the commander selection process including fuzzy matching. - - Args: - df: DataFrame containing commander data - - Returns: - Selected commander name - - Raises: - CommanderSelectionError: If commander selection fails - """ - while True: - try: - card_choice = self.input_handler.questionnaire( - 'Text', - 'Enter a card name to be your commander' - ) - - # Use builder_utils for fuzzy matching - match, choices, exact_match = builder_utils.process_fuzzy_matches(card_choice, df) - - if exact_match: - return match - - # Handle multiple matches - choices.append(('Neither', 0)) - logger.info("Multiple commander matches found") - - choice = self.input_handler.questionnaire( - 'Choice', - 'Multiple matches found. Please select:', - choices_list=[name for name, _ in choices] - ) - - if choice != 'Neither': - return choice - - except DeckBuilderError as e: - logger.warning(f"Commander selection attempt failed: {e}") - continue - - def _confirm_commander(self, df: pd.DataFrame, commander_name: str) -> Dict: - """Confirm commander selection and validate data. - - Args: - df: DataFrame containing commander data - commander_name: Name of selected commander - - Returns: - Dictionary containing commander data - - Raises: - CommanderValidationError: If commander data is invalid - """ - try: - # Validate commander data - commander_data = builder_utils.validate_commander_selection(df, commander_name) - - # Store commander DataFrame - self.commander_df = pd.DataFrame(commander_data) - - # Display commander info - print('\nSelected Commander:') - pprint.pprint(commander_data, sort_dicts=False) - - # Confirm selection - if not self.input_handler.questionnaire('Confirm', 'Is this the commander you want?', True): - raise CommanderSelectionError("Commander selection cancelled by user") - - # Check price if enabled - if self.price_checker: - self.price_checker.get_card_price(commander_name) - - return commander_data - - except DeckBuilderError as e: - logger.error(f"Commander confirmation failed: {e}") - raise - - def _initialize_commander(self, commander_data: Dict) -> None: - """Initialize commander attributes from validated data. - - Args: - commander_data: Dictionary containing commander information - - Raises: - CommanderValidationError: If required attributes are missing - """ - try: - # Store commander info - self.commander_info = commander_data - self.commander = commander_data['name'][0] - - # Initialize commander attributes - self.commander_setup() - logger.debug("Commander attributes initialized successfully") - - except Exception as e: - logger.error(f"Commander initialization failed: {e}") - raise CommanderValidationError(f"Failed to initialize commander: {str(e)}") - - # Setup Commander - def commander_setup(self) -> None: - """Set up commander attributes and initialize deck building. - - This method orchestrates the commander setup process by calling specialized - helper methods to handle different aspects of initialization. - - Raises: - CommanderValidationError: If commander validation fails - DeckBuilderError: If deck building initialization fails - """ - try: - # Initialize commander attributes - self._initialize_commander_attributes() - - # Set up commander components - self._setup_commander_type_and_text() - self._setup_commander_stats() - self._setup_color_identity() - self._setup_creature_types() - self._setup_commander_tags() - - # Initialize commander dictionary and deck - self._initialize_commander_dict() - self._initialize_deck_building() - - logger.info("Commander setup completed successfully") - - except CommanderValidationError as e: - logger.error(f"Commander validation failed: {e}") - raise - except DeckBuilderError as e: - logger.error(f"Deck building initialization failed: {e}") - raise - - def _initialize_commander_attributes(self) -> None: - """Initialize basic commander attributes with defaults. - - Uses settings.py constants for default values. - """ - self.commander_power = COMMANDER_POWER_DEFAULT - self.commander_toughness = COMMANDER_TOUGHNESS_DEFAULT - self.commander_mana_value = COMMANDER_MANA_VALUE_DEFAULT - self.commander_type = COMMANDER_TYPE_DEFAULT - self.commander_text = COMMANDER_TEXT_DEFAULT - self.commander_mana_cost = COMMANDER_MANA_COST_DEFAULT - self.color_identity = COMMANDER_COLOR_IDENTITY_DEFAULT - self.colors = COMMANDER_COLORS_DEFAULT.copy() - self.creature_types = COMMANDER_CREATURE_TYPES_DEFAULT - self.commander_tags = COMMANDER_TAGS_DEFAULT.copy() - self.themes = COMMANDER_THEMES_DEFAULT.copy() - - def _setup_commander_type_and_text(self) -> None: - """Set up and validate commander type line and text. - - Raises: - CommanderTypeError: If type line validation fails - """ - df = self.commander_df - type_line = str(df.at[0, 'type']) - self.commander_type = self.input_handler.validate_commander_type(type_line) - self.commander_text = str(df.at[0, 'text']) - - def _setup_commander_stats(self) -> None: - """Set up and validate commander power, toughness, and mana values. - - Raises: - CommanderStatsError: If stats validation fails - """ - df = self.commander_df - - # Validate power and toughness - self.commander_power = self.input_handler.validate_commander_stats( - 'power', str(df.at[0, 'power'])) - self.commander_toughness = self.input_handler.validate_commander_stats( - 'toughness', str(df.at[0, 'toughness'])) - - # Set mana cost and value - self.commander_mana_cost = str(df.at[0, 'manaCost']) - self.commander_mana_value = self.input_handler.validate_commander_stats( - 'mana value', int(df.at[0, 'manaValue'])) - - def _setup_color_identity(self) -> None: - """Set up and validate commander color identity. - - Raises: - CommanderColorError: If color identity validation fails - """ - df = self.commander_df - try: - color_id = df.at[0, 'colorIdentity'] - if pd.isna(color_id): - color_id = 'COLORLESS' - - self.color_identity = self.input_handler.validate_commander_colors(color_id) - self.color_identity_full = '' - self.determine_color_identity() - print(self.color_identity_full) - - # Set colors list - if pd.notna(df.at[0, 'colors']) and df.at[0, 'colors'].strip(): - self.colors = [color.strip() for color in df.at[0, 'colors'].split(',') if color.strip()] - if not self.colors: - self.colors = ['COLORLESS'] - else: - self.colors = ['COLORLESS'] - - except Exception as e: - raise CommanderColorError(f"Failed to set color identity: {str(e)}") - - def _setup_creature_types(self) -> None: - """Set up commander creature types.""" - df = self.commander_df - self.creature_types = str(df.at[0, 'creatureTypes']) - - def _setup_commander_tags(self) -> None: - """Set up and validate commander theme tags. - - Raises: - CommanderTagError: If tag validation fails - """ - df = self.commander_df - tags = list(df.at[0, 'themeTags']) - self.commander_tags = self.input_handler.validate_commander_tags(tags) - self.determine_themes() - - def _initialize_commander_dict(self) -> None: - """Initialize the commander dictionary with validated data.""" - self.commander_dict: CommanderDict = { - 'Commander Name': self.commander, - 'Mana Cost': self.commander_mana_cost, - 'Mana Value': self.commander_mana_value, - 'Color Identity': self.color_identity_full, - 'Colors': self.colors, - 'Type': self.commander_type, - 'Creature Types': self.creature_types, - 'Text': self.commander_text, - 'Power': self.commander_power, - 'Toughness': self.commander_toughness, - 'Themes': self.themes, - 'CMC': 0.0 - } - self.add_card(self.commander, self.commander_type, - self.commander_mana_cost, self.commander_mana_value, - self.creature_types, self.commander_tags, True) - - def _initialize_deck_building(self) -> None: - """Initialize deck building process. - - Raises: - DeckBuilderError: If deck building initialization fails - """ - try: - # Set up initial deck structure - self.setup_dataframes() - self.determine_ideals() - - # Add cards by category - self.add_lands() - self.add_creatures() - self.add_ramp() - self.add_board_wipes() - self.add_interaction() - self.add_card_advantage() - - # Fill remaining slots if needed - if len(self.card_library) < 100: - self.fill_out_deck() - - # Process and organize deck - self.organize_library() - - # Log deck composition - self._log_deck_composition() - - # Finalize deck - self.get_cmc() - self.count_pips() - self.concatenate_duplicates() - self.organize_library() - self.sort_library() - self.commander_to_top() - - # Save final deck - FILE_TIME = time.strftime("%Y%m%d-%H%M%S") - DECK_FILE = f'{self.commander}_{FILE_TIME}.csv' - self.card_library.to_csv(f'{DECK_DIRECTORY}/{DECK_FILE}', index=False) - - except Exception as e: - raise DeckBuilderError(f"Failed to initialize deck building: {str(e)}") - - def _log_deck_composition(self) -> None: - """Log the deck composition statistics.""" - logger.info(f'Creature cards (including commander): {self.creature_cards}') - logger.info(f'Planeswalker cards: {self.planeswalker_cards}') - logger.info(f'Battle cards: {self.battle_cards}') - logger.info(f'Instant cards: {self.instant_cards}') - logger.info(f'Sorcery cards: {self.sorcery_cards}') - logger.info(f'Artifact cards: {self.artifact_cards}') - logger.info(f'Enchantment cards: {self.enchantment_cards}') - logger.info(f'Land cards cards: {self.land_cards}') - logger.info(f'Number of cards in Library: {len(self.card_library)}') - - # Determine and validate color identity - def determine_color_identity(self) -> None: - """Determine the deck's color identity and set related attributes. - - This method orchestrates the color identity determination process by: - 1. Validating the color identity input - 2. Determining the appropriate color combination type - 3. Setting color identity attributes based on the combination - - Raises: - CommanderColorError: If color identity validation fails - """ - try: - # Validate color identity using input handler - validated_identity = self.input_handler.validate_commander_colors(self.color_identity) - - # Determine color combination type and set attributes - if self._determine_mono_color(validated_identity): - return - - if self._determine_dual_color(validated_identity): - return - - if self._determine_tri_color(validated_identity): - return - - if self._determine_other_color(validated_identity): - return - - # Handle unknown color identity - logger.warning(f"Unknown color identity: {validated_identity}") - self.color_identity_full = 'Unknown' - self.files_to_load = ['colorless'] - - except CommanderColorError as e: - logger.error(f"Color identity validation failed: {e}") - raise - except Exception as e: - logger.error(f"Error in determine_color_identity: {e}") - raise CommanderColorError(f"Failed to determine color identity: {str(e)}") - - def _determine_mono_color(self, color_identity: str) -> bool: - """Handle single color identities. - - Args: - color_identity: Validated color identity string - - Returns: - True if color identity was handled, False otherwise - """ - from settings import MONO_COLOR_MAP - - if color_identity in MONO_COLOR_MAP: - self.color_identity_full, self.files_to_load = MONO_COLOR_MAP[color_identity] - return True - return False - - def _determine_dual_color(self, color_identity: str) -> bool: - """Handle two-color combinations. - - Args: - color_identity: Validated color identity string - - Returns: - True if color identity was handled, False otherwise - """ - from settings import DUAL_COLOR_MAP - - if color_identity in DUAL_COLOR_MAP: - identity_info = DUAL_COLOR_MAP[color_identity] - self.color_identity_full = identity_info[0] - self.color_identity_options = identity_info[1] - self.files_to_load = identity_info[2] - return True - return False - - def _determine_tri_color(self, color_identity: str) -> bool: - """Handle three-color combinations. - - Args: - color_identity: Validated color identity string - - Returns: - True if color identity was handled, False otherwise - """ - from settings import TRI_COLOR_MAP - - if color_identity in TRI_COLOR_MAP: - identity_info = TRI_COLOR_MAP[color_identity] - self.color_identity_full = identity_info[0] - self.color_identity_options = identity_info[1] - self.files_to_load = identity_info[2] - return True - return False - - def _determine_other_color(self, color_identity: str) -> bool: - """Handle four and five color combinations. - - Args: - color_identity: Validated color identity string - - Returns: - True if color identity was handled, False otherwise - """ - from builder_constants import OTHER_COLOR_MAP - - if color_identity in OTHER_COLOR_MAP: - identity_info = OTHER_COLOR_MAP[color_identity] - self.color_identity_full = identity_info[0] - self.color_identity_options = identity_info[1] - self.files_to_load = identity_info[2] - return True - return False - - # CSV and dataframe functionality - def read_csv(self, filename: str, converters: dict | None = None) -> pd.DataFrame: - """Read and validate CSV file with comprehensive error handling. - - Args: - filename: Name of the CSV file without extension - converters: Dictionary of converters for specific columns - - Returns: - pd.DataFrame: Validated and processed DataFrame - - Raises: - CSVReadError: If file cannot be read - CSVValidationError: If data fails validation - CSVTimeoutError: If read operation times out - EmptyDataFrameError: If DataFrame is empty - """ - filepath = f'{CSV_DIRECTORY}/{filename}_cards.csv' - - try: - # Read with timeout - df = pd.read_csv( - filepath, - converters=converters or {'themeTags': pd.eval, 'creatureTypes': pd.eval}, - ) - - # Check for empty DataFrame - if df.empty: - raise EmptyDataFrameError(f"Empty DataFrame from {filename}_cards.csv") - - # Validate required columns - missing_cols = set(CSV_REQUIRED_COLUMNS) - set(df.columns) - if missing_cols: - raise CSVValidationError(f"Missing required columns: {missing_cols}") - - # Validate data rules - for col, rules in CSV_VALIDATION_RULES.items(): - if rules.get('required', False) and df[col].isnull().any(): - raise CSVValidationError(f"Missing required values in column: {col}") - if 'type' in rules: - expected_type = rules['type'] - actual_type = df[col].dtype.name - if expected_type == 'str' and actual_type not in ['object', 'string']: - raise CSVValidationError(f"Invalid type for column {col}: expected {expected_type}, got {actual_type}") - elif expected_type != 'str' and not actual_type.startswith(expected_type): - raise CSVValidationError(f"Invalid type for column {col}: expected {expected_type}, got {actual_type}") - logger.debug(f"Successfully read and validated {filename}_cards.csv") - #print(df.columns) - return df - - except pd.errors.EmptyDataError: - raise EmptyDataFrameError(f"Empty CSV file: {filename}_cards.csv") - - except FileNotFoundError as e: - logger.error(f"File {filename}_cards.csv not found: {e}") - setup_utils.regenerate_csvs_all() - return self.read_csv(filename, converters) - - except TimeoutError: - raise CSVTimeoutError(f"Timeout reading {filename}_cards.csv", CSV_READ_TIMEOUT) - - except Exception as e: - logger.error(f"Error reading {filename}_cards.csv: {e}") - raise CSVReadError(f"Failed to read {filename}_cards.csv: {str(e)}") - - def write_csv(self, df: pd.DataFrame, filename: str) -> None: - """Write DataFrame to CSV with error handling and logger. - - Args: - df: DataFrame to write - filename: Name of the CSV file without extension - """ - try: - filepath = f'{CSV_DIRECTORY}/{filename}.csv' - df.to_csv(filepath, index=False) - logger.debug(f"Successfully wrote {filename}.csv") - except Exception as e: - logger.error(f"Error writing {filename}.csv: {e}") - - def _load_and_combine_data(self) -> pd.DataFrame: - """Load and combine data from multiple CSV files. - - Returns: - Combined DataFrame from all source files - - Raises: - CSVError: If data loading or combining fails - EmptyDataFrameError: If no valid data is loaded - """ - logger.info("Loading and combining data from CSV files...") - all_df = [] - - try: - # Wrap files_to_load with tqdm for progress bar - for file in tqdm(self.files_to_load, desc="Loading card data files", leave=False): - df = self.read_csv(file) - if df.empty: - raise EmptyDataFrameError(f"Empty DataFrame from {file}") - all_df.append(df) - #print(df.columns) - return builder_utils.combine_dataframes(all_df) - - except (CSVError, EmptyDataFrameError) as e: - logger.error(f"Error loading and combining data: {e}") - raise - - def _split_into_specialized_frames(self, df: pd.DataFrame) -> None: - """Split combined DataFrame into specialized component frames. - - Args: - df: Source DataFrame to split - - Raises: - DataFrameValidationError: If data splitting fails - """ - try: - # Extract lands - self.land_df = df[df['type'].str.contains('Land')].copy() - self.land_df.sort_values(by='edhrecRank', inplace=True) - - # Remove lands from main DataFrame - df = df[~df['type'].str.contains('Land')] - df.to_csv(f'{CSV_DIRECTORY}/test_cards.csv', index=False) - - # Create specialized frames - self.artifact_df = df[df['type'].str.contains('Artifact')].copy() - self.battle_df = df[df['type'].str.contains('Battle')].copy() - self.creature_df = df[df['type'].str.contains('Creature')].copy() - self.noncreature_df = df[~df['type'].str.contains('Creature')].copy() - self.enchantment_df = df[df['type'].str.contains('Enchantment')].copy() - self.instant_df = df[df['type'].str.contains('Instant')].copy() - self.planeswalker_df = df[df['type'].str.contains('Planeswalker')].copy() - self.nonplaneswalker_df = df[~df['type'].str.contains('Planeswalker')].copy() - self.sorcery_df = df[df['type'].str.contains('Sorcery')].copy() - - self.battle_df.to_csv(f'{CSV_DIRECTORY}/test_battle_cards.csv', index=False) - - # Sort all frames - for frame in [self.artifact_df, self.battle_df, self.creature_df, - self.noncreature_df, self.enchantment_df, self.instant_df, - self.planeswalker_df, self.sorcery_df]: - frame.sort_values(by='edhrecRank', inplace=True) - - except Exception as e: - logger.error(f"Error splitting DataFrames: {e}") - raise DataFrameValidationError("DataFrame splitting failed", {}, {"error": str(e)}) - - def _validate_dataframes(self) -> None: - """Validate all component DataFrames. - - Raises: - DataFrameValidationError: If validation fails - """ - try: - frames_to_validate = { - 'land': self.land_df, - 'artifact': self.artifact_df, - 'battle': self.battle_df, - 'creature': self.creature_df, - 'noncreature': self.noncreature_df, - 'enchantment': self.enchantment_df, - 'instant': self.instant_df, - 'planeswalker': self.planeswalker_df, - 'sorcery': self.sorcery_df - } - - for name, frame in frames_to_validate.items(): - rules = builder_utils.get_validation_rules(name) - if not builder_utils.validate_dataframe(frame, rules): - raise DataFrameValidationError(f"{name} validation failed", rules) - - except Exception as e: - logger.error(f"DataFrame validation failed: {e}") - raise - - def _save_intermediate_results(self) -> None: - """Save intermediate DataFrames for debugging and analysis. - - Raises: - CSVError: If saving fails - """ - try: - frames_to_save = { - 'lands': self.land_df, - 'artifacts': self.artifact_df, - 'battles': self.battle_df, - 'creatures': self.creature_df, - 'noncreatures': self.noncreature_df, - 'enchantments': self.enchantment_df, - 'instants': self.instant_df, - 'planeswalkers': self.planeswalker_df, - 'sorcerys': self.sorcery_df - } - - for name, frame in frames_to_save.items(): - self.write_csv(frame, f'test_{name}') - - except Exception as e: - logger.error(f"Error saving intermediate results: {e}") - raise CSVError(f"Failed to save intermediate results: {str(e)}") - - def setup_dataframes(self) -> None: - """Initialize and validate all required DataFrames. - - This method orchestrates the DataFrame setup process by: - 1. Loading and combining data from CSV files - 2. Splitting into specialized component frames - 3. Validating all DataFrames - 4. Saving intermediate results - - Raises: - CSVError: If any CSV operations fail - EmptyDataFrameError: If any required DataFrame is empty - DataFrameValidationError: If validation fails - """ - try: - # Load and combine data - self.full_df = self._load_and_combine_data() - self.full_df = self.full_df[~self.full_df['name'].str.contains(self.commander)] - self.full_df.sort_values(by='edhrecRank', inplace=True) - self.full_df.to_csv(f'{CSV_DIRECTORY}/test_full_cards.csv', index=False) - - # Split into specialized frames - self._split_into_specialized_frames(self.full_df) - # Validate all frames - self._validate_dataframes() - - # Save intermediate results - self._save_intermediate_results() - - logger.info("DataFrame setup completed successfully") - - except (CSVError, EmptyDataFrameError, DataFrameValidationError) as e: - logger.error(f"Error in DataFrame setup: {e}") - raise - - # Theme selection - def determine_themes(self) -> None: - """Determine and set up themes for the deck building process. - - This method handles: - 1. Theme selection (primary, secondary, tertiary) - 2. Theme weight calculations - 3. Hidden theme detection and setup - - Raises: - ThemeSelectionError: If theme selection fails - ThemeWeightError: If weight calculation fails - """ - try: - # Get available themes from commander tags - themes = self.commander_tags.copy() - - # Get available themes from commander tags - themes = self.commander_tags.copy() - - # Initialize theme flags - self.hidden_theme = False - self.secondary_theme = False - self.tertiary_theme = False - - # Select primary theme (required) - self.primary_theme = builder_utils.select_theme( - themes, - 'Choose a primary theme for your commander deck.\n' - 'This will be the "focus" of the deck, in a kindred deck this will typically be a creature type for example.' - ) - themes.remove(self.primary_theme) - - # Initialize self.weights from settings - from settings import THEME_WEIGHTS_DEFAULT - self.weights = THEME_WEIGHTS_DEFAULT.copy() - # Set initial weights for primary-only case - self.weights['primary'] = 1.0 - self.weights['secondary'] = 0.0 - self.weights['tertiary'] = 0.0 - self.primary_weight = 1.0 - - # Select secondary theme if desired - if themes: - self.secondary_theme = builder_utils.select_theme( - themes, - 'Choose a secondary theme for your commander deck.\n' - 'This will typically be a secondary focus, like card draw for Spellslinger, or +1/+1 counters for Aggro.', - optional=True - ) - - # Check for Stop Here before modifying themes list - if self.secondary_theme == 'Stop Here': - self.secondary_theme = False - elif self.secondary_theme: - themes.remove(self.secondary_theme) - self.weights['secondary'] = 0.6 - self.weights = builder_utils.adjust_theme_weights( - self.primary_theme, - self.secondary_theme, - None, # No tertiary theme yet - self.weights - ) - self.primary_weight = self.weights['primary'] - self.secondary_weight = self.weights['secondary'] - - # Select tertiary theme if desired - if themes and self.secondary_theme and self.secondary_theme != 'Stop Here': - self.tertiary_theme = builder_utils.select_theme( - themes, - 'Choose a tertiary theme for your commander deck.\n' - 'This will typically be a tertiary focus, or just something else to do that your commander is good at.', - optional=True - ) - - # Check for Stop Here before modifying themes list - if self.tertiary_theme == 'Stop Here': - self.tertiary_theme = False - elif self.tertiary_theme: - self.weights['tertiary'] = 0.3 - self.weights = builder_utils.adjust_theme_weights( - self.primary_theme, - self.secondary_theme, - self.tertiary_theme, - self.weights - ) - self.primary_weight = self.weights['primary'] - self.secondary_weight = self.weights['secondary'] - self.tertiary_weight = self.weights['tertiary'] - - # Build final themes list - self.themes = [self.primary_theme] - if self.secondary_theme: - self.themes.append(self.secondary_theme) - if self.tertiary_theme: - self.themes.append - self.determine_hidden_themes() - - except (ThemeSelectionError, ThemeWeightError) as e: - logger.error(f"Error in theme determination: {e}") - raise - - def determine_hidden_themes(self) -> None: - """ - Setting 'Hidden' themes for multiple-copy cards, such as 'Hare Apparent' or 'Shadowborn Apostle'. - These are themes that will be prompted for under specific conditions, such as a matching Kindred theme or a matching color combination and Spellslinger theme for example. - Typically a hidden theme won't come up, but if it does, it will take priority with theme self.weights to ensure a decent number of the specialty cards are added. - """ - # Setting hidden theme for Kindred-specific themes - hidden_themes = ['Advisor Kindred', 'Demon Kindred', 'Dwarf Kindred', 'Rabbit Kindred', 'Rat Kindred', 'Wraith Kindred'] - theme_cards = ['Persistent Petitioners', 'Shadowborn Apostle', 'Seven Dwarves', 'Hare Apparent', ['Rat Colony', 'Relentless Rats'], 'Nazgûl'] - color = ['B', 'B', 'R', 'W', 'B', 'B'] - for i in range(min(len(hidden_themes), len(theme_cards), len(color))): - if (hidden_themes[i] in self.themes - and hidden_themes[i] != 'Rat Kindred' - and color[i] in self.colors): - logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') - choice = self.input_handler.questionnaire('Confirm', message='', default_value=False) - if choice: - self.hidden_theme = theme_cards[i] - self.themes.append(self.hidden_theme) - self.weights['primary'] = round(self.weights['primary'] / 3, 2) - self.weights['secondary'] = round(self.weights['secondary'] / 2, 2) - self.weights['tertiary'] = self.weights['tertiary'] - self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2) - self.primary_weight = self.weights['primary'] - self.secondary_weight = self.weights['secondary'] - self.tertiary_weight = self.weights['tertiary'] - self.hidden_weight = self.weights['hidden'] - else: - continue - - elif (hidden_themes[i] in self.themes - and hidden_themes[i] == 'Rat Kindred' - and color[i] in self.colors): - logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i][0]} or {theme_cards[i][1]} deck?') - choice = self.input_handler.questionnaire('Confirm', message='', default_value=False) - if choice: - print('Which one?') - choice = self.input_handler.questionnaire('Choice', choices_list=theme_cards[i], message='') - if choice: - self.hidden_theme = choice - self.themes.append(self.hidden_theme) - self.weights['primary'] = round(self.weights['primary'] / 3, 2) - self.weights['secondary'] = round(self.weights['secondary'] / 2, 2) - self.weights['tertiary'] = self.weights['tertiary'] - self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2) - self.primary_weight = self.weights['primary'] - self.secondary_weight = self.weights['secondary'] - self.tertiary_weight = self.weights['tertiary'] - self.hidden_weight = self.weights['hidden'] - else: - continue - - # Setting the hidden theme for non-Kindred themes - hidden_themes = ['Little Fellas', 'Mill', 'Spellslinger', 'Spells Matter', 'Spellslinger', 'Spells Matter',] - theme_cards = ['Hare Apparent', 'Persistent Petitions', 'Dragon\'s Approach', 'Dragon\'s Approach', 'Slime Against Humanity', 'Slime Against Humanity'] - color = ['W', 'B', 'R', 'R', 'G', 'G'] - for i in range(min(len(hidden_themes), len(theme_cards), len(color))): - if (hidden_themes[i] in self.themes - and color[i] in self.colors): - logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') - choice = self.input_handler.questionnaire('Confirm', message='', default_value=False) - if choice: - self.hidden_theme = theme_cards[i] - self.themes.append(self.hidden_theme) - self.weights['primary'] = round(self.weights['primary'] / 3, 2) - self.weights['secondary'] = round(self.weights['secondary'] / 2, 2) - self.weights['tertiary'] = self.weights['tertiary'] - self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2) - self.primary_weight = self.weights['primary'] - self.secondary_weight = self.weights['secondary'] - self.tertiary_weight = self.weights['tertiary'] - self.hidden_weight = self.weights['hidden'] - else: - continue - - # Setting ideals - def determine_ideals(self): - """Determine ideal card counts and price settings for the deck. - - This method handles: - 1. Price configuration (if price checking is enabled) - 2. Setting ideal counts for different card types - 3. Calculating remaining free slots - - Raises: - PriceConfigurationError: If there are issues configuring price settings - IdealDeterminationError: If there are issues determining ideal counts - """ - try: - # Initialize free slots - self.free_slots = 99 - - # Configure price settings if enabled - if use_scrython: - try: - builder_utils.configure_price_settings(self.price_checker, self.input_handler) - except ValueError as e: - raise PriceConfigurationError(f"Failed to configure price settings: {str(e)}") - - # Get deck composition values - try: - composition = builder_utils.get_deck_composition_values(self.input_handler) - except ValueError as e: - raise IdealDeterminationError(f"Failed to determine deck composition: {str(e)}") - - # Update class attributes with composition values - self.ideal_ramp = composition['ramp'] - self.ideal_land_count = composition['lands'] - self.min_basics = composition['basic_lands'] - self.ideal_creature_count = composition['creatures'] - self.ideal_removal = composition['removal'] - self.ideal_wipes = composition['wipes'] - self.ideal_card_advantage = composition['card_advantage'] - self.ideal_protection = composition['protection'] - - # Update free slots - for value in [self.ideal_ramp, self.ideal_land_count, self.ideal_creature_count, - self.ideal_removal, self.ideal_wipes, self.ideal_card_advantage, - self.ideal_protection]: - self.free_slots -= value - - print(f'\nFree slots that aren\'t part of the ideals: {self.free_slots}') - print('Keep in mind that many of the ideals can also cover multiple roles, but this will give a baseline POV.') - - except (PriceConfigurationError, IdealDeterminationError) as e: - logger.error(f"Error in determine_ideals: {e}") - raise - - # Adding card to library - def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, creature_types: list = None, tags: list = None, is_commander: bool = False) -> None: - """Add a card to the deck library with price checking if enabled. - Args: - card (str): Name of the card to add - card_type (str): Type of the card (e.g., 'Creature', 'Instant') - mana_cost (str): Mana cost string representation - mana_value (int): Converted mana cost/mana value - creature_types (list): List of creature types in the card (if any) - themes (list): List of themes the card has - is_commander (bool, optional): Whether this card is the commander. Defaults to False. - - Returns: - None - - Raises: - PriceLimitError: If card price exceeds maximum allowed price - PriceAPIError: If there is an error fetching the price - PriceTimeoutError: If the price check times out - PriceValidationError: If the price data is invalid - """ - multiple_copies = BASIC_LANDS + MULTIPLE_COPY_CARDS - - # Skip if card already exists and isn't allowed multiple copies - if card in pd.Series(self.card_library['Card Name']).values and card not in multiple_copies: - return - - # Handle price checking - card_price = 0.0 - try: - # Get price and validate - card_price = self.price_checker.get_card_price(card) - self.price_checker.validate_card_price(card, card_price) - self.price_checker.update_deck_price(card_price) - except (PriceAPIError, PriceTimeoutError, PriceValidationError, PriceLimitError) as e: - logger.warning(str(e)) - return - - # Create card entry - card_entry = [card, card_type, mana_cost, mana_value, creature_types, tags, is_commander] - - # Add to library - self.card_library.loc[len(self.card_library)] = card_entry - - logger.debug(f"Added {card} to deck library") - - # Get card counts, sort library, set commander at index 1, and combine duplicates into 1 entry - def organize_library(self): - """Organize and count cards in the library by their types. - - This method counts the number of cards for each card type in the library - and updates the corresponding instance variables. It uses the count_cards_by_type - helper function from builder_utils for efficient counting. - - The method handles the following card types: - - Artifacts - - Battles - - Creatures - - Enchantments - - Instants - - Kindred (if applicable) - - Lands - - Planeswalkers - - Sorceries - - Raises: - CardTypeCountError: If there are issues counting cards by type - LibraryOrganizationError: If library organization fails - """ - try: - # Get all card types to count, including Kindred if not already present - all_types = CARD_TYPES + ['Kindred'] if 'Kindred' not in CARD_TYPES else CARD_TYPES - - # Use helper function to count cards by type - card_counters = builder_utils.count_cards_by_type(self.card_library, all_types) - - # Update instance variables with counts - self.artifact_cards = card_counters['Artifact'] - self.battle_cards = card_counters['Battle'] - self.creature_cards = card_counters['Creature'] - self.enchantment_cards = card_counters['Enchantment'] - self.instant_cards = card_counters['Instant'] - self.kindred_cards = card_counters.get('Kindred', 0) - self.land_cards = card_counters['Land'] - self.planeswalker_cards = card_counters['Planeswalker'] - self.sorcery_cards = card_counters['Sorcery'] - - logger.debug(f"Library organized successfully with {len(self.card_library)} total cards") - - except (CardTypeCountError, Exception) as e: - logger.error(f"Error organizing library: {e}") - raise LibraryOrganizationError(f"Failed to organize library: {str(e)}") - - def sort_library(self) -> None: - """Sort the card library by card type and name. - - This method sorts the card library first by card type according to the - CARD_TYPE_SORT_ORDER constant, and then alphabetically by card name. - It uses the assign_sort_order() helper function to ensure consistent - type-based sorting across the application. - - The sorting order is: - 1. Card type (Planeswalker -> Battle -> Creature -> Instant -> Sorcery -> - Artifact -> Enchantment -> Land) - 2. Card name (alphabetically) - - Raises: - LibrarySortError: If there are issues during the sorting process - """ - try: - # Use the assign_sort_order helper function to add sort order - sorted_library = builder_utils.assign_sort_order(self.card_library) - - # Sort by Sort Order and Card Name - sorted_library = sorted_library.sort_values( - by=['Sort Order', 'Card Name'], - ascending=[True, True] - ) - - # Clean up and reset index - self.card_library = ( - sorted_library - .drop(columns=['Sort Order']) - .reset_index(drop=True) - ) - - logger.debug("Card library sorted successfully") - - except Exception as e: - logger.error(f"Error sorting library: {e}") - raise LibrarySortError( - "Failed to sort card library", - {"error": str(e)} - ) - - def commander_to_top(self) -> None: - """Move commander card to the top of the library while preserving commander status. - - This method identifies the commander card in the library using a boolean mask, - removes it from its current position, and prepends it to the top of the library. - The commander's status and attributes are preserved during the move. - - Raises: - CommanderMoveError: If the commander cannot be found in the library or - if there are issues with the move operation. - """ - try: - # Create boolean mask to identify commander - commander_mask = self.card_library['Commander'] - - # Check if commander exists in library - if not commander_mask.any(): - error_msg = "Commander not found in library" - logger.warning(error_msg) - raise CommanderMoveError(error_msg) - - # Get commander row and name for logging - commander_row = self.card_library[commander_mask].copy() - commander_name = commander_row['Card Name'].iloc[0] - - # Remove commander from current position - self.card_library = self.card_library[~commander_mask] - - # Prepend commander to top of library - self.card_library = pd.concat([commander_row, self.card_library], ignore_index=True) - - logger.info(f"Successfully moved commander '{commander_name}' to top of library") - - except CommanderMoveError: - raise - except Exception as e: - error_msg = f"Error moving commander to top: {str(e)}" - logger.error(error_msg) - raise CommanderMoveError(error_msg) - - def concatenate_duplicates(self): - """Process duplicate cards in the library using the helper function. - - This method consolidates duplicate cards (like basic lands and special cards - that can have multiple copies) into single entries with updated counts. - It uses the process_duplicate_cards helper function from builder_utils. - - Raises: - DuplicateCardError: If there are issues processing duplicate cards - """ - try: - # Get list of cards that can have duplicates - duplicate_lists = BASIC_LANDS + MULTIPLE_COPY_CARDS - - # Process duplicates using helper function - self.card_library = builder_utils.process_duplicate_cards( - self.card_library, - duplicate_lists - ) - - logger.info("Successfully processed duplicate cards") - - except DuplicateCardError as e: - logger.error(f"Error processing duplicate cards: {e}") - raise - - # Land Management - def add_lands(self): - """ - Add lands to the deck based on ideal count and deck requirements. - - The process follows these steps: - 1. Add basic lands distributed by color identity - 2. Add utility/staple lands - 3. Add fetch lands if requested - 4. Add theme-specific lands (e.g., Kindred) - 5. Add multi-color lands based on color count - 6. Add miscellaneous utility lands - 7. Adjust total land count to match ideal count - """ - MAX_ADJUSTMENT_ATTEMPTS = (self.ideal_land_count - self.min_basics) * 1.5 - self.total_basics = 0 - - try: - # Add lands in sequence - self.add_basics() - self.check_basics() - self.add_standard_non_basics() - self.add_fetches() - - # Add theme and color-specific lands - if any('Kindred' in theme for theme in self.themes): - self.add_kindred_lands() - if len(self.colors) >= 2: - self.add_dual_lands() - if len(self.colors) >= 3: - self.add_triple_lands() - - self.add_misc_lands() - - # Clean up land database - mask = self.land_df['name'].isin(self.card_library['Card Name']) - self.land_df = self.land_df[~mask] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - # Adjust to ideal land count - self.check_basics() - print() - logger.info('Adjusting total land count to match ideal count...') - self.organize_library() - - attempts = 0 - while self.land_cards > int(self.ideal_land_count) and attempts < MAX_ADJUSTMENT_ATTEMPTS: - logger.info(f'Current lands: {self.land_cards}, Target: {self.ideal_land_count}') - self.remove_basic() - self.organize_library() - attempts += 1 - - if attempts >= MAX_ADJUSTMENT_ATTEMPTS: - logger.warning(f"Could not reach ideal land count after {MAX_ADJUSTMENT_ATTEMPTS} attempts") - - logger.info(f'Final land count: {self.land_cards}') - - except Exception as e: - logger.error(f"Error during land addition: {e}") - raise - - def add_basics(self): - """Add basic lands to the deck based on color identity and commander tags. - - This method: - 1. Calculates total basics needed based on ideal land count - 2. Gets appropriate basic land mapping (normal or snow-covered) - 3. Distributes basics across colors - 4. Updates the land database - - Raises: - BasicLandError: If there are issues with basic land addition - LandDistributionError: If land distribution fails - """ - try: - # Calculate total basics needed - total_basics = self.ideal_land_count - DEFAULT_NON_BASIC_LAND_SLOTS - if total_basics <= 0: - raise BasicLandError("Invalid basic land count calculation") - - # Get appropriate basic land mapping - use_snow = 'Snow' in self.commander_tags - color_to_basic = builder_utils.get_basic_land_mapping(use_snow) - - # Calculate distribution - basics_per_color, remaining = builder_utils.calculate_basics_per_color( - total_basics, - len(self.colors) - ) - - print() - logger.info( - f'Adding {total_basics} basic lands distributed across ' - f'{len(self.colors)} colors' - ) - - # Initialize distribution dictionary - distribution = {color: basics_per_color for color in self.colors} - - # Distribute remaining basics - if remaining > 0: - distribution = builder_utils.distribute_remaining_basics( - distribution, - remaining, - self.colors - ) - - # Add basics according to distribution - lands_to_remove = [] - for color, count in distribution.items(): - basic = color_to_basic.get(color) - if basic: - for _ in range(count): - self.add_card(basic, 'Basic Land', None, 0, is_commander=False) - lands_to_remove.append(basic) - - # Update land database - self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - except Exception as e: - logger.error(f"Error adding basic lands: {e}") - raise BasicLandError(f"Failed to add basic lands: {str(e)}") - - def add_standard_non_basics(self): - """Add staple utility lands to the deck based on predefined conditions and requirements. - - This method processes the STAPLE_LAND_CONDITIONS from settings to add appropriate - utility lands to the deck. For each potential staple land, it: - - 1. Validates the land against deck requirements using: - - Commander tags - - Color identity - - Commander power level - - Other predefined conditions - - 2. Adds validated lands to the deck and tracks them in self.staples - - 3. Updates the land database to remove added lands - - The method ensures no duplicate lands are added and maintains proper logging - of all additions. - - Raises: - StapleLandError: If there are issues adding staple lands, such as - validation failures or database update errors. - """ - print() - logger.info('Adding staple non-basic lands') - self.staples = [] - - try: - for land in STAPLE_LAND_CONDITIONS: - if builder_utils.validate_staple_land_conditions( - land, - STAPLE_LAND_CONDITIONS, - self.commander_tags, - self.colors, - self.commander_power - ): - if land not in self.card_library['Card Name'].values: - self.add_card(land, 'Land', None, 0) - self.staples.append(land) - logger.debug(f"Added staple land: {land}") - - self.land_df = builder_utils.process_staple_lands( - self.staples, self.card_library, self.land_df - ) - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - logger.info(f'Added {len(self.staples)} staple lands:') - print(*self.staples, sep='\n') - except Exception as e: - logger.error(f"Error adding staple lands: {e}") - raise StapleLandError(f"Failed to add staple lands: {str(e)}") - - def add_fetches(self): - """Add fetch lands to the deck based on user input and deck colors. - - This method handles: - 1. Getting user input for desired number of fetch lands - 2. Validating the input - 3. Getting available fetch lands based on deck colors - 4. Selecting and adding appropriate fetch lands - 5. Updating the land database - - Raises: - FetchLandValidationError: If fetch land count is invalid - FetchLandSelectionError: If unable to select required fetch lands - PriceLimitError: If fetch lands exceed price limits - """ - try: - # Get user input for fetch lands - print() - logger.info('Adding fetch lands') - print('How many fetch lands would you like to include?\n' - 'For most decks you\'ll likely be good with 3 or 4, just enough to thin the deck and help ensure the color availability.\n' - 'If you\'re doing Landfall, more fetches would be recommended just to get as many Landfall triggers per turn.') - - # Get and validate fetch count - fetch_count = self.input_handler.questionnaire('Number', default_value=FETCH_LAND_DEFAULT_COUNT, message='Default') - validated_count = builder_utils.validate_fetch_land_count(fetch_count) - - # Get available fetch lands based on colors and budget - max_price = self.max_card_price if hasattr(self, 'max_card_price') else None - available_fetches = builder_utils.get_available_fetch_lands( - self.colors, - self.price_checker if use_scrython else None, - max_price - ) - - # Select fetch lands - selected_fetches = builder_utils.select_fetch_lands( - available_fetches, - validated_count - ) - - # Add selected fetch lands to deck - lands_to_remove = set() - for fetch in selected_fetches: - self.add_card(fetch, 'Land', None, 0) - lands_to_remove.add(fetch) - - # Update land database - self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(selected_fetches)} fetch lands:') - print(*selected_fetches, sep='\n') - - except (FetchLandValidationError, FetchLandSelectionError, PriceLimitError) as e: - logger.error(f"Error adding fetch lands: {e}") - raise - - def add_kindred_lands(self): - """Add Kindred-themed lands to the deck based on commander themes. - - This method handles: - 1. Getting available Kindred lands based on deck themes - 2. Selecting and adding appropriate Kindred lands - 3. Updating the land database - - Raises: - KindredLandSelectionError: If unable to select required Kindred lands - PriceLimitError: If Kindred lands exceed price limits - """ - try: - print() - logger.info('Adding Kindred-themed lands') - - # Get available Kindred lands based on themes and budget - max_price = self.max_card_price if hasattr(self, 'max_card_price') else None - available_lands = builder_utils.get_available_kindred_lands( - self.land_df, - self.colors, - self.commander_tags, - self.price_checker if use_scrython else None, - max_price - ) - - # Select Kindred lands - selected_lands = builder_utils.select_kindred_lands( - available_lands, - len(available_lands) - ) - - # Add selected Kindred lands to deck - lands_to_remove = set() - for land in selected_lands: - self.add_card(land, 'Land', None, 0) - lands_to_remove.add(land) - - # Update land database - self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(selected_lands)} Kindred-themed lands:') - print(*selected_lands, sep='\n') - - except Exception as e: - logger.error(f"Error adding Kindred lands: {e}") - raise - - def add_dual_lands(self): - """Add dual lands to the deck based on color identity and user preference. - - This method handles the addition of dual lands by: - 1. Validating if dual lands should be added - 2. Getting available dual lands based on deck colors - 3. Selecting appropriate dual lands - 4. Adding selected lands to the deck - 5. Updating the land database - - The process uses helper functions from builder_utils for modular operation. - """ - try: - # Check if we should add dual lands - print() - print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?') - use_duals = self.input_handler.questionnaire('Confirm', message='', default_value=True) - - if not use_duals: - logger.info('Skipping adding Dual-type land cards.') - return - - logger.info('Adding Dual-type lands') - # Get color pairs by checking DUAL_LAND_TYPE_MAP keys against files_to_load - color_pairs = [] - for key in DUAL_LAND_TYPE_MAP: - if key in self.files_to_load: - color_pairs.extend([f'Land — {DUAL_LAND_TYPE_MAP[key]}', f'Snow Land — {DUAL_LAND_TYPE_MAP[key]}']) - - # Validate dual lands for these color pairs - if not builder_utils.validate_dual_lands(color_pairs, 'Snow' in self.commander_tags): - logger.info('No valid dual lands available for this color combination.') - return - - # Get available dual lands - dual_df = builder_utils.get_available_dual_lands( - self.land_df, - color_pairs, - 'Snow' in self.commander_tags - ) - - # Select appropriate dual lands - selected_lands = builder_utils.select_dual_lands( - dual_df, - self.price_checker if use_scrython else None, - self.max_card_price if hasattr(self, 'max_card_price') else None - ) - - # Add selected lands to deck - for land in selected_lands: - self.add_card(land['name'], land['type'], - land['manaCost'], land['manaValue']) - - # Update land database - self.land_df = builder_utils.process_dual_lands( - selected_lands, - self.card_library, - self.land_df - ) - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(selected_lands)} Dual-type land cards:') - for card in selected_lands: - print(card['name']) - - except Exception as e: - logger.error(f"Error adding dual lands: {e}") - raise - - def add_triple_lands(self): - """Add triple lands to the deck based on color identity and user preference. - - This method handles the addition of triple lands by: - 1. Validating if triple lands should be added - 2. Getting available triple lands based on deck colors - 3. Selecting appropriate triple lands - 4. Adding selected lands to the deck - 5. Updating the land database - - The process uses helper functions from builder_utils for modular operation. - """ - try: - # Check if we should add triple lands - print() - print('Would you like to include triple lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?') - use_triples = self.input_handler.questionnaire('Confirm', message='', default_value=True) - - if not use_triples: - logger.info('Skipping adding triple lands.') - return - - logger.info('Adding triple lands') - # Get color triplets by checking TRIPLE_LAND_TYPE_MAP keys against files_to_load - color_triplets = [] - for key in TRIPLE_LAND_TYPE_MAP: - if key in self.files_to_load: - color_triplets.extend([f'Land — {TRIPLE_LAND_TYPE_MAP[key]}']) - - # Validate triple lands for these color triplets - if not builder_utils.validate_triple_lands(color_triplets, 'Snow' in self.commander_tags): - logger.info('No valid triple lands available for this color combination.') - return - - # Get available triple lands - triple_df = builder_utils.get_available_triple_lands( - self.land_df, - color_triplets, - 'Snow' in self.commander_tags - ) - - # Select appropriate triple lands - selected_lands = builder_utils.select_triple_lands( - triple_df, - self.price_checker if use_scrython else None, - self.max_card_price if hasattr(self, 'max_card_price') else None - ) - - # Add selected lands to deck - for land in selected_lands: - self.add_card(land['name'], land['type'], - land['manaCost'], land['manaValue']) - - # Update land database - self.land_df = builder_utils.process_triple_lands( - selected_lands, - self.card_library, - self.land_df - ) - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(selected_lands)} triple lands:') - for card in selected_lands: - print(card['name']) - - except Exception as e: - logger.error(f"Error adding triple lands: {e}") - - def add_misc_lands(self): - """Add additional utility lands that fit the deck's color identity. - - This method randomly selects a number of miscellaneous utility lands to add to the deck. - The number of lands is randomly determined between MISC_LAND_MIN_COUNT and MISC_LAND_MAX_COUNT. - Lands are selected from a filtered pool of the top MISC_LAND_POOL_SIZE lands by EDHREC rank. - - The method handles price constraints if price checking is enabled and updates the land - database after adding lands to prevent duplicates. - - Raises: - MiscLandSelectionError: If there are issues selecting appropriate misc lands - """ - print() - logger.info('Adding miscellaneous utility lands') - - try: - # Get available misc lands - available_lands = builder_utils.get_available_misc_lands( - self.land_df, - MISC_LAND_POOL_SIZE - ) - - if not available_lands: - logger.warning("No eligible miscellaneous lands found") - return - - # Select random number of lands - selected_lands = builder_utils.select_misc_lands( - available_lands, - MISC_LAND_MIN_COUNT, - MISC_LAND_MAX_COUNT, - self.price_checker if use_scrython else None, - self.max_card_price if hasattr(self, 'max_card_price') else None - ) - - # Add selected lands - lands_to_remove = set() - for card in selected_lands: - self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue']) - lands_to_remove.add(card['name']) - - # Update land database - self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(selected_lands)} miscellaneous lands:') - for card in selected_lands: - print(card['name']) - - except Exception as e: - logger.error(f"Error adding misc lands: {e}") - raise - - def check_basics(self): - """Check and display counts of each basic land type in the deck. - - This method analyzes the deck's basic land composition by: - 1. Counting each type of basic land (including snow-covered) - 2. Displaying the counts for each basic land type - 3. Calculating and storing the total number of basic lands - - The method uses helper functions from builder_utils for consistent - counting and display formatting. - - Raises: - BasicLandCountError: If there are issues counting basic lands - - Note: - Updates self.total_basics with the sum of all basic lands - """ - basic_lands = { - 'Plains': 0, - 'Island': 0, - 'Swamp': 0, - 'Mountain': 0, - 'Forest': 0, - 'Snow-Covered Plains': 0, - 'Snow-Covered Island': 0, - 'Snow-Covered Swamp': 0, - 'Snow-Covered Mountain': 0, - 'Snow-Covered Forest': 0 - } - - self.total_basics = 0 - - try: - for land in basic_lands: - count = len(self.card_library[self.card_library['Card Name'] == land]) - basic_lands[land] = count - self.total_basics += count - print() - logger.info("Basic Land Counts:") - for land, count in basic_lands.items(): - if count > 0: - print(f"{land}: {count}") - logger.info(f"Total basic lands: {self.total_basics}") - except BasicLandCountError as e: - logger.error(f"Error counting basic lands: {e}") - self.total_basics = 0 - raise - - def remove_basic(self, max_attempts: int = 3): - """ - Remove a basic land while maintaining color balance. - Attempts to remove from colors with more basics first. - - Args: - max_attempts: Maximum number of removal attempts before falling back to non-basics - """ - print() - logger.info('Land count over ideal count, removing a basic land.') - - color_to_basic = { - 'W': 'Plains', 'U': 'Island', 'B': 'Swamp', - 'R': 'Mountain', 'G': 'Forest' - } - - # Get current basic land counts using vectorized operations - basic_counts = { - basic: len(self.card_library[self.card_library['Card Name'] == basic]) - for color, basic in color_to_basic.items() - if color in self.colors - } - - sum_basics = sum(basic_counts.values()) - attempts = 0 - - while attempts < max_attempts and sum_basics > self.min_basics: - if not basic_counts: - logger.warning("No basic lands found to remove") - break - - basic_land = max(basic_counts.items(), key=lambda x: x[1])[0] - try: - # Use boolean indexing for efficiency - mask = self.card_library['Card Name'] == basic_land - if not mask.any(): - basic_counts.pop(basic_land) - continue - - index_to_drop = self.card_library[mask].index[0] - self.card_library = self.card_library.drop(index_to_drop).reset_index(drop=True) - logger.info(f'{basic_land} removed successfully') - return - - except (IndexError, KeyError) as e: - logger.error(f"Error removing {basic_land}: {e}") - basic_counts.pop(basic_land) - - attempts += 1 - - # If we couldn't remove a basic land, try removing a non-basic - logger.warning("Could not remove basic land, attempting to remove non-basic") - self.remove_land() - - def remove_land(self): - """Remove a random non-basic, non-staple land from the deck. - - This method attempts to remove a non-protected land from the deck up to - LAND_REMOVAL_MAX_ATTEMPTS times. It uses helper functions to filter removable - lands and select a land for removal. - - Raises: - LandRemovalError: If no removable lands are found or removal fails - """ - print() - logger.info('Attempting to remove a non-protected land') - attempts = 0 - - while attempts < LAND_REMOVAL_MAX_ATTEMPTS: - try: - # Get removable lands - removable_lands = builder_utils.filter_removable_lands(self.card_library, PROTECTED_LANDS + self.staples) - - # Select a land for removal - card_index, card_name = builder_utils.select_land_for_removal(removable_lands) - - # Remove the selected land - logger.info(f"Removing {card_name}") - self.card_library.drop(card_index, inplace=True) - self.card_library.reset_index(drop=True, inplace=True) - logger.info("Land removed successfully") - return - - except LandRemovalError as e: - logger.warning(f"Attempt {attempts + 1} failed: {e}") - attempts += 1 - continue - except Exception as e: - logger.error(f"Unexpected error removing land: {e}") - raise LandRemovalError(f"Failed to remove land: {str(e)}") - - # If we reach here, we've exceeded max attempts - raise LandRemovalError(f"Could not find a removable land after {LAND_REMOVAL_MAX_ATTEMPTS} attempts") - # Count pips and get average CMC - def count_pips(self): - """Analyze and display the distribution of colored mana symbols (pips) in card casting costs. - - This method processes the mana costs of all cards in the deck to: - 1. Count the number of colored mana symbols for each color - 2. Calculate the percentage distribution of colors - 3. Log detailed pip distribution information - - The analysis uses helper functions from builder_utils for consistent counting - and percentage calculations. Results are logged with detailed breakdowns - of pip counts and distributions. - - Dependencies: - - MANA_COLORS from settings.py for color iteration - - builder_utils.count_color_pips() for counting pips - - builder_utils.calculate_pip_percentages() for distribution calculation - - Returns: - None - - Raises: - ManaPipError: If there are issues with: - - Counting pips for specific colors - - Calculating pip percentages - - Unexpected errors during analysis - - Logs: - - Warning if no colored mana symbols are found - - Info with detailed pip distribution and percentages - - Error details if analysis fails - """ - print() - logger.info('Analyzing color pip distribution...') - - try: - # Get mana costs from card library - mana_costs = self.card_library['Mana Cost'].dropna() - - # Count pips for each color using helper function - pip_counts = {} - for color in MANA_COLORS: - try: - pip_counts[color] = builder_utils.count_color_pips(mana_costs, color) - except (TypeError, ValueError) as e: - raise ManaPipError( - f"Error counting {color} pips", - {"color": color, "error": str(e)} - ) - - # Calculate percentages using helper function - try: - percentages = builder_utils.calculate_pip_percentages(pip_counts) - except (TypeError, ValueError) as e: - raise ManaPipError( - "Error calculating pip percentages", - {"error": str(e)} - ) - - # Log detailed pip distribution - total_pips = sum(pip_counts.values()) - if total_pips == 0: - logger.warning("No colored mana symbols found in casting costs") - return - - logger.info("Color Pip Distribution:") - for color in MANA_COLORS: - count = pip_counts[color] - if count > 0: - percentage = percentages[color] - print(f"{color}: {count} pips ({percentage:.1f}%)") - print() - logger.info(f"Total colored pips: {total_pips}") - # Filter out zero percentages - non_zero_percentages = {color: pct for color, pct in percentages.items() if pct > 0} - logger.info(f"Distribution ratios: {non_zero_percentages}\n") - - except ManaPipError as e: - logger.error(f"Mana pip analysis failed: {e}") - raise - except Exception as e: - logger.error(f"Unexpected error in pip analysis: {e}") - raise ManaPipError("Failed to analyze mana pips", {"error": str(e)}) - - def get_cmc(self): - """Calculate average converted mana cost of non-land cards.""" - logger.info('Calculating average mana value of non-land cards.') - - try: - # Filter non-land cards - non_land = self.card_library[ - ~self.card_library['Card Type'].str.contains('Land') - ].copy() - - if non_land.empty: - logger.warning("No non-land cards found") - self.cmc = 0.0 - else: - total_cmc = non_land['Mana Value'].sum() - self.cmc = round(total_cmc / len(non_land), 2) - - self.commander_dict.update({'CMC': float(self.cmc)}) - logger.info(f"Average CMC: {self.cmc}") - - except Exception as e: - logger.error(f"Error calculating CMC: {e}") - self.cmc = 0.0 - - def weight_by_theme(self, tag: str, ideal: int = 1, weight: float = 1.0, df: Optional[pd.DataFrame] = None) -> None: - """Add cards with specific tag up to weighted ideal count. - - Args: - tag: Theme tag to filter cards by - ideal: Target number of cards to add - weight: Theme weight factor (0.0-1.0) - df: Source DataFrame to filter cards from - - Raises: - ThemeWeightingError: If weight calculation fails - ThemePoolError: If card pool is empty or insufficient - """ - try: - # Calculate target card count using weight and safety multiplier - target_count = math.ceil(ideal * weight * THEME_WEIGHT_MULTIPLIER) - logger.info(f'Finding {target_count} cards with the "{tag}" tag...') - - # Handle Kindred theme special case - tags = [tag, 'Kindred Support'] if 'Kindred' in tag else [tag] - - # Calculate initial pool size - pool_size = builder_utils.calculate_weighted_pool_size(target_count, weight) - - # Filter cards by theme - if df is None: - raise ThemePoolError(f"No source DataFrame provided for theme {tag}") - - tag_df = builder_utils.filter_theme_cards(df, tags, pool_size) - if tag_df.empty: - raise ThemePoolError(f"No cards found for theme {tag}") - - # Select cards considering price and duplicates - selected_cards = builder_utils.select_weighted_cards( - tag_df, - target_count, - self.price_checker if use_scrython else None, - self.max_card_price if hasattr(self, 'max_card_price') else None - ) - - # Process selected cards - cards_added = [] - for card in selected_cards: - # Handle multiple copy cards - if card['name'] in MULTIPLE_COPY_CARDS: - copies = { - 'Nazgûl': 9, - 'Seven Dwarves': 7 - }.get(card['name'], target_count - len(cards_added)) - - for _ in range(copies): - cards_added.append(card) - - # Handle regular cards - elif card['name'] not in self.card_library['Card Name'].values: - cards_added.append(card) - else: - logger.warning(f"{card['name']} already in Library, skipping it.") - - # Add selected cards to library - for card in cards_added: - self.add_card( - card['name'], - card['type'], - card['manaCost'], - card['manaValue'], - card.get('creatureTypes'), - card['themeTags'] - ) - - # Update DataFrames - used_cards = {card['name'] for card in selected_cards} - self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(used_cards)] - - logger.info(f'Added {len(cards_added)} {tag} cards') - for card in cards_added: - print(card['name']) - - except (ThemeWeightingError, ThemePoolError) as e: - logger.error(f"Error in weight_by_theme: {e}") - raise - except Exception as e: - logger.error(f"Unexpected error in weight_by_theme: {e}") - raise ThemeWeightingError(f"Failed to process theme {tag}: {str(e)}") - - def add_by_tags(self, tag, ideal_value=1, df=None, ignore_existing=False): - """Add cards with specific tag up to ideal_value count. - Args: - tag: The theme tag to filter cards by - ideal_value: Target number of cards to add - df: DataFrame containing candidate cards - - Raises: - ThemeTagError: If there are issues with tag processing or card selection - """ - try: - # Count existing cards with target tag - print() - if not ignore_existing: - existing_count = len(self.card_library[self.card_library['Themes'].apply(lambda x: x is not None and tag in x)]) - remaining_slots = max(0, ideal_value - existing_count + 1) - else: - existing_count = 0 - remaining_slots = max(0, ideal_value - existing_count + 1) - - if remaining_slots == 0: - if not ignore_existing: - logger.info(f'Already have {existing_count} cards with tag "{tag}" - no additional cards needed') - return - else: - logger.info(f'Already have {ideal_value} cards with tag "{tag}" - no additional cards needed') - return - - logger.info(f'Finding {remaining_slots} additional cards with the "{tag}" tag...') - - # Filter cards with the given tag - skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1 - tag_df = df.copy() - tag_df.sort_values(by='edhrecRank', inplace=True) - tag_df = tag_df[tag_df['themeTags'].apply(lambda x: x is not None and tag in x)] - - # Calculate initial pool size using THEME_POOL_SIZE_MULTIPLIER - pool_size = int(remaining_slots * THEME_POOL_SIZE_MULTIPLIER) - tag_df = tag_df.head(pool_size) - - # Convert to list of card dictionaries with priority scores - card_pool = [] - for _, row in tag_df.iterrows(): - theme_tags = row['themeTags'] if row['themeTags'] is not None else [] - priority = builder_utils.calculate_theme_priority(theme_tags, self.themes, THEME_PRIORITY_BONUS) - card_pool.append({ - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'], - 'creatureTypes': row['creatureTypes'], - 'themeTags': theme_tags, - 'priority': priority - }) - - # Sort card pool by priority score - card_pool.sort(key=lambda x: x['priority'], reverse=True) - - # Select cards up to remaining slots - cards_to_add = [] - for card in card_pool: - if len(cards_to_add) >= remaining_slots: - break - - # Check price constraints if enabled - if use_scrython and hasattr(self, 'max_card_price') and self.max_card_price: - price = self.price_checker.get_card_price(card['name']) - if price > self.max_card_price * 1.1: - continue - - # Handle multiple-copy cards - if card['name'] in MULTIPLE_COPY_CARDS: - existing_copies = len(self.card_library[self.card_library['Card Name'] == card['name']]) - if existing_copies < ideal_value: - cards_to_add.append(card) - continue - - # Add new cards if not already in library - if card['name'] not in self.card_library['Card Name'].values: - if 'Creature' in card['type'] and skip_creatures: - continue - else: - if 'Creature' in card['type']: - self.creature_cards += 1 - skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1 - cards_to_add.append(card) - - # Add selected cards to library - for card in cards_to_add: - if len(self.card_library) < 100: - self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue'], - card['creatureTypes'], card['themeTags']) - else: - break - - # Update DataFrames - card_pool_names = [item['name'] for item in card_pool] - self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)] - - logger.info(f'Added {len(cards_to_add)} {tag} cards (total with tag: {existing_count + len(cards_to_add)})') - for card in cards_to_add: - print(card['name']) - - except Exception as e: - raise ThemeTagError(f"Error processing tag '{tag}'", {"error": str(e)}) - - def add_creatures(self): - """ - Add creatures to the deck based on themes and weights. - - This method processes the primary, secondary, and tertiary themes to add - creatures proportionally according to their weights. The total number of - creatures added will approximate the ideal_creature_count. - - The method follows this process: - 1. Process hidden theme if present - 2. Process primary theme - 3. Process secondary theme if present - 4. Process tertiary theme if present - - Each theme is weighted according to its importance: - - Hidden theme: Highest priority if present - - Primary theme: Main focus - - Secondary theme: Supporting focus - - Tertiary theme: Minor focus - - Args: - None - - Returns: - None - - Raises: - ThemeWeightingError: If there are issues with theme weight calculations - ThemePoolError: If the card pool for a theme is insufficient - Exception: For any other unexpected errors during creature addition - - Note: - The method uses error handling to ensure the deck building process - continues even if a particular theme encounters issues. - """ - print() - logger.info(f'Adding creatures to deck based on the ideal creature count of {self.ideal_creature_count}...') - - try: - if self.hidden_theme: - print() - logger.info(f'Processing Hidden theme: {self.hidden_theme}') - self.weight_by_theme(self.hidden_theme, self.ideal_creature_count, self.hidden_weight, self.creature_df) - - logger.info(f'Processing primary theme: {self.primary_theme}') - self.weight_by_theme(self.primary_theme, self.ideal_creature_count, self.primary_weight, self.creature_df) - - if self.secondary_theme: - print() - logger.info(f'Processing secondary theme: {self.secondary_theme}') - self.weight_by_theme(self.secondary_theme, self.ideal_creature_count, self.secondary_weight, self.creature_df) - - if self.tertiary_theme: - print() - logger.info(f'Processing tertiary theme: {self.tertiary_theme}') - self.weight_by_theme(self.tertiary_theme, self.ideal_creature_count, self.tertiary_weight, self.creature_df) - - except Exception as e: - logger.error(f"Error while adding creatures: {e}") - finally: - self.organize_library() - - def add_ramp(self): - """Add ramp cards to the deck based on ideal ramp count. - - This method adds three categories of ramp cards: - 1. Mana rocks (artifacts that produce mana) - ~1/3 of ideal ramp count - 2. Mana dorks (creatures that produce mana) - ~1/4 of ideal ramp count - 3. General ramp spells - remaining portion of ideal ramp count - - The method uses the add_by_tags() helper to add cards from each category - while respecting the deck's themes and color identity. - - Args: - None - - Returns: - None - - Raises: - ThemeTagError: If there are issues adding cards with ramp-related tags - """ - try: - self.add_by_tags('Mana Rock', math.ceil(self.ideal_ramp / 3), self.noncreature_df) - self.add_by_tags('Mana Dork', math.ceil(self.ideal_ramp / 4), self.creature_df) - self.add_by_tags('Ramp', self.ideal_ramp, self.noncreature_df) - except Exception as e: - logger.error(f"Error while adding Ramp: {e}") - - def add_interaction(self): - """Add interaction cards to the deck for removal and protection. - - This method adds two categories of interaction cards: - 1. Removal spells based on ideal_removal count - 2. Protection spells based on ideal_protection count - - Cards are selected from non-planeswalker cards to ensure appropriate - interaction types are added. - - Args: - None - - Returns: - None - - Raises: - ThemeTagError: If there are issues adding cards with interaction-related tags - """ - try: - self.add_by_tags('Removal', self.ideal_removal, self.nonplaneswalker_df) - self.add_by_tags('Protection', self.ideal_protection, self.nonplaneswalker_df) - except Exception as e: - logger.error(f"Error while adding Interaction: {e}") - - def add_board_wipes(self): - """Add board wipe cards to the deck. - - This method adds board wipe cards based on the ideal_wipes count. - Board wipes are selected from the full card pool to include all possible - options across different card types. - - Args: - None - - Returns: - None - - Raises: - ThemeTagError: If there are issues adding cards with the 'Board Wipes' tag - """ - try: - self.add_by_tags('Board Wipes', self.ideal_wipes, self.full_df) - except Exception as e: - logger.error(f"Error while adding Board Wipes: {e}") - - def add_card_advantage(self): - """Add card advantage effects to the deck. - - This method adds two categories of card draw effects: - 1. Conditional draw effects (20% of ideal_card_advantage) - - Cards that draw based on specific conditions or triggers - 2. Unconditional draw effects (80% of ideal_card_advantage) - - Cards that provide straightforward card draw - - Cards are selected from appropriate pools while avoiding planeswalkers - for unconditional draw effects. - - Args: - None - - Returns: - None - - Raises: - ThemeTagError: If there are issues adding cards with draw-related tags - """ - try: - self.add_by_tags('Conditional Draw', math.ceil(self.ideal_card_advantage * 0.2), self.full_df) - self.add_by_tags('Unconditional Draw', math.ceil(self.ideal_card_advantage * 0.8), self.nonplaneswalker_df) - except Exception as e: - logger.error(f"Error while adding Card Draw: {e}") - - def fill_out_deck(self): - """Fill out the deck to 100 cards with theme-appropriate cards. - - This method completes the deck by adding remaining cards up to the 100-card - requirement, prioritizing cards that match the deck's themes. The process - follows these steps: - - 1. Calculate how many cards are needed to reach 100 - 2. Add cards from each theme with weighted distribution: - - Hidden theme (if present) - - Tertiary theme (20% weight if present) - - Secondary theme (30% weight if present) - - Primary theme (50% weight) - - The method includes safeguards: - - Maximum attempts limit to prevent infinite loops - - Timeout to prevent excessive runtime - - Progress tracking to break early if insufficient progress - - Args: - None - - Returns: - None - - Raises: - ThemeTagError: If there are issues adding cards with specific theme tags - TimeoutError: If the process exceeds the maximum allowed time - - Note: - If the deck cannot be filled to 100 cards, a warning message is logged - indicating manual additions may be needed. - """ - print() - logger.info('Filling out the Library to 100 with cards fitting the themes.') - cards_needed = 100 - len(self.card_library) - if cards_needed <= 0: - return - - logger.info(f"Need to add {cards_needed} more cards") - - # Define maximum attempts and timeout - MAX_ATTEMPTS = max(20, cards_needed * 2) - MAX_TIME = 60 # Maximum time in seconds - start_time = time.time() - attempts = 0 - - while len(self.card_library) < 100 and attempts < MAX_ATTEMPTS: - # Check timeout - if time.time() - start_time > MAX_TIME: - logger.error("Timeout reached while filling deck") - break - - initial_count = len(self.card_library) - remaining = 100 - len(self.card_library) - - # Adjust self.weights based on remaining cards needed - weight_multiplier = remaining / cards_needed - - try: - # Add cards from each theme with adjusted self.weights - if self.hidden_theme and remaining > 0: - self.add_by_tags(self.hidden_theme, - math.ceil(weight_multiplier), - self.full_df, - True) - - # Adjust self.weights based on remaining cards needed - remaining = 100 - len(self.card_library) - weight_multiplier = remaining / cards_needed - if self.tertiary_theme and remaining > 0: - self.add_by_tags(self.tertiary_theme, - math.ceil(weight_multiplier * 0.2), - self.noncreature_df, - True) - - if self.secondary_theme and remaining > 0: - self.add_by_tags(self.secondary_theme, - math.ceil(weight_multiplier * 0.3), - self.noncreature_df, - True) - if remaining > 0: - self.add_by_tags(self.primary_theme, - math.ceil(weight_multiplier * 0.5), - self.noncreature_df, - True) - - # Check if we made progress - if len(self.card_library) == initial_count: - attempts += 1 - if attempts % 5 == 0: - print() - logger.warning(f"Made {attempts} attempts, still need {100 - len(self.card_library)} cards") - - # Break early if we're stuck - if attempts >= MAX_ATTEMPTS / 2 and len(self.card_library) < initial_count + (cards_needed / 4): - print() - logger.warning("Insufficient progress being made, breaking early") - break - - except Exception as e: - print() - logger.error(f"Error while adding cards: {e}") - attempts += 1 - - final_count = len(self.card_library) - if final_count < 100: - message = f"\nWARNING: Deck is incomplete with {final_count} cards. Manual additions may be needed." - print() - logger.warning(message) - else: - print() - logger.info(f"Successfully filled deck to {final_count} cards in {attempts} attempts") - -def main(): - """Main entry point for deck builder application.""" - build_deck = DeckBuilder() - build_deck.determine_commander() - pprint.pprint(build_deck.commander_dict, sort_dicts=False) - -if __name__ == '__main__': - main() \ No newline at end of file +pd.set_option('display.max_colwidth', 50) \ No newline at end of file diff --git a/code/deck_builder/builder_utils.py b/code/deck_builder/builder_utils.py index 471a20c..87345bc 100644 --- a/code/deck_builder/builder_utils.py +++ b/code/deck_builder/builder_utils.py @@ -121,7 +121,7 @@ def get_validation_rules(data_type: str) -> Dict[str, Dict[str, Any]]: Returns: Dictionary of validation rules """ - from settings import ( + from .builder_constants import ( CREATURE_VALIDATION_RULES, SPELL_VALIDATION_RULES, LAND_VALIDATION_RULES diff --git a/code/deck_builder_old/__init__.py b/code/deck_builder_old/__init__.py new file mode 100644 index 0000000..3f168f8 --- /dev/null +++ b/code/deck_builder_old/__init__.py @@ -0,0 +1,7 @@ +from .builder import DeckBuilder +from .builder_utils import * +from .builder_constants import * + +__all__ = [ + 'DeckBuilder', +] \ No newline at end of file diff --git a/code/deck_builder_old/builder.py b/code/deck_builder_old/builder.py new file mode 100644 index 0000000..e0eb0d8 --- /dev/null +++ b/code/deck_builder_old/builder.py @@ -0,0 +1,2497 @@ +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 +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) + +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 +try: + import scrython + from price_check import PriceChecker + use_scrython = True +except ImportError: + scrython = None + PriceChecker = None + use_scrython = False + logger.warning("Scrython is not installed. Price checking features will be unavailable." + ) + +pd.set_option('display.max_columns', None) +pd.set_option('display.max_rows', None) +pd.set_option('display.max_colwidth', 50) + +def new_line(num_lines: int = 1) -> None: + """Print specified number of newlines for formatting output. + + Args: + num_lines (int): Number of newlines to print. Defaults to 1. + + Returns: + None + """ + if num_lines < 0: + raise ValueError("Number of lines cannot be negative") + print('\n' * num_lines) + +class DeckBuilder: + + def __init__(self) -> None: + """Initialize DeckBuilder with empty dataframes and default attributes.""" + # Initialize dataframes with type hints + self.card_library: CardLibraryDF = pd.DataFrame({ + 'Card Name': pd.Series(dtype='str'), + 'Card Type': pd.Series(dtype='str'), + 'Mana Cost': pd.Series(dtype='str'), + 'Mana Value': pd.Series(dtype='int'), + 'Creature Types': pd.Series(dtype='object'), + 'Themes': pd.Series(dtype='object'), + 'Commander': pd.Series(dtype='bool'), + }) + + # Initialize component dataframes + self.commander_df: CommanderDF = pd.DataFrame() + self.land_df: LandDF = pd.DataFrame() + self.artifact_df: ArtifactDF = pd.DataFrame() + self.creature_df: CreatureDF = pd.DataFrame() + self.noncreature_df: NonCreatureDF = pd.DataFrame() + self.nonplaneswalker_df: NonPlaneswalkerDF = pd.DataFrame() + # Initialize other attributes with type hints + self.commander_info: Dict = {} + self.max_card_price: Optional[float] = None + self.commander_dict: CommanderDict = {} + self.commander: str = '' + self.commander_type: str = '' + self.commander_text: str = '' + self.commander_power: int = 0 + self.commander_toughness: int = 0 + self.commander_mana_cost: str = '' + self.commander_mana_value: int = 0 + self.color_identity: Union[str, List[str]] = '' + self.color_identity_full: str = '' + self.colors: List[str] = [] + self.creature_types: str = '' + self.commander_tags: List[str] = [] + self.themes: List[str] = [] + + # Initialize handlers + self.price_checker = PriceChecker() if PriceChecker else None + self.input_handler = InputHandler() + + def pause_with_message(self, message: str = "Press Enter to continue...") -> None: + """Display a message and wait for user input. + + Args: + message: Message to display before pausing + """ + """Helper function to pause execution with a message.""" + print(f"\n{message}") + input() + + # Determine and Validate commander + def determine_commander(self) -> None: + """Main orchestrator method for commander selection and initialization process. + + This method coordinates the commander selection workflow by: + 1. Loading commander data + 2. Facilitating commander selection + 3. Confirming the selection + 4. Initializing commander attributes + + Raises: + CommanderLoadError: If commander data cannot be loaded + CommanderSelectionError: If commander selection fails + CommanderValidationError: If commander data is invalid + """ + logger.info("Starting commander selection process") + + try: + # Load commander data using builder_utils + df = builder_utils.load_commander_data() + logger.debug("Commander data loaded successfully") + + # Select commander + commander_name = self._select_commander(df) + logger.info(f"Commander selected: {commander_name}") + + # Confirm selection + commander_data = self._confirm_commander(df, commander_name) + logger.info("Commander selection confirmed") + + # Initialize commander + self._initialize_commander(commander_data) + logger.info("Commander initialization complete") + + except DeckBuilderError as e: + logger.error(f"Commander selection failed: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error in commander selection: {e}") + raise DeckBuilderError(f"Commander selection failed: {str(e)}") + + def _select_commander(self, df: pd.DataFrame) -> str: + """Handle the commander selection process including fuzzy matching. + + Args: + df: DataFrame containing commander data + + Returns: + Selected commander name + + Raises: + CommanderSelectionError: If commander selection fails + """ + while True: + try: + card_choice = self.input_handler.questionnaire( + 'Text', + 'Enter a card name to be your commander' + ) + + # Use builder_utils for fuzzy matching + match, choices, exact_match = builder_utils.process_fuzzy_matches(card_choice, df) + + if exact_match: + return match + + # Handle multiple matches + choices.append(('Neither', 0)) + logger.info("Multiple commander matches found") + + choice = self.input_handler.questionnaire( + 'Choice', + 'Multiple matches found. Please select:', + choices_list=[name for name, _ in choices] + ) + + if choice != 'Neither': + return choice + + except DeckBuilderError as e: + logger.warning(f"Commander selection attempt failed: {e}") + continue + + def _confirm_commander(self, df: pd.DataFrame, commander_name: str) -> Dict: + """Confirm commander selection and validate data. + + Args: + df: DataFrame containing commander data + commander_name: Name of selected commander + + Returns: + Dictionary containing commander data + + Raises: + CommanderValidationError: If commander data is invalid + """ + try: + # Validate commander data + commander_data = builder_utils.validate_commander_selection(df, commander_name) + + # Store commander DataFrame + self.commander_df = pd.DataFrame(commander_data) + + # Display commander info + print('\nSelected Commander:') + pprint.pprint(commander_data, sort_dicts=False) + + # Confirm selection + if not self.input_handler.questionnaire('Confirm', 'Is this the commander you want?', True): + raise CommanderSelectionError("Commander selection cancelled by user") + + # Check price if enabled + if self.price_checker: + self.price_checker.get_card_price(commander_name) + + return commander_data + + except DeckBuilderError as e: + logger.error(f"Commander confirmation failed: {e}") + raise + + def _initialize_commander(self, commander_data: Dict) -> None: + """Initialize commander attributes from validated data. + + Args: + commander_data: Dictionary containing commander information + + Raises: + CommanderValidationError: If required attributes are missing + """ + try: + # Store commander info + self.commander_info = commander_data + self.commander = commander_data['name'][0] + + # Initialize commander attributes + self.commander_setup() + logger.debug("Commander attributes initialized successfully") + + except Exception as e: + logger.error(f"Commander initialization failed: {e}") + raise CommanderValidationError(f"Failed to initialize commander: {str(e)}") + + # Setup Commander + def commander_setup(self) -> None: + """Set up commander attributes and initialize deck building. + + This method orchestrates the commander setup process by calling specialized + helper methods to handle different aspects of initialization. + + Raises: + CommanderValidationError: If commander validation fails + DeckBuilderError: If deck building initialization fails + """ + try: + # Initialize commander attributes + self._initialize_commander_attributes() + + # Set up commander components + self._setup_commander_type_and_text() + self._setup_commander_stats() + self._setup_color_identity() + self._setup_creature_types() + self._setup_commander_tags() + + # Initialize commander dictionary and deck + self._initialize_commander_dict() + self._initialize_deck_building() + + logger.info("Commander setup completed successfully") + + except CommanderValidationError as e: + logger.error(f"Commander validation failed: {e}") + raise + except DeckBuilderError as e: + logger.error(f"Deck building initialization failed: {e}") + raise + + def _initialize_commander_attributes(self) -> None: + """Initialize basic commander attributes with defaults. + + Uses settings.py constants for default values. + """ + self.commander_power = COMMANDER_POWER_DEFAULT + self.commander_toughness = COMMANDER_TOUGHNESS_DEFAULT + self.commander_mana_value = COMMANDER_MANA_VALUE_DEFAULT + self.commander_type = COMMANDER_TYPE_DEFAULT + self.commander_text = COMMANDER_TEXT_DEFAULT + self.commander_mana_cost = COMMANDER_MANA_COST_DEFAULT + self.color_identity = COMMANDER_COLOR_IDENTITY_DEFAULT + self.colors = COMMANDER_COLORS_DEFAULT.copy() + self.creature_types = COMMANDER_CREATURE_TYPES_DEFAULT + self.commander_tags = COMMANDER_TAGS_DEFAULT.copy() + self.themes = COMMANDER_THEMES_DEFAULT.copy() + + def _setup_commander_type_and_text(self) -> None: + """Set up and validate commander type line and text. + + Raises: + CommanderTypeError: If type line validation fails + """ + df = self.commander_df + type_line = str(df.at[0, 'type']) + self.commander_type = self.input_handler.validate_commander_type(type_line) + self.commander_text = str(df.at[0, 'text']) + + def _setup_commander_stats(self) -> None: + """Set up and validate commander power, toughness, and mana values. + + Raises: + CommanderStatsError: If stats validation fails + """ + df = self.commander_df + + # Validate power and toughness + self.commander_power = self.input_handler.validate_commander_stats( + 'power', str(df.at[0, 'power'])) + self.commander_toughness = self.input_handler.validate_commander_stats( + 'toughness', str(df.at[0, 'toughness'])) + + # Set mana cost and value + self.commander_mana_cost = str(df.at[0, 'manaCost']) + self.commander_mana_value = self.input_handler.validate_commander_stats( + 'mana value', int(df.at[0, 'manaValue'])) + + def _setup_color_identity(self) -> None: + """Set up and validate commander color identity. + + Raises: + CommanderColorError: If color identity validation fails + """ + df = self.commander_df + try: + color_id = df.at[0, 'colorIdentity'] + if pd.isna(color_id): + color_id = 'COLORLESS' + + self.color_identity = self.input_handler.validate_commander_colors(color_id) + self.color_identity_full = '' + self.determine_color_identity() + print(self.color_identity_full) + + # Set colors list + if pd.notna(df.at[0, 'colors']) and df.at[0, 'colors'].strip(): + self.colors = [color.strip() for color in df.at[0, 'colors'].split(',') if color.strip()] + if not self.colors: + self.colors = ['COLORLESS'] + else: + self.colors = ['COLORLESS'] + + except Exception as e: + raise CommanderColorError(f"Failed to set color identity: {str(e)}") + + def _setup_creature_types(self) -> None: + """Set up commander creature types.""" + df = self.commander_df + self.creature_types = str(df.at[0, 'creatureTypes']) + + def _setup_commander_tags(self) -> None: + """Set up and validate commander theme tags. + + Raises: + CommanderTagError: If tag validation fails + """ + df = self.commander_df + tags = list(df.at[0, 'themeTags']) + self.commander_tags = self.input_handler.validate_commander_tags(tags) + self.determine_themes() + + def _initialize_commander_dict(self) -> None: + """Initialize the commander dictionary with validated data.""" + self.commander_dict: CommanderDict = { + 'Commander Name': self.commander, + 'Mana Cost': self.commander_mana_cost, + 'Mana Value': self.commander_mana_value, + 'Color Identity': self.color_identity_full, + 'Colors': self.colors, + 'Type': self.commander_type, + 'Creature Types': self.creature_types, + 'Text': self.commander_text, + 'Power': self.commander_power, + 'Toughness': self.commander_toughness, + 'Themes': self.themes, + 'CMC': 0.0 + } + self.add_card(self.commander, self.commander_type, + self.commander_mana_cost, self.commander_mana_value, + self.creature_types, self.commander_tags, True) + + def _initialize_deck_building(self) -> None: + """Initialize deck building process. + + Raises: + DeckBuilderError: If deck building initialization fails + """ + try: + # Set up initial deck structure + self.setup_dataframes() + self.determine_ideals() + + # Add cards by category + self.add_lands() + self.add_creatures() + self.add_ramp() + self.add_board_wipes() + self.add_interaction() + self.add_card_advantage() + + # Fill remaining slots if needed + if len(self.card_library) < 100: + self.fill_out_deck() + + # Process and organize deck + self.organize_library() + + # Log deck composition + self._log_deck_composition() + + # Finalize deck + self.get_cmc() + self.count_pips() + self.concatenate_duplicates() + self.organize_library() + self.sort_library() + self.commander_to_top() + + # Save final deck + FILE_TIME = time.strftime("%Y%m%d-%H%M%S") + DECK_FILE = f'{self.commander}_{FILE_TIME}.csv' + self.card_library.to_csv(f'{DECK_DIRECTORY}/{DECK_FILE}', index=False) + + except Exception as e: + raise DeckBuilderError(f"Failed to initialize deck building: {str(e)}") + + def _log_deck_composition(self) -> None: + """Log the deck composition statistics.""" + logger.info(f'Creature cards (including commander): {self.creature_cards}') + logger.info(f'Planeswalker cards: {self.planeswalker_cards}') + logger.info(f'Battle cards: {self.battle_cards}') + logger.info(f'Instant cards: {self.instant_cards}') + logger.info(f'Sorcery cards: {self.sorcery_cards}') + logger.info(f'Artifact cards: {self.artifact_cards}') + logger.info(f'Enchantment cards: {self.enchantment_cards}') + logger.info(f'Land cards cards: {self.land_cards}') + logger.info(f'Number of cards in Library: {len(self.card_library)}') + + # Determine and validate color identity + def determine_color_identity(self) -> None: + """Determine the deck's color identity and set related attributes. + + This method orchestrates the color identity determination process by: + 1. Validating the color identity input + 2. Determining the appropriate color combination type + 3. Setting color identity attributes based on the combination + + Raises: + CommanderColorError: If color identity validation fails + """ + try: + # Validate color identity using input handler + validated_identity = self.input_handler.validate_commander_colors(self.color_identity) + + # Determine color combination type and set attributes + if self._determine_mono_color(validated_identity): + return + + if self._determine_dual_color(validated_identity): + return + + if self._determine_tri_color(validated_identity): + return + + if self._determine_other_color(validated_identity): + return + + # Handle unknown color identity + logger.warning(f"Unknown color identity: {validated_identity}") + self.color_identity_full = 'Unknown' + self.files_to_load = ['colorless'] + + except CommanderColorError as e: + logger.error(f"Color identity validation failed: {e}") + raise + except Exception as e: + logger.error(f"Error in determine_color_identity: {e}") + raise CommanderColorError(f"Failed to determine color identity: {str(e)}") + + def _determine_mono_color(self, color_identity: str) -> bool: + """Handle single color identities. + + Args: + color_identity: Validated color identity string + + Returns: + True if color identity was handled, False otherwise + """ + from settings import MONO_COLOR_MAP + + if color_identity in MONO_COLOR_MAP: + self.color_identity_full, self.files_to_load = MONO_COLOR_MAP[color_identity] + return True + return False + + def _determine_dual_color(self, color_identity: str) -> bool: + """Handle two-color combinations. + + Args: + color_identity: Validated color identity string + + Returns: + True if color identity was handled, False otherwise + """ + from settings import DUAL_COLOR_MAP + + if color_identity in DUAL_COLOR_MAP: + identity_info = DUAL_COLOR_MAP[color_identity] + self.color_identity_full = identity_info[0] + self.color_identity_options = identity_info[1] + self.files_to_load = identity_info[2] + return True + return False + + def _determine_tri_color(self, color_identity: str) -> bool: + """Handle three-color combinations. + + Args: + color_identity: Validated color identity string + + Returns: + True if color identity was handled, False otherwise + """ + from settings import TRI_COLOR_MAP + + if color_identity in TRI_COLOR_MAP: + identity_info = TRI_COLOR_MAP[color_identity] + self.color_identity_full = identity_info[0] + self.color_identity_options = identity_info[1] + self.files_to_load = identity_info[2] + return True + return False + + def _determine_other_color(self, color_identity: str) -> bool: + """Handle four and five color combinations. + + Args: + color_identity: Validated color identity string + + Returns: + True if color identity was handled, False otherwise + """ + from builder_constants import OTHER_COLOR_MAP + + if color_identity in OTHER_COLOR_MAP: + identity_info = OTHER_COLOR_MAP[color_identity] + self.color_identity_full = identity_info[0] + self.color_identity_options = identity_info[1] + self.files_to_load = identity_info[2] + return True + return False + + # CSV and dataframe functionality + def read_csv(self, filename: str, converters: dict | None = None) -> pd.DataFrame: + """Read and validate CSV file with comprehensive error handling. + + Args: + filename: Name of the CSV file without extension + converters: Dictionary of converters for specific columns + + Returns: + pd.DataFrame: Validated and processed DataFrame + + Raises: + CSVReadError: If file cannot be read + CSVValidationError: If data fails validation + CSVTimeoutError: If read operation times out + EmptyDataFrameError: If DataFrame is empty + """ + filepath = f'{CSV_DIRECTORY}/{filename}_cards.csv' + + try: + # Read with timeout + df = pd.read_csv( + filepath, + converters=converters or {'themeTags': pd.eval, 'creatureTypes': pd.eval}, + ) + + # Check for empty DataFrame + if df.empty: + raise EmptyDataFrameError(f"Empty DataFrame from {filename}_cards.csv") + + # Validate required columns + missing_cols = set(CSV_REQUIRED_COLUMNS) - set(df.columns) + if missing_cols: + raise CSVValidationError(f"Missing required columns: {missing_cols}") + + # Validate data rules + for col, rules in CSV_VALIDATION_RULES.items(): + if rules.get('required', False) and df[col].isnull().any(): + raise CSVValidationError(f"Missing required values in column: {col}") + if 'type' in rules: + expected_type = rules['type'] + actual_type = df[col].dtype.name + if expected_type == 'str' and actual_type not in ['object', 'string']: + raise CSVValidationError(f"Invalid type for column {col}: expected {expected_type}, got {actual_type}") + elif expected_type != 'str' and not actual_type.startswith(expected_type): + raise CSVValidationError(f"Invalid type for column {col}: expected {expected_type}, got {actual_type}") + logger.debug(f"Successfully read and validated {filename}_cards.csv") + #print(df.columns) + return df + + except pd.errors.EmptyDataError: + raise EmptyDataFrameError(f"Empty CSV file: {filename}_cards.csv") + + except FileNotFoundError as e: + logger.error(f"File {filename}_cards.csv not found: {e}") + setup_utils.regenerate_csvs_all() + return self.read_csv(filename, converters) + + except TimeoutError: + raise CSVTimeoutError(f"Timeout reading {filename}_cards.csv", CSV_READ_TIMEOUT) + + except Exception as e: + logger.error(f"Error reading {filename}_cards.csv: {e}") + raise CSVReadError(f"Failed to read {filename}_cards.csv: {str(e)}") + + def write_csv(self, df: pd.DataFrame, filename: str) -> None: + """Write DataFrame to CSV with error handling and logger. + + Args: + df: DataFrame to write + filename: Name of the CSV file without extension + """ + try: + filepath = f'{CSV_DIRECTORY}/{filename}.csv' + df.to_csv(filepath, index=False) + logger.debug(f"Successfully wrote {filename}.csv") + except Exception as e: + logger.error(f"Error writing {filename}.csv: {e}") + + def _load_and_combine_data(self) -> pd.DataFrame: + """Load and combine data from multiple CSV files. + + Returns: + Combined DataFrame from all source files + + Raises: + CSVError: If data loading or combining fails + EmptyDataFrameError: If no valid data is loaded + """ + logger.info("Loading and combining data from CSV files...") + all_df = [] + + try: + # Wrap files_to_load with tqdm for progress bar + for file in tqdm(self.files_to_load, desc="Loading card data files", leave=False): + df = self.read_csv(file) + if df.empty: + raise EmptyDataFrameError(f"Empty DataFrame from {file}") + all_df.append(df) + #print(df.columns) + return builder_utils.combine_dataframes(all_df) + + except (CSVError, EmptyDataFrameError) as e: + logger.error(f"Error loading and combining data: {e}") + raise + + def _split_into_specialized_frames(self, df: pd.DataFrame) -> None: + """Split combined DataFrame into specialized component frames. + + Args: + df: Source DataFrame to split + + Raises: + DataFrameValidationError: If data splitting fails + """ + try: + # Extract lands + self.land_df = df[df['type'].str.contains('Land')].copy() + self.land_df.sort_values(by='edhrecRank', inplace=True) + + # Remove lands from main DataFrame + df = df[~df['type'].str.contains('Land')] + df.to_csv(f'{CSV_DIRECTORY}/test_cards.csv', index=False) + + # Create specialized frames + self.artifact_df = df[df['type'].str.contains('Artifact')].copy() + self.battle_df = df[df['type'].str.contains('Battle')].copy() + self.creature_df = df[df['type'].str.contains('Creature')].copy() + self.noncreature_df = df[~df['type'].str.contains('Creature')].copy() + self.enchantment_df = df[df['type'].str.contains('Enchantment')].copy() + self.instant_df = df[df['type'].str.contains('Instant')].copy() + self.planeswalker_df = df[df['type'].str.contains('Planeswalker')].copy() + self.nonplaneswalker_df = df[~df['type'].str.contains('Planeswalker')].copy() + self.sorcery_df = df[df['type'].str.contains('Sorcery')].copy() + + self.battle_df.to_csv(f'{CSV_DIRECTORY}/test_battle_cards.csv', index=False) + + # Sort all frames + for frame in [self.artifact_df, self.battle_df, self.creature_df, + self.noncreature_df, self.enchantment_df, self.instant_df, + self.planeswalker_df, self.sorcery_df]: + frame.sort_values(by='edhrecRank', inplace=True) + + except Exception as e: + logger.error(f"Error splitting DataFrames: {e}") + raise DataFrameValidationError("DataFrame splitting failed", {}, {"error": str(e)}) + + def _validate_dataframes(self) -> None: + """Validate all component DataFrames. + + Raises: + DataFrameValidationError: If validation fails + """ + try: + frames_to_validate = { + 'land': self.land_df, + 'artifact': self.artifact_df, + 'battle': self.battle_df, + 'creature': self.creature_df, + 'noncreature': self.noncreature_df, + 'enchantment': self.enchantment_df, + 'instant': self.instant_df, + 'planeswalker': self.planeswalker_df, + 'sorcery': self.sorcery_df + } + + for name, frame in frames_to_validate.items(): + rules = builder_utils.get_validation_rules(name) + if not builder_utils.validate_dataframe(frame, rules): + raise DataFrameValidationError(f"{name} validation failed", rules) + + except Exception as e: + logger.error(f"DataFrame validation failed: {e}") + raise + + def _save_intermediate_results(self) -> None: + """Save intermediate DataFrames for debugging and analysis. + + Raises: + CSVError: If saving fails + """ + try: + frames_to_save = { + 'lands': self.land_df, + 'artifacts': self.artifact_df, + 'battles': self.battle_df, + 'creatures': self.creature_df, + 'noncreatures': self.noncreature_df, + 'enchantments': self.enchantment_df, + 'instants': self.instant_df, + 'planeswalkers': self.planeswalker_df, + 'sorcerys': self.sorcery_df + } + + for name, frame in frames_to_save.items(): + self.write_csv(frame, f'test_{name}') + + except Exception as e: + logger.error(f"Error saving intermediate results: {e}") + raise CSVError(f"Failed to save intermediate results: {str(e)}") + + def setup_dataframes(self) -> None: + """Initialize and validate all required DataFrames. + + This method orchestrates the DataFrame setup process by: + 1. Loading and combining data from CSV files + 2. Splitting into specialized component frames + 3. Validating all DataFrames + 4. Saving intermediate results + + Raises: + CSVError: If any CSV operations fail + EmptyDataFrameError: If any required DataFrame is empty + DataFrameValidationError: If validation fails + """ + try: + # Load and combine data + self.full_df = self._load_and_combine_data() + self.full_df = self.full_df[~self.full_df['name'].str.contains(self.commander)] + self.full_df.sort_values(by='edhrecRank', inplace=True) + self.full_df.to_csv(f'{CSV_DIRECTORY}/test_full_cards.csv', index=False) + + # Split into specialized frames + self._split_into_specialized_frames(self.full_df) + # Validate all frames + self._validate_dataframes() + + # Save intermediate results + self._save_intermediate_results() + + logger.info("DataFrame setup completed successfully") + + except (CSVError, EmptyDataFrameError, DataFrameValidationError) as e: + logger.error(f"Error in DataFrame setup: {e}") + raise + + # Theme selection + def determine_themes(self) -> None: + """Determine and set up themes for the deck building process. + + This method handles: + 1. Theme selection (primary, secondary, tertiary) + 2. Theme weight calculations + 3. Hidden theme detection and setup + + Raises: + ThemeSelectionError: If theme selection fails + ThemeWeightError: If weight calculation fails + """ + try: + # Get available themes from commander tags + themes = self.commander_tags.copy() + + # Get available themes from commander tags + themes = self.commander_tags.copy() + + # Initialize theme flags + self.hidden_theme = False + self.secondary_theme = False + self.tertiary_theme = False + + # Select primary theme (required) + self.primary_theme = builder_utils.select_theme( + themes, + 'Choose a primary theme for your commander deck.\n' + 'This will be the "focus" of the deck, in a kindred deck this will typically be a creature type for example.' + ) + themes.remove(self.primary_theme) + + # Initialize self.weights from settings + from settings import THEME_WEIGHTS_DEFAULT + self.weights = THEME_WEIGHTS_DEFAULT.copy() + # Set initial weights for primary-only case + self.weights['primary'] = 1.0 + self.weights['secondary'] = 0.0 + self.weights['tertiary'] = 0.0 + self.primary_weight = 1.0 + + # Select secondary theme if desired + if themes: + self.secondary_theme = builder_utils.select_theme( + themes, + 'Choose a secondary theme for your commander deck.\n' + 'This will typically be a secondary focus, like card draw for Spellslinger, or +1/+1 counters for Aggro.', + optional=True + ) + + # Check for Stop Here before modifying themes list + if self.secondary_theme == 'Stop Here': + self.secondary_theme = False + elif self.secondary_theme: + themes.remove(self.secondary_theme) + self.weights['secondary'] = 0.6 + self.weights = builder_utils.adjust_theme_weights( + self.primary_theme, + self.secondary_theme, + None, # No tertiary theme yet + self.weights + ) + self.primary_weight = self.weights['primary'] + self.secondary_weight = self.weights['secondary'] + + # Select tertiary theme if desired + if themes and self.secondary_theme and self.secondary_theme != 'Stop Here': + self.tertiary_theme = builder_utils.select_theme( + themes, + 'Choose a tertiary theme for your commander deck.\n' + 'This will typically be a tertiary focus, or just something else to do that your commander is good at.', + optional=True + ) + + # Check for Stop Here before modifying themes list + if self.tertiary_theme == 'Stop Here': + self.tertiary_theme = False + elif self.tertiary_theme: + self.weights['tertiary'] = 0.3 + self.weights = builder_utils.adjust_theme_weights( + self.primary_theme, + self.secondary_theme, + self.tertiary_theme, + self.weights + ) + self.primary_weight = self.weights['primary'] + self.secondary_weight = self.weights['secondary'] + self.tertiary_weight = self.weights['tertiary'] + + # Build final themes list + self.themes = [self.primary_theme] + if self.secondary_theme: + self.themes.append(self.secondary_theme) + if self.tertiary_theme: + self.themes.append + self.determine_hidden_themes() + + except (ThemeSelectionError, ThemeWeightError) as e: + logger.error(f"Error in theme determination: {e}") + raise + + def determine_hidden_themes(self) -> None: + """ + Setting 'Hidden' themes for multiple-copy cards, such as 'Hare Apparent' or 'Shadowborn Apostle'. + These are themes that will be prompted for under specific conditions, such as a matching Kindred theme or a matching color combination and Spellslinger theme for example. + Typically a hidden theme won't come up, but if it does, it will take priority with theme self.weights to ensure a decent number of the specialty cards are added. + """ + # Setting hidden theme for Kindred-specific themes + hidden_themes = ['Advisor Kindred', 'Demon Kindred', 'Dwarf Kindred', 'Rabbit Kindred', 'Rat Kindred', 'Wraith Kindred'] + theme_cards = ['Persistent Petitioners', 'Shadowborn Apostle', 'Seven Dwarves', 'Hare Apparent', ['Rat Colony', 'Relentless Rats'], 'Nazgûl'] + color = ['B', 'B', 'R', 'W', 'B', 'B'] + for i in range(min(len(hidden_themes), len(theme_cards), len(color))): + if (hidden_themes[i] in self.themes + and hidden_themes[i] != 'Rat Kindred' + and color[i] in self.colors): + logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') + choice = self.input_handler.questionnaire('Confirm', message='', default_value=False) + if choice: + self.hidden_theme = theme_cards[i] + self.themes.append(self.hidden_theme) + self.weights['primary'] = round(self.weights['primary'] / 3, 2) + self.weights['secondary'] = round(self.weights['secondary'] / 2, 2) + self.weights['tertiary'] = self.weights['tertiary'] + self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2) + self.primary_weight = self.weights['primary'] + self.secondary_weight = self.weights['secondary'] + self.tertiary_weight = self.weights['tertiary'] + self.hidden_weight = self.weights['hidden'] + else: + continue + + elif (hidden_themes[i] in self.themes + and hidden_themes[i] == 'Rat Kindred' + and color[i] in self.colors): + logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i][0]} or {theme_cards[i][1]} deck?') + choice = self.input_handler.questionnaire('Confirm', message='', default_value=False) + if choice: + print('Which one?') + choice = self.input_handler.questionnaire('Choice', choices_list=theme_cards[i], message='') + if choice: + self.hidden_theme = choice + self.themes.append(self.hidden_theme) + self.weights['primary'] = round(self.weights['primary'] / 3, 2) + self.weights['secondary'] = round(self.weights['secondary'] / 2, 2) + self.weights['tertiary'] = self.weights['tertiary'] + self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2) + self.primary_weight = self.weights['primary'] + self.secondary_weight = self.weights['secondary'] + self.tertiary_weight = self.weights['tertiary'] + self.hidden_weight = self.weights['hidden'] + else: + continue + + # Setting the hidden theme for non-Kindred themes + hidden_themes = ['Little Fellas', 'Mill', 'Spellslinger', 'Spells Matter', 'Spellslinger', 'Spells Matter',] + theme_cards = ['Hare Apparent', 'Persistent Petitions', 'Dragon\'s Approach', 'Dragon\'s Approach', 'Slime Against Humanity', 'Slime Against Humanity'] + color = ['W', 'B', 'R', 'R', 'G', 'G'] + for i in range(min(len(hidden_themes), len(theme_cards), len(color))): + if (hidden_themes[i] in self.themes + and color[i] in self.colors): + logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') + choice = self.input_handler.questionnaire('Confirm', message='', default_value=False) + if choice: + self.hidden_theme = theme_cards[i] + self.themes.append(self.hidden_theme) + self.weights['primary'] = round(self.weights['primary'] / 3, 2) + self.weights['secondary'] = round(self.weights['secondary'] / 2, 2) + self.weights['tertiary'] = self.weights['tertiary'] + self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2) + self.primary_weight = self.weights['primary'] + self.secondary_weight = self.weights['secondary'] + self.tertiary_weight = self.weights['tertiary'] + self.hidden_weight = self.weights['hidden'] + else: + continue + + # Setting ideals + def determine_ideals(self): + """Determine ideal card counts and price settings for the deck. + + This method handles: + 1. Price configuration (if price checking is enabled) + 2. Setting ideal counts for different card types + 3. Calculating remaining free slots + + Raises: + PriceConfigurationError: If there are issues configuring price settings + IdealDeterminationError: If there are issues determining ideal counts + """ + try: + # Initialize free slots + self.free_slots = 99 + + # Configure price settings if enabled + if use_scrython: + try: + builder_utils.configure_price_settings(self.price_checker, self.input_handler) + except ValueError as e: + raise PriceConfigurationError(f"Failed to configure price settings: {str(e)}") + + # Get deck composition values + try: + composition = builder_utils.get_deck_composition_values(self.input_handler) + except ValueError as e: + raise IdealDeterminationError(f"Failed to determine deck composition: {str(e)}") + + # Update class attributes with composition values + self.ideal_ramp = composition['ramp'] + self.ideal_land_count = composition['lands'] + self.min_basics = composition['basic_lands'] + self.ideal_creature_count = composition['creatures'] + self.ideal_removal = composition['removal'] + self.ideal_wipes = composition['wipes'] + self.ideal_card_advantage = composition['card_advantage'] + self.ideal_protection = composition['protection'] + + # Update free slots + for value in [self.ideal_ramp, self.ideal_land_count, self.ideal_creature_count, + self.ideal_removal, self.ideal_wipes, self.ideal_card_advantage, + self.ideal_protection]: + self.free_slots -= value + + print(f'\nFree slots that aren\'t part of the ideals: {self.free_slots}') + print('Keep in mind that many of the ideals can also cover multiple roles, but this will give a baseline POV.') + + except (PriceConfigurationError, IdealDeterminationError) as e: + logger.error(f"Error in determine_ideals: {e}") + raise + + # Adding card to library + def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, creature_types: list = None, tags: list = None, is_commander: bool = False) -> None: + """Add a card to the deck library with price checking if enabled. + Args: + card (str): Name of the card to add + card_type (str): Type of the card (e.g., 'Creature', 'Instant') + mana_cost (str): Mana cost string representation + mana_value (int): Converted mana cost/mana value + creature_types (list): List of creature types in the card (if any) + themes (list): List of themes the card has + is_commander (bool, optional): Whether this card is the commander. Defaults to False. + + Returns: + None + + Raises: + PriceLimitError: If card price exceeds maximum allowed price + PriceAPIError: If there is an error fetching the price + PriceTimeoutError: If the price check times out + PriceValidationError: If the price data is invalid + """ + multiple_copies = BASIC_LANDS + MULTIPLE_COPY_CARDS + + # Skip if card already exists and isn't allowed multiple copies + if card in pd.Series(self.card_library['Card Name']).values and card not in multiple_copies: + return + + # Handle price checking + card_price = 0.0 + try: + # Get price and validate + card_price = self.price_checker.get_card_price(card) + self.price_checker.validate_card_price(card, card_price) + self.price_checker.update_deck_price(card_price) + except (PriceAPIError, PriceTimeoutError, PriceValidationError, PriceLimitError) as e: + logger.warning(str(e)) + return + + # Create card entry + card_entry = [card, card_type, mana_cost, mana_value, creature_types, tags, is_commander] + + # Add to library + self.card_library.loc[len(self.card_library)] = card_entry + + logger.debug(f"Added {card} to deck library") + + # Get card counts, sort library, set commander at index 1, and combine duplicates into 1 entry + def organize_library(self): + """Organize and count cards in the library by their types. + + This method counts the number of cards for each card type in the library + and updates the corresponding instance variables. It uses the count_cards_by_type + helper function from builder_utils for efficient counting. + + The method handles the following card types: + - Artifacts + - Battles + - Creatures + - Enchantments + - Instants + - Kindred (if applicable) + - Lands + - Planeswalkers + - Sorceries + + Raises: + CardTypeCountError: If there are issues counting cards by type + LibraryOrganizationError: If library organization fails + """ + try: + # Get all card types to count, including Kindred if not already present + all_types = CARD_TYPES + ['Kindred'] if 'Kindred' not in CARD_TYPES else CARD_TYPES + + # Use helper function to count cards by type + card_counters = builder_utils.count_cards_by_type(self.card_library, all_types) + + # Update instance variables with counts + self.artifact_cards = card_counters['Artifact'] + self.battle_cards = card_counters['Battle'] + self.creature_cards = card_counters['Creature'] + self.enchantment_cards = card_counters['Enchantment'] + self.instant_cards = card_counters['Instant'] + self.kindred_cards = card_counters.get('Kindred', 0) + self.land_cards = card_counters['Land'] + self.planeswalker_cards = card_counters['Planeswalker'] + self.sorcery_cards = card_counters['Sorcery'] + + logger.debug(f"Library organized successfully with {len(self.card_library)} total cards") + + except (CardTypeCountError, Exception) as e: + logger.error(f"Error organizing library: {e}") + raise LibraryOrganizationError(f"Failed to organize library: {str(e)}") + + def sort_library(self) -> None: + """Sort the card library by card type and name. + + This method sorts the card library first by card type according to the + CARD_TYPE_SORT_ORDER constant, and then alphabetically by card name. + It uses the assign_sort_order() helper function to ensure consistent + type-based sorting across the application. + + The sorting order is: + 1. Card type (Planeswalker -> Battle -> Creature -> Instant -> Sorcery -> + Artifact -> Enchantment -> Land) + 2. Card name (alphabetically) + + Raises: + LibrarySortError: If there are issues during the sorting process + """ + try: + # Use the assign_sort_order helper function to add sort order + sorted_library = builder_utils.assign_sort_order(self.card_library) + + # Sort by Sort Order and Card Name + sorted_library = sorted_library.sort_values( + by=['Sort Order', 'Card Name'], + ascending=[True, True] + ) + + # Clean up and reset index + self.card_library = ( + sorted_library + .drop(columns=['Sort Order']) + .reset_index(drop=True) + ) + + logger.debug("Card library sorted successfully") + + except Exception as e: + logger.error(f"Error sorting library: {e}") + raise LibrarySortError( + "Failed to sort card library", + {"error": str(e)} + ) + + def commander_to_top(self) -> None: + """Move commander card to the top of the library while preserving commander status. + + This method identifies the commander card in the library using a boolean mask, + removes it from its current position, and prepends it to the top of the library. + The commander's status and attributes are preserved during the move. + + Raises: + CommanderMoveError: If the commander cannot be found in the library or + if there are issues with the move operation. + """ + try: + # Create boolean mask to identify commander + commander_mask = self.card_library['Commander'] + + # Check if commander exists in library + if not commander_mask.any(): + error_msg = "Commander not found in library" + logger.warning(error_msg) + raise CommanderMoveError(error_msg) + + # Get commander row and name for logging + commander_row = self.card_library[commander_mask].copy() + commander_name = commander_row['Card Name'].iloc[0] + + # Remove commander from current position + self.card_library = self.card_library[~commander_mask] + + # Prepend commander to top of library + self.card_library = pd.concat([commander_row, self.card_library], ignore_index=True) + + logger.info(f"Successfully moved commander '{commander_name}' to top of library") + + except CommanderMoveError: + raise + except Exception as e: + error_msg = f"Error moving commander to top: {str(e)}" + logger.error(error_msg) + raise CommanderMoveError(error_msg) + + def concatenate_duplicates(self): + """Process duplicate cards in the library using the helper function. + + This method consolidates duplicate cards (like basic lands and special cards + that can have multiple copies) into single entries with updated counts. + It uses the process_duplicate_cards helper function from builder_utils. + + Raises: + DuplicateCardError: If there are issues processing duplicate cards + """ + try: + # Get list of cards that can have duplicates + duplicate_lists = BASIC_LANDS + MULTIPLE_COPY_CARDS + + # Process duplicates using helper function + self.card_library = builder_utils.process_duplicate_cards( + self.card_library, + duplicate_lists + ) + + logger.info("Successfully processed duplicate cards") + + except DuplicateCardError as e: + logger.error(f"Error processing duplicate cards: {e}") + raise + + # Land Management + def add_lands(self): + """ + Add lands to the deck based on ideal count and deck requirements. + + The process follows these steps: + 1. Add basic lands distributed by color identity + 2. Add utility/staple lands + 3. Add fetch lands if requested + 4. Add theme-specific lands (e.g., Kindred) + 5. Add multi-color lands based on color count + 6. Add miscellaneous utility lands + 7. Adjust total land count to match ideal count + """ + MAX_ADJUSTMENT_ATTEMPTS = (self.ideal_land_count - self.min_basics) * 1.5 + self.total_basics = 0 + + try: + # Add lands in sequence + self.add_basics() + self.check_basics() + self.add_standard_non_basics() + self.add_fetches() + + # Add theme and color-specific lands + if any('Kindred' in theme for theme in self.themes): + self.add_kindred_lands() + if len(self.colors) >= 2: + self.add_dual_lands() + if len(self.colors) >= 3: + self.add_triple_lands() + + self.add_misc_lands() + + # Clean up land database + mask = self.land_df['name'].isin(self.card_library['Card Name']) + self.land_df = self.land_df[~mask] + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + # Adjust to ideal land count + self.check_basics() + print() + logger.info('Adjusting total land count to match ideal count...') + self.organize_library() + + attempts = 0 + while self.land_cards > int(self.ideal_land_count) and attempts < MAX_ADJUSTMENT_ATTEMPTS: + logger.info(f'Current lands: {self.land_cards}, Target: {self.ideal_land_count}') + self.remove_basic() + self.organize_library() + attempts += 1 + + if attempts >= MAX_ADJUSTMENT_ATTEMPTS: + logger.warning(f"Could not reach ideal land count after {MAX_ADJUSTMENT_ATTEMPTS} attempts") + + logger.info(f'Final land count: {self.land_cards}') + + except Exception as e: + logger.error(f"Error during land addition: {e}") + raise + + def add_basics(self): + """Add basic lands to the deck based on color identity and commander tags. + + This method: + 1. Calculates total basics needed based on ideal land count + 2. Gets appropriate basic land mapping (normal or snow-covered) + 3. Distributes basics across colors + 4. Updates the land database + + Raises: + BasicLandError: If there are issues with basic land addition + LandDistributionError: If land distribution fails + """ + try: + # Calculate total basics needed + total_basics = self.ideal_land_count - DEFAULT_NON_BASIC_LAND_SLOTS + if total_basics <= 0: + raise BasicLandError("Invalid basic land count calculation") + + # Get appropriate basic land mapping + use_snow = 'Snow' in self.commander_tags + color_to_basic = builder_utils.get_basic_land_mapping(use_snow) + + # Calculate distribution + basics_per_color, remaining = builder_utils.calculate_basics_per_color( + total_basics, + len(self.colors) + ) + + print() + logger.info( + f'Adding {total_basics} basic lands distributed across ' + f'{len(self.colors)} colors' + ) + + # Initialize distribution dictionary + distribution = {color: basics_per_color for color in self.colors} + + # Distribute remaining basics + if remaining > 0: + distribution = builder_utils.distribute_remaining_basics( + distribution, + remaining, + self.colors + ) + + # Add basics according to distribution + lands_to_remove = [] + for color, count in distribution.items(): + basic = color_to_basic.get(color) + if basic: + for _ in range(count): + self.add_card(basic, 'Basic Land', None, 0, is_commander=False) + lands_to_remove.append(basic) + + # Update land database + self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + except Exception as e: + logger.error(f"Error adding basic lands: {e}") + raise BasicLandError(f"Failed to add basic lands: {str(e)}") + + def add_standard_non_basics(self): + """Add staple utility lands to the deck based on predefined conditions and requirements. + + This method processes the STAPLE_LAND_CONDITIONS from settings to add appropriate + utility lands to the deck. For each potential staple land, it: + + 1. Validates the land against deck requirements using: + - Commander tags + - Color identity + - Commander power level + - Other predefined conditions + + 2. Adds validated lands to the deck and tracks them in self.staples + + 3. Updates the land database to remove added lands + + The method ensures no duplicate lands are added and maintains proper logging + of all additions. + + Raises: + StapleLandError: If there are issues adding staple lands, such as + validation failures or database update errors. + """ + print() + logger.info('Adding staple non-basic lands') + self.staples = [] + + try: + for land in STAPLE_LAND_CONDITIONS: + if builder_utils.validate_staple_land_conditions( + land, + STAPLE_LAND_CONDITIONS, + self.commander_tags, + self.colors, + self.commander_power + ): + if land not in self.card_library['Card Name'].values: + self.add_card(land, 'Land', None, 0) + self.staples.append(land) + logger.debug(f"Added staple land: {land}") + + self.land_df = builder_utils.process_staple_lands( + self.staples, self.card_library, self.land_df + ) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + logger.info(f'Added {len(self.staples)} staple lands:') + print(*self.staples, sep='\n') + except Exception as e: + logger.error(f"Error adding staple lands: {e}") + raise StapleLandError(f"Failed to add staple lands: {str(e)}") + + def add_fetches(self): + """Add fetch lands to the deck based on user input and deck colors. + + This method handles: + 1. Getting user input for desired number of fetch lands + 2. Validating the input + 3. Getting available fetch lands based on deck colors + 4. Selecting and adding appropriate fetch lands + 5. Updating the land database + + Raises: + FetchLandValidationError: If fetch land count is invalid + FetchLandSelectionError: If unable to select required fetch lands + PriceLimitError: If fetch lands exceed price limits + """ + try: + # Get user input for fetch lands + print() + logger.info('Adding fetch lands') + print('How many fetch lands would you like to include?\n' + 'For most decks you\'ll likely be good with 3 or 4, just enough to thin the deck and help ensure the color availability.\n' + 'If you\'re doing Landfall, more fetches would be recommended just to get as many Landfall triggers per turn.') + + # Get and validate fetch count + fetch_count = self.input_handler.questionnaire('Number', default_value=FETCH_LAND_DEFAULT_COUNT, message='Default') + validated_count = builder_utils.validate_fetch_land_count(fetch_count) + + # Get available fetch lands based on colors and budget + max_price = self.max_card_price if hasattr(self, 'max_card_price') else None + available_fetches = builder_utils.get_available_fetch_lands( + self.colors, + self.price_checker if use_scrython else None, + max_price + ) + + # Select fetch lands + selected_fetches = builder_utils.select_fetch_lands( + available_fetches, + validated_count + ) + + # Add selected fetch lands to deck + lands_to_remove = set() + for fetch in selected_fetches: + self.add_card(fetch, 'Land', None, 0) + lands_to_remove.add(fetch) + + # Update land database + self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + logger.info(f'Added {len(selected_fetches)} fetch lands:') + print(*selected_fetches, sep='\n') + + except (FetchLandValidationError, FetchLandSelectionError, PriceLimitError) as e: + logger.error(f"Error adding fetch lands: {e}") + raise + + def add_kindred_lands(self): + """Add Kindred-themed lands to the deck based on commander themes. + + This method handles: + 1. Getting available Kindred lands based on deck themes + 2. Selecting and adding appropriate Kindred lands + 3. Updating the land database + + Raises: + KindredLandSelectionError: If unable to select required Kindred lands + PriceLimitError: If Kindred lands exceed price limits + """ + try: + print() + logger.info('Adding Kindred-themed lands') + + # Get available Kindred lands based on themes and budget + max_price = self.max_card_price if hasattr(self, 'max_card_price') else None + available_lands = builder_utils.get_available_kindred_lands( + self.land_df, + self.colors, + self.commander_tags, + self.price_checker if use_scrython else None, + max_price + ) + + # Select Kindred lands + selected_lands = builder_utils.select_kindred_lands( + available_lands, + len(available_lands) + ) + + # Add selected Kindred lands to deck + lands_to_remove = set() + for land in selected_lands: + self.add_card(land, 'Land', None, 0) + lands_to_remove.add(land) + + # Update land database + self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + logger.info(f'Added {len(selected_lands)} Kindred-themed lands:') + print(*selected_lands, sep='\n') + + except Exception as e: + logger.error(f"Error adding Kindred lands: {e}") + raise + + def add_dual_lands(self): + """Add dual lands to the deck based on color identity and user preference. + + This method handles the addition of dual lands by: + 1. Validating if dual lands should be added + 2. Getting available dual lands based on deck colors + 3. Selecting appropriate dual lands + 4. Adding selected lands to the deck + 5. Updating the land database + + The process uses helper functions from builder_utils for modular operation. + """ + try: + # Check if we should add dual lands + print() + print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?') + use_duals = self.input_handler.questionnaire('Confirm', message='', default_value=True) + + if not use_duals: + logger.info('Skipping adding Dual-type land cards.') + return + + logger.info('Adding Dual-type lands') + # Get color pairs by checking DUAL_LAND_TYPE_MAP keys against files_to_load + color_pairs = [] + for key in DUAL_LAND_TYPE_MAP: + if key in self.files_to_load: + color_pairs.extend([f'Land — {DUAL_LAND_TYPE_MAP[key]}', f'Snow Land — {DUAL_LAND_TYPE_MAP[key]}']) + + # Validate dual lands for these color pairs + if not builder_utils.validate_dual_lands(color_pairs, 'Snow' in self.commander_tags): + logger.info('No valid dual lands available for this color combination.') + return + + # Get available dual lands + dual_df = builder_utils.get_available_dual_lands( + self.land_df, + color_pairs, + 'Snow' in self.commander_tags + ) + + # Select appropriate dual lands + selected_lands = builder_utils.select_dual_lands( + dual_df, + self.price_checker if use_scrython else None, + self.max_card_price if hasattr(self, 'max_card_price') else None + ) + + # Add selected lands to deck + for land in selected_lands: + self.add_card(land['name'], land['type'], + land['manaCost'], land['manaValue']) + + # Update land database + self.land_df = builder_utils.process_dual_lands( + selected_lands, + self.card_library, + self.land_df + ) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + logger.info(f'Added {len(selected_lands)} Dual-type land cards:') + for card in selected_lands: + print(card['name']) + + except Exception as e: + logger.error(f"Error adding dual lands: {e}") + raise + + def add_triple_lands(self): + """Add triple lands to the deck based on color identity and user preference. + + This method handles the addition of triple lands by: + 1. Validating if triple lands should be added + 2. Getting available triple lands based on deck colors + 3. Selecting appropriate triple lands + 4. Adding selected lands to the deck + 5. Updating the land database + + The process uses helper functions from builder_utils for modular operation. + """ + try: + # Check if we should add triple lands + print() + print('Would you like to include triple lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?') + use_triples = self.input_handler.questionnaire('Confirm', message='', default_value=True) + + if not use_triples: + logger.info('Skipping adding triple lands.') + return + + logger.info('Adding triple lands') + # Get color triplets by checking TRIPLE_LAND_TYPE_MAP keys against files_to_load + color_triplets = [] + for key in TRIPLE_LAND_TYPE_MAP: + if key in self.files_to_load: + color_triplets.extend([f'Land — {TRIPLE_LAND_TYPE_MAP[key]}']) + + # Validate triple lands for these color triplets + if not builder_utils.validate_triple_lands(color_triplets, 'Snow' in self.commander_tags): + logger.info('No valid triple lands available for this color combination.') + return + + # Get available triple lands + triple_df = builder_utils.get_available_triple_lands( + self.land_df, + color_triplets, + 'Snow' in self.commander_tags + ) + + # Select appropriate triple lands + selected_lands = builder_utils.select_triple_lands( + triple_df, + self.price_checker if use_scrython else None, + self.max_card_price if hasattr(self, 'max_card_price') else None + ) + + # Add selected lands to deck + for land in selected_lands: + self.add_card(land['name'], land['type'], + land['manaCost'], land['manaValue']) + + # Update land database + self.land_df = builder_utils.process_triple_lands( + selected_lands, + self.card_library, + self.land_df + ) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + logger.info(f'Added {len(selected_lands)} triple lands:') + for card in selected_lands: + print(card['name']) + + except Exception as e: + logger.error(f"Error adding triple lands: {e}") + + def add_misc_lands(self): + """Add additional utility lands that fit the deck's color identity. + + This method randomly selects a number of miscellaneous utility lands to add to the deck. + The number of lands is randomly determined between MISC_LAND_MIN_COUNT and MISC_LAND_MAX_COUNT. + Lands are selected from a filtered pool of the top MISC_LAND_POOL_SIZE lands by EDHREC rank. + + The method handles price constraints if price checking is enabled and updates the land + database after adding lands to prevent duplicates. + + Raises: + MiscLandSelectionError: If there are issues selecting appropriate misc lands + """ + print() + logger.info('Adding miscellaneous utility lands') + + try: + # Get available misc lands + available_lands = builder_utils.get_available_misc_lands( + self.land_df, + MISC_LAND_POOL_SIZE + ) + + if not available_lands: + logger.warning("No eligible miscellaneous lands found") + return + + # Select random number of lands + selected_lands = builder_utils.select_misc_lands( + available_lands, + MISC_LAND_MIN_COUNT, + MISC_LAND_MAX_COUNT, + self.price_checker if use_scrython else None, + self.max_card_price if hasattr(self, 'max_card_price') else None + ) + + # Add selected lands + lands_to_remove = set() + for card in selected_lands: + self.add_card(card['name'], card['type'], + card['manaCost'], card['manaValue']) + lands_to_remove.add(card['name']) + + # Update land database + self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + logger.info(f'Added {len(selected_lands)} miscellaneous lands:') + for card in selected_lands: + print(card['name']) + + except Exception as e: + logger.error(f"Error adding misc lands: {e}") + raise + + def check_basics(self): + """Check and display counts of each basic land type in the deck. + + This method analyzes the deck's basic land composition by: + 1. Counting each type of basic land (including snow-covered) + 2. Displaying the counts for each basic land type + 3. Calculating and storing the total number of basic lands + + The method uses helper functions from builder_utils for consistent + counting and display formatting. + + Raises: + BasicLandCountError: If there are issues counting basic lands + + Note: + Updates self.total_basics with the sum of all basic lands + """ + basic_lands = { + 'Plains': 0, + 'Island': 0, + 'Swamp': 0, + 'Mountain': 0, + 'Forest': 0, + 'Snow-Covered Plains': 0, + 'Snow-Covered Island': 0, + 'Snow-Covered Swamp': 0, + 'Snow-Covered Mountain': 0, + 'Snow-Covered Forest': 0 + } + + self.total_basics = 0 + + try: + for land in basic_lands: + count = len(self.card_library[self.card_library['Card Name'] == land]) + basic_lands[land] = count + self.total_basics += count + print() + logger.info("Basic Land Counts:") + for land, count in basic_lands.items(): + if count > 0: + print(f"{land}: {count}") + logger.info(f"Total basic lands: {self.total_basics}") + except BasicLandCountError as e: + logger.error(f"Error counting basic lands: {e}") + self.total_basics = 0 + raise + + def remove_basic(self, max_attempts: int = 3): + """ + Remove a basic land while maintaining color balance. + Attempts to remove from colors with more basics first. + + Args: + max_attempts: Maximum number of removal attempts before falling back to non-basics + """ + print() + logger.info('Land count over ideal count, removing a basic land.') + + color_to_basic = { + 'W': 'Plains', 'U': 'Island', 'B': 'Swamp', + 'R': 'Mountain', 'G': 'Forest' + } + + # Get current basic land counts using vectorized operations + basic_counts = { + basic: len(self.card_library[self.card_library['Card Name'] == basic]) + for color, basic in color_to_basic.items() + if color in self.colors + } + + sum_basics = sum(basic_counts.values()) + attempts = 0 + + while attempts < max_attempts and sum_basics > self.min_basics: + if not basic_counts: + logger.warning("No basic lands found to remove") + break + + basic_land = max(basic_counts.items(), key=lambda x: x[1])[0] + try: + # Use boolean indexing for efficiency + mask = self.card_library['Card Name'] == basic_land + if not mask.any(): + basic_counts.pop(basic_land) + continue + + index_to_drop = self.card_library[mask].index[0] + self.card_library = self.card_library.drop(index_to_drop).reset_index(drop=True) + logger.info(f'{basic_land} removed successfully') + return + + except (IndexError, KeyError) as e: + logger.error(f"Error removing {basic_land}: {e}") + basic_counts.pop(basic_land) + + attempts += 1 + + # If we couldn't remove a basic land, try removing a non-basic + logger.warning("Could not remove basic land, attempting to remove non-basic") + self.remove_land() + + def remove_land(self): + """Remove a random non-basic, non-staple land from the deck. + + This method attempts to remove a non-protected land from the deck up to + LAND_REMOVAL_MAX_ATTEMPTS times. It uses helper functions to filter removable + lands and select a land for removal. + + Raises: + LandRemovalError: If no removable lands are found or removal fails + """ + print() + logger.info('Attempting to remove a non-protected land') + attempts = 0 + + while attempts < LAND_REMOVAL_MAX_ATTEMPTS: + try: + # Get removable lands + removable_lands = builder_utils.filter_removable_lands(self.card_library, PROTECTED_LANDS + self.staples) + + # Select a land for removal + card_index, card_name = builder_utils.select_land_for_removal(removable_lands) + + # Remove the selected land + logger.info(f"Removing {card_name}") + self.card_library.drop(card_index, inplace=True) + self.card_library.reset_index(drop=True, inplace=True) + logger.info("Land removed successfully") + return + + except LandRemovalError as e: + logger.warning(f"Attempt {attempts + 1} failed: {e}") + attempts += 1 + continue + except Exception as e: + logger.error(f"Unexpected error removing land: {e}") + raise LandRemovalError(f"Failed to remove land: {str(e)}") + + # If we reach here, we've exceeded max attempts + raise LandRemovalError(f"Could not find a removable land after {LAND_REMOVAL_MAX_ATTEMPTS} attempts") + # Count pips and get average CMC + def count_pips(self): + """Analyze and display the distribution of colored mana symbols (pips) in card casting costs. + + This method processes the mana costs of all cards in the deck to: + 1. Count the number of colored mana symbols for each color + 2. Calculate the percentage distribution of colors + 3. Log detailed pip distribution information + + The analysis uses helper functions from builder_utils for consistent counting + and percentage calculations. Results are logged with detailed breakdowns + of pip counts and distributions. + + Dependencies: + - MANA_COLORS from settings.py for color iteration + - builder_utils.count_color_pips() for counting pips + - builder_utils.calculate_pip_percentages() for distribution calculation + + Returns: + None + + Raises: + ManaPipError: If there are issues with: + - Counting pips for specific colors + - Calculating pip percentages + - Unexpected errors during analysis + + Logs: + - Warning if no colored mana symbols are found + - Info with detailed pip distribution and percentages + - Error details if analysis fails + """ + print() + logger.info('Analyzing color pip distribution...') + + try: + # Get mana costs from card library + mana_costs = self.card_library['Mana Cost'].dropna() + + # Count pips for each color using helper function + pip_counts = {} + for color in MANA_COLORS: + try: + pip_counts[color] = builder_utils.count_color_pips(mana_costs, color) + except (TypeError, ValueError) as e: + raise ManaPipError( + f"Error counting {color} pips", + {"color": color, "error": str(e)} + ) + + # Calculate percentages using helper function + try: + percentages = builder_utils.calculate_pip_percentages(pip_counts) + except (TypeError, ValueError) as e: + raise ManaPipError( + "Error calculating pip percentages", + {"error": str(e)} + ) + + # Log detailed pip distribution + total_pips = sum(pip_counts.values()) + if total_pips == 0: + logger.warning("No colored mana symbols found in casting costs") + return + + logger.info("Color Pip Distribution:") + for color in MANA_COLORS: + count = pip_counts[color] + if count > 0: + percentage = percentages[color] + print(f"{color}: {count} pips ({percentage:.1f}%)") + print() + logger.info(f"Total colored pips: {total_pips}") + # Filter out zero percentages + non_zero_percentages = {color: pct for color, pct in percentages.items() if pct > 0} + logger.info(f"Distribution ratios: {non_zero_percentages}\n") + + except ManaPipError as e: + logger.error(f"Mana pip analysis failed: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error in pip analysis: {e}") + raise ManaPipError("Failed to analyze mana pips", {"error": str(e)}) + + def get_cmc(self): + """Calculate average converted mana cost of non-land cards.""" + logger.info('Calculating average mana value of non-land cards.') + + try: + # Filter non-land cards + non_land = self.card_library[ + ~self.card_library['Card Type'].str.contains('Land') + ].copy() + + if non_land.empty: + logger.warning("No non-land cards found") + self.cmc = 0.0 + else: + total_cmc = non_land['Mana Value'].sum() + self.cmc = round(total_cmc / len(non_land), 2) + + self.commander_dict.update({'CMC': float(self.cmc)}) + logger.info(f"Average CMC: {self.cmc}") + + except Exception as e: + logger.error(f"Error calculating CMC: {e}") + self.cmc = 0.0 + + def weight_by_theme(self, tag: str, ideal: int = 1, weight: float = 1.0, df: Optional[pd.DataFrame] = None) -> None: + """Add cards with specific tag up to weighted ideal count. + + Args: + tag: Theme tag to filter cards by + ideal: Target number of cards to add + weight: Theme weight factor (0.0-1.0) + df: Source DataFrame to filter cards from + + Raises: + ThemeWeightingError: If weight calculation fails + ThemePoolError: If card pool is empty or insufficient + """ + try: + # Calculate target card count using weight and safety multiplier + target_count = math.ceil(ideal * weight * THEME_WEIGHT_MULTIPLIER) + logger.info(f'Finding {target_count} cards with the "{tag}" tag...') + + # Handle Kindred theme special case + tags = [tag, 'Kindred Support'] if 'Kindred' in tag else [tag] + + # Calculate initial pool size + pool_size = builder_utils.calculate_weighted_pool_size(target_count, weight) + + # Filter cards by theme + if df is None: + raise ThemePoolError(f"No source DataFrame provided for theme {tag}") + + tag_df = builder_utils.filter_theme_cards(df, tags, pool_size) + if tag_df.empty: + raise ThemePoolError(f"No cards found for theme {tag}") + + # Select cards considering price and duplicates + selected_cards = builder_utils.select_weighted_cards( + tag_df, + target_count, + self.price_checker if use_scrython else None, + self.max_card_price if hasattr(self, 'max_card_price') else None + ) + + # Process selected cards + cards_added = [] + for card in selected_cards: + # Handle multiple copy cards + if card['name'] in MULTIPLE_COPY_CARDS: + copies = { + 'Nazgûl': 9, + 'Seven Dwarves': 7 + }.get(card['name'], target_count - len(cards_added)) + + for _ in range(copies): + cards_added.append(card) + + # Handle regular cards + elif card['name'] not in self.card_library['Card Name'].values: + cards_added.append(card) + else: + logger.warning(f"{card['name']} already in Library, skipping it.") + + # Add selected cards to library + for card in cards_added: + self.add_card( + card['name'], + card['type'], + card['manaCost'], + card['manaValue'], + card.get('creatureTypes'), + card['themeTags'] + ) + + # Update DataFrames + used_cards = {card['name'] for card in selected_cards} + self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(used_cards)] + + logger.info(f'Added {len(cards_added)} {tag} cards') + for card in cards_added: + print(card['name']) + + except (ThemeWeightingError, ThemePoolError) as e: + logger.error(f"Error in weight_by_theme: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error in weight_by_theme: {e}") + raise ThemeWeightingError(f"Failed to process theme {tag}: {str(e)}") + + def add_by_tags(self, tag, ideal_value=1, df=None, ignore_existing=False): + """Add cards with specific tag up to ideal_value count. + Args: + tag: The theme tag to filter cards by + ideal_value: Target number of cards to add + df: DataFrame containing candidate cards + + Raises: + ThemeTagError: If there are issues with tag processing or card selection + """ + try: + # Count existing cards with target tag + print() + if not ignore_existing: + existing_count = len(self.card_library[self.card_library['Themes'].apply(lambda x: x is not None and tag in x)]) + remaining_slots = max(0, ideal_value - existing_count + 1) + else: + existing_count = 0 + remaining_slots = max(0, ideal_value - existing_count + 1) + + if remaining_slots == 0: + if not ignore_existing: + logger.info(f'Already have {existing_count} cards with tag "{tag}" - no additional cards needed') + return + else: + logger.info(f'Already have {ideal_value} cards with tag "{tag}" - no additional cards needed') + return + + logger.info(f'Finding {remaining_slots} additional cards with the "{tag}" tag...') + + # Filter cards with the given tag + skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1 + tag_df = df.copy() + tag_df.sort_values(by='edhrecRank', inplace=True) + tag_df = tag_df[tag_df['themeTags'].apply(lambda x: x is not None and tag in x)] + + # Calculate initial pool size using THEME_POOL_SIZE_MULTIPLIER + pool_size = int(remaining_slots * THEME_POOL_SIZE_MULTIPLIER) + tag_df = tag_df.head(pool_size) + + # Convert to list of card dictionaries with priority scores + card_pool = [] + for _, row in tag_df.iterrows(): + theme_tags = row['themeTags'] if row['themeTags'] is not None else [] + priority = builder_utils.calculate_theme_priority(theme_tags, self.themes, THEME_PRIORITY_BONUS) + card_pool.append({ + 'name': row['name'], + 'type': row['type'], + 'manaCost': row['manaCost'], + 'manaValue': row['manaValue'], + 'creatureTypes': row['creatureTypes'], + 'themeTags': theme_tags, + 'priority': priority + }) + + # Sort card pool by priority score + card_pool.sort(key=lambda x: x['priority'], reverse=True) + + # Select cards up to remaining slots + cards_to_add = [] + for card in card_pool: + if len(cards_to_add) >= remaining_slots: + break + + # Check price constraints if enabled + if use_scrython and hasattr(self, 'max_card_price') and self.max_card_price: + price = self.price_checker.get_card_price(card['name']) + if price > self.max_card_price * 1.1: + continue + + # Handle multiple-copy cards + if card['name'] in MULTIPLE_COPY_CARDS: + existing_copies = len(self.card_library[self.card_library['Card Name'] == card['name']]) + if existing_copies < ideal_value: + cards_to_add.append(card) + continue + + # Add new cards if not already in library + if card['name'] not in self.card_library['Card Name'].values: + if 'Creature' in card['type'] and skip_creatures: + continue + else: + if 'Creature' in card['type']: + self.creature_cards += 1 + skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1 + cards_to_add.append(card) + + # Add selected cards to library + for card in cards_to_add: + if len(self.card_library) < 100: + self.add_card(card['name'], card['type'], + card['manaCost'], card['manaValue'], + card['creatureTypes'], card['themeTags']) + else: + break + + # Update DataFrames + card_pool_names = [item['name'] for item in card_pool] + self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)] + + logger.info(f'Added {len(cards_to_add)} {tag} cards (total with tag: {existing_count + len(cards_to_add)})') + for card in cards_to_add: + print(card['name']) + + except Exception as e: + raise ThemeTagError(f"Error processing tag '{tag}'", {"error": str(e)}) + + def add_creatures(self): + """ + Add creatures to the deck based on themes and weights. + + This method processes the primary, secondary, and tertiary themes to add + creatures proportionally according to their weights. The total number of + creatures added will approximate the ideal_creature_count. + + The method follows this process: + 1. Process hidden theme if present + 2. Process primary theme + 3. Process secondary theme if present + 4. Process tertiary theme if present + + Each theme is weighted according to its importance: + - Hidden theme: Highest priority if present + - Primary theme: Main focus + - Secondary theme: Supporting focus + - Tertiary theme: Minor focus + + Args: + None + + Returns: + None + + Raises: + ThemeWeightingError: If there are issues with theme weight calculations + ThemePoolError: If the card pool for a theme is insufficient + Exception: For any other unexpected errors during creature addition + + Note: + The method uses error handling to ensure the deck building process + continues even if a particular theme encounters issues. + """ + print() + logger.info(f'Adding creatures to deck based on the ideal creature count of {self.ideal_creature_count}...') + + try: + if self.hidden_theme: + print() + logger.info(f'Processing Hidden theme: {self.hidden_theme}') + self.weight_by_theme(self.hidden_theme, self.ideal_creature_count, self.hidden_weight, self.creature_df) + + logger.info(f'Processing primary theme: {self.primary_theme}') + self.weight_by_theme(self.primary_theme, self.ideal_creature_count, self.primary_weight, self.creature_df) + + if self.secondary_theme: + print() + logger.info(f'Processing secondary theme: {self.secondary_theme}') + self.weight_by_theme(self.secondary_theme, self.ideal_creature_count, self.secondary_weight, self.creature_df) + + if self.tertiary_theme: + print() + logger.info(f'Processing tertiary theme: {self.tertiary_theme}') + self.weight_by_theme(self.tertiary_theme, self.ideal_creature_count, self.tertiary_weight, self.creature_df) + + except Exception as e: + logger.error(f"Error while adding creatures: {e}") + finally: + self.organize_library() + + def add_ramp(self): + """Add ramp cards to the deck based on ideal ramp count. + + This method adds three categories of ramp cards: + 1. Mana rocks (artifacts that produce mana) - ~1/3 of ideal ramp count + 2. Mana dorks (creatures that produce mana) - ~1/4 of ideal ramp count + 3. General ramp spells - remaining portion of ideal ramp count + + The method uses the add_by_tags() helper to add cards from each category + while respecting the deck's themes and color identity. + + Args: + None + + Returns: + None + + Raises: + ThemeTagError: If there are issues adding cards with ramp-related tags + """ + try: + self.add_by_tags('Mana Rock', math.ceil(self.ideal_ramp / 3), self.noncreature_df) + self.add_by_tags('Mana Dork', math.ceil(self.ideal_ramp / 4), self.creature_df) + self.add_by_tags('Ramp', self.ideal_ramp, self.noncreature_df) + except Exception as e: + logger.error(f"Error while adding Ramp: {e}") + + def add_interaction(self): + """Add interaction cards to the deck for removal and protection. + + This method adds two categories of interaction cards: + 1. Removal spells based on ideal_removal count + 2. Protection spells based on ideal_protection count + + Cards are selected from non-planeswalker cards to ensure appropriate + interaction types are added. + + Args: + None + + Returns: + None + + Raises: + ThemeTagError: If there are issues adding cards with interaction-related tags + """ + try: + self.add_by_tags('Removal', self.ideal_removal, self.nonplaneswalker_df) + self.add_by_tags('Protection', self.ideal_protection, self.nonplaneswalker_df) + except Exception as e: + logger.error(f"Error while adding Interaction: {e}") + + def add_board_wipes(self): + """Add board wipe cards to the deck. + + This method adds board wipe cards based on the ideal_wipes count. + Board wipes are selected from the full card pool to include all possible + options across different card types. + + Args: + None + + Returns: + None + + Raises: + ThemeTagError: If there are issues adding cards with the 'Board Wipes' tag + """ + try: + self.add_by_tags('Board Wipes', self.ideal_wipes, self.full_df) + except Exception as e: + logger.error(f"Error while adding Board Wipes: {e}") + + def add_card_advantage(self): + """Add card advantage effects to the deck. + + This method adds two categories of card draw effects: + 1. Conditional draw effects (20% of ideal_card_advantage) + - Cards that draw based on specific conditions or triggers + 2. Unconditional draw effects (80% of ideal_card_advantage) + - Cards that provide straightforward card draw + + Cards are selected from appropriate pools while avoiding planeswalkers + for unconditional draw effects. + + Args: + None + + Returns: + None + + Raises: + ThemeTagError: If there are issues adding cards with draw-related tags + """ + try: + self.add_by_tags('Conditional Draw', math.ceil(self.ideal_card_advantage * 0.2), self.full_df) + self.add_by_tags('Unconditional Draw', math.ceil(self.ideal_card_advantage * 0.8), self.nonplaneswalker_df) + except Exception as e: + logger.error(f"Error while adding Card Draw: {e}") + + def fill_out_deck(self): + """Fill out the deck to 100 cards with theme-appropriate cards. + + This method completes the deck by adding remaining cards up to the 100-card + requirement, prioritizing cards that match the deck's themes. The process + follows these steps: + + 1. Calculate how many cards are needed to reach 100 + 2. Add cards from each theme with weighted distribution: + - Hidden theme (if present) + - Tertiary theme (20% weight if present) + - Secondary theme (30% weight if present) + - Primary theme (50% weight) + + The method includes safeguards: + - Maximum attempts limit to prevent infinite loops + - Timeout to prevent excessive runtime + - Progress tracking to break early if insufficient progress + + Args: + None + + Returns: + None + + Raises: + ThemeTagError: If there are issues adding cards with specific theme tags + TimeoutError: If the process exceeds the maximum allowed time + + Note: + If the deck cannot be filled to 100 cards, a warning message is logged + indicating manual additions may be needed. + """ + print() + logger.info('Filling out the Library to 100 with cards fitting the themes.') + cards_needed = 100 - len(self.card_library) + if cards_needed <= 0: + return + + logger.info(f"Need to add {cards_needed} more cards") + + # Define maximum attempts and timeout + MAX_ATTEMPTS = max(20, cards_needed * 2) + MAX_TIME = 60 # Maximum time in seconds + start_time = time.time() + attempts = 0 + + while len(self.card_library) < 100 and attempts < MAX_ATTEMPTS: + # Check timeout + if time.time() - start_time > MAX_TIME: + logger.error("Timeout reached while filling deck") + break + + initial_count = len(self.card_library) + remaining = 100 - len(self.card_library) + + # Adjust self.weights based on remaining cards needed + weight_multiplier = remaining / cards_needed + + try: + # Add cards from each theme with adjusted self.weights + if self.hidden_theme and remaining > 0: + self.add_by_tags(self.hidden_theme, + math.ceil(weight_multiplier), + self.full_df, + True) + + # Adjust self.weights based on remaining cards needed + remaining = 100 - len(self.card_library) + weight_multiplier = remaining / cards_needed + if self.tertiary_theme and remaining > 0: + self.add_by_tags(self.tertiary_theme, + math.ceil(weight_multiplier * 0.2), + self.noncreature_df, + True) + + if self.secondary_theme and remaining > 0: + self.add_by_tags(self.secondary_theme, + math.ceil(weight_multiplier * 0.3), + self.noncreature_df, + True) + if remaining > 0: + self.add_by_tags(self.primary_theme, + math.ceil(weight_multiplier * 0.5), + self.noncreature_df, + True) + + # Check if we made progress + if len(self.card_library) == initial_count: + attempts += 1 + if attempts % 5 == 0: + print() + logger.warning(f"Made {attempts} attempts, still need {100 - len(self.card_library)} cards") + + # Break early if we're stuck + if attempts >= MAX_ATTEMPTS / 2 and len(self.card_library) < initial_count + (cards_needed / 4): + print() + logger.warning("Insufficient progress being made, breaking early") + break + + except Exception as e: + print() + logger.error(f"Error while adding cards: {e}") + attempts += 1 + + final_count = len(self.card_library) + if final_count < 100: + message = f"\nWARNING: Deck is incomplete with {final_count} cards. Manual additions may be needed." + print() + logger.warning(message) + else: + print() + logger.info(f"Successfully filled deck to {final_count} cards in {attempts} attempts") + +def main(): + """Main entry point for deck builder application.""" + build_deck = DeckBuilder() + build_deck.determine_commander() + pprint.pprint(build_deck.commander_dict, sort_dicts=False) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/code/deck_builder_old/builder_constants.py b/code/deck_builder_old/builder_constants.py new file mode 100644 index 0000000..9761812 --- /dev/null +++ b/code/deck_builder_old/builder_constants.py @@ -0,0 +1,437 @@ +from typing import Dict, List, Final, Tuple, Union, Callable +from settings import CARD_DATA_COLUMNS as CSV_REQUIRED_COLUMNS # unified + +__all__ = [ + 'CSV_REQUIRED_COLUMNS' +] +import ast + +# Commander selection configuration +# Format string for displaying duplicate cards in deck lists +FUZZY_MATCH_THRESHOLD: Final[int] = 90 # Threshold for fuzzy name matching +MAX_FUZZY_CHOICES: Final[int] = 5 # Maximum number of fuzzy match choices + +# Commander-related constants +DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}' +COMMANDER_CSV_PATH: Final[str] = 'csv_files/commander_cards.csv' +DECK_DIRECTORY = '../deck_files' +COMMANDER_CONVERTERS: Final[Dict[str, str]] = {'themeTags': ast.literal_eval, 'creatureTypes': ast.literal_eval} # CSV loading converters +COMMANDER_POWER_DEFAULT: Final[int] = 0 +COMMANDER_TOUGHNESS_DEFAULT: Final[int] = 0 +COMMANDER_MANA_VALUE_DEFAULT: Final[int] = 0 +COMMANDER_TYPE_DEFAULT: Final[str] = '' +COMMANDER_TEXT_DEFAULT: Final[str] = '' +COMMANDER_MANA_COST_DEFAULT: Final[str] = '' +COMMANDER_COLOR_IDENTITY_DEFAULT: Final[str] = '' +COMMANDER_COLORS_DEFAULT: Final[List[str]] = [] +COMMANDER_CREATURE_TYPES_DEFAULT: Final[str] = '' +COMMANDER_TAGS_DEFAULT: Final[List[str]] = [] +COMMANDER_THEMES_DEFAULT: Final[List[str]] = [] + +CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery', + 'Kindred', 'Dungeon', 'Battle'] + +# Basic mana colors +MANA_COLORS: Final[List[str]] = ['W', 'U', 'B', 'R', 'G'] + +# Mana pip patterns for each color +MANA_PIP_PATTERNS: Final[Dict[str, str]] = { + color: f'{{{color}}}' for color in MANA_COLORS +} + +MONO_COLOR_MAP: Final[Dict[str, Tuple[str, List[str]]]] = { + 'COLORLESS': ('Colorless', ['colorless']), + 'W': ('White', ['colorless', 'white']), + 'U': ('Blue', ['colorless', 'blue']), + 'B': ('Black', ['colorless', 'black']), + 'R': ('Red', ['colorless', 'red']), + 'G': ('Green', ['colorless', 'green']) +} + +DUAL_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = { + 'B, G': ('Golgari: Black/Green', ['B', 'G', 'B, G'], ['colorless', 'black', 'green', 'golgari']), + 'B, R': ('Rakdos: Black/Red', ['B', 'R', 'B, R'], ['colorless', 'black', 'red', 'rakdos']), + 'B, U': ('Dimir: Black/Blue', ['B', 'U', 'B, U'], ['colorless', 'black', 'blue', 'dimir']), + 'B, W': ('Orzhov: Black/White', ['B', 'W', 'B, W'], ['colorless', 'black', 'white', 'orzhov']), + 'G, R': ('Gruul: Green/Red', ['G', 'R', 'G, R'], ['colorless', 'green', 'red', 'gruul']), + 'G, U': ('Simic: Green/Blue', ['G', 'U', 'G, U'], ['colorless', 'green', 'blue', 'simic']), + 'G, W': ('Selesnya: Green/White', ['G', 'W', 'G, W'], ['colorless', 'green', 'white', 'selesnya']), + 'R, U': ('Izzet: Blue/Red', ['U', 'R', 'U, R'], ['colorless', 'blue', 'red', 'izzet']), + 'U, W': ('Azorius: Blue/White', ['U', 'W', 'U, W'], ['colorless', 'blue', 'white', 'azorius']), + 'R, W': ('Boros: Red/White', ['R', 'W', 'R, W'], ['colorless', 'red', 'white', 'boros']) +} + +TRI_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = { + 'B, G, U': ('Sultai: Black/Blue/Green', ['B', 'G', 'U', 'B, G', 'B, U', 'G, U', 'B, G, U'], + ['colorless', 'black', 'blue', 'green', 'dimir', 'golgari', 'simic', 'sultai']), + 'B, G, R': ('Jund: Black/Red/Green', ['B', 'G', 'R', 'B, G', 'B, R', 'G, R', 'B, G, R'], + ['colorless', 'black', 'green', 'red', 'golgari', 'rakdos', 'gruul', 'jund']), + 'B, G, W': ('Abzan: Black/Green/White', ['B', 'G', 'W', 'B, G', 'B, W', 'G, W', 'B, G, W'], + ['colorless', 'black', 'green', 'white', 'golgari', 'orzhov', 'selesnya', 'abzan']), + 'B, R, U': ('Grixis: Black/Blue/Red', ['B', 'R', 'U', 'B, R', 'B, U', 'R, U', 'B, R, U'], + ['colorless', 'black', 'blue', 'red', 'dimir', 'rakdos', 'izzet', 'grixis']), + 'B, R, W': ('Mardu: Black/Red/White', ['B', 'R', 'W', 'B, R', 'B, W', 'R, W', 'B, R, W'], + ['colorless', 'black', 'red', 'white', 'rakdos', 'orzhov', 'boros', 'mardu']), + 'B, U, W': ('Esper: Black/Blue/White', ['B', 'U', 'W', 'B, U', 'B, W', 'U, W', 'B, U, W'], + ['colorless', 'black', 'blue', 'white', 'dimir', 'orzhov', 'azorius', 'esper']), + 'G, R, U': ('Temur: Blue/Green/Red', ['G', 'R', 'U', 'G, R', 'G, U', 'R, U', 'G, R, U'], + ['colorless', 'green', 'red', 'blue', 'simic', 'izzet', 'gruul', 'temur']), + 'G, R, W': ('Naya: Green/Red/White', ['G', 'R', 'W', 'G, R', 'G, W', 'R, W', 'G, R, W'], + ['colorless', 'green', 'red', 'white', 'gruul', 'selesnya', 'boros', 'naya']), + 'G, U, W': ('Bant: Blue/Green/White', ['G', 'U', 'W', 'G, U', 'G, W', 'U, W', 'G, U, W'], + ['colorless', 'green', 'blue', 'white', 'simic', 'azorius', 'selesnya', 'bant']), + 'R, U, W': ('Jeskai: Blue/Red/White', ['R', 'U', 'W', 'R, U', 'U, W', 'R, W', 'R, U, W'], + ['colorless', 'blue', 'red', 'white', 'izzet', 'azorius', 'boros', 'jeskai']) +} + +OTHER_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = { + 'B, G, R, U': ('Glint: Black/Blue/Green/Red', + ['B', 'G', 'R', 'U', 'B, G', 'B, R', 'B, U', 'G, R', 'G, U', 'R, U', 'B, G, R', + 'B, G, U', 'B, R, U', 'G, R, U', 'B, G, R, U'], + ['colorless', 'black', 'blue', 'green', 'red', 'golgari', 'rakdos', 'dimir', + 'gruul', 'simic', 'izzet', 'jund', 'sultai', 'grixis', 'temur', 'glint']), + 'B, G, R, W': ('Dune: Black/Green/Red/White', + ['B', 'G', 'R', 'W', 'B, G', 'B, R', 'B, W', 'G, R', 'G, W', 'R, W', 'B, G, R', + 'B, G, W', 'B, R, W', 'G, R, W', 'B, G, R, W'], + ['colorless', 'black', 'green', 'red', 'white', 'golgari', 'rakdos', 'orzhov', + 'gruul', 'selesnya', 'boros', 'jund', 'abzan', 'mardu', 'naya', 'dune']), + 'B, G, U, W': ('Witch: Black/Blue/Green/White', + ['B', 'G', 'U', 'W', 'B, G', 'B, U', 'B, W', 'G, U', 'G, W', 'U, W', 'B, G, U', + 'B, G, W', 'B, U, W', 'G, U, W', 'B, G, U, W'], + ['colorless', 'black', 'blue', 'green', 'white', 'golgari', 'dimir', 'orzhov', + 'simic', 'selesnya', 'azorius', 'sultai', 'abzan', 'esper', 'bant', 'witch']), + 'B, R, U, W': ('Yore: Black/Blue/Red/White', + ['B', 'R', 'U', 'W', 'B, R', 'B, U', 'B, W', 'R, U', 'R, W', 'U, W', 'B, R, U', + 'B, R, W', 'B, U, W', 'R, U, W', 'B, R, U, W'], + ['colorless', 'black', 'blue', 'red', 'white', 'rakdos', 'dimir', 'orzhov', + 'izzet', 'boros', 'azorius', 'grixis', 'mardu', 'esper', 'jeskai', 'yore']), + 'G, R, U, W': ('Ink: Blue/Green/Red/White', + ['G', 'R', 'U', 'W', 'G, R', 'G, U', 'G, W', 'R, U', 'R, W', 'U, W', 'G, R, U', + 'G, R, W', 'G, U, W', 'R, U, W', 'G, R, U, W'], + ['colorless', 'blue', 'green', 'red', 'white', 'gruul', 'simic', 'selesnya', + 'izzet', 'boros', 'azorius', 'temur', 'naya', 'bant', 'jeskai', 'ink']), + 'B, G, R, U, W': ('WUBRG: All colors', + ['B', 'G', 'R', 'U', 'W', 'B, G', 'B, R', 'B, U', 'B, W', 'G, R', 'G, U', + 'G, W', 'R, U', 'R, W', 'U, W', 'B, G, R', 'B, G, U', 'B, G, W', 'B, R, U', + 'B, R, W', 'B, U, W', 'G, R, U', 'G, R, W', 'G, U, W', 'R, U, W', + 'B, G, R, U', 'B, G, R, W', 'B, G, U, W', 'B, R, U, W', 'G, R, U, W', + 'B, G, R, U, W'], + ['colorless', 'black', 'green', 'red', 'blue', 'white', 'golgari', 'rakdos', + 'dimir', 'orzhov', 'gruul', 'simic', 'selesnya', 'izzet', 'boros', 'azorius', + 'jund', 'sultai', 'abzan', 'grixis', 'mardu', 'esper', 'temur', 'naya', + 'bant', 'jeskai', 'glint', 'dune', 'witch', 'yore', 'ink', 'wubrg']) +} + +# 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 +PRICE_CACHE_SIZE: Final[int] = 128 # Size of price check LRU cache +PRICE_CHECK_TIMEOUT: Final[int] = 30 # Timeout for price check requests in seconds +PRICE_TOLERANCE_MULTIPLIER: Final[float] = 1.1 # Multiplier for price tolerance +DEFAULT_MAX_CARD_PRICE: Final[float] = 20.0 # Default maximum price per card + +# Deck composition defaults +DEFAULT_RAMP_COUNT: Final[int] = 8 # Default number of ramp pieces +DEFAULT_LAND_COUNT: Final[int] = 35 # Default total land count +DEFAULT_BASIC_LAND_COUNT: Final[int] = 20 # Default minimum basic lands +DEFAULT_NON_BASIC_LAND_SLOTS: Final[int] = 10 # Default number of non-basic land slots to reserve +DEFAULT_BASICS_PER_COLOR: Final[int] = 5 # Default number of basic lands to add per color + +# Miscellaneous land configuration +MISC_LAND_MIN_COUNT: Final[int] = 5 # Minimum number of miscellaneous lands to add +MISC_LAND_MAX_COUNT: Final[int] = 10 # Maximum number of miscellaneous lands to add +MISC_LAND_POOL_SIZE: Final[int] = 100 # Maximum size of initial land pool to select from + +# Default fetch land count +FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include + +# Basic Lands +BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest'] + +# Basic land mappings +COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = { + 'W': 'Plains', + 'U': 'Island', + 'B': 'Swamp', + 'R': 'Mountain', + 'G': 'Forest', + 'C': 'Wastes' +} + +# Dual land type mappings +DUAL_LAND_TYPE_MAP: Final[Dict[str, str]] = { + 'azorius': 'Plains Island', + 'dimir': 'Island Swamp', + 'rakdos': 'Swamp Mountain', + 'gruul': 'Mountain Forest', + 'selesnya': 'Forest Plains', + 'orzhov': 'Plains Swamp', + 'golgari': 'Swamp Forest', + 'simic': 'Forest Island', + 'izzet': 'Island Mountain', + 'boros': 'Mountain Plains' +} + +# Triple land type mappings +TRIPLE_LAND_TYPE_MAP: Final[Dict[str, str]] = { + 'bant': 'Forest Plains Island', + 'esper': 'Plains Island Swamp', + 'grixis': 'Island Swamp Mountain', + 'jund': 'Swamp Mountain Forest', + 'naya': 'Mountain Forest Plains', + 'mardu': 'Mountain Plains Swamp', + 'abzan': 'Plains Swamp Forest', + 'sultai': 'Swamp Forest Island', + 'temur': 'Forest Island Mountain', + 'jeskai': 'Island Mountain Plains' +} + +# Default preference for including dual lands +DEFAULT_DUAL_LAND_ENABLED: Final[bool] = True + +# Default preference for including triple lands +DEFAULT_TRIPLE_LAND_ENABLED: Final[bool] = True + +SNOW_COVERED_BASIC_LANDS: Final[Dict[str, str]] = { + 'W': 'Snow-Covered Plains', + 'U': 'Snow-Covered Island', + 'B': 'Snow-Covered Swamp', + 'G': 'Snow-Covered Forest' +} + +SNOW_BASIC_LAND_MAPPING: Final[Dict[str, str]] = { + 'W': 'Snow-Covered Plains', + 'U': 'Snow-Covered Island', + 'B': 'Snow-Covered Swamp', + 'R': 'Snow-Covered Mountain', + 'G': 'Snow-Covered Forest', + 'C': 'Wastes' # Note: No snow-covered version exists for Wastes +} + +# Generic fetch lands list +GENERIC_FETCH_LANDS: Final[List[str]] = [ + 'Evolving Wilds', + 'Terramorphic Expanse', + 'Shire Terrace', + 'Escape Tunnel', + 'Promising Vein', + 'Myriad Landscape', + 'Fabled Passage', + 'Terminal Moraine', + 'Prismatic Vista' +] + +# Kindred land constants +KINDRED_STAPLE_LANDS: Final[List[Dict[str, str]]] = [ + { + 'name': 'Path of Ancestry', + 'type': 'Land' + }, + { + 'name': 'Three Tree City', + 'type': 'Legendary Land' + }, + {'name': 'Cavern of Souls', 'type': 'Land'} +] + +# Color-specific fetch land mappings +COLOR_TO_FETCH_LANDS: Final[Dict[str, List[str]]] = { + 'W': [ + 'Flooded Strand', + 'Windswept Heath', + 'Marsh Flats', + 'Arid Mesa', + 'Brokers Hideout', + 'Obscura Storefront', + 'Cabaretti Courtyard' + ], + 'U': [ + 'Flooded Strand', + 'Polluted Delta', + 'Scalding Tarn', + 'Misty Rainforest', + 'Brokers Hideout', + 'Obscura Storefront', + 'Maestros Theater' + ], + 'B': [ + 'Polluted Delta', + 'Bloodstained Mire', + 'Marsh Flats', + 'Verdant Catacombs', + 'Obscura Storefront', + 'Maestros Theater', + 'Riveteers Overlook' + ], + 'R': [ + 'Bloodstained Mire', + 'Wooded Foothills', + 'Scalding Tarn', + 'Arid Mesa', + 'Maestros Theater', + 'Riveteers Overlook', + 'Cabaretti Courtyard' + ], + 'G': [ + 'Wooded Foothills', + 'Windswept Heath', + 'Verdant Catacombs', + 'Misty Rainforest', + 'Brokers Hideout', + 'Riveteers Overlook', + 'Cabaretti Courtyard' + ] +} + +# Staple land conditions mapping +STAPLE_LAND_CONDITIONS: Final[Dict[str, Callable[[List[str], List[str], int], bool]]] = { + 'Reliquary Tower': lambda commander_tags, colors, commander_power: True, # Always include + 'Ash Barrens': lambda commander_tags, colors, commander_power: 'Landfall' not in commander_tags, + 'Command Tower': lambda commander_tags, colors, commander_power: len(colors) > 1, + 'Exotic Orchard': lambda commander_tags, colors, commander_power: len(colors) > 1, + 'War Room': lambda commander_tags, colors, commander_power: len(colors) <= 2, + 'Rogue\'s Passage': lambda commander_tags, colors, commander_power: commander_power >= 5 +} + +# Constants for land removal functionality +LAND_REMOVAL_MAX_ATTEMPTS: Final[int] = 3 + +# Protected lands that cannot be removed during land removal process +PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + [land['name'] for land in KINDRED_STAPLE_LANDS] + +# Other defaults +DEFAULT_CREATURE_COUNT: Final[int] = 25 # Default number of creatures +DEFAULT_REMOVAL_COUNT: Final[int] = 10 # Default number of spot removal spells +DEFAULT_WIPES_COUNT: Final[int] = 2 # Default number of board wipes + +DEFAULT_CARD_ADVANTAGE_COUNT: Final[int] = 10 # Default number of card advantage pieces +DEFAULT_PROTECTION_COUNT: Final[int] = 8 # Default number of protection spells + +# Deck composition prompts +DECK_COMPOSITION_PROMPTS: Final[Dict[str, str]] = { + 'ramp': 'Enter desired number of ramp pieces (default: 8):', + 'lands': 'Enter desired number of total lands (default: 35):', + 'basic_lands': 'Enter minimum number of basic lands (default: 20):', + 'creatures': 'Enter desired number of creatures (default: 25):', + 'removal': 'Enter desired number of spot removal spells (default: 10):', + 'wipes': 'Enter desired number of board wipes (default: 2):', + 'card_advantage': 'Enter desired number of card advantage pieces (default: 10):', + 'protection': 'Enter desired number of protection spells (default: 8):', + 'max_deck_price': 'Enter maximum total deck price in dollars (default: 400.0):', + 'max_card_price': 'Enter maximum price per card in dollars (default: 20.0):' +} +DEFAULT_MAX_DECK_PRICE: Final[float] = 400.0 # Default maximum total deck price +BATCH_PRICE_CHECK_SIZE: Final[int] = 50 # Number of cards to check prices for in one batch +# Constants for input validation + +# Type aliases +CardName = str +CardType = str +ThemeTag = str +ColorIdentity = str +ColorList = List[str] +ColorInfo = Tuple[str, List[str], List[str]] + +INPUT_VALIDATION = { + 'max_attempts': 3, + 'default_text_message': 'Please enter a valid text response.', + 'default_number_message': 'Please enter a valid number.', + 'default_confirm_message': 'Please enter Y/N or Yes/No.', + 'default_choice_message': 'Please select a valid option from the list.' +} + +QUESTION_TYPES = [ + 'Text', + 'Number', + 'Confirm', + 'Choice' +] + +# Constants for theme weight management and selection +# Multiplier for initial card pool size during theme-based selection +THEME_POOL_SIZE_MULTIPLIER: Final[float] = 2.0 + +# Bonus multiplier for cards that match multiple deck themes +THEME_PRIORITY_BONUS: Final[float] = 1.2 + +# Safety multiplier to avoid overshooting target counts +THEME_WEIGHT_MULTIPLIER: Final[float] = 0.9 + +THEME_WEIGHTS_DEFAULT: Final[Dict[str, float]] = { + 'primary': 1.0, + 'secondary': 0.6, + 'tertiary': 0.3, + 'hidden': 0.0 +} + +WEIGHT_ADJUSTMENT_FACTORS: Final[Dict[str, float]] = { + 'kindred_primary': 1.5, # Boost for Kindred themes as primary + 'kindred_secondary': 1.3, # Boost for Kindred themes as secondary + 'kindred_tertiary': 1.2, # Boost for Kindred themes as tertiary + 'theme_synergy': 1.2 # Boost for themes that work well together +} + +DEFAULT_THEME_TAGS = [ + 'Aggro', 'Aristocrats', 'Artifacts Matter', 'Big Mana', 'Blink', + 'Board Wipes', 'Burn', 'Cantrips', 'Card Draw', 'Clones', + 'Combat Matters', 'Control', 'Counters Matter', 'Energy', + 'Enter the Battlefield', 'Equipment', 'Exile Matters', 'Infect', + 'Interaction', 'Lands Matter', 'Leave the Battlefield', 'Legends Matter', + 'Life Matters', 'Mill', 'Monarch', 'Protection', 'Ramp', 'Reanimate', + 'Removal', 'Sacrifice Matters', 'Spellslinger', 'Stax', 'Super Friends', + 'Theft', 'Token Creation', 'Tokens Matter', 'Voltron', 'X Spells' +] + +# CSV processing configuration +CSV_READ_TIMEOUT: Final[int] = 30 # Timeout in seconds for CSV read operations +CSV_PROCESSING_BATCH_SIZE: Final[int] = 1000 # Number of rows to process in each batch + +# CSV validation configuration +CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float]]]] = { + 'name': {'type': ('str', 'object'), 'required': True, 'unique': True}, + 'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000}, + 'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20}, + 'power': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'}, + 'toughness': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'} +} + +# (CSV_REQUIRED_COLUMNS imported from settings to avoid duplication) + +# DataFrame processing configuration +BATCH_SIZE: Final[int] = 1000 # Number of records to process at once +DATAFRAME_BATCH_SIZE: Final[int] = 500 # Batch size for DataFrame operations +TRANSFORM_BATCH_SIZE: Final[int] = 250 # Batch size for data transformations +CSV_DOWNLOAD_TIMEOUT: Final[int] = 30 # Timeout in seconds for CSV downloads +PROGRESS_UPDATE_INTERVAL: Final[int] = 100 # Number of records between progress updates + +# DataFrame operation timeouts +DATAFRAME_READ_TIMEOUT: Final[int] = 30 # Timeout for DataFrame read operations +DATAFRAME_WRITE_TIMEOUT: Final[int] = 30 # Timeout for DataFrame write operations +DATAFRAME_TRANSFORM_TIMEOUT: Final[int] = 45 # Timeout for DataFrame transformations +DATAFRAME_VALIDATION_TIMEOUT: Final[int] = 20 # Timeout for DataFrame validation + +# Required DataFrame columns +DATAFRAME_REQUIRED_COLUMNS: Final[List[str]] = [ + 'name', 'type', 'colorIdentity', 'manaValue', 'text', + 'edhrecRank', 'themeTags', 'keywords' +] + +# DataFrame validation rules +DATAFRAME_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { + 'name': {'type': ('str', 'object'), 'required': True, 'unique': True}, + 'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000}, + 'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20}, + 'power': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'}, + 'toughness': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'}, + 'colorIdentity': {'type': ('str', 'object'), 'required': True}, + 'text': {'type': ('str', 'object'), 'required': False} +} + +# Card type sorting order for organizing libraries +# This constant defines the order in which different card types should be sorted +# when organizing a deck library. The order is designed to group cards logically, +# starting with Planeswalkers and ending with Lands. +CARD_TYPE_SORT_ORDER: Final[List[str]] = [ + 'Planeswalker', 'Battle', 'Creature', 'Instant', 'Sorcery', + 'Artifact', 'Enchantment', 'Land' +] \ No newline at end of file diff --git a/code/deck_builder_old/builder_utils.py b/code/deck_builder_old/builder_utils.py new file mode 100644 index 0000000..87345bc --- /dev/null +++ b/code/deck_builder_old/builder_utils.py @@ -0,0 +1,1642 @@ +"""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/requirements.txt b/requirements.txt index 90b7f5b..ca651e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,10 @@ inquirer>=3.1.3 typing_extensions>=4.5.0 fuzzywuzzy>=0.18.0 python-Levenshtein>=0.12.0 +tqdm>=4.66.0 +scrython>=1.10.0 +numpy>=1.24.0 +requests>=2.31.0 # Development dependencies mypy>=1.3.0