Finshed refactoring land addtiions, all that's left is adding cards by theme and other tags

This commit is contained in:
mwisnowski 2025-01-16 11:55:12 -08:00
parent 47c2cee00f
commit 8936fa347f
4 changed files with 1089 additions and 222 deletions

View file

@ -34,6 +34,9 @@ from settings import (
COLOR_TO_BASIC_LAND, COLOR_TO_BASIC_LAND,
SNOW_BASIC_LAND_MAPPING, SNOW_BASIC_LAND_MAPPING,
KINDRED_STAPLE_LANDS, KINDRED_STAPLE_LANDS,
DUAL_LAND_TYPE_MAP,
MANA_COLORS,
MANA_PIP_PATTERNS
) )
from exceptions import ( from exceptions import (
DeckBuilderError, DeckBuilderError,
@ -46,6 +49,7 @@ from exceptions import (
FetchLandValidationError, FetchLandValidationError,
KindredLandSelectionError, KindredLandSelectionError,
KindredLandValidationError, KindredLandValidationError,
LandRemovalError,
ThemeSelectionError, ThemeSelectionError,
ThemeWeightError, ThemeWeightError,
CardTypeCountError CardTypeCountError
@ -584,7 +588,7 @@ def process_duplicate_cards(card_library: pd.DataFrame, duplicate_lists: List[st
# Process each allowed duplicate card # Process each allowed duplicate card
for card_name in duplicate_lists: for card_name in duplicate_lists:
# Find all instances of the card # 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() card_count = card_mask.sum()
if card_count > 1: 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] first_instance = processed_library[card_mask].iloc[0]
processed_library = processed_library[~card_mask] 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, card_name=card_name,
count=card_count 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: for card_type in card_types:
# Use pandas str.contains() for efficient type matching # Use pandas str.contains() for efficient type matching
# Case-insensitive matching with na=False to handle missing values # 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, card_type,
case=False, case=False,
na=False na=False
@ -633,6 +637,7 @@ def count_cards_by_type(card_library: pd.DataFrame, card_types: List[str]) -> Di
return type_counts return type_counts
except Exception as e: except Exception as e:
print(card_type)
logger.error(f"Error counting cards by type: {e}") logger.error(f"Error counting cards by type: {e}")
raise CardTypeCountError(f"Failed to count cards by type: {str(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 fetch for fetch in available_fetches
if price_checker.get_card_price(fetch) <= max_price * 1.1 if price_checker.get_card_price(fetch) <= max_price * 1.1
] ]
return available_fetches return available_fetches
def select_fetch_lands(available_fetches: List[str], count: int, 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}", f"Failed to validate Kindred land {land_name}",
{"error": str(e), "tags": commander_tags, "colors": colors} {"error": str(e), "tags": commander_tags, "colors": colors}
) )
def get_available_kindred_lands(land_df: pd.DataFrame, colors: List[str], commander_tags: List[str],
def get_available_kindred_lands(colors: List[str], commander_tags: List[str],
price_checker: Optional[Any] = None, price_checker: Optional[Any] = None,
max_price: Optional[float] = None) -> List[str]: max_price: Optional[float] = None) -> List[str]:
"""Get list of Kindred lands available for the deck's colors and themes. """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']) >>> get_available_kindred_lands(['G'], ['Elf Kindred'])
['Cavern of Souls', 'Path of Ancestry', ...] ['Cavern of Souls', 'Path of Ancestry', ...]
""" """
# Start with staple Kindred lands # Only proceed if deck has tribal themes
available_lands = [land['name'] for land in KINDRED_STAPLE_LANDS] 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 # Filter by price if price checking is enabled
if price_checker and max_price: 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 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]: allow_duplicates: bool = False) -> List[str]:
"""Select Kindred lands from the available pool. """Select Kindred lands from the available pool.
Args: Args:
available_lands: List of available Kindred lands available_lands: List of available Kindred lands
count: Number of Kindred lands to select
allow_duplicates: Whether to allow duplicate selections
Returns: Returns:
List of selected Kindred land names 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 KindredLandSelectionError: If unable to select required number of lands
Example: Example:
>>> select_kindred_lands(['Cavern of Souls', 'Path of Ancestry'], 2) >>> select_kindred_lands(['Cavern of Souls', 'Path of Ancestry'])
['Path of Ancestry', 'Cavern of Souls'] ['Cavern of Souls', 'Path of Ancestry']
""" """
import random import random
if not available_lands: if not available_lands:
raise KindredLandSelectionError( raise KindredLandSelectionError(
"No Kindred lands available to select from", "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 DataFrame without 'Cavern of Souls' in the available lands
""" """
updated_land_df = land_df[~land_df['name'].isin(lands_to_add)] updated_land_df = land_df[~land_df['name'].isin(lands_to_add)]
return updated_land_df 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

View file

@ -21,11 +21,13 @@ from settings import (
COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT, COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT,
COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT, COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT,
COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_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, 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 import setup_utils
from setup import determine_commanders from setup import determine_commanders
from input_handler import InputHandler from input_handler import InputHandler
@ -52,6 +54,7 @@ from exceptions import (
IdealDeterminationError, IdealDeterminationError,
InvalidNumberError, InvalidNumberError,
InvalidQuestionTypeError, InvalidQuestionTypeError,
LandRemovalError,
LibraryOrganizationError, LibraryOrganizationError,
LibrarySortError, LibrarySortError,
MaxAttemptsError, MaxAttemptsError,
@ -63,7 +66,8 @@ from exceptions import (
ThemeSelectionError, ThemeSelectionError,
ThemeWeightError, ThemeWeightError,
StapleLandError, StapleLandError,
StapleLandError StapleLandError,
ManaPipError
) )
from type_definitions import ( from type_definitions import (
CardDict, CardDict,
@ -134,7 +138,9 @@ class DeckBuilder:
'Card Type': pd.Series(dtype='str'), 'Card Type': pd.Series(dtype='str'),
'Mana Cost': pd.Series(dtype='str'), 'Mana Cost': pd.Series(dtype='str'),
'Mana Value': pd.Series(dtype='int'), '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 # Initialize component dataframes
@ -461,7 +467,8 @@ class DeckBuilder:
'CMC': 0.0 'CMC': 0.0
} }
self.add_card(self.commander, self.commander_type, 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: def _initialize_deck_building(self) -> None:
"""Initialize deck building process. """Initialize deck building process.
@ -869,6 +876,7 @@ class DeckBuilder:
logger.error(f"Error in DataFrame setup: {e}") logger.error(f"Error in DataFrame setup: {e}")
raise raise
# Theme selection
def determine_themes(self) -> None: def determine_themes(self) -> None:
"""Determine and set up themes for the deck building process. """Determine and set up themes for the deck building process.
@ -1046,7 +1054,8 @@ class DeckBuilder:
self.hidden_weight = self.weights['hidden'] self.hidden_weight = self.weights['hidden']
else: else:
continue continue
# Setting ideals
def determine_ideals(self): def determine_ideals(self):
"""Determine ideal card counts and price settings for the deck. """Determine ideal card counts and price settings for the deck.
@ -1099,13 +1108,16 @@ class DeckBuilder:
logger.error(f"Error in determine_ideals: {e}") logger.error(f"Error in determine_ideals: {e}")
raise 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. """Add a card to the deck library with price checking if enabled.
Args: Args:
card (str): Name of the card to add card (str): Name of the card to add
card_type (str): Type of the card (e.g., 'Creature', 'Instant') card_type (str): Type of the card (e.g., 'Creature', 'Instant')
mana_cost (str): Mana cost string representation mana_cost (str): Mana cost string representation
mana_value (int): Converted mana cost/mana value 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. is_commander (bool, optional): Whether this card is the commander. Defaults to False.
Returns: Returns:
@ -1135,13 +1147,14 @@ class DeckBuilder:
return return
# Create card entry # 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 # Add to library
self.card_library.loc[len(self.card_library)] = card_entry self.card_library.loc[len(self.card_library)] = card_entry
logger.debug(f"Added {card} to deck library") 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): def organize_library(self):
"""Organize and count cards in the library by their types. """Organize and count cards in the library by their types.
@ -1296,6 +1309,7 @@ class DeckBuilder:
logger.error(f"Error processing duplicate cards: {e}") logger.error(f"Error processing duplicate cards: {e}")
raise raise
# Land Management
def add_lands(self): def add_lands(self):
""" """
Add lands to the deck based on ideal count and deck requirements. Add lands to the deck based on ideal count and deck requirements.
@ -1336,6 +1350,7 @@ class DeckBuilder:
# Adjust to ideal land count # Adjust to ideal land count
self.check_basics() self.check_basics()
print()
logger.info('Adjusting total land count to match ideal count...') logger.info('Adjusting total land count to match ideal count...')
self.organize_library() self.organize_library()
@ -1546,6 +1561,7 @@ class DeckBuilder:
# Get available Kindred lands based on themes and budget # Get available Kindred lands based on themes and budget
max_price = self.max_card_price if hasattr(self, 'max_card_price') else None max_price = self.max_card_price if hasattr(self, 'max_card_price') else None
available_lands = builder_utils.get_available_kindred_lands( available_lands = builder_utils.get_available_kindred_lands(
self.land_df,
self.colors, self.colors,
self.commander_tags, self.commander_tags,
self.price_checker if use_scrython else None, self.price_checker if use_scrython else None,
@ -1554,7 +1570,8 @@ class DeckBuilder:
# Select Kindred lands # Select Kindred lands
selected_lands = builder_utils.select_kindred_lands( selected_lands = builder_utils.select_kindred_lands(
available_lands available_lands,
len(available_lands)
) )
# Add selected Kindred lands to deck # Add selected Kindred lands to deck
@ -1575,158 +1592,181 @@ class DeckBuilder:
raise raise
def add_dual_lands(self): 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 This method handles the addition of dual lands by:
print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?') 1. Validating if dual lands should be added
choice = self.input_handler.questionnaire('Confirm', message='', default_value=True) 2. Getting available dual lands based on deck colors
color_filter = [] 3. Selecting appropriate dual lands
color_dict = { 4. Adding selected lands to the deck
'azorius': 'Plains Island', 5. Updating the land database
'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'])
self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] The process uses helper functions from builder_utils for modular operation.
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
try: try:
# Create filtered pool of candidate lands # Check if we should add dual lands
land_pool = (self.land_df print()
.head(MAX_POOL_SIZE) print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?')
.copy() use_duals = self.input_handler.questionnaire('Confirm', message='', default_value=True)
.reset_index(drop=True))
# Convert to card dictionaries if not use_duals:
card_pool = [ logger.info('Skipping adding Dual-type land cards.')
{
'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")
return return
# Randomly select lands within constraints logger.info('Adding Dual-type lands')
target_count = random.randint(MIN_MISC_LANDS, MAX_MISC_LANDS) # Get color pairs by checking DUAL_LAND_TYPE_MAP keys against files_to_load
cards_to_add = [] 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: # Validate dual lands for these color pairs
card = random.choice(card_pool) if not builder_utils.validate_dual_lands(color_pairs, 'Snow' in self.commander_tags):
card_pool.remove(card) logger.info('No valid dual lands available for this color combination.')
return
# Check price if enabled
if use_scrython and self.set_max_card_price: # Get available dual lands
price = self.price_checker.get_card_price(card['name']) dual_df = builder_utils.get_available_dual_lands(
if price > self.max_card_price * 1.1: self.land_df,
continue color_pairs,
'Snow' in self.commander_tags
cards_to_add.append(card) )
# 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 # Add selected lands
lands_to_remove = set() lands_to_remove = set()
for card in cards_to_add: for card in selected_lands:
self.add_card(card['name'], card['type'], self.add_card(card['name'], card['type'],
card['manaCost'], card['manaValue']) card['manaCost'], card['manaValue'])
lands_to_remove.add(card['name']) 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 = self.land_df[~self.land_df['name'].isin(lands_to_remove)]
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) 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: except Exception as e:
logger.error(f"Error adding misc lands: {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]) count = len(self.card_library[self.card_library['Card Name'] == land])
basic_lands[land] = count basic_lands[land] = count
self.total_basics += count self.total_basics += count
print()
logger.info("Basic Land Counts:") logger.info("Basic Land Counts:")
for land, count in basic_lands.items(): for land, count in basic_lands.items():
if count > 0: if count > 0:
@ -1797,6 +1839,7 @@ class DeckBuilder:
Args: Args:
max_attempts: Maximum number of removal attempts before falling back to non-basics 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.') logger.info('Land count over ideal count, removing a basic land.')
color_to_basic = { color_to_basic = {
@ -1843,62 +1886,127 @@ class DeckBuilder:
self.remove_land() self.remove_land()
def remove_land(self): def remove_land(self):
"""Remove a random non-basic, non-staple land from the deck.""" """Remove a random non-basic, non-staple land from the deck.
logger.info('Removing a random nonbasic land.')
# Define basic lands including snow-covered variants This method attempts to remove a non-protected land from the deck up to
basic_lands = [ LAND_REMOVAL_MAX_ATTEMPTS times. It uses helper functions to filter removable
'Plains', 'Island', 'Swamp', 'Mountain', 'Forest', lands and select a land for removal.
'Snow-Covered Plains', 'Snow-Covered Island', 'Snow-Covered Swamp',
'Snow-Covered Mountain', 'Snow-Covered Forest'
]
try: Raises:
# Filter for non-basic, non-staple lands LandRemovalError: If no removable lands are found or removal fails
library_filter = self.card_library[ """
(self.card_library['Card Type'].str.contains('Land')) & print()
(~self.card_library['Card Name'].isin(basic_lands + self.staples)) logger.info('Attempting to remove a non-protected land')
].copy() attempts = 0
if len(library_filter) == 0: while attempts < LAND_REMOVAL_MAX_ATTEMPTS:
logger.warning("No suitable non-basic lands found to remove.") 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 return
# Select random land to remove except LandRemovalError as e:
card_index = np.random.choice(library_filter.index) logger.warning(f"Attempt {attempts + 1} failed: {e}")
card_name = self.card_library.loc[card_index, 'Card Name'] 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}") # If we reach here, we've exceeded max attempts
self.card_library.drop(card_index, inplace=True) raise LandRemovalError(f"Could not find a removable land after {LAND_REMOVAL_MAX_ATTEMPTS} attempts")
self.card_library.reset_index(drop=True, inplace=True) # Count pips and get average CMC
logger.info("Card removed successfully.")
except Exception as e:
logger.error(f"Error removing land: {e}")
logger.warning("Failed to remove land card.")
def count_pips(self): 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...') logger.info('Analyzing color pip distribution...')
# Define colors to check try:
colors = ['W', 'U', 'B', 'R', 'G'] # Get mana costs from card library
mana_costs = self.card_library['Mana Cost'].dropna()
# Use vectorized string operations
mana_costs = self.card_library['Mana Cost'].dropna() # Count pips for each color using helper function
pip_counts = {color: mana_costs.str.count(color).sum() for color in colors} pip_counts = {}
for color in MANA_COLORS:
total_pips = sum(pip_counts.values()) try:
if total_pips == 0: pip_counts[color] = builder_utils.count_color_pips(mana_costs, color)
logger.error("No colored mana symbols found in casting costs.") except (TypeError, ValueError) as e:
return raise ManaPipError(
f"Error counting {color} pips",
logger.info("\nColor Pip Distribution:") {"color": color, "error": str(e)}
for color, count in pip_counts.items(): )
if count > 0:
percentage = (count / total_pips) * 100 # Calculate percentages using helper function
print(f"{color}: {count} pips ({percentage:.1f}%)") try:
logger.info(f"Total colored pips: {total_pips}\n") 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): def get_cmc(self):
"""Calculate average converted mana cost of non-land cards.""" """Calculate average converted mana cost of non-land cards."""
@ -1947,7 +2055,9 @@ class DeckBuilder:
'name': row['name'], 'name': row['name'],
'type': row['type'], 'type': row['type'],
'manaCost': row['manaCost'], 'manaCost': row['manaCost'],
'manaValue': row['manaValue'] 'manaValue': row['manaValue'],
'creatureTypes': row['creatureTypes'],
'themeTags': row['themeTags']
} }
for _, row in tag_df.iterrows() for _, row in tag_df.iterrows()
] ]
@ -1990,7 +2100,8 @@ class DeckBuilder:
# Add selected cards to library # Add selected cards to library
for card in cards_to_add: for card in cards_to_add:
self.add_card(card['name'], card['type'], 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] card_pool_names = [item['name'] for item in card_pool]
self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)] self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)]
@ -2017,7 +2128,9 @@ class DeckBuilder:
'name': row['name'], 'name': row['name'],
'type': row['type'], 'type': row['type'],
'manaCost': row['manaCost'], 'manaCost': row['manaCost'],
'manaValue': row['manaValue'] 'manaValue': row['manaValue'],
'creatureTypes': row['creatureTypes'],
'themeTags': row['themeTags']
} }
for _, row in tag_df.iterrows() for _, row in tag_df.iterrows()
] ]
@ -2047,8 +2160,9 @@ class DeckBuilder:
# Add selected cards to library # Add selected cards to library
for card in cards_to_add: for card in cards_to_add:
if len(self.card_library) < 100: if len(self.card_library) < 100:
self.add_card(card['name'], card['type'], self.add_card(card['name'], card['type'],
card['manaCost'], card['manaValue']) card['manaCost'], card['manaValue'],
card['creatureTypes'], card['themeTags'])
else: else:
continue continue

View file

@ -1051,4 +1051,231 @@ class FetchLandSelectionError(FetchLandError):
message: Description of the selection failure message: Description of the selection failure
details: Additional context about the error details: Additional context about the error
""" """
super().__init__(message, code="FETCH_SELECT_ERR", details=details) 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
)

View file

@ -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_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_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 # Default fetch land count
FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include
# Basic land mappings # Basic land mappings
COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = { COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = {
'W': 'Plains', 'W': 'Plains',
@ -49,6 +55,41 @@ COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = {
'C': 'Wastes' '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]] = { SNOW_COVERED_BASIC_LANDS: Final[Dict[str, str]] = {
'W': 'Snow-Covered Plains', 'W': 'Snow-Covered Plains',
'U': 'Snow-Covered Island', 'U': 'Snow-Covered Island',
@ -226,6 +267,12 @@ banned_cards = [# in commander
BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest'] 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 # Constants for lands matter functionality
LANDS_MATTER_PATTERNS: Dict[str, List[str]] = { LANDS_MATTER_PATTERNS: Dict[str, List[str]] = {
'land_play': [ 'land_play': [
@ -427,6 +474,7 @@ ARISTOCRAT_EXCLUSION_PATTERNS = [
'from your library', 'from your library',
'into your hand' 'into your hand'
] ]
STAX_TEXT_PATTERNS = [ STAX_TEXT_PATTERNS = [
'an opponent controls' 'an opponent controls'
'can\'t attack', 'can\'t attack',
@ -699,6 +747,7 @@ BOARD_WIPE_EXCLUSION_PATTERNS = [
'target player\'s library', 'target player\'s library',
'that player\'s library' 'that player\'s library'
] ]
CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery', CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery',
'Kindred', 'Dungeon', 'Battle'] 'Kindred', 'Dungeon', 'Battle']
@ -741,6 +790,15 @@ TYPE_TAG_MAPPING = {
CSV_DIRECTORY = 'csv_files' CSV_DIRECTORY = 'csv_files'
# Color identity constants and mappings # 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]]]] = { MONO_COLOR_MAP: Final[Dict[str, Tuple[str, List[str]]]] = {
'COLORLESS': ('Colorless', ['colorless']), 'COLORLESS': ('Colorless', ['colorless']),
'W': ('White', ['colorless', 'white']), 'W': ('White', ['colorless', 'white']),
@ -1033,6 +1091,7 @@ AURA_SPECIFIC_CARDS = [
'Ivy, Gleeful Spellthief', # Copies spells that have single target 'Ivy, Gleeful Spellthief', # Copies spells that have single target
'Killian, Ink Duelist', # Targetted spell cost reduction 'Killian, Ink Duelist', # Targetted spell cost reduction
] ]
# Equipment-related constants # Equipment-related constants
EQUIPMENT_EXCLUSIONS = [ EQUIPMENT_EXCLUSIONS = [
'Bruenor Battlehammer', # Equipment cost reduction 'Bruenor Battlehammer', # Equipment cost reduction