diff --git a/builder_utils.py b/builder_utils.py index d3ff1b5..ea33dd3 100644 --- a/builder_utils.py +++ b/builder_utils.py @@ -1,4 +1,7 @@ -from typing import Dict, List, Tuple, Optional, Any, Callable, TypeVar, Union +from typing import Dict, List, Tuple, Optional, Any, Callable, TypeVar, Union, cast +import pandas as pd +from price_check import PriceChecker +from input_handler import InputHandler import logging import functools import time @@ -13,14 +16,39 @@ from settings import ( DATAFRAME_VALIDATION_TIMEOUT, DATAFRAME_BATCH_SIZE, DATAFRAME_TRANSFORM_TIMEOUT, - DATAFRAME_REQUIRED_COLUMNS + DATAFRAME_REQUIRED_COLUMNS, + WEIGHT_ADJUSTMENT_FACTORS, + DEFAULT_MAX_DECK_PRICE, + DEFAULT_MAX_CARD_PRICE, + DECK_COMPOSITION_PROMPTS, + DEFAULT_RAMP_COUNT, + DEFAULT_LAND_COUNT, + DEFAULT_BASIC_LAND_COUNT, + DEFAULT_CREATURE_COUNT, + DEFAULT_REMOVAL_COUNT, + DEFAULT_CARD_ADVANTAGE_COUNT, + DEFAULT_PROTECTION_COUNT, + DEFAULT_WIPES_COUNT, + CARD_TYPE_SORT_ORDER, + DUPLICATE_CARD_FORMAT, + COLOR_TO_BASIC_LAND, + SNOW_BASIC_LAND_MAPPING, + KINDRED_STAPLE_LANDS, ) from exceptions import ( DeckBuilderError, + DuplicateCardError, CSVValidationError, DataFrameValidationError, DataFrameTimeoutError, - EmptyDataFrameError + EmptyDataFrameError, + FetchLandSelectionError, + FetchLandValidationError, + KindredLandSelectionError, + KindredLandValidationError, + ThemeSelectionError, + ThemeWeightError, + CardTypeCountError ) logging.basicConfig( @@ -328,4 +356,649 @@ def validate_commander_selection(df: pd.DataFrame, commander_name: str) -> Dict: 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)}") \ No newline at end of file + 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()} + + print(adjusted_weights) + 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['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['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['type'].str.contains( + card_type, + case=False, + na=False + ) + type_counts[card_type] = int(type_mask.sum()) + + return type_counts + except Exception as e: + 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(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', ...] + """ + # Start with staple Kindred lands + available_lands = [land['name'] for land in KINDRED_STAPLE_LANDS] + + # 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, + allow_duplicates: bool = False) -> List[str]: + """Select Kindred lands from the available pool. + + Args: + available_lands: List of available Kindred lands + count: Number of Kindred lands to select + allow_duplicates: Whether to allow duplicate selections + + 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'], 2) + ['Path of Ancestry', 'Cavern of Souls'] + """ + 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 \ No newline at end of file diff --git a/deck_builder.py b/deck_builder.py index 0912ade..a9551c6 100644 --- a/deck_builder.py +++ b/deck_builder.py @@ -13,21 +13,27 @@ import keyboard # type: ignore import pandas as pd # type: ignore import pprint # type: ignore from fuzzywuzzy import process # type: ignore +from tqdm import tqdm # type: ignore from settings import ( - BASIC_LANDS, CARD_TYPES, CSV_DIRECTORY, multiple_copy_cards, - COMMANDER_CSV_PATH, FUZZY_MATCH_THRESHOLD, MAX_FUZZY_CHOICES, + BASIC_LANDS, CARD_TYPES, CSV_DIRECTORY, multiple_copy_cards, 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, COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_DEFAULT, COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, - CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS + CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS, + STAPLE_LAND_CONDITIONS ) import builder_utils import setup_utils from setup import determine_commanders from input_handler import InputHandler from exceptions import ( + BasicLandCountError, + BasicLandError, + CommanderMoveError, + CardTypeCountError, CommanderColorError, CommanderLoadError, CommanderSelectionError, @@ -37,16 +43,27 @@ from exceptions import ( CSVTimeoutError, CSVValidationError, DataFrameValidationError, + DuplicateCardError, DeckBuilderError, EmptyDataFrameError, - EmptyInputError, + EmptyInputError, + FetchLandSelectionError, + FetchLandValidationError, + IdealDeterminationError, InvalidNumberError, InvalidQuestionTypeError, + LibraryOrganizationError, + LibrarySortError, MaxAttemptsError, PriceAPIError, + PriceConfigurationError, PriceLimitError, PriceTimeoutError, - PriceValidationError + PriceValidationError, + ThemeSelectionError, + ThemeWeightError, + StapleLandError, + StapleLandError ) from type_definitions import ( CardDict, @@ -129,6 +146,7 @@ class DeckBuilder: # Initialize other attributes with type hints self.commander_info: Dict = {} + self.commander_dict: CommanderDict = {} self.commander: str = '' self.commander_type: str = '' self.commander_text: str = '' @@ -428,7 +446,7 @@ class DeckBuilder: def _initialize_commander_dict(self) -> None: """Initialize the commander dictionary with validated data.""" - self.commander_dict = { + self.commander_dict: CommanderDict = { 'Commander Name': self.commander, 'Mana Cost': self.commander_mana_cost, 'Mana Value': self.commander_mana_value, @@ -439,7 +457,8 @@ class DeckBuilder: 'Text': self.commander_text, 'Power': self.commander_power, 'Toughness': self.commander_toughness, - 'Themes': self.themes + 'Themes': self.themes, + 'CMC': 0.0 } self.add_card(self.commander, self.commander_type, self.commander_mana_cost, self.commander_mana_value, True) @@ -651,15 +670,6 @@ class DeckBuilder: if missing_cols: raise CSVValidationError(f"Missing required columns: {missing_cols}") - # Process in batches - processed_dfs = [] - for i in range(0, len(df), CSV_PROCESSING_BATCH_SIZE): - batch = df.iloc[i:i + CSV_PROCESSING_BATCH_SIZE] - processed_batch = setup_utils.process_card_dataframe(batch, skip_availability_checks=True) - processed_dfs.append(processed_batch) - - df = pd.concat(processed_dfs, ignore_index=True) - # Validate data rules for col, rules in CSV_VALIDATION_RULES.items(): if rules.get('required', False) and df[col].isnull().any(): @@ -667,11 +677,10 @@ class DeckBuilder: if 'type' in rules: expected_type = rules['type'] actual_type = df[col].dtype.name - if expected_type == 'str' and not actual_type in ['object', 'string']: + 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 @@ -704,6 +713,7 @@ class DeckBuilder: 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. @@ -718,7 +728,8 @@ class DeckBuilder: all_df = [] try: - for file in self.files_to_load: + # 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}") @@ -746,6 +757,7 @@ class DeckBuilder: # Remove lands from main DataFrame df = df[~df['type'].str.contains('Land')] + df.to_csv(f'{CSV_DIRECTORY}/test_cards.csv') # Create specialized frames self.artifact_df = df[df['type'].str.contains('Artifact')].copy() @@ -756,6 +768,8 @@ class DeckBuilder: self.instant_df = df[df['type'].str.contains('Instant')].copy() self.planeswalker_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') # Sort all frames for frame in [self.artifact_df, self.battle_df, self.creature_df, @@ -839,10 +853,10 @@ class DeckBuilder: # Load and combine data self.full_df = self._load_and_combine_data() self.full_df.sort_values(by='edhrecRank', inplace=True) + self.full_df.to_csv(f'{CSV_DIRECTORY}/test_full_cards.csv') # Split into specialized frames self._split_into_specialized_frames(self.full_df) - # Validate all frames self._validate_dataframes() @@ -854,315 +868,239 @@ class DeckBuilder: except (CSVError, EmptyDataFrameError, DataFrameValidationError) as e: logger.error(f"Error in DataFrame setup: {e}") raise - def determine_themes(self): - themes = self.commander_tags - print('Your commander deck will likely have a number of viable themes, but you\'ll want to narrow it down for focus.\n' - 'This will go through the process of choosing up to three themes for the deck.\n') - while True: - # Choose a primary theme - print('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.') - choice = self.input_handler.questionnaire('Choice', choices_list=themes) - self.primary_theme = choice - weights_default = { - 'primary': 1.0, - 'secondary': 0.0, - 'tertiary': 0.0, - 'hidden': 0.0 - } - weights = weights_default.copy() - themes.remove(choice) - themes.append('Stop Here') - self.primary_weight = weights['primary'] - - secondary_theme_chosen = False - tertiary_theme_chosen = False - self.hidden_theme = False - - while not secondary_theme_chosen: - # Secondary theme - print('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.') - choice = self.input_handler.questionnaire('Choice', choices_list=themes) - while True: - if choice == 'Stop Here': - logger.warning('You\'ve only selected one theme, are you sure you want to stop?\n') - confirm_done = self.input_handler.questionnaire('Confirm', False) - if confirm_done: - secondary_theme_chosen = True - self.secondary_theme = False - tertiary_theme_chosen = True - self.tertiary_theme = False - themes.remove(choice) - break - else: - pass - - else: - weights = weights_default.copy() # primary = 1.0, secondary = 0.0, tertiary = 0.0 - self.secondary_theme = choice - themes.remove(choice) - secondary_theme_chosen = True - # Set weights for primary/secondary themes - if 'Kindred' in self.primary_theme and 'Kindred' not in self.secondary_theme: - weights['primary'] -= 0.1 # 0.8 - weights['secondary'] += 0.1 # 0.1 - elif 'Kindred' in self.primary_theme and 'Kindred' in self.secondary_theme: - weights['primary'] -= 0.7 # 0.7 - weights['secondary'] += 0.3 # 0.3 - else: - weights['primary'] -= 0.4 # 0.6 - weights['secondary'] += 0.4 # 0.4 - self.primary_weight = weights['primary'] - self.secondary_weight = weights['secondary'] - break - - while not tertiary_theme_chosen: - # Tertiary theme - print('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.') - choice = self.input_handler.questionnaire('Choice', choices_list=themes) - while True: - if choice == 'Stop Here': - logger.warning('You\'ve only selected two themes, are you sure you want to stop?\n') - confirm_done = self.input_handler.questionnaire('Confirm', False) - if confirm_done: - tertiary_theme_chosen = True - self.tertiary_theme = False - themes.remove(choice) - break - else: - pass - - else: - weights = weights_default.copy() # primary = 1.0, secondary = 0.0, tertiary = 0.0 - self.tertiary_theme = choice - tertiary_theme_chosen = True - - # Set weights for themes: - if 'Kindred' in self.primary_theme and 'Kindred' not in self.secondary_theme and 'Kindred' not in self.tertiary_theme: - weights['primary'] -= 0.2 # 0.8 - weights['secondary'] += 0.1 # 0.1 - weights['tertiary'] += 0.1 # 0.1 - elif 'Kindred' in self.primary_theme and 'Kindred' in self.secondary_theme and 'Kindred' not in self.tertiary_theme: - weights['primary'] -= 0.3 # 0.7 - weights['secondary'] += 0.2 # 0.2 - weights['tertiary'] += 0.1 # 0.1 - elif 'Kindred' in self.primary_theme and 'Kindred' in self.secondary_theme and 'Kindred' in self.tertiary_theme: - weights['primary'] -= 0.5 # 0.5 - weights['secondary'] += 0.3 # 0.3 - weights['tertiary'] += 0.2 # 0.2 - else: - weights['primary'] -= 0.6 # 0.4 - weights['secondary'] += 0.3 # 0.3 - weights['tertiary'] += 0.3 # 0.3 - self.primary_weight = weights['primary'] - self.secondary_weight = weights['secondary'] - self.tertiary_weight = weights['tertiary'] - break - - self.themes = [self.primary_theme] - if not self.secondary_theme: - pass - else: - self.themes.append(self.secondary_theme) - if not self.tertiary_theme: - pass - else: - self.themes.append(self.tertiary_theme) - - """ - 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 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', False) - if choice: - self.hidden_theme = theme_cards[i] - self.themes.append(self.hidden_theme) - weights['primary'] = round(weights['primary'] / 3, 2) - weights['secondary'] = round(weights['secondary'] / 2, 2) - weights['tertiary'] = weights['tertiary'] - weights['hidden'] = round(1.0 - weights['primary'] - weights['secondary'] - weights['tertiary'], 2) - self.primary_weight = weights['primary'] - self.secondary_weight = weights['secondary'] - self.tertiary_weight = weights['tertiary'] - self.hidden_weight = 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', False) - if choice: - print('Which one?') - choice = self.input_handler.questionnaire('Choice', choices_list=theme_cards[i]) - if choice: - self.hidden_theme = choice - self.themes.append(self.hidden_theme) - weights['primary'] = round(weights['primary'] / 3, 2) - weights['secondary'] = round(weights['secondary'] / 2, 2) - weights['tertiary'] = weights['tertiary'] - weights['hidden'] = round(1.0 - weights['primary'] - weights['secondary'] - weights['tertiary'], 2) - self.primary_weight = weights['primary'] - self.secondary_weight = weights['secondary'] - self.tertiary_weight = weights['tertiary'] - self.hidden_weight = 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', False) - if choice: - self.hidden_theme = theme_cards[i] - self.themes.append(self.hidden_theme) - weights['primary'] = round(weights['primary'] / 3, 2) - weights['secondary'] = round(weights['secondary'] / 2, 2) - weights['tertiary'] = weights['tertiary'] - weights['hidden'] = round(1.0 - weights['primary'] - weights['secondary'] - weights['tertiary'], 2) - self.primary_weight = weights['primary'] - self.secondary_weight = weights['secondary'] - self.tertiary_weight = weights['tertiary'] - self.hidden_weight = weights['hidden'] - else: - continue - - break - def determine_ideals(self): - # "Free" slots that can be used for anything that isn't the ideals - self.free_slots = 99 + def determine_themes(self) -> None: + """Determine and set up themes for the deck building process. - if use_scrython: - 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.') - choice = self.input_handler.questionnaire('Confirm', False) - if choice: - print('What would you like the max price to be?') - max_deck_price = float(self.input_handler.questionnaire('Number', 400)) - self.price_checker.max_deck_price = max_deck_price - new_line() - else: - new_line() + 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() - 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.') - choice = self.input_handler.questionnaire('Confirm', False) - if choice: - print('What would you like the max price to be?') - answer = float(self.input_handler.questionnaire('Number', 20)) - self.price_checker.max_card_price = answer - new_line() - else: - new_line() - - # Determine ramp - print('How many pieces of ramp would you like to include?\n' - 'This includes mana rocks, mana dorks, and land ramp spells.\n' - 'A good baseline is 8-12 pieces, scaling up with higher average CMC\n' - 'Default: 8') - answer = self.input_handler.questionnaire('Number', 8) - self.ideal_ramp = int(answer) - self.free_slots -= self.ideal_ramp - new_line() - - # Determine ideal land count - print('How many total lands would you like to include?\n' - 'Before ramp is considered, 38-40 lands is typical for most decks.\n' - "For landfall decks, consider starting at 40 lands before ramp.\n" - 'As a guideline, each mana source from ramp can reduce land count by ~1.\n' - 'Default: 35') - answer = self.input_handler.questionnaire('Number', 35) - self.ideal_land_count = int(answer) - self.free_slots -= self.ideal_land_count - new_line() - - # Determine minimum basics to have - print('How many basic lands would you like to have at minimum?\n' - 'This can vary widely depending on your commander, colors in color identity, and what you want to do.\n' - 'Some decks may be fine with as low as 10, others may want 25.\n' - 'Default: 20') - answer = self.input_handler.questionnaire('Number', 20) - self.min_basics = int(answer) - new_line() - - # Determine ideal creature count - print('How many creatures would you like to include?\n' - 'Something like 25-30 would be a good starting point.\n' - "If you're going for a kindred theme, going past 30 is likely normal.\n" - "Also be sure to take into account token generation, but remember you'll want enough to stay safe\n" - 'Default: 25') - answer = self.input_handler.questionnaire('Number', 25) - self.ideal_creature_count = int(answer) - self.free_slots -= self.ideal_creature_count - new_line() - - # Determine spot/targetted removal - print('How many spot removal pieces would you like to include?\n' - 'A good starting point is about 8-12 pieces of spot removal.\n' - 'Counterspells can be considered proactive removal and protection.\n' - 'If you\'re going spellslinger, more would be a good idea as you might have less cretaures.\n' - 'Default: 10') - answer = self.input_handler.questionnaire('Number', 10) - self.ideal_removal = int(answer) - self.free_slots -= self.ideal_removal - new_line() + # 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 + ) + print(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.tertiary_theme) + print(self.weights) + self.determine_hidden_themes() - # Determine board wipes - print('How many board wipes would you like to include?\n' - 'Somewhere around 2-3 is good to help eliminate threats, but also prevent the game from running long\n.' - 'This can include damaging wipes like "Blasphemous Act" or toughness reduction like "Meathook Massacre".\n' - 'Default: 2') - answer = self.input_handler.questionnaire('Number', 2) - self.ideal_wipes = int(answer) - self.free_slots -= self.ideal_wipes - new_line() + 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 - # Determine card advantage - print('How many pieces of card advantage would you like to include?\n' - '10 pieces of card advantage is good, up to 14 is better.\n' - 'Try to have a majority of it be non-conditional, and only have a couple of "Rhystic Study" style effects.\n' - 'Default: 10') - answer = self.input_handler.questionnaire('Number', 10) - self.ideal_card_advantage = int(answer) - self.free_slots -= self.ideal_card_advantage - new_line() + # 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 - # Determine how many protection spells - print('How many protection spells would you like to include?\n' - 'This can be individual protection, board protection, fogs, or similar effects.\n' - 'Things that grant indestructible, hexproof, phase out, or even just counterspells.\n' - 'It\'s recommended to have 5 to 15, depending on your commander and preferred strategy.\n' - 'Default: 8') - answer = self.input_handler.questionnaire('Number', 8) - self.ideal_protection = int(answer) - self.free_slots -= self.ideal_protection - new_line() - - print(f'Free 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.') + 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 def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, 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') @@ -1205,109 +1143,159 @@ class DeckBuilder: logger.debug(f"Added {card} to deck library") def organize_library(self): - # Initialize counters dictionary dynamically from CARD_TYPES including Kindred - all_types = CARD_TYPES + ['Kindred'] if 'Kindred' not in CARD_TYPES else CARD_TYPES - card_counters = {card_type: 0 for card_type in all_types} + """Organize and count cards in the library by their types. - # Count cards by type - for card_type in CARD_TYPES: - type_df = self.card_library[self.card_library['Card Type'].apply(lambda x: card_type in x)] - card_counters[card_type] = len(type_df) + 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. - # Assign counts to instance variables - 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) # Use get() with default value - self.land_cards = card_counters['Land'] - self.planeswalker_cards = card_counters['Planeswalker'] - self.sorcery_cards = card_counters['Sorcery'] - - def sort_library(self): - self.card_library['Sort Order'] = pd.Series(dtype='str') - for index, row in self.card_library.iterrows(): - for card_type in CARD_TYPES: - if card_type in row['Card Type']: - if row['Sort Order'] == 'Creature': - continue - if row['Sort Order'] != 'Creature': - self.card_library.loc[index, 'Sort Order'] = card_type + The method handles the following card types: + - Artifacts + - Battles + - Creatures + - Enchantments + - Instants + - Kindred (if applicable) + - Lands + - Planeswalkers + - Sorceries - custom_order = ['Planeswalker', 'Battle', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land'] - self.card_library['Sort Order'] = pd.Categorical( - self.card_library['Sort Order'], - categories=custom_order, - ordered=True - ) - self.card_library = (self.card_library - .sort_values(by=['Sort Order', 'Card Name'], ascending=[True, True]) - .drop(columns=['Sort Order']) - .reset_index(drop=True) - ) - - def commander_to_top(self) -> None: - """Move commander card to the top of the library while preserving commander status.""" - try: - commander_row = self.card_library[self.card_library['Commander']].copy() - if commander_row.empty: - logger.warning("No commander found in library") - return - - self.card_library = self.card_library[~self.card_library['Commander']] - - self.card_library = pd.concat([commander_row, self.card_library], ignore_index=True) - - commander_name = commander_row['Card Name'].iloc[0] - logger.info(f"Successfully moved commander '{commander_name}' to top") - except Exception as e: - logger.error(f"Error moving commander to top: {str(e)}") - def concatenate_duplicates(self): - """Handle duplicate cards in the library while maintaining data integrity.""" - duplicate_lists = BASIC_LANDS + multiple_copy_cards - - # Create a count column for duplicates - self.card_library['Card Count'] = 1 - - for duplicate in duplicate_lists: - mask = self.card_library['Card Name'] == duplicate - count = mask.sum() - - if count > 0: - logger.info(f'Found {count} copies of {duplicate}') - - # Keep first occurrence with updated count - first_idx = mask.idxmax() - self.card_library.loc[first_idx, 'Card Count'] = count - - # Drop other occurrences - self.card_library = self.card_library.drop( - self.card_library[mask & (self.card_library.index != first_idx)].index - ) - - # Update card names with counts where applicable - mask = self.card_library['Card Count'] > 1 - self.card_library.loc[mask, 'Card Name'] = ( - self.card_library.loc[mask, 'Card Name'] + - ' x ' + - self.card_library.loc[mask, 'Card Count'].astype(str) - ) - - # Clean up - self.card_library = self.card_library.drop(columns=['Card Count']) - self.card_library = self.card_library.reset_index(drop=True) - def drop_card(self, dataframe: pd.DataFrame, index: int) -> None: - """Safely drop a card from the dataframe by index. - - Args: - dataframe: DataFrame to modify - index: Index to drop + Raises: + CardTypeCountError: If there are issues counting cards by type + LibraryOrganizationError: If library organization fails """ try: - dataframe.drop(index, inplace=True) - except KeyError: - logger.warning(f"Attempted to drop non-existent index {index}") + # 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 + def add_lands(self): """ Add lands to the deck based on ideal count and deck requirements. @@ -1368,231 +1356,230 @@ class DeckBuilder: raise def add_basics(self): - base_basics = self.ideal_land_count - 10 # Reserve 10 slots for non-basic lands - basics_per_color = base_basics // len(self.colors) - remaining_basics = base_basics % len(self.colors) + """Add basic lands to the deck based on color identity and commander tags. - color_to_basic = { - 'W': 'Plains', - 'U': 'Island', - 'B': 'Swamp', - 'R': 'Mountain', - 'G': 'Forest', - 'COLORLESS': 'Wastes' - } + 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 - if 'Snow' in self.commander_tags: - color_to_basic = { - 'W': 'Snow-Covered Plains', - 'U': 'Snow-Covered Island', - 'B': 'Snow-Covered Swamp', - 'R': 'Snow-Covered Mountain', - 'G': 'Snow-Covered Forest', - 'COLORLESS': 'Snow-Covered Wastes' - } + 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") - print(f'Adding {base_basics} basic lands distributed across {len(self.colors)} colors') + # Get appropriate basic land mapping + use_snow = 'Snow' in self.commander_tags + color_to_basic = builder_utils.get_basic_land_mapping(use_snow) - # Add equal distribution first - for color in self.colors: - basic = color_to_basic.get(color) - if basic: - # Add basics with explicit commander flag and track count - for _ in range(basics_per_color): - self.add_card(basic, 'Basic Land', None, 0, is_commander=False) + # Calculate distribution + basics_per_color, remaining = builder_utils.calculate_basics_per_color( + total_basics, + len(self.colors) + ) - # Distribute remaining basics based on color requirements - if remaining_basics > 0: - for color in self.colors[:remaining_basics]: + 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: - self.add_card(basic, 'Basic Land', None, 0, is_commander=False) + for _ in range(count): + self.add_card(basic, 'Basic Land', None, 0, is_commander=False) + lands_to_remove.append(basic) - lands_to_remove = [] - for key in color_to_basic: - basic = color_to_basic.get(key) - lands_to_remove.append(basic) - - 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) + # 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 based on deck requirements.""" + """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') - - # Define staple lands and their conditions - staple_lands = { - 'Reliquary Tower': lambda: True, # Always include - 'Ash Barrens': lambda: 'Landfall' not in self.commander_tags, - 'Command Tower': lambda: len(self.colors) > 1, - 'Exotic Orchard': lambda: len(self.colors) > 1, - 'War Room': lambda: len(self.colors) <= 2, - 'Rogue\'s Passage': lambda: self.commander_power >= 5 - } - self.staples = [] + try: - # Add lands that meet their conditions - for land, condition in staple_lands.items(): - if condition(): + 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}") - - # Update land database - self.land_df = self.land_df[~self.land_df['name'].isin(self.staples)] + + 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') - + 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 + raise StapleLandError(f"Failed to add staple lands: {str(e)}") + def add_fetches(self): - # Determine how many fetches in total - 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.') - answer = self.input_handler.questionnaire('Number', 2) - MAX_ATTEMPTS = 50 # Maximum attempts to prevent infinite loops - attempt_count = 0 - desired_fetches = int(answer) - chosen_fetches = [] - - generic_fetches = [ - 'Evolving Wilds', 'Terramorphic Expanse', 'Shire Terrace', - 'Escape Tunnel', 'Promising Vein', 'Myriad Landscape', - 'Fabled Passage', 'Terminal Moraine' - ] - fetches = generic_fetches.copy() - lands_to_remove = generic_fetches.copy() - - # Adding in expensive fetches - if (use_scrython and self.set_max_card_price): - if self.price_checker.get_card_price('Prismatic Vista') <= self.max_card_price * 1.1: - lands_to_remove.append('Prismatic Vista') - fetches.append('Prismatic Vista') - else: - lands_to_remove.append('Prismatic Vista') - pass - else: - lands_to_remove.append('Prismatic Vista') - fetches.append('Prismatic Vista') - - color_to_fetch = { - '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'] - } - - for color in self.colors: - fetch = color_to_fetch.get(color) - if fetch not in fetches: - fetches.extend(fetch) - if fetch not in lands_to_remove: - lands_to_remove.extend(fetch) - for color in color_to_fetch: - fetch = color_to_fetch.get(color) - if fetch not in fetches: - fetches.extend(fetch) - if fetch not in lands_to_remove: - lands_to_remove.extend(fetch) - - # Randomly choose fetches up to the desired number - while len(chosen_fetches) < desired_fetches + 3 and attempt_count < MAX_ATTEMPTS: - if not fetches: # If we run out of fetches to choose from - break - - fetch_choice = random.choice(fetches) - if use_scrython and self.set_max_card_price: - if self.price_checker.get_card_price(fetch_choice) <= self.max_card_price * 1.1: - chosen_fetches.append(fetch_choice) - fetches.remove(fetch_choice) - else: - chosen_fetches.append(fetch_choice) - fetches.remove(fetch_choice) - - attempt_count += 1 + """Add fetch lands to the deck based on user input and deck colors. - # Select final fetches to add - fetches_to_add = [] - available_fetches = chosen_fetches[:desired_fetches] - for fetch in available_fetches: - if fetch not in fetches_to_add: - fetches_to_add.append(fetch) + 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 - if attempt_count >= MAX_ATTEMPTS: - logger.warning(f"Reached maximum attempts ({MAX_ATTEMPTS}) while selecting fetch lands") - - for card in fetches_to_add: - self.add_card(card, 'Land', None, 0) - - 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) - - def add_kindred_lands(self): - """Add lands that support tribal/kindred themes.""" - logger.info('Adding Kindred-themed lands') - - # Standard Kindred support lands - KINDRED_STAPLES = [ - {'name': 'Path of Ancestry', 'type': 'Land'}, - {'name': 'Three Tree City', 'type': 'Legendary Land'}, - {'name': 'Cavern of Souls', 'type': 'Land'} - ] - - kindred_lands = KINDRED_STAPLES.copy() - lands_to_remove = set() - + Raises: + FetchLandValidationError: If fetch land count is invalid + FetchLandSelectionError: If unable to select required fetch lands + PriceLimitError: If fetch lands exceed price limits + """ try: - # Process each Kindred theme - for theme in self.themes: - if 'Kindred' in theme: - creature_type = theme.replace(' Kindred', '') - logger.info(f'Searching for {creature_type}-specific lands') - - # Filter lands by creature type - type_specific = self.land_df[ - self.land_df['text'].notna() & - (self.land_df['text'].str.contains(creature_type, case=False) | - self.land_df['type'].str.contains(creature_type, case=False)) - ] - - # Add matching lands to pool - for _, row in type_specific.iterrows(): - kindred_lands.append({ - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - }) - lands_to_remove.add(row['name']) + # 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.') - # Add lands to deck - for card in kindred_lands: - if card['name'] not in self.card_library['Card Name'].values: - self.add_card(card['name'], card['type'], - None, 0) - lands_to_remove.add(card['name']) + # 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(lands_to_remove)} Kindred-themed lands') + 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.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 + ) + + # 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): - # Determine dual-color lands available + def add_dual_lands(self): + # Determine dual-color lands available + # Determine if using the dual-type lands print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?') - choice = self.input_handler.questionnaire('Confirm', True) + choice = self.input_handler.questionnaire('Confirm', message='', default_value=True) color_filter = [] color_dict = { 'azorius': 'Plains Island', @@ -1642,7 +1629,7 @@ class DeckBuilder: def add_triple_lands(self): # Determine if using Triome lands print('Would you like to include triome lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?') - choice = self.input_handler.questionnaire('Confirm', True) + choice = self.input_handler.questionnaire('Confirm', message='', default_value=True) color_filter = [] color_dict = { @@ -1753,8 +1740,24 @@ class DeckBuilder: 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.""" + """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, @@ -1769,16 +1772,22 @@ class DeckBuilder: } self.total_basics = 0 - for land in basic_lands: - count = len(self.card_library[self.card_library['Card Name'] == land]) - basic_lands[land] = count - self.total_basics += count - logger.info("\nBasic Land Counts:") - for land, count in basic_lands.items(): - if count > 0: - logger.info(f"{land}: {count}") - logger.info(f"Total basic lands: {self.total_basics}\n") + 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 + + 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): """ @@ -2051,10 +2060,10 @@ class DeckBuilder: def add_creatures(self): """ - Add creatures to the deck based on themes and weights. + Add creatures to the deck based on themes and self.weights. This method processes the primary, secondary, and tertiary themes to add - creatures proportionally according to their weights. The total number of + creatures proportionally according to their self.weights. The total number of creatures added will approximate the ideal_creature_count. Themes are processed in order of importance (primary -> secondary -> tertiary) @@ -2146,11 +2155,11 @@ class DeckBuilder: initial_count = len(self.card_library) remaining = 100 - len(self.card_library) - # Adjust weights based on remaining cards needed + # Adjust self.weights based on remaining cards needed weight_multiplier = remaining / cards_needed try: - # Add cards from each theme with adjusted weights + # Add cards from each theme with adjusted self.weights if self.tertiary_theme: self.add_by_tags(self.tertiary_theme, math.ceil(self.tertiary_weight * 10 * weight_multiplier), diff --git a/exceptions.py b/exceptions.py index 876256e..7b8aa57 100644 --- a/exceptions.py +++ b/exceptions.py @@ -583,4 +583,472 @@ class CommanderThemeError(CommanderValidationError): message: Description of the theme validation failure details: Additional context about the error """ - super().__init__(message, code="CMD_THEME_ERR", details=details) \ No newline at end of file + super().__init__(message, code="CMD_THEME_ERR", details=details) + +class CommanderMoveError(DeckBuilderError): + """Raised when there are issues moving the commander to the top of the library. + + This exception is used when the commander_to_top() method encounters problems + such as commander not found in library, invalid deck state, or other issues + preventing the commander from being moved to the top position. + + Examples: + >>> raise CommanderMoveError( + ... "Commander not found in library", + ... {"commander_name": "Atraxa, Praetors' Voice"} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize commander move error. + + Args: + message: Description of the move operation failure + details: Additional context about the error + """ + super().__init__(message, code="CMD_MOVE_ERR", details=details) + +class LibraryOrganizationError(DeckBuilderError): + """Base exception class for library organization errors. + + This exception serves as the base for all errors related to organizing + and managing the card library, including card type counting and validation. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "LIB_ORG_ERR", details: dict | None = None): + """Initialize the library organization error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) + +class LibrarySortError(LibraryOrganizationError): + """Raised when there are issues sorting the card library. + + This exception is used when the sort_library() method encounters problems + organizing cards by type and name, such as invalid sort orders or + card type categorization errors. + + Examples: + >>> raise LibrarySortError( + ... "Invalid card type sort order", + ... "Card type 'Unknown' not in sort order list" + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize library sort error. + + Args: + message: Description of the sorting failure + details: Additional context about the error + """ + if details: + details = details or {} + details['sort_error'] = True + super().__init__(message, code="LIB_SORT_ERR", details=details) + +class DuplicateCardError(LibraryOrganizationError): + """Raised when there are issues processing duplicate cards in the library. + + This exception is used when the concatenate_duplicates() method encounters problems + processing duplicate cards, such as invalid card names, missing data, or + inconsistencies in duplicate card information. + + Examples: + >>> raise DuplicateCardError( + ... "Failed to process duplicate cards", + ... "Sol Ring", + ... {"duplicate_count": 3} + ... ) + """ + + def __init__(self, message: str, card_name: str | None = None, details: dict | None = None): + """Initialize duplicate card error. + + Args: + message: Description of the duplicate processing failure + card_name: Name of the card causing the duplication error + details: Additional context about the error + """ + if card_name: + details = details or {} + details['card_name'] = card_name + super().__init__(message, code="DUPLICATE_CARD", details=details) + +class CardTypeCountError(LibraryOrganizationError): + """Raised when there are issues counting cards of specific types. + + This exception is used when card type counting operations fail or + produce invalid results during library organization. + + Examples: + >>> raise CardTypeCountError( + ... "Invalid creature count", + ... "creature", + ... {"expected": 30, "actual": 15} + ... ) + """ + + def __init__(self, message: str, card_type: str, details: dict | None = None): + """Initialize card type count error. + + Args: + message: Description of the counting failure + card_type: The type of card that caused the counting error + details: Additional context about the error + """ + if card_type: + details = details or {} + details['card_type'] = card_type + super().__init__(message, code="CARD_TYPE_COUNT", details=details) + +class ThemeError(DeckBuilderError): + """Base exception class for theme-related errors. + + This exception serves as the base for all theme-related errors in the deck builder, + including theme selection, validation, and weight calculation issues. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "THEME_ERR", details: dict | None = None): + """Initialize the base theme error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) + +class ThemeSelectionError(ThemeError): + """Raised when theme selection fails or is invalid. + + This exception is used when an invalid theme is selected or when + the theme selection process is canceled by the user. + + Examples: + >>> raise ThemeSelectionError( + ... "Invalid theme selected", + ... "artifacts", + ... {"available_themes": ["tokens", "lifegain", "counters"]} + ... ) + """ + + def __init__(self, message: str, selected_theme: str | None = None, details: dict | None = None): + """Initialize theme selection error. + + Args: + message: Description of the selection failure + selected_theme: The invalid theme that was selected (if any) + details: Additional context about the error + """ + if selected_theme: + details = details or {} + details['selected_theme'] = selected_theme + super().__init__(message, code="THEME_SELECT", details=details) + +class ThemeWeightError(ThemeError): + """Raised when theme weight calculation fails. + + This exception is used when there are errors in calculating or validating + theme weights during the theme selection process. + """ + + def __init__(self, message: str, theme: str | None = None, details: dict | None = None): + """Initialize theme weight error. + + Args: + message: Description of the weight calculation failure + theme: The theme that caused the weight calculation error + details: Additional context about the error + """ + if theme: + details = details or {} + details['theme'] = theme + super().__init__(message, code="THEME_WEIGHT", details=details) + +class IdealDeterminationError(DeckBuilderError): + """Raised when there are issues determining deck composition ideals. + + This exception is used when the determine_ideals() method encounters problems + calculating or validating deck composition ratios and requirements. + + Examples: + >>> raise IdealDeterminationError( + ... "Invalid land ratio calculation", + ... {"calculated_ratio": 0.1, "min_allowed": 0.3} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize ideal determination error. + + Args: + message: Description of the ideal calculation failure + details: Additional context about the error + """ + super().__init__(message, code="IDEAL_ERR", details=details) + +class PriceConfigurationError(DeckBuilderError): + """Raised when there are issues configuring price settings. + + This exception is used when price-related configuration in determine_ideals() + is invalid or cannot be properly applied. + + Examples: + >>> raise PriceConfigurationError( + ... "Invalid budget allocation", + ... {"total_budget": 100, "min_card_price": 200} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize price configuration error. + + Args: + message: Description of the price configuration failure + details: Additional context about the error + """ + super().__init__(message, code="PRICE_CONFIG_ERR", details=details) + +class BasicLandError(DeckBuilderError): + """Base exception class for basic land related errors. + + This exception serves as the base for all basic land related errors in the deck builder, + including land distribution, snow-covered lands, and colorless deck handling. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "BASIC_LAND_ERR", details: dict | None = None): + """Initialize the basic land error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + """ + super().__init__(message, code=code, details=details) + +class BasicLandCountError(BasicLandError): + """Raised when there are issues with counting basic lands. + + This exception is used when basic land counting operations fail or + produce unexpected results during deck validation or analysis. + + Examples: + >>> raise BasicLandCountError( + ... "Failed to count basic lands in deck", + ... {"expected_count": 35, "actual_count": 0} + ... ) + + >>> raise BasicLandCountError( + ... "Invalid basic land count for color distribution", + ... {"color": "U", "count": -1} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize basic land count error. + + Args: + message: Description of the counting operation failure + details: Additional context about the error + """ + super().__init__(message, code="BASIC_LAND_COUNT_ERR", details=details) + +class StapleLandError(DeckBuilderError): + """Raised when there are issues adding staple lands. + ``` + This exception is used when there are problems adding staple lands + to the deck, such as invalid land types, missing lands, or + incompatible color requirements. + + Examples: + >>> raise StapleLandError( + ... "Failed to add required shock lands", + ... {"missing_lands": ["Steam Vents", "Breeding Pool"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize staple land error. + + Args: + message: Description of the staple land operation failure + details: Additional context about the error + """ + super().__init__( + message, + code="STAPLE_LAND_ERR", + details=details + ) + +class LandDistributionError(BasicLandError): + """Raised when there are issues with basic land distribution. + + This exception is used when there are problems distributing basic lands + across colors, such as invalid color ratios or unsupported color combinations. + + Examples: + >>> raise LandDistributionError( + ... "Invalid land distribution for colorless deck", + ... {"colors": [], "requested_lands": 40} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize land distribution error. + + Args: + message: Description of the land distribution failure + details: Additional context about the error + """ + super().__init__(message, code="LAND_DIST_ERR", details=details) + +class FetchLandError(DeckBuilderError): + """Base exception class for fetch land-related errors. + + This exception serves as the base for all fetch land-related errors in the deck builder, + including validation errors, selection errors, and fetch land processing issues. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "FETCH_ERR", details: dict | None = None): + """Initialize the base fetch land error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) + +class KindredLandError(DeckBuilderError): + """Base exception class for Kindred land-related errors. + + This exception serves as the base for all Kindred land-related errors in the deck builder, + including validation errors, selection errors, and Kindred land processing issues. + + Attributes: + code (str): Error code for identifying the error type + message (str): Descriptive error message + details (dict): Additional error context and details + """ + + def __init__(self, message: str, code: str = "KINDRED_ERR", details: dict | None = None): + """Initialize the base Kindred land error. + + Args: + message: Human-readable error description + code: Error code for identification and handling + details: Additional context about the error + """ + super().__init__(message, code=code, details=details) + +class KindredLandValidationError(KindredLandError): + """Raised when Kindred land validation fails. + + This exception is used when there are issues validating Kindred land inputs, + such as invalid land types, unsupported creature types, or color identity mismatches. + + Examples: + >>> raise KindredLandValidationError( + ... "Invalid Kindred land type", + ... {"land_type": "Non-Kindred Land", "creature_type": "Elf"} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize Kindred land validation error. + + Args: + message: Description of the validation failure + details: Additional context about the error + """ + super().__init__(message, code="KINDRED_VALID_ERR", details=details) + +class KindredLandSelectionError(KindredLandError): + """Raised when Kindred land selection fails. + + This exception is used when there are issues selecting appropriate Kindred lands, + such as no valid lands found, creature type mismatches, or price constraints. + + Examples: + >>> raise KindredLandSelectionError( + ... "No valid Kindred lands found for creature type", + ... {"creature_type": "Dragon", "attempted_lands": ["Cavern of Souls"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize Kindred land selection error. + + Args: + message: Description of the selection failure + details: Additional context about the error + """ + super().__init__(message, code="KINDRED_SELECT_ERR", details=details) + +class FetchLandValidationError(FetchLandError): + """Raised when fetch land validation fails. + + This exception is used when there are issues validating fetch land inputs, + such as invalid fetch count, unsupported colors, or invalid fetch land types. + + Examples: + >>> raise FetchLandValidationError( + ... "Invalid fetch land count", + ... {"requested": 10, "maximum": 9} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize fetch land validation error. + + Args: + message: Description of the validation failure + details: Additional context about the error + """ + super().__init__(message, code="FETCH_VALID_ERR", details=details) + +class FetchLandSelectionError(FetchLandError): + """Raised when fetch land selection fails. + + This exception is used when there are issues selecting appropriate fetch lands, + such as no valid fetches found, color identity mismatches, or price constraints. + + Examples: + >>> raise FetchLandSelectionError( + ... "No valid fetch lands found for color identity", + ... {"colors": ["W", "U"], "attempted_fetches": ["Flooded Strand", "Polluted Delta"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize fetch land selection error. + + Args: + message: Description of the selection failure + details: Additional context about the error + """ + super().__init__(message, code="FETCH_SELECT_ERR", details=details) \ No newline at end of file diff --git a/input_handler.py b/input_handler.py index ee9c083..47fbbb6 100644 --- a/input_handler.py +++ b/input_handler.py @@ -190,19 +190,19 @@ class InputHandler: question = [ inquirer.Text( 'text', - message=message or 'Enter text', + message=f'{message}' or 'Enter text', default=default_value or self.default_text ) ] result = inquirer.prompt(question)['text'] if self.validate_text(result): - return result + return str(result) elif question_type == 'Price': question = [ inquirer.Text( 'price', - message=message or 'Enter price (or "unlimited")', + message=f'{message}' or 'Enter price (or "unlimited")', default=str(default_value or DEFAULT_MAX_CARD_PRICE) ) ] @@ -210,12 +210,13 @@ class InputHandler: price, is_unlimited = self.validate_price(result) if not is_unlimited: self.validate_price_threshold(price) - return price + return float(price) + elif question_type == 'Number': question = [ inquirer.Text( 'number', - message=message or 'Enter number', + message=f'{message}' or 'Enter number', default=str(default_value or self.default_number) ) ] @@ -226,7 +227,7 @@ class InputHandler: question = [ inquirer.Confirm( 'confirm', - message=message or 'Confirm?', + message=f'{message}' or 'Confirm?', default=default_value if default_value is not None else self.default_confirm ) ] @@ -239,7 +240,7 @@ class InputHandler: question = [ inquirer.List( 'selection', - message=message or 'Select an option', + message=f'{message}' or 'Select an option', choices=choices_list, carousel=True ) diff --git a/settings.py b/settings.py index 2ef5348..8db80d7 100644 --- a/settings.py +++ b/settings.py @@ -1,12 +1,14 @@ -from typing import Dict, List, Optional, Final, Tuple, Pattern, Union +from typing import Dict, List, Optional, Final, Tuple, Pattern, Union, Callable import ast # Commander selection configuration +# Format string for displaying duplicate cards in deck lists +DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}' + COMMANDER_CSV_PATH: Final[str] = 'csv_files/commander_cards.csv' FUZZY_MATCH_THRESHOLD: Final[int] = 90 # Threshold for fuzzy name matching MAX_FUZZY_CHOICES: Final[int] = 5 # Maximum number of fuzzy match choices COMMANDER_CONVERTERS: Final[Dict[str, str]] = {'themeTags': ast.literal_eval, 'creatureTypes': ast.literal_eval} # CSV loading converters - # Commander-related constants COMMANDER_POWER_DEFAULT: Final[int] = 0 COMMANDER_TOUGHNESS_DEFAULT: Final[int] = 0 @@ -27,6 +29,148 @@ 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 + +# Default fetch land count +FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include +# Basic land mappings +COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = { + 'W': 'Plains', + 'U': 'Island', + 'B': 'Swamp', + 'R': 'Mountain', + 'G': 'Forest', + 'C': 'Wastes' +} + +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' + ] +} + +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 + +# 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 +} + + +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 @@ -555,11 +699,31 @@ BOARD_WIPE_EXCLUSION_PATTERNS = [ 'target player\'s library', 'that player\'s library' ] - - CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery', 'Kindred', 'Dungeon', 'Battle'] +# 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' +] + +# Default counts for each card type +CARD_TYPE_COUNT_DEFAULTS: Final[Dict[str, int]] = { + 'Artifact': 0, + 'Battle': 0, + 'Creature': 0, + 'Enchantment': 0, + 'Instant': 0, + 'Kindred': 0, + 'Land': 0, + 'Planeswalker': 0, + 'Sorcery': 0 +} + # Mapping of card types to their corresponding theme tags TYPE_TAG_MAPPING = { 'Artifact': ['Artifacts Matter'], @@ -810,6 +974,21 @@ REQUIRED_COLUMNS: List[str] = [ 'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side' ] +# Constants for theme weight management +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', @@ -976,9 +1155,9 @@ DATAFRAME_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, boo # Card category validation rules CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { - 'power': {'type': ('str', 'int', 'float'), 'required': True}, - 'toughness': {'type': ('str', 'int', 'float'), 'required': True}, - 'creatureTypes': {'type': 'list', 'required': True} + 'power': {'type': ('str', 'int', 'float', 'object'), 'required': True}, + 'toughness': {'type': ('str', 'int', 'float', 'object'), 'required': True}, + 'creatureTypes': {'type': ('list', 'object'), 'required': True} } SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = {