diff --git a/builder_utils.py b/builder_utils.py index ea33dd3..7cdf45a 100644 --- a/builder_utils.py +++ b/builder_utils.py @@ -34,6 +34,9 @@ from settings import ( COLOR_TO_BASIC_LAND, SNOW_BASIC_LAND_MAPPING, KINDRED_STAPLE_LANDS, + DUAL_LAND_TYPE_MAP, + MANA_COLORS, + MANA_PIP_PATTERNS ) from exceptions import ( DeckBuilderError, @@ -46,6 +49,7 @@ from exceptions import ( FetchLandValidationError, KindredLandSelectionError, KindredLandValidationError, + LandRemovalError, ThemeSelectionError, ThemeWeightError, CardTypeCountError @@ -584,7 +588,7 @@ def process_duplicate_cards(card_library: pd.DataFrame, duplicate_lists: List[st # 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_mask = processed_library['Card Name'] == card_name card_count = card_mask.sum() if card_count > 1: @@ -592,7 +596,7 @@ def process_duplicate_cards(card_library: pd.DataFrame, duplicate_lists: List[st first_instance = processed_library[card_mask].iloc[0] processed_library = processed_library[~card_mask] - first_instance['name'] = DUPLICATE_CARD_FORMAT.format( + first_instance['Card Name'] = DUPLICATE_CARD_FORMAT.format( card_name=card_name, count=card_count ) @@ -624,7 +628,7 @@ def count_cards_by_type(card_library: pd.DataFrame, card_types: List[str]) -> Di 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( + type_mask = card_library['Card Type'].str.contains( card_type, case=False, na=False @@ -633,6 +637,7 @@ def count_cards_by_type(card_library: pd.DataFrame, card_types: List[str]) -> Di return type_counts except Exception as e: + print(card_type) logger.error(f"Error counting cards by type: {e}") raise CardTypeCountError(f"Failed to count cards by type: {str(e)}") @@ -838,7 +843,6 @@ def get_available_fetch_lands(colors: List[str], price_checker: Optional[Any] = 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, @@ -915,8 +919,7 @@ def validate_kindred_lands(land_name: str, commander_tags: List[str], colors: Li 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], +def get_available_kindred_lands(land_df: pd.DataFrame, colors: List[str], commander_tags: List[str], price_checker: Optional[Any] = None, max_price: Optional[float] = None) -> List[str]: """Get list of Kindred lands available for the deck's colors and themes. @@ -934,8 +937,35 @@ def get_available_kindred_lands(colors: List[str], commander_tags: List[str], >>> 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] + # Only proceed if deck has tribal themes + if not any('Kindred' in tag for tag in commander_tags): + return [] + + available_lands = [] + + # Add staple Kindred lands first + available_lands.extend([land['name'] for land in KINDRED_STAPLE_LANDS + if validate_kindred_lands(land['name'], commander_tags, colors)]) + + # Extract creature types from Kindred themes + creature_types = [tag.replace(' Kindred', '') + for tag in commander_tags + if 'Kindred' in tag] + + # Find lands specific to each creature type + for creature_type in creature_types: + logging.info(f'Searching for {creature_type}-specific lands') + + # Filter lands by creature type mentions in text or type + type_specific = land_df[ + land_df['text'].notna() & + (land_df['text'].str.contains(creature_type, case=False) | + land_df['type'].str.contains(creature_type, case=False)) + ] + + # Add any found type-specific lands + if not type_specific.empty: + available_lands.extend(type_specific['name'].tolist()) # Filter by price if price checking is enabled if price_checker and max_price: @@ -946,14 +976,12 @@ def get_available_kindred_lands(colors: List[str], commander_tags: List[str], return available_lands -def select_kindred_lands(available_lands: List[str], count: int, +def select_kindred_lands(available_lands: List[str], count: int = None, allow_duplicates: bool = False) -> List[str]: """Select Kindred lands from the available pool. Args: available_lands: List of available Kindred lands - count: Number of Kindred lands to select - allow_duplicates: Whether to allow duplicate selections Returns: List of selected Kindred land names @@ -962,11 +990,10 @@ def select_kindred_lands(available_lands: List[str], count: int, 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'] + >>> select_kindred_lands(['Cavern of Souls', 'Path of Ancestry']) + ['Cavern of Souls', 'Path of Ancestry'] """ import random - if not available_lands: raise KindredLandSelectionError( "No Kindred lands available to select from", @@ -1001,4 +1028,444 @@ def process_kindred_lands(lands_to_add: List[str], card_library: pd.DataFrame, 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 + return updated_land_df + +def validate_dual_lands(color_pairs: List[str], use_snow: bool = False) -> bool: + """Validate if dual lands should be added based on deck configuration. + + Args: + color_pairs: List of color pair combinations (e.g., ['azorius', 'orzhov']) + use_snow: Whether to use snow-covered lands + + Returns: + bool: True if dual lands should be added, False otherwise + + Example: + >>> validate_dual_lands(['azorius', 'orzhov'], False) + True + """ + if not color_pairs: + return False + + # Validate color pairs against DUAL_LAND_TYPE_MAP + return len(color_pairs) > 0 + +def get_available_dual_lands(land_df: pd.DataFrame, color_pairs: List[str], + use_snow: bool = False) -> pd.DataFrame: + """Get available dual lands based on color pairs and snow preference. + + Args: + land_df: DataFrame containing available lands + color_pairs: List of color pair combinations + use_snow: Whether to use snow-covered lands + + Returns: + DataFrame containing available dual lands + + Example: + >>> get_available_dual_lands(land_df, ['azorius'], False) + DataFrame with azorius dual lands + """ + # Create type filters based on color pairs + type_filters = color_pairs + + # Filter lands + if type_filters: + return land_df[land_df['type'].isin(type_filters)].copy() + return pd.DataFrame() + +def select_dual_lands(dual_df: pd.DataFrame, price_checker: Optional[Any] = None, + max_price: Optional[float] = None) -> List[Dict[str, Any]]: + """Select appropriate dual lands from available pool. + + Args: + dual_df: DataFrame of available dual lands + price_checker: Optional price checker instance + max_price: Maximum allowed price per card + + Returns: + List of selected dual land dictionaries + + Example: + >>> select_dual_lands(dual_df, price_checker, 20.0) + [{'name': 'Hallowed Fountain', 'type': 'Land — Plains Island', ...}] + """ + if dual_df.empty: + return [] + + # Sort by EDHREC rank + dual_df.sort_values(by='edhrecRank', inplace=True) + + # Convert to list of card dictionaries + selected_lands = [] + for _, row in dual_df.iterrows(): + card = { + 'name': row['name'], + 'type': row['type'], + 'manaCost': row['manaCost'], + 'manaValue': row['manaValue'] + } + + # Check price if enabled + if price_checker and max_price: + try: + price = price_checker.get_card_price(card['name']) + if price > max_price * 1.1: + continue + except Exception as e: + logger.warning(f"Price check failed for {card['name']}: {e}") + continue + + selected_lands.append(card) + + return selected_lands + +def process_dual_lands(lands_to_add: List[Dict[str, Any]], card_library: pd.DataFrame, + land_df: pd.DataFrame) -> pd.DataFrame: + """Update land DataFrame after adding dual lands. + + Args: + lands_to_add: List of dual lands to be added + card_library: Current deck library + land_df: DataFrame of available lands + + Returns: + Updated land DataFrame + + Example: + >>> process_dual_lands(dual_lands, card_library, land_df) + Updated DataFrame without added dual lands + """ + lands_to_remove = set(land['name'] for land in lands_to_add) + return land_df[~land_df['name'].isin(lands_to_remove)] + +def validate_triple_lands(color_triplets: List[str], use_snow: bool = False) -> bool: + """Validate if triple lands should be added based on deck configuration. + + Args: + color_triplets: List of color triplet combinations (e.g., ['esper', 'bant']) + use_snow: Whether to use snow-covered lands + + Returns: + bool: True if triple lands should be added, False otherwise + + Example: + >>> validate_triple_lands(['esper', 'bant'], False) + True + """ + if not color_triplets: + return False + + # Validate color triplets + return len(color_triplets) > 0 + +def get_available_triple_lands(land_df: pd.DataFrame, color_triplets: List[str], + use_snow: bool = False) -> pd.DataFrame: + """Get available triple lands based on color triplets and snow preference. + + Args: + land_df: DataFrame containing available lands + color_triplets: List of color triplet combinations + use_snow: Whether to use snow-covered lands + + Returns: + DataFrame containing available triple lands + + Example: + >>> get_available_triple_lands(land_df, ['esper'], False) + DataFrame with esper triple lands + """ + # Create type filters based on color triplets + type_filters = color_triplets + + # Filter lands + if type_filters: + return land_df[land_df['type'].isin(type_filters)].copy() + return pd.DataFrame() + +def select_triple_lands(triple_df: pd.DataFrame, price_checker: Optional[Any] = None, + max_price: Optional[float] = None) -> List[Dict[str, Any]]: + """Select appropriate triple lands from available pool. + + Args: + triple_df: DataFrame of available triple lands + price_checker: Optional price checker instance + max_price: Maximum allowed price per card + + Returns: + List of selected triple land dictionaries + + Example: + >>> select_triple_lands(triple_df, price_checker, 20.0) + [{'name': 'Raffine's Tower', 'type': 'Land — Plains Island Swamp', ...}] + """ + if triple_df.empty: + return [] + + # Sort by EDHREC rank + triple_df.sort_values(by='edhrecRank', inplace=True) + + # Convert to list of card dictionaries + selected_lands = [] + for _, row in triple_df.iterrows(): + card = { + 'name': row['name'], + 'type': row['type'], + 'manaCost': row['manaCost'], + 'manaValue': row['manaValue'] + } + + # Check price if enabled + if price_checker and max_price: + try: + price = price_checker.get_card_price(card['name']) + if price > max_price * 1.1: + continue + except Exception as e: + logger.warning(f"Price check failed for {card['name']}: {e}") + continue + + selected_lands.append(card) + + return selected_lands + +def process_triple_lands(lands_to_add: List[Dict[str, Any]], card_library: pd.DataFrame, + land_df: pd.DataFrame) -> pd.DataFrame: + """Update land DataFrame after adding triple lands. + + Args: + lands_to_add: List of triple lands to be added + card_library: Current deck library + land_df: DataFrame of available lands + + Returns: + Updated land DataFrame + + Example: + >>> process_triple_lands(triple_lands, card_library, land_df) + Updated DataFrame without added triple lands + """ + lands_to_remove = set(land['name'] for land in lands_to_add) + return land_df[~land_df['name'].isin(lands_to_remove)] + +def get_available_misc_lands(land_df: pd.DataFrame, max_pool_size: int) -> List[Dict[str, Any]]: + """Retrieve the top N lands from land_df for miscellaneous land selection. + + Args: + land_df: DataFrame containing available lands + max_pool_size: Maximum number of lands to include in the pool + + Returns: + List of dictionaries containing land information + + Example: + >>> get_available_misc_lands(land_df, 100) + [{'name': 'Command Tower', 'type': 'Land', ...}, ...] + """ + try: + # Take top N lands by EDHREC rank + top_lands = land_df.head(max_pool_size).copy() + + # Convert to list of dictionaries + available_lands = [ + { + 'name': row['name'], + 'type': row['type'], + 'manaCost': row['manaCost'], + 'manaValue': row['manaValue'] + } + for _, row in top_lands.iterrows() + ] + + return available_lands + + except Exception as e: + logger.error(f"Error getting available misc lands: {e}") + return [] + +def select_misc_lands(available_lands: List[Dict[str, Any]], min_count: int, max_count: int, + price_checker: Optional[PriceChecker] = None, + max_price: Optional[float] = None) -> List[Dict[str, Any]]: + """Randomly select a number of lands between min_count and max_count. + + Args: + available_lands: List of available lands to select from + min_count: Minimum number of lands to select + max_count: Maximum number of lands to select + price_checker: Optional price checker instance + max_price: Maximum allowed price per card + + Returns: + List of selected land dictionaries + + Example: + >>> select_misc_lands(available_lands, 5, 10) + [{'name': 'Command Tower', 'type': 'Land', ...}, ...] + """ + import random + + if not available_lands: + return [] + + # Randomly determine number of lands to select + target_count = random.randint(min_count, max_count) + selected_lands = [] + + # Create a copy of available lands to avoid modifying the original + land_pool = available_lands.copy() + + while land_pool and len(selected_lands) < target_count: + # Randomly select a land + land = random.choice(land_pool) + land_pool.remove(land) + + # Check price if enabled + if price_checker and max_price: + try: + price = price_checker.get_card_price(land['name']) + if price > max_price * 1.1: + continue + except Exception as e: + logger.warning(f"Price check failed for {land['name']}: {e}") + continue + + selected_lands.append(land) + + return selected_lands + + +def filter_removable_lands(card_library: pd.DataFrame, protected_lands: List[str]) -> pd.DataFrame: + """Filter the card library to get lands that can be removed. + + Args: + card_library: DataFrame containing all cards in the deck + protected_lands: List of land names that cannot be removed + + Returns: + DataFrame containing only removable lands + + Raises: + LandRemovalError: If no removable lands are found + DataFrameValidationError: If card_library validation fails + """ + try: + # Validate input DataFrame + if card_library.empty: + raise EmptyDataFrameError("filter_removable_lands") + + # Filter for lands only + lands_df = card_library[card_library['Card Type'].str.contains('Land', case=False, na=False)].copy() + + # Remove protected lands + removable_lands = lands_df[~lands_df['Card Name'].isin(protected_lands)] + + if removable_lands.empty: + raise LandRemovalError( + "No removable lands found in deck", + {"protected_lands": protected_lands} + ) + + logger.debug(f"Found {len(removable_lands)} removable lands") + return removable_lands + + except Exception as e: + logger.error(f"Error filtering removable lands: {e}") + raise + +def select_land_for_removal(filtered_lands: pd.DataFrame) -> Tuple[int, str]: + """Randomly select a land for removal from filtered lands. + + Args: + filtered_lands: DataFrame containing only removable lands + + Returns: + Tuple containing (index in original DataFrame, name of selected land) + + Raises: + LandRemovalError: If filtered_lands is empty + DataFrameValidationError: If filtered_lands validation fails + """ + try: + if filtered_lands.empty: + raise LandRemovalError( + "No lands available for removal", + {"filtered_lands_size": len(filtered_lands)} + ) + + # Randomly select a land + selected_land = filtered_lands.sample(n=1).iloc[0] + index = selected_land.name + land_name = selected_land['Card Name'] + + logger.info(f"Selected land for removal: {land_name}") + return index, land_name + + except Exception as e: + logger.error(f"Error selecting land for removal: {e}") + raise + +def count_color_pips(mana_costs: pd.Series, color: str) -> int: + """Count the number of colored mana pips of a specific color in mana costs. + + Args: + mana_costs: Series of mana cost strings to analyze + color: Color to count pips for (W, U, B, R, or G) + + Returns: + Total number of pips of the specified color + + Example: + >>> mana_costs = pd.Series(['{2}{W}{W}', '{W}{U}', '{B}{R}']) + >>> count_color_pips(mana_costs, 'W') + 3 + """ + if not isinstance(mana_costs, pd.Series): + raise TypeError("mana_costs must be a pandas Series") + + if color not in MANA_COLORS: + raise ValueError(f"Invalid color: {color}. Must be one of {MANA_COLORS}") + + pattern = MANA_PIP_PATTERNS[color] + + # Count occurrences of the pattern in non-null mana costs + pip_counts = mana_costs.fillna('').str.count(pattern) + + return int(pip_counts.sum()) + +def calculate_pip_percentages(pip_counts: Dict[str, int]) -> Dict[str, float]: + """Calculate the percentage distribution of mana pips for each color. + + Args: + pip_counts: Dictionary mapping colors to their pip counts + + Returns: + Dictionary mapping colors to their percentage of total pips (0-100) + + Example: + >>> pip_counts = {'W': 10, 'U': 5, 'B': 5, 'R': 0, 'G': 0} + >>> calculate_pip_percentages(pip_counts) + {'W': 50.0, 'U': 25.0, 'B': 25.0, 'R': 0.0, 'G': 0.0} + + Note: + If total pip count is 0, returns 0% for all colors to avoid division by zero. + """ + if not isinstance(pip_counts, dict): + raise TypeError("pip_counts must be a dictionary") + + # Validate colors + invalid_colors = set(pip_counts.keys()) - set(MANA_COLORS) + if invalid_colors: + raise ValueError(f"Invalid colors in pip_counts: {invalid_colors}") + + total_pips = sum(pip_counts.values()) + + if total_pips == 0: + return {color: 0.0 for color in MANA_COLORS} + + percentages = {} + for color in MANA_COLORS: + count = pip_counts.get(color, 0) + percentage = (count / total_pips) * 100 + percentages[color] = round(percentage, 1) + + return percentages diff --git a/deck_builder.py b/deck_builder.py index a9551c6..6dbeb15 100644 --- a/deck_builder.py +++ b/deck_builder.py @@ -21,11 +21,13 @@ from settings import ( 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, + COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, DUAL_LAND_TYPE_MAP, CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS, - STAPLE_LAND_CONDITIONS + STAPLE_LAND_CONDITIONS, TRIPLE_LAND_TYPE_MAP, MISC_LAND_MAX_COUNT, MISC_LAND_MIN_COUNT, + MISC_LAND_POOL_SIZE, LAND_REMOVAL_MAX_ATTEMPTS, PROTECTED_LANDS, + MANA_COLORS, MANA_PIP_PATTERNS ) -import builder_utils +import builder_utils import setup_utils from setup import determine_commanders from input_handler import InputHandler @@ -52,6 +54,7 @@ from exceptions import ( IdealDeterminationError, InvalidNumberError, InvalidQuestionTypeError, + LandRemovalError, LibraryOrganizationError, LibrarySortError, MaxAttemptsError, @@ -63,7 +66,8 @@ from exceptions import ( ThemeSelectionError, ThemeWeightError, StapleLandError, - StapleLandError + StapleLandError, + ManaPipError ) from type_definitions import ( CardDict, @@ -134,7 +138,9 @@ class DeckBuilder: 'Card Type': pd.Series(dtype='str'), 'Mana Cost': pd.Series(dtype='str'), 'Mana Value': pd.Series(dtype='int'), - 'Commander': pd.Series(dtype='bool') + 'Creature Types': pd.Series(dtype='object'), + 'Themes': pd.Series(dtype='object'), + 'Commander': pd.Series(dtype='bool'), }) # Initialize component dataframes @@ -461,7 +467,8 @@ class DeckBuilder: 'CMC': 0.0 } self.add_card(self.commander, self.commander_type, - self.commander_mana_cost, self.commander_mana_value, True) + self.commander_mana_cost, self.commander_mana_value, + self.creature_types, self.commander_tags, True) def _initialize_deck_building(self) -> None: """Initialize deck building process. @@ -869,6 +876,7 @@ class DeckBuilder: logger.error(f"Error in DataFrame setup: {e}") raise + # Theme selection def determine_themes(self) -> None: """Determine and set up themes for the deck building process. @@ -1046,7 +1054,8 @@ class DeckBuilder: self.hidden_weight = self.weights['hidden'] else: continue - + + # Setting ideals def determine_ideals(self): """Determine ideal card counts and price settings for the deck. @@ -1099,13 +1108,16 @@ class DeckBuilder: 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: + # Adding card to library + def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, creature_types: list = None, tags: list = None, is_commander: bool = False) -> None: """Add a card to the deck library with price checking if enabled. Args: card (str): Name of the card to add card_type (str): Type of the card (e.g., 'Creature', 'Instant') mana_cost (str): Mana cost string representation mana_value (int): Converted mana cost/mana value + creature_types (list): List of creature types in the card (if any) + themes (list): List of themes the card has is_commander (bool, optional): Whether this card is the commander. Defaults to False. Returns: @@ -1135,13 +1147,14 @@ class DeckBuilder: return # Create card entry - card_entry = [card, card_type, mana_cost, mana_value, is_commander] + card_entry = [card, card_type, mana_cost, mana_value, creature_types, tags, is_commander] # Add to library self.card_library.loc[len(self.card_library)] = card_entry logger.debug(f"Added {card} to deck library") + # Get card counts, sort library, set commander at index 1, and combine duplicates into 1 entry def organize_library(self): """Organize and count cards in the library by their types. @@ -1296,6 +1309,7 @@ class DeckBuilder: logger.error(f"Error processing duplicate cards: {e}") raise + # Land Management def add_lands(self): """ Add lands to the deck based on ideal count and deck requirements. @@ -1336,6 +1350,7 @@ class DeckBuilder: # Adjust to ideal land count self.check_basics() + print() logger.info('Adjusting total land count to match ideal count...') self.organize_library() @@ -1546,6 +1561,7 @@ class DeckBuilder: # Get available Kindred lands based on themes and budget max_price = self.max_card_price if hasattr(self, 'max_card_price') else None available_lands = builder_utils.get_available_kindred_lands( + self.land_df, self.colors, self.commander_tags, self.price_checker if use_scrython else None, @@ -1554,7 +1570,8 @@ class DeckBuilder: # Select Kindred lands selected_lands = builder_utils.select_kindred_lands( - available_lands + available_lands, + len(available_lands) ) # Add selected Kindred lands to deck @@ -1575,158 +1592,181 @@ class DeckBuilder: raise def add_dual_lands(self): - # Determine dual-color lands available + """Add dual lands to the deck based on color identity and user preference. - # 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', message='', default_value=True) - color_filter = [] - color_dict = { - 'azorius': 'Plains Island', - 'dimir': 'Island Swamp', - 'rakdos': 'Swamp Mountain', - 'gruul': 'Mountain Forest', - 'selesnya': 'Forest Plains', - 'orzhov': 'Plains Swamp', - 'golgari': 'Swamp Forest', - 'simic': 'Forest Island', - 'izzet': 'Island Mountain', - 'boros': 'Mountain Plains' - } - - if choice: - for key in color_dict: - if key in self.files_to_load: - color_filter.extend([f'Land — {color_dict[key]}', f'Snow Land — {color_dict[key]}']) - - dual_df = self.land_df[self.land_df['type'].isin(color_filter)].copy() - - # Convert to list of card dictionaries - card_pool = [] - for _, row in dual_df.iterrows(): - card = { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - } - card_pool.append(card) - - lands_to_remove = [] - for card in card_pool: - self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue']) - lands_to_remove.append(card['name']) + This method handles the addition of dual lands by: + 1. Validating if dual lands should be added + 2. Getting available dual lands based on deck colors + 3. Selecting appropriate dual lands + 4. Adding selected lands to the deck + 5. Updating the land database - 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(card_pool)} Dual-type land cards.') - - if not choice: - logger.info('Skipping adding Dual-type land cards.') - - 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', message='', default_value=True) - - color_filter = [] - color_dict = { - 'bant': 'Forest Plains Island', - 'esper': 'Plains Island Swamp', - 'grixis': 'Island Swamp Mountain', - 'jund': 'Swamp Mountain Forest', - 'naya': 'Mountain Forest Plains', - 'mardu': 'Mountain Plains Swamp', - 'abzan': 'Plains Swamp Forest', - 'sultai': 'Swamp Forest Island', - 'temur': 'Forest Island Mountain', - 'jeska': 'Island Mountain Plains' - } - - if choice: - for key in color_dict: - if key in self.files_to_load: - color_filter.extend([f'Land — {color_dict[key]}']) - - triome_df = self.land_df[self.land_df['type'].isin(color_filter)].copy() - - # Convert to list of card dictionaries - card_pool = [] - for _, row in triome_df.iterrows(): - card = { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - } - card_pool.append(card) - - lands_to_remove = [] - for card in card_pool: - self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue']) - lands_to_remove.append(card['name']) - - 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(card_pool)} Triome land cards.') - - if not choice: - logger.info('Skipping adding Triome land cards.') - - def add_misc_lands(self): - """Add additional utility lands that fit the deck's color identity.""" - logger.info('Adding miscellaneous utility lands') - - MIN_MISC_LANDS = 5 - MAX_MISC_LANDS = 15 - MAX_POOL_SIZE = 100 - + The process uses helper functions from builder_utils for modular operation. + """ try: - # Create filtered pool of candidate lands - land_pool = (self.land_df - .head(MAX_POOL_SIZE) - .copy() - .reset_index(drop=True)) + # Check if we should add dual lands + print() + print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?') + use_duals = self.input_handler.questionnaire('Confirm', message='', default_value=True) - # Convert to card dictionaries - card_pool = [ - { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - } - for _, row in land_pool.iterrows() - if row['name'] not in self.card_library['Card Name'].values - ] - - if not card_pool: - logger.warning("No eligible misc lands found") + if not use_duals: + logger.info('Skipping adding Dual-type land cards.') return - # Randomly select lands within constraints - target_count = random.randint(MIN_MISC_LANDS, MAX_MISC_LANDS) - cards_to_add = [] + logger.info('Adding Dual-type lands') + # Get color pairs by checking DUAL_LAND_TYPE_MAP keys against files_to_load + color_pairs = [] + for key in DUAL_LAND_TYPE_MAP: + if key in self.files_to_load: + color_pairs.extend([f'Land — {DUAL_LAND_TYPE_MAP[key]}', f'Snow Land — {DUAL_LAND_TYPE_MAP[key]}']) - while card_pool and len(cards_to_add) < target_count: - card = random.choice(card_pool) - card_pool.remove(card) - - # Check price if enabled - if use_scrython and self.set_max_card_price: - price = self.price_checker.get_card_price(card['name']) - if price > self.max_card_price * 1.1: - continue - - cards_to_add.append(card) + # Validate dual lands for these color pairs + if not builder_utils.validate_dual_lands(color_pairs, 'Snow' in self.commander_tags): + logger.info('No valid dual lands available for this color combination.') + return + + # Get available dual lands + dual_df = builder_utils.get_available_dual_lands( + self.land_df, + color_pairs, + 'Snow' in self.commander_tags + ) + + # Select appropriate dual lands + selected_lands = builder_utils.select_dual_lands( + dual_df, + self.price_checker if use_scrython else None, + self.max_card_price if hasattr(self, 'max_card_price') else None + ) + + # Add selected lands to deck + for land in selected_lands: + self.add_card(land['name'], land['type'], + land['manaCost'], land['manaValue']) + + # Update land database + self.land_df = builder_utils.process_dual_lands( + selected_lands, + self.card_library, + self.land_df + ) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + logger.info(f'Added {len(selected_lands)} Dual-type land cards:') + for card in selected_lands: + print(card['name']) + + except Exception as e: + logger.error(f"Error adding dual lands: {e}") + raise + + def add_triple_lands(self): + """Add triple lands to the deck based on color identity and user preference. + + This method handles the addition of triple lands by: + 1. Validating if triple lands should be added + 2. Getting available triple lands based on deck colors + 3. Selecting appropriate triple lands + 4. Adding selected lands to the deck + 5. Updating the land database + + The process uses helper functions from builder_utils for modular operation. + """ + try: + # Check if we should add triple lands + print() + print('Would you like to include triple lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?') + use_triples = self.input_handler.questionnaire('Confirm', message='', default_value=True) + + if not use_triples: + logger.info('Skipping adding triple lands.') + return + + logger.info('Adding triple lands') + # Get color triplets by checking TRIPLE_LAND_TYPE_MAP keys against files_to_load + color_triplets = [] + for key in TRIPLE_LAND_TYPE_MAP: + if key in self.files_to_load: + color_triplets.extend([f'Land — {TRIPLE_LAND_TYPE_MAP[key]}']) + + # Validate triple lands for these color triplets + if not builder_utils.validate_triple_lands(color_triplets, 'Snow' in self.commander_tags): + logger.info('No valid triple lands available for this color combination.') + return + + # Get available triple lands + triple_df = builder_utils.get_available_triple_lands( + self.land_df, + color_triplets, + 'Snow' in self.commander_tags + ) + + # Select appropriate triple lands + selected_lands = builder_utils.select_triple_lands( + triple_df, + self.price_checker if use_scrython else None, + self.max_card_price if hasattr(self, 'max_card_price') else None + ) + + # Add selected lands to deck + for land in selected_lands: + self.add_card(land['name'], land['type'], + land['manaCost'], land['manaValue']) + + # Update land database + self.land_df = builder_utils.process_triple_lands( + selected_lands, + self.card_library, + self.land_df + ) + self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) + + logger.info(f'Added {len(selected_lands)} triple lands:') + for card in selected_lands: + print(card['name']) + + except Exception as e: + logger.error(f"Error adding triple lands: {e}") + + def add_misc_lands(self): + """Add additional utility lands that fit the deck's color identity. + + This method randomly selects a number of miscellaneous utility lands to add to the deck. + The number of lands is randomly determined between MISC_LAND_MIN_COUNT and MISC_LAND_MAX_COUNT. + Lands are selected from a filtered pool of the top MISC_LAND_POOL_SIZE lands by EDHREC rank. + + The method handles price constraints if price checking is enabled and updates the land + database after adding lands to prevent duplicates. + + Raises: + MiscLandSelectionError: If there are issues selecting appropriate misc lands + """ + print() + logger.info('Adding miscellaneous utility lands') + + try: + # Get available misc lands + available_lands = builder_utils.get_available_misc_lands( + self.land_df, + MISC_LAND_POOL_SIZE + ) + + if not available_lands: + logger.warning("No eligible miscellaneous lands found") + return + + # Select random number of lands + selected_lands = builder_utils.select_misc_lands( + available_lands, + MISC_LAND_MIN_COUNT, + MISC_LAND_MAX_COUNT, + self.price_checker if use_scrython else None, + self.max_card_price if hasattr(self, 'max_card_price') else None + ) # Add selected lands lands_to_remove = set() - for card in cards_to_add: + for card in selected_lands: self.add_card(card['name'], card['type'], card['manaCost'], card['manaValue']) lands_to_remove.add(card['name']) @@ -1735,7 +1775,9 @@ class DeckBuilder: 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(cards_to_add)} miscellaneous lands') + logger.info(f'Added {len(selected_lands)} miscellaneous lands:') + for card in selected_lands: + print(card['name']) except Exception as e: logger.error(f"Error adding misc lands: {e}") @@ -1778,7 +1820,7 @@ class DeckBuilder: count = len(self.card_library[self.card_library['Card Name'] == land]) basic_lands[land] = count self.total_basics += count - + print() logger.info("Basic Land Counts:") for land, count in basic_lands.items(): if count > 0: @@ -1797,6 +1839,7 @@ class DeckBuilder: Args: max_attempts: Maximum number of removal attempts before falling back to non-basics """ + print() logger.info('Land count over ideal count, removing a basic land.') color_to_basic = { @@ -1843,62 +1886,127 @@ class DeckBuilder: self.remove_land() def remove_land(self): - """Remove a random non-basic, non-staple land from the deck.""" - logger.info('Removing a random nonbasic land.') + """Remove a random non-basic, non-staple land from the deck. - # Define basic lands including snow-covered variants - basic_lands = [ - 'Plains', 'Island', 'Swamp', 'Mountain', 'Forest', - 'Snow-Covered Plains', 'Snow-Covered Island', 'Snow-Covered Swamp', - 'Snow-Covered Mountain', 'Snow-Covered Forest' - ] + This method attempts to remove a non-protected land from the deck up to + LAND_REMOVAL_MAX_ATTEMPTS times. It uses helper functions to filter removable + lands and select a land for removal. - try: - # Filter for non-basic, non-staple lands - library_filter = self.card_library[ - (self.card_library['Card Type'].str.contains('Land')) & - (~self.card_library['Card Name'].isin(basic_lands + self.staples)) - ].copy() + Raises: + LandRemovalError: If no removable lands are found or removal fails + """ + print() + logger.info('Attempting to remove a non-protected land') + attempts = 0 - if len(library_filter) == 0: - logger.warning("No suitable non-basic lands found to remove.") + while attempts < LAND_REMOVAL_MAX_ATTEMPTS: + try: + # Get removable lands + removable_lands = builder_utils.filter_removable_lands(self.card_library, PROTECTED_LANDS + self.staples) + + # Select a land for removal + card_index, card_name = builder_utils.select_land_for_removal(removable_lands) + + # Remove the selected land + logger.info(f"Removing {card_name}") + self.card_library.drop(card_index, inplace=True) + self.card_library.reset_index(drop=True, inplace=True) + logger.info("Land removed successfully") return - # Select random land to remove - card_index = np.random.choice(library_filter.index) - card_name = self.card_library.loc[card_index, 'Card Name'] + except LandRemovalError as e: + logger.warning(f"Attempt {attempts + 1} failed: {e}") + attempts += 1 + continue + except Exception as e: + logger.error(f"Unexpected error removing land: {e}") + raise LandRemovalError(f"Failed to remove land: {str(e)}") - logger.info(f"Removing {card_name}") - self.card_library.drop(card_index, inplace=True) - self.card_library.reset_index(drop=True, inplace=True) - logger.info("Card removed successfully.") - - except Exception as e: - logger.error(f"Error removing land: {e}") - logger.warning("Failed to remove land card.") - + # If we reach here, we've exceeded max attempts + raise LandRemovalError(f"Could not find a removable land after {LAND_REMOVAL_MAX_ATTEMPTS} attempts") + # Count pips and get average CMC def count_pips(self): - """Count and display the number of colored mana symbols in casting costs using vectorized operations.""" + """Analyze and display the distribution of colored mana symbols (pips) in card casting costs. + + This method processes the mana costs of all cards in the deck to: + 1. Count the number of colored mana symbols for each color + 2. Calculate the percentage distribution of colors + 3. Log detailed pip distribution information + + The analysis uses helper functions from builder_utils for consistent counting + and percentage calculations. Results are logged with detailed breakdowns + of pip counts and distributions. + + Dependencies: + - MANA_COLORS from settings.py for color iteration + - builder_utils.count_color_pips() for counting pips + - builder_utils.calculate_pip_percentages() for distribution calculation + + Returns: + None + + Raises: + ManaPipError: If there are issues with: + - Counting pips for specific colors + - Calculating pip percentages + - Unexpected errors during analysis + + Logs: + - Warning if no colored mana symbols are found + - Info with detailed pip distribution and percentages + - Error details if analysis fails + """ + print() logger.info('Analyzing color pip distribution...') - # Define colors to check - colors = ['W', 'U', 'B', 'R', 'G'] - - # Use vectorized string operations - mana_costs = self.card_library['Mana Cost'].dropna() - pip_counts = {color: mana_costs.str.count(color).sum() for color in colors} - - total_pips = sum(pip_counts.values()) - if total_pips == 0: - logger.error("No colored mana symbols found in casting costs.") - return - - logger.info("\nColor Pip Distribution:") - for color, count in pip_counts.items(): - if count > 0: - percentage = (count / total_pips) * 100 - print(f"{color}: {count} pips ({percentage:.1f}%)") - logger.info(f"Total colored pips: {total_pips}\n") + try: + # Get mana costs from card library + mana_costs = self.card_library['Mana Cost'].dropna() + + # Count pips for each color using helper function + pip_counts = {} + for color in MANA_COLORS: + try: + pip_counts[color] = builder_utils.count_color_pips(mana_costs, color) + except (TypeError, ValueError) as e: + raise ManaPipError( + f"Error counting {color} pips", + {"color": color, "error": str(e)} + ) + + # Calculate percentages using helper function + try: + percentages = builder_utils.calculate_pip_percentages(pip_counts) + except (TypeError, ValueError) as e: + raise ManaPipError( + "Error calculating pip percentages", + {"error": str(e)} + ) + + # Log detailed pip distribution + total_pips = sum(pip_counts.values()) + if total_pips == 0: + logger.warning("No colored mana symbols found in casting costs") + return + + logger.info("Color Pip Distribution:") + for color in MANA_COLORS: + count = pip_counts[color] + if count > 0: + percentage = percentages[color] + print(f"{color}: {count} pips ({percentage:.1f}%)") + print() + logger.info(f"Total colored pips: {total_pips}") + # Filter out zero percentages + non_zero_percentages = {color: pct for color, pct in percentages.items() if pct > 0} + logger.info(f"Distribution ratios: {non_zero_percentages}\n") + + except ManaPipError as e: + logger.error(f"Mana pip analysis failed: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error in pip analysis: {e}") + raise ManaPipError("Failed to analyze mana pips", {"error": str(e)}) def get_cmc(self): """Calculate average converted mana cost of non-land cards.""" @@ -1947,7 +2055,9 @@ class DeckBuilder: 'name': row['name'], 'type': row['type'], 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] + 'manaValue': row['manaValue'], + 'creatureTypes': row['creatureTypes'], + 'themeTags': row['themeTags'] } for _, row in tag_df.iterrows() ] @@ -1990,7 +2100,8 @@ class DeckBuilder: # Add selected cards to library for card in cards_to_add: self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue']) + card['manaCost'], card['manaValue'], + card['creatureTypes'], card['themeTags']) card_pool_names = [item['name'] for item in card_pool] self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)] @@ -2017,7 +2128,9 @@ class DeckBuilder: 'name': row['name'], 'type': row['type'], 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] + 'manaValue': row['manaValue'], + 'creatureTypes': row['creatureTypes'], + 'themeTags': row['themeTags'] } for _, row in tag_df.iterrows() ] @@ -2047,8 +2160,9 @@ class DeckBuilder: # Add selected cards to library for card in cards_to_add: if len(self.card_library) < 100: - self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue']) + self.add_card(card['name'], card['type'], + card['manaCost'], card['manaValue'], + card['creatureTypes'], card['themeTags']) else: continue diff --git a/exceptions.py b/exceptions.py index 7b8aa57..77b7a60 100644 --- a/exceptions.py +++ b/exceptions.py @@ -1051,4 +1051,231 @@ class FetchLandSelectionError(FetchLandError): 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 + super().__init__(message, code="FETCH_SELECT_ERR", details=details) + +class DualLandError(DeckBuilderError): + """Base exception class for dual land-related errors. + + This exception serves as the base for all dual land-related errors in the deck builder, + including validation errors, selection errors, and dual 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 = "DUAL_ERR", details: dict | None = None): + """Initialize the base dual 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 DualLandValidationError(DualLandError): + """Raised when dual land validation fails. + + This exception is used when there are issues validating dual land inputs, + such as invalid dual land types, color identity mismatches, or budget constraints. + + Examples: + >>> raise DualLandValidationError( + ... "Invalid dual land type", + ... {"land_type": "Not a dual land", "colors": ["W", "U"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize dual land validation error. + + Args: + message: Description of the validation failure + details: Additional context about the error + """ + super().__init__(message, code="DUAL_VALID_ERR", details=details) + +class DualLandSelectionError(DualLandError): + """Raised when dual land selection fails. + + This exception is used when there are issues selecting appropriate dual lands, + such as no valid duals found, color identity mismatches, or price constraints. + + Examples: + >>> raise DualLandSelectionError( + ... "No valid dual lands found for color identity", + ... {"colors": ["W", "U"], "attempted_duals": ["Tundra", "Hallowed Fountain"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize dual land selection error. + + Args: + message: Description of the selection failure + details: Additional context about the error + """ + super().__init__(message, code="DUAL_SELECT_ERR", details=details) + +class TripleLandError(DeckBuilderError): + """Base exception class for triple land-related errors. + + This exception serves as the base for all triple land-related errors in the deck builder, + including validation errors, selection errors, and triple 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 = "TRIPLE_ERR", details: dict | None = None): + """Initialize the base triple 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 TripleLandValidationError(TripleLandError): + """Raised when triple land validation fails. + + This exception is used when there are issues validating triple land inputs, + such as invalid triple land types, color identity mismatches, or budget constraints. + + Examples: + >>> raise TripleLandValidationError( + ... "Invalid triple land type", + ... {"land_type": "Not a triple land", "colors": ["W", "U", "B"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize triple land validation error. + + Args: + message: Description of the validation failure + details: Additional context about the error + """ + super().__init__(message, code="TRIPLE_VALID_ERR", details=details) + +class TripleLandSelectionError(TripleLandError): + """Raised when triple land selection fails. + + This exception is used when there are issues selecting appropriate triple lands, + such as no valid triples found, color identity mismatches, or price constraints. + + Examples: + >>> raise TripleLandSelectionError( + ... "No valid triple lands found for color identity", + ... {"colors": ["W", "U", "B"], "attempted_triples": ["Arcane Sanctum", "Seaside Citadel"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize triple land selection error. + + Args: + message: Description of the selection failure + details: Additional context about the error + """ + super().__init__(message, code="TRIPLE_SELECT_ERR", details=details) + +class MiscLandSelectionError(DeckBuilderError): + """Raised when miscellaneous land selection fails. + + This exception is used when there are issues selecting appropriate miscellaneous lands, + such as insufficient lands in the pool, invalid land types, or selection criteria failures. + + Examples: + >>> raise MiscLandSelectionError( + ... "Insufficient lands in pool for selection", + ... {"available_count": 50, "required_count": 100} + ... ) + + >>> raise MiscLandSelectionError( + ... "Invalid land type in selection pool", + ... {"invalid_lands": ["Not a Land", "Also Not a Land"]} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize miscellaneous land selection error. + + Args: + message: Description of the selection failure + details: Additional context about the error + """ + super().__init__( + message, + code="MISC_LAND_ERR", + details=details + ) + +class LandRemovalError(DeckBuilderError): + """Raised when there are issues removing lands from the deck. + + This exception is used when the land removal process encounters problems, + such as no removable lands available, invalid land selection criteria, + or when removing lands would violate deck construction rules. + + Examples: + >>> raise LandRemovalError( + ... "No removable lands found in deck", + ... {"deck_size": 100, "current_lands": 36, "minimum_lands": 36} + ... ) + + >>> raise LandRemovalError( + ... "Cannot remove required basic lands", + ... {"land_type": "Basic Forest", "current_count": 5, "minimum_required": 5} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize land removal error. + + Args: + message: Description of the land removal failure + details: Additional context about the error + """ + super().__init__( + message, + code="LAND_REMOVE_ERR", + details=details + ) + +class ManaPipError(DeckBuilderError): + """Raised when there are issues analyzing mana pips in the deck. + + This exception is used when there are problems analyzing or calculating + mana pips in the deck, such as invalid mana costs, calculation errors, + or inconsistencies in pip distribution analysis. + + Examples: + >>> raise ManaPipError( + ... "Invalid mana cost format", + ... {"card_name": "Invalid Card", "mana_cost": "Not Valid"} + ... ) + + >>> raise ManaPipError( + ... "Error calculating color pip distribution", + ... {"colors": ["W", "U"], "pip_counts": "invalid"} + ... ) + """ + + def __init__(self, message: str, details: dict | None = None): + """Initialize mana pip error. + + Args: + message: Description of the mana pip analysis failure + details: Additional context about the error + """ + super().__init__( + message, + code="MANA_PIP_ERR", + details=details + ) \ No newline at end of file diff --git a/settings.py b/settings.py index 8db80d7..3c8d745 100644 --- a/settings.py +++ b/settings.py @@ -37,8 +37,14 @@ DEFAULT_BASIC_LAND_COUNT: Final[int] = 20 # Default minimum basic lands DEFAULT_NON_BASIC_LAND_SLOTS: Final[int] = 10 # Default number of non-basic land slots to reserve DEFAULT_BASICS_PER_COLOR: Final[int] = 5 # Default number of basic lands to add per color +# Miscellaneous land configuration +MISC_LAND_MIN_COUNT: Final[int] = 5 # Minimum number of miscellaneous lands to add +MISC_LAND_MAX_COUNT: Final[int] = 10 # Maximum number of miscellaneous lands to add +MISC_LAND_POOL_SIZE: Final[int] = 100 # Maximum size of initial land pool to select from + # Default fetch land count FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include + # Basic land mappings COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = { 'W': 'Plains', @@ -49,6 +55,41 @@ COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = { 'C': 'Wastes' } +# Dual land type mappings +DUAL_LAND_TYPE_MAP: Final[Dict[str, str]] = { + 'azorius': 'Plains Island', + 'dimir': 'Island Swamp', + 'rakdos': 'Swamp Mountain', + 'gruul': 'Mountain Forest', + 'selesnya': 'Forest Plains', + 'orzhov': 'Plains Swamp', + 'golgari': 'Swamp Forest', + 'simic': 'Forest Island', + 'izzet': 'Island Mountain', + 'boros': 'Mountain Plains' +} + +# Triple land type mappings +TRIPLE_LAND_TYPE_MAP: Final[Dict[str, str]] = { + 'bant': 'Forest Plains Island', + 'esper': 'Plains Island Swamp', + 'grixis': 'Island Swamp Mountain', + 'jund': 'Swamp Mountain Forest', + 'naya': 'Mountain Forest Plains', + 'mardu': 'Mountain Plains Swamp', + 'abzan': 'Plains Swamp Forest', + 'sultai': 'Swamp Forest Island', + 'temur': 'Forest Island Mountain', + 'jeska': 'Island Mountain Plains' +} + +# Default preference for including dual lands +DEFAULT_DUAL_LAND_ENABLED: Final[bool] = True + +# Default preference for including triple lands +DEFAULT_TRIPLE_LAND_ENABLED: Final[bool] = True + + SNOW_COVERED_BASIC_LANDS: Final[Dict[str, str]] = { 'W': 'Snow-Covered Plains', 'U': 'Snow-Covered Island', @@ -226,6 +267,12 @@ banned_cards = [# in commander BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest'] +# Constants for land removal functionality +LAND_REMOVAL_MAX_ATTEMPTS: Final[int] = 3 + +# Protected lands that cannot be removed during land removal process +PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + [land['name'] for land in KINDRED_STAPLE_LANDS] + # Constants for lands matter functionality LANDS_MATTER_PATTERNS: Dict[str, List[str]] = { 'land_play': [ @@ -427,6 +474,7 @@ ARISTOCRAT_EXCLUSION_PATTERNS = [ 'from your library', 'into your hand' ] + STAX_TEXT_PATTERNS = [ 'an opponent controls' 'can\'t attack', @@ -699,6 +747,7 @@ BOARD_WIPE_EXCLUSION_PATTERNS = [ 'target player\'s library', 'that player\'s library' ] + CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery', 'Kindred', 'Dungeon', 'Battle'] @@ -741,6 +790,15 @@ TYPE_TAG_MAPPING = { CSV_DIRECTORY = 'csv_files' # Color identity constants and mappings + +# Basic mana colors +MANA_COLORS: Final[List[str]] = ['W', 'U', 'B', 'R', 'G'] + +# Mana pip patterns for each color +MANA_PIP_PATTERNS: Final[Dict[str, str]] = { + color: f'{{{color}}}' for color in MANA_COLORS +} + MONO_COLOR_MAP: Final[Dict[str, Tuple[str, List[str]]]] = { 'COLORLESS': ('Colorless', ['colorless']), 'W': ('White', ['colorless', 'white']), @@ -1033,6 +1091,7 @@ AURA_SPECIFIC_CARDS = [ 'Ivy, Gleeful Spellthief', # Copies spells that have single target 'Killian, Ink Duelist', # Targetted spell cost reduction ] + # Equipment-related constants EQUIPMENT_EXCLUSIONS = [ 'Bruenor Battlehammer', # Equipment cost reduction