Started refactoring up through the add_dual_lands function in deck_builder

This commit is contained in:
mwisnowski 2025-01-15 11:56:25 -08:00
parent 319f7848d3
commit 47c2cee00f
5 changed files with 1970 additions and 640 deletions

View file

@ -1,4 +1,7 @@
from typing import Dict, List, Tuple, Optional, Any, Callable, TypeVar, Union from typing import Dict, List, Tuple, Optional, Any, Callable, TypeVar, Union, cast
import pandas as pd
from price_check import PriceChecker
from input_handler import InputHandler
import logging import logging
import functools import functools
import time import time
@ -13,14 +16,39 @@ from settings import (
DATAFRAME_VALIDATION_TIMEOUT, DATAFRAME_VALIDATION_TIMEOUT,
DATAFRAME_BATCH_SIZE, DATAFRAME_BATCH_SIZE,
DATAFRAME_TRANSFORM_TIMEOUT, DATAFRAME_TRANSFORM_TIMEOUT,
DATAFRAME_REQUIRED_COLUMNS DATAFRAME_REQUIRED_COLUMNS,
WEIGHT_ADJUSTMENT_FACTORS,
DEFAULT_MAX_DECK_PRICE,
DEFAULT_MAX_CARD_PRICE,
DECK_COMPOSITION_PROMPTS,
DEFAULT_RAMP_COUNT,
DEFAULT_LAND_COUNT,
DEFAULT_BASIC_LAND_COUNT,
DEFAULT_CREATURE_COUNT,
DEFAULT_REMOVAL_COUNT,
DEFAULT_CARD_ADVANTAGE_COUNT,
DEFAULT_PROTECTION_COUNT,
DEFAULT_WIPES_COUNT,
CARD_TYPE_SORT_ORDER,
DUPLICATE_CARD_FORMAT,
COLOR_TO_BASIC_LAND,
SNOW_BASIC_LAND_MAPPING,
KINDRED_STAPLE_LANDS,
) )
from exceptions import ( from exceptions import (
DeckBuilderError, DeckBuilderError,
DuplicateCardError,
CSVValidationError, CSVValidationError,
DataFrameValidationError, DataFrameValidationError,
DataFrameTimeoutError, DataFrameTimeoutError,
EmptyDataFrameError EmptyDataFrameError,
FetchLandSelectionError,
FetchLandValidationError,
KindredLandSelectionError,
KindredLandValidationError,
ThemeSelectionError,
ThemeWeightError,
CardTypeCountError
) )
logging.basicConfig( logging.basicConfig(
@ -329,3 +357,648 @@ def validate_commander_selection(df: pd.DataFrame, commander_name: str) -> Dict:
except Exception as e: except Exception as e:
logger.error(f"Error validating commander selection: {e}") logger.error(f"Error validating commander selection: {e}")
raise DeckBuilderError(f"Failed to validate commander selection: {str(e)}") raise DeckBuilderError(f"Failed to validate commander selection: {str(e)}")
def select_theme(themes_list: List[str], prompt: str, optional=False) -> str:
"""Handle the selection of a theme from a list with user interaction.
Args:
themes_list: List of available themes to choose from
prompt: Message to display when prompting for theme selection
Returns:
str: Selected theme name
Raises:
ThemeSelectionError: If user chooses to stop without selecting a theme
"""
try:
if not themes_list:
raise ThemeSelectionError("No themes available for selection")
print(prompt)
for idx, theme in enumerate(themes_list, 1):
print(f"{idx}. {theme}")
print("0. Stop selection")
while True:
try:
choice = int(input("Enter the number of your choice: "))
if choice == 0:
return 'Stop Here'
if 1 <= choice <= len(themes_list):
return themes_list[choice - 1]
print("Invalid choice. Please try again.")
except ValueError:
print("Please enter a valid number.")
except Exception as e:
logger.error(f"Error in theme selection: {e}")
raise ThemeSelectionError(f"Theme selection failed: {str(e)}")
def adjust_theme_weights(primary_theme: str,
secondary_theme: str,
tertiary_theme: str,
weights: Dict[str, float]) -> Dict[str, float]:
"""Calculate adjusted theme weights based on theme combinations.
Args:
primary_theme: The main theme selected
secondary_theme: The second theme selected
tertiary_theme: The third theme selected
weights: Initial theme weights dictionary
Returns:
Dict[str, float]: Adjusted theme weights
Raises:
ThemeWeightError: If weight calculations fail
"""
try:
adjusted_weights = weights.copy()
for theme, factors in WEIGHT_ADJUSTMENT_FACTORS.items():
if theme in [primary_theme, secondary_theme, tertiary_theme]:
for target_theme, factor in factors.items():
if target_theme in adjusted_weights:
adjusted_weights[target_theme] = round(adjusted_weights[target_theme] * factor, 2)
# Normalize weights to ensure they sum to 1.0
total_weight = sum(adjusted_weights.values())
if total_weight > 0:
adjusted_weights = {k: round(v/total_weight, 2) for k, v in adjusted_weights.items()}
print(adjusted_weights)
return adjusted_weights
except Exception as e:
logger.error(f"Error adjusting theme weights: {e}")
raise ThemeWeightError(f"Failed to adjust theme weights: {str(e)}")
def configure_price_settings(price_checker: Optional[PriceChecker], input_handler: InputHandler) -> None:
"""Handle configuration of price settings if price checking is enabled.
Args:
price_checker: Optional PriceChecker instance for price validation
input_handler: InputHandler instance for user input
Returns:
None
Raises:
ValueError: If invalid price values are provided
"""
if not price_checker:
return
try:
# Configure max deck price
print('Would you like to set an intended max price of the deck?\n'
'There will be some leeway of ~10%, with a couple alternative options provided.')
if input_handler.questionnaire('Confirm', message='', default_value=False):
print('What would you like the max price to be?')
max_deck_price = float(input_handler.questionnaire('Number', default_value=DEFAULT_MAX_DECK_PRICE))
price_checker.max_deck_price = max_deck_price
print()
# Configure max card price
print('Would you like to set a max price per card?\n'
'There will be some leeway of ~10% when choosing cards and you can choose to keep it or not.')
if input_handler.questionnaire('Confirm', message='', default_value=False):
print('What would you like the max price to be?')
max_card_price = float(input_handler.questionnaire('Number', default_value=DEFAULT_MAX_CARD_PRICE))
price_checker.max_card_price = max_card_price
print()
except ValueError as e:
logger.error(f"Error configuring price settings: {e}")
raise
def get_deck_composition_values(input_handler: InputHandler) -> Dict[str, int]:
"""Collect deck composition values from the user.
Args:
input_handler: InputHandler instance for user input
Returns:
Dict[str, int]: Mapping of component names to their values
Raises:
ValueError: If invalid numeric values are provided
"""
try:
composition = {}
for component, prompt in DECK_COMPOSITION_PROMPTS.items():
if component not in ['max_deck_price', 'max_card_price']:
default_map = {
'ramp': DEFAULT_RAMP_COUNT,
'lands': DEFAULT_LAND_COUNT,
'basic_lands': DEFAULT_BASIC_LAND_COUNT,
'creatures': DEFAULT_CREATURE_COUNT,
'removal': DEFAULT_REMOVAL_COUNT,
'wipes': DEFAULT_WIPES_COUNT,
'card_advantage': DEFAULT_CARD_ADVANTAGE_COUNT,
'protection': DEFAULT_PROTECTION_COUNT
}
default_value = default_map.get(component, 0)
print(prompt)
composition[component] = int(input_handler.questionnaire('Number', message='Default', default_value=default_value))
print()
return composition
except ValueError as e:
logger.error(f"Error getting deck composition values: {e}")
raise
def assign_sort_order(df: pd.DataFrame) -> pd.DataFrame:
"""Assign sort order to cards based on their types.
This function adds a 'Sort Order' column to the DataFrame based on the
CARD_TYPE_SORT_ORDER constant from settings. Cards are sorted according to
their primary type, with the order specified in CARD_TYPE_SORT_ORDER.
Args:
df: DataFrame containing card information with a 'Card Type' column
Returns:
DataFrame with an additional 'Sort Order' column
Example:
>>> df = pd.DataFrame({
... 'Card Type': ['Creature', 'Instant', 'Land']
... })
>>> sorted_df = assign_sort_order(df)
>>> sorted_df['Sort Order'].tolist()
['Creature', 'Instant', 'Land']
"""
# Create a copy of the input DataFrame
df = df.copy()
# Initialize Sort Order column with default value
df['Sort Order'] = 'Other'
# Assign sort order based on card types
for card_type in CARD_TYPE_SORT_ORDER:
mask = df['Card Type'].str.contains(card_type, case=False, na=False)
df.loc[mask, 'Sort Order'] = card_type
# Convert Sort Order to categorical for proper sorting
df['Sort Order'] = pd.Categorical(
df['Sort Order'],
categories=CARD_TYPE_SORT_ORDER + ['Other'],
ordered=True
)
return df
def process_duplicate_cards(card_library: pd.DataFrame, duplicate_lists: List[str]) -> pd.DataFrame:
"""Process duplicate cards in the library and consolidate them with updated counts.
This function identifies duplicate cards that are allowed to have multiple copies
(like basic lands and certain special cards), consolidates them into single entries,
and updates their counts. Card names are formatted using DUPLICATE_CARD_FORMAT.
Args:
card_library: DataFrame containing the deck's card library
duplicate_lists: List of card names allowed to have multiple copies
Returns:
DataFrame with processed duplicate cards and updated counts
Raises:
DuplicateCardError: If there are issues processing duplicate cards
Example:
>>> card_library = pd.DataFrame({
... 'name': ['Forest', 'Forest', 'Mountain', 'Mountain', 'Sol Ring'],
... 'type': ['Basic Land', 'Basic Land', 'Basic Land', 'Basic Land', 'Artifact']
... })
>>> duplicate_lists = ['Forest', 'Mountain']
>>> result = process_duplicate_cards(card_library, duplicate_lists)
>>> print(result['name'].tolist())
['Forest x 2', 'Mountain x 2', 'Sol Ring']
"""
try:
# Create a copy of the input DataFrame
processed_library = card_library.copy()
# Process each allowed duplicate card
for card_name in duplicate_lists:
# Find all instances of the card
card_mask = processed_library['name'] == card_name
card_count = card_mask.sum()
if card_count > 1:
# Keep only the first instance and update its name with count
first_instance = processed_library[card_mask].iloc[0]
processed_library = processed_library[~card_mask]
first_instance['name'] = DUPLICATE_CARD_FORMAT.format(
card_name=card_name,
count=card_count
)
processed_library = pd.concat([processed_library, pd.DataFrame([first_instance])])
return processed_library.reset_index(drop=True)
except Exception as e:
raise DuplicateCardError(
f"Failed to process duplicate cards: {str(e)}",
details={'error': str(e)}
)
def count_cards_by_type(card_library: pd.DataFrame, card_types: List[str]) -> Dict[str, int]:
"""Count the number of cards for each specified card type in the library.
Args:
card_library: DataFrame containing the card library
card_types: List of card types to count
Returns:
Dictionary mapping card types to their counts
Raises:
CardTypeCountError: If counting fails for any card type
"""
try:
type_counts = {}
for card_type in card_types:
# Use pandas str.contains() for efficient type matching
# Case-insensitive matching with na=False to handle missing values
type_mask = card_library['type'].str.contains(
card_type,
case=False,
na=False
)
type_counts[card_type] = int(type_mask.sum())
return type_counts
except Exception as e:
logger.error(f"Error counting cards by type: {e}")
raise CardTypeCountError(f"Failed to count cards by type: {str(e)}")
def calculate_basics_per_color(total_basics: int, num_colors: int) -> Tuple[int, int]:
"""Calculate the number of basic lands per color and remaining basics.
Args:
total_basics: Total number of basic lands to distribute
num_colors: Number of colors in the deck
Returns:
Tuple containing (basics per color, remaining basics)
Example:
>>> calculate_basics_per_color(20, 3)
(6, 2) # 6 basics per color with 2 remaining
"""
if num_colors == 0:
return 0, total_basics
basics_per_color = total_basics // num_colors
remaining_basics = total_basics % num_colors
return basics_per_color, remaining_basics
def get_basic_land_mapping(use_snow_covered: bool = False) -> Dict[str, str]:
"""Get the appropriate basic land mapping based on snow-covered preference.
Args:
use_snow_covered: Whether to use snow-covered basic lands
Returns:
Dictionary mapping colors to their corresponding basic land names
Example:
>>> get_basic_land_mapping(False)
{'W': 'Plains', 'U': 'Island', ...}
>>> get_basic_land_mapping(True)
{'W': 'Snow-Covered Plains', 'U': 'Snow-Covered Island', ...}
"""
return SNOW_BASIC_LAND_MAPPING if use_snow_covered else COLOR_TO_BASIC_LAND
def distribute_remaining_basics(
basics_per_color: Dict[str, int],
remaining_basics: int,
colors: List[str]
) -> Dict[str, int]:
"""Distribute remaining basic lands across colors.
This function takes the initial distribution of basic lands and distributes
any remaining basics across the colors. The distribution prioritizes colors
based on their position in the color list (typically WUBRG order).
Args:
basics_per_color: Initial distribution of basics per color
remaining_basics: Number of remaining basics to distribute
colors: List of colors to distribute basics across
Returns:
Updated dictionary with final basic land counts per color
Example:
>>> distribute_remaining_basics(
... {'W': 6, 'U': 6, 'B': 6},
... 2,
... ['W', 'U', 'B']
... )
{'W': 7, 'U': 7, 'B': 6}
"""
if not colors:
return basics_per_color
# Create a copy to avoid modifying the input dictionary
final_distribution = basics_per_color.copy()
# Distribute remaining basics
color_index = 0
while remaining_basics > 0 and color_index < len(colors):
color = colors[color_index]
if color in final_distribution:
final_distribution[color] += 1
remaining_basics -= 1
color_index = (color_index + 1) % len(colors)
return final_distribution
def validate_staple_land_conditions(
land_name: str,
conditions: dict,
commander_tags: List[str],
colors: List[str],
commander_power: int
) -> bool:
"""Validate if a staple land meets its inclusion conditions.
Args:
land_name: Name of the staple land to validate
conditions: Dictionary mapping land names to their condition functions
commander_tags: List of tags associated with the commander
colors: List of colors in the deck
commander_power: Power level of the commander
Returns:
bool: True if the land meets its conditions, False otherwise
Example:
>>> conditions = {'Command Tower': lambda tags, colors, power: len(colors) > 1}
>>> validate_staple_land_conditions('Command Tower', conditions, [], ['W', 'U'], 7)
True
"""
condition = conditions.get(land_name)
if not condition:
return False
return condition(commander_tags, colors, commander_power)
def process_staple_lands(
lands_to_add: List[str],
card_library: pd.DataFrame,
land_df: pd.DataFrame
) -> pd.DataFrame:
"""Update the land DataFrame by removing added staple lands.
Args:
lands_to_add: List of staple land names to be added
card_library: DataFrame containing all available cards
land_df: DataFrame containing available lands
Returns:
Updated land DataFrame with staple lands removed
Example:
>>> process_staple_lands(['Command Tower'], card_library, land_df)
DataFrame without 'Command Tower' in the available lands
"""
updated_land_df = land_df[~land_df['name'].isin(lands_to_add)]
return updated_land_df
def validate_fetch_land_count(count: int, min_count: int = 0, max_count: int = 9) -> int:
"""Validate the requested number of fetch lands.
Args:
count: Number of fetch lands requested
min_count: Minimum allowed fetch lands (default: 0)
max_count: Maximum allowed fetch lands (default: 9)
Returns:
Validated fetch land count
Raises:
FetchLandValidationError: If count is invalid
Example:
>>> validate_fetch_land_count(5)
5
>>> validate_fetch_land_count(-1) # raises FetchLandValidationError
"""
try:
fetch_count = int(count)
if fetch_count < min_count or fetch_count > max_count:
raise FetchLandValidationError(
f"Fetch land count must be between {min_count} and {max_count}",
{"requested": fetch_count, "min": min_count, "max": max_count}
)
return fetch_count
except ValueError:
raise FetchLandValidationError(
f"Invalid fetch land count: {count}",
{"value": count}
)
def get_available_fetch_lands(colors: List[str], price_checker: Optional[Any] = None,
max_price: Optional[float] = None) -> List[str]:
"""Get list of fetch lands available for the deck's colors and budget.
Args:
colors: List of deck colors
price_checker: Optional price checker instance
max_price: Maximum allowed price per card
Returns:
List of available fetch land names
Example:
>>> get_available_fetch_lands(['U', 'R'])
['Scalding Tarn', 'Flooded Strand', ...]
"""
from settings import GENERIC_FETCH_LANDS, COLOR_TO_FETCH_LANDS
# Start with generic fetches that work in any deck
available_fetches = GENERIC_FETCH_LANDS.copy()
# Add color-specific fetches
for color in colors:
if color in COLOR_TO_FETCH_LANDS:
available_fetches.extend(COLOR_TO_FETCH_LANDS[color])
# Remove duplicates while preserving order
available_fetches = list(dict.fromkeys(available_fetches))
# Filter by price if price checking is enabled
if price_checker and max_price:
available_fetches = [
fetch for fetch in available_fetches
if price_checker.get_card_price(fetch) <= max_price * 1.1
]
return available_fetches
def select_fetch_lands(available_fetches: List[str], count: int,
allow_duplicates: bool = False) -> List[str]:
"""Randomly select fetch lands from the available pool.
Args:
available_fetches: List of available fetch lands
count: Number of fetch lands to select
allow_duplicates: Whether to allow duplicate selections
Returns:
List of selected fetch land names
Raises:
FetchLandSelectionError: If unable to select required number of fetches
Example:
>>> select_fetch_lands(['Flooded Strand', 'Polluted Delta'], 2)
['Polluted Delta', 'Flooded Strand']
"""
import random
if not available_fetches:
raise FetchLandSelectionError(
"No fetch lands available to select from",
{"requested": count}
)
if not allow_duplicates and count > len(available_fetches):
raise FetchLandSelectionError(
f"Not enough unique fetch lands available (requested {count}, have {len(available_fetches)})",
{"requested": count, "available": len(available_fetches)}
)
if allow_duplicates:
return random.choices(available_fetches, k=count)
else:
return random.sample(available_fetches, k=count)
def validate_kindred_lands(land_name: str, commander_tags: List[str], colors: List[str]) -> bool:
"""Validate if a Kindred land meets inclusion criteria.
Args:
land_name: Name of the Kindred land to validate
commander_tags: List of tags associated with the commander
colors: List of colors in the deck
Returns:
bool: True if the land meets criteria, False otherwise
Raises:
KindredLandValidationError: If validation fails
Example:
>>> validate_kindred_lands('Cavern of Souls', ['Elf Kindred'], ['G'])
True
"""
try:
# Check if any commander tags are Kindred-related
has_kindred_theme = any('Kindred' in tag for tag in commander_tags)
if not has_kindred_theme:
return False
# Validate color requirements
if land_name in KINDRED_STAPLE_LANDS:
return True
# Additional validation logic can be added here
return True
except Exception as e:
raise KindredLandValidationError(
f"Failed to validate Kindred land {land_name}",
{"error": str(e), "tags": commander_tags, "colors": colors}
)
def get_available_kindred_lands(colors: List[str], commander_tags: List[str],
price_checker: Optional[Any] = None,
max_price: Optional[float] = None) -> List[str]:
"""Get list of Kindred lands available for the deck's colors and themes.
Args:
colors: List of deck colors
commander_tags: List of commander theme tags
price_checker: Optional price checker instance
max_price: Maximum allowed price per card
Returns:
List of available Kindred land names
Example:
>>> get_available_kindred_lands(['G'], ['Elf Kindred'])
['Cavern of Souls', 'Path of Ancestry', ...]
"""
# Start with staple Kindred lands
available_lands = [land['name'] for land in KINDRED_STAPLE_LANDS]
# Filter by price if price checking is enabled
if price_checker and max_price:
available_lands = [
land for land in available_lands
if price_checker.get_card_price(land) <= max_price * 1.1
]
return available_lands
def select_kindred_lands(available_lands: List[str], count: int,
allow_duplicates: bool = False) -> List[str]:
"""Select Kindred lands from the available pool.
Args:
available_lands: List of available Kindred lands
count: Number of Kindred lands to select
allow_duplicates: Whether to allow duplicate selections
Returns:
List of selected Kindred land names
Raises:
KindredLandSelectionError: If unable to select required number of lands
Example:
>>> select_kindred_lands(['Cavern of Souls', 'Path of Ancestry'], 2)
['Path of Ancestry', 'Cavern of Souls']
"""
import random
if not available_lands:
raise KindredLandSelectionError(
"No Kindred lands available to select from",
{"requested": count}
)
if not allow_duplicates and count > len(available_lands):
raise KindredLandSelectionError(
f"Not enough unique Kindred lands available (requested {count}, have {len(available_lands)})",
{"requested": count, "available": len(available_lands)}
)
if allow_duplicates:
return random.choices(available_lands, k=count)
else:
return random.sample(available_lands, k=count)
def process_kindred_lands(lands_to_add: List[str], card_library: pd.DataFrame,
land_df: pd.DataFrame) -> pd.DataFrame:
"""Update the land DataFrame by removing added Kindred lands.
Args:
lands_to_add: List of Kindred land names to be added
card_library: DataFrame containing all available cards
land_df: DataFrame containing available lands
Returns:
Updated land DataFrame with Kindred lands removed
Example:
>>> process_kindred_lands(['Cavern of Souls'], card_library, land_df)
DataFrame without 'Cavern of Souls' in the available lands
"""
updated_land_df = land_df[~land_df['name'].isin(lands_to_add)]
return updated_land_df

File diff suppressed because it is too large Load diff

View file

@ -584,3 +584,471 @@ class CommanderThemeError(CommanderValidationError):
details: Additional context about the error details: Additional context about the error
""" """
super().__init__(message, code="CMD_THEME_ERR", details=details) super().__init__(message, code="CMD_THEME_ERR", details=details)
class CommanderMoveError(DeckBuilderError):
"""Raised when there are issues moving the commander to the top of the library.
This exception is used when the commander_to_top() method encounters problems
such as commander not found in library, invalid deck state, or other issues
preventing the commander from being moved to the top position.
Examples:
>>> raise CommanderMoveError(
... "Commander not found in library",
... {"commander_name": "Atraxa, Praetors' Voice"}
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize commander move error.
Args:
message: Description of the move operation failure
details: Additional context about the error
"""
super().__init__(message, code="CMD_MOVE_ERR", details=details)
class LibraryOrganizationError(DeckBuilderError):
"""Base exception class for library organization errors.
This exception serves as the base for all errors related to organizing
and managing the card library, including card type counting and validation.
Attributes:
code (str): Error code for identifying the error type
message (str): Descriptive error message
details (dict): Additional error context and details
"""
def __init__(self, message: str, code: str = "LIB_ORG_ERR", details: dict | None = None):
"""Initialize the library organization error.
Args:
message: Human-readable error description
code: Error code for identification and handling
details: Additional context about the error
"""
super().__init__(message, code=code, details=details)
class LibrarySortError(LibraryOrganizationError):
"""Raised when there are issues sorting the card library.
This exception is used when the sort_library() method encounters problems
organizing cards by type and name, such as invalid sort orders or
card type categorization errors.
Examples:
>>> raise LibrarySortError(
... "Invalid card type sort order",
... "Card type 'Unknown' not in sort order list"
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize library sort error.
Args:
message: Description of the sorting failure
details: Additional context about the error
"""
if details:
details = details or {}
details['sort_error'] = True
super().__init__(message, code="LIB_SORT_ERR", details=details)
class DuplicateCardError(LibraryOrganizationError):
"""Raised when there are issues processing duplicate cards in the library.
This exception is used when the concatenate_duplicates() method encounters problems
processing duplicate cards, such as invalid card names, missing data, or
inconsistencies in duplicate card information.
Examples:
>>> raise DuplicateCardError(
... "Failed to process duplicate cards",
... "Sol Ring",
... {"duplicate_count": 3}
... )
"""
def __init__(self, message: str, card_name: str | None = None, details: dict | None = None):
"""Initialize duplicate card error.
Args:
message: Description of the duplicate processing failure
card_name: Name of the card causing the duplication error
details: Additional context about the error
"""
if card_name:
details = details or {}
details['card_name'] = card_name
super().__init__(message, code="DUPLICATE_CARD", details=details)
class CardTypeCountError(LibraryOrganizationError):
"""Raised when there are issues counting cards of specific types.
This exception is used when card type counting operations fail or
produce invalid results during library organization.
Examples:
>>> raise CardTypeCountError(
... "Invalid creature count",
... "creature",
... {"expected": 30, "actual": 15}
... )
"""
def __init__(self, message: str, card_type: str, details: dict | None = None):
"""Initialize card type count error.
Args:
message: Description of the counting failure
card_type: The type of card that caused the counting error
details: Additional context about the error
"""
if card_type:
details = details or {}
details['card_type'] = card_type
super().__init__(message, code="CARD_TYPE_COUNT", details=details)
class ThemeError(DeckBuilderError):
"""Base exception class for theme-related errors.
This exception serves as the base for all theme-related errors in the deck builder,
including theme selection, validation, and weight calculation issues.
Attributes:
code (str): Error code for identifying the error type
message (str): Descriptive error message
details (dict): Additional error context and details
"""
def __init__(self, message: str, code: str = "THEME_ERR", details: dict | None = None):
"""Initialize the base theme error.
Args:
message: Human-readable error description
code: Error code for identification and handling
details: Additional context about the error
"""
super().__init__(message, code=code, details=details)
class ThemeSelectionError(ThemeError):
"""Raised when theme selection fails or is invalid.
This exception is used when an invalid theme is selected or when
the theme selection process is canceled by the user.
Examples:
>>> raise ThemeSelectionError(
... "Invalid theme selected",
... "artifacts",
... {"available_themes": ["tokens", "lifegain", "counters"]}
... )
"""
def __init__(self, message: str, selected_theme: str | None = None, details: dict | None = None):
"""Initialize theme selection error.
Args:
message: Description of the selection failure
selected_theme: The invalid theme that was selected (if any)
details: Additional context about the error
"""
if selected_theme:
details = details or {}
details['selected_theme'] = selected_theme
super().__init__(message, code="THEME_SELECT", details=details)
class ThemeWeightError(ThemeError):
"""Raised when theme weight calculation fails.
This exception is used when there are errors in calculating or validating
theme weights during the theme selection process.
"""
def __init__(self, message: str, theme: str | None = None, details: dict | None = None):
"""Initialize theme weight error.
Args:
message: Description of the weight calculation failure
theme: The theme that caused the weight calculation error
details: Additional context about the error
"""
if theme:
details = details or {}
details['theme'] = theme
super().__init__(message, code="THEME_WEIGHT", details=details)
class IdealDeterminationError(DeckBuilderError):
"""Raised when there are issues determining deck composition ideals.
This exception is used when the determine_ideals() method encounters problems
calculating or validating deck composition ratios and requirements.
Examples:
>>> raise IdealDeterminationError(
... "Invalid land ratio calculation",
... {"calculated_ratio": 0.1, "min_allowed": 0.3}
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize ideal determination error.
Args:
message: Description of the ideal calculation failure
details: Additional context about the error
"""
super().__init__(message, code="IDEAL_ERR", details=details)
class PriceConfigurationError(DeckBuilderError):
"""Raised when there are issues configuring price settings.
This exception is used when price-related configuration in determine_ideals()
is invalid or cannot be properly applied.
Examples:
>>> raise PriceConfigurationError(
... "Invalid budget allocation",
... {"total_budget": 100, "min_card_price": 200}
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize price configuration error.
Args:
message: Description of the price configuration failure
details: Additional context about the error
"""
super().__init__(message, code="PRICE_CONFIG_ERR", details=details)
class BasicLandError(DeckBuilderError):
"""Base exception class for basic land related errors.
This exception serves as the base for all basic land related errors in the deck builder,
including land distribution, snow-covered lands, and colorless deck handling.
Attributes:
code (str): Error code for identifying the error type
message (str): Descriptive error message
details (dict): Additional error context and details
"""
def __init__(self, message: str, code: str = "BASIC_LAND_ERR", details: dict | None = None):
"""Initialize the basic land error.
Args:
message: Human-readable error description
code: Error code for identification and handling
"""
super().__init__(message, code=code, details=details)
class BasicLandCountError(BasicLandError):
"""Raised when there are issues with counting basic lands.
This exception is used when basic land counting operations fail or
produce unexpected results during deck validation or analysis.
Examples:
>>> raise BasicLandCountError(
... "Failed to count basic lands in deck",
... {"expected_count": 35, "actual_count": 0}
... )
>>> raise BasicLandCountError(
... "Invalid basic land count for color distribution",
... {"color": "U", "count": -1}
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize basic land count error.
Args:
message: Description of the counting operation failure
details: Additional context about the error
"""
super().__init__(message, code="BASIC_LAND_COUNT_ERR", details=details)
class StapleLandError(DeckBuilderError):
"""Raised when there are issues adding staple lands.
```
This exception is used when there are problems adding staple lands
to the deck, such as invalid land types, missing lands, or
incompatible color requirements.
Examples:
>>> raise StapleLandError(
... "Failed to add required shock lands",
... {"missing_lands": ["Steam Vents", "Breeding Pool"]}
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize staple land error.
Args:
message: Description of the staple land operation failure
details: Additional context about the error
"""
super().__init__(
message,
code="STAPLE_LAND_ERR",
details=details
)
class LandDistributionError(BasicLandError):
"""Raised when there are issues with basic land distribution.
This exception is used when there are problems distributing basic lands
across colors, such as invalid color ratios or unsupported color combinations.
Examples:
>>> raise LandDistributionError(
... "Invalid land distribution for colorless deck",
... {"colors": [], "requested_lands": 40}
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize land distribution error.
Args:
message: Description of the land distribution failure
details: Additional context about the error
"""
super().__init__(message, code="LAND_DIST_ERR", details=details)
class FetchLandError(DeckBuilderError):
"""Base exception class for fetch land-related errors.
This exception serves as the base for all fetch land-related errors in the deck builder,
including validation errors, selection errors, and fetch land processing issues.
Attributes:
code (str): Error code for identifying the error type
message (str): Descriptive error message
details (dict): Additional error context and details
"""
def __init__(self, message: str, code: str = "FETCH_ERR", details: dict | None = None):
"""Initialize the base fetch land error.
Args:
message: Human-readable error description
code: Error code for identification and handling
details: Additional context about the error
"""
super().__init__(message, code=code, details=details)
class KindredLandError(DeckBuilderError):
"""Base exception class for Kindred land-related errors.
This exception serves as the base for all Kindred land-related errors in the deck builder,
including validation errors, selection errors, and Kindred land processing issues.
Attributes:
code (str): Error code for identifying the error type
message (str): Descriptive error message
details (dict): Additional error context and details
"""
def __init__(self, message: str, code: str = "KINDRED_ERR", details: dict | None = None):
"""Initialize the base Kindred land error.
Args:
message: Human-readable error description
code: Error code for identification and handling
details: Additional context about the error
"""
super().__init__(message, code=code, details=details)
class KindredLandValidationError(KindredLandError):
"""Raised when Kindred land validation fails.
This exception is used when there are issues validating Kindred land inputs,
such as invalid land types, unsupported creature types, or color identity mismatches.
Examples:
>>> raise KindredLandValidationError(
... "Invalid Kindred land type",
... {"land_type": "Non-Kindred Land", "creature_type": "Elf"}
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize Kindred land validation error.
Args:
message: Description of the validation failure
details: Additional context about the error
"""
super().__init__(message, code="KINDRED_VALID_ERR", details=details)
class KindredLandSelectionError(KindredLandError):
"""Raised when Kindred land selection fails.
This exception is used when there are issues selecting appropriate Kindred lands,
such as no valid lands found, creature type mismatches, or price constraints.
Examples:
>>> raise KindredLandSelectionError(
... "No valid Kindred lands found for creature type",
... {"creature_type": "Dragon", "attempted_lands": ["Cavern of Souls"]}
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize Kindred land selection error.
Args:
message: Description of the selection failure
details: Additional context about the error
"""
super().__init__(message, code="KINDRED_SELECT_ERR", details=details)
class FetchLandValidationError(FetchLandError):
"""Raised when fetch land validation fails.
This exception is used when there are issues validating fetch land inputs,
such as invalid fetch count, unsupported colors, or invalid fetch land types.
Examples:
>>> raise FetchLandValidationError(
... "Invalid fetch land count",
... {"requested": 10, "maximum": 9}
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize fetch land validation error.
Args:
message: Description of the validation failure
details: Additional context about the error
"""
super().__init__(message, code="FETCH_VALID_ERR", details=details)
class FetchLandSelectionError(FetchLandError):
"""Raised when fetch land selection fails.
This exception is used when there are issues selecting appropriate fetch lands,
such as no valid fetches found, color identity mismatches, or price constraints.
Examples:
>>> raise FetchLandSelectionError(
... "No valid fetch lands found for color identity",
... {"colors": ["W", "U"], "attempted_fetches": ["Flooded Strand", "Polluted Delta"]}
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize fetch land selection error.
Args:
message: Description of the selection failure
details: Additional context about the error
"""
super().__init__(message, code="FETCH_SELECT_ERR", details=details)

View file

@ -190,19 +190,19 @@ class InputHandler:
question = [ question = [
inquirer.Text( inquirer.Text(
'text', 'text',
message=message or 'Enter text', message=f'{message}' or 'Enter text',
default=default_value or self.default_text default=default_value or self.default_text
) )
] ]
result = inquirer.prompt(question)['text'] result = inquirer.prompt(question)['text']
if self.validate_text(result): if self.validate_text(result):
return result return str(result)
elif question_type == 'Price': elif question_type == 'Price':
question = [ question = [
inquirer.Text( inquirer.Text(
'price', 'price',
message=message or 'Enter price (or "unlimited")', message=f'{message}' or 'Enter price (or "unlimited")',
default=str(default_value or DEFAULT_MAX_CARD_PRICE) default=str(default_value or DEFAULT_MAX_CARD_PRICE)
) )
] ]
@ -210,12 +210,13 @@ class InputHandler:
price, is_unlimited = self.validate_price(result) price, is_unlimited = self.validate_price(result)
if not is_unlimited: if not is_unlimited:
self.validate_price_threshold(price) self.validate_price_threshold(price)
return price return float(price)
elif question_type == 'Number': elif question_type == 'Number':
question = [ question = [
inquirer.Text( inquirer.Text(
'number', 'number',
message=message or 'Enter number', message=f'{message}' or 'Enter number',
default=str(default_value or self.default_number) default=str(default_value or self.default_number)
) )
] ]
@ -226,7 +227,7 @@ class InputHandler:
question = [ question = [
inquirer.Confirm( inquirer.Confirm(
'confirm', 'confirm',
message=message or 'Confirm?', message=f'{message}' or 'Confirm?',
default=default_value if default_value is not None else self.default_confirm default=default_value if default_value is not None else self.default_confirm
) )
] ]
@ -239,7 +240,7 @@ class InputHandler:
question = [ question = [
inquirer.List( inquirer.List(
'selection', 'selection',
message=message or 'Select an option', message=f'{message}' or 'Select an option',
choices=choices_list, choices=choices_list,
carousel=True carousel=True
) )

View file

@ -1,12 +1,14 @@
from typing import Dict, List, Optional, Final, Tuple, Pattern, Union from typing import Dict, List, Optional, Final, Tuple, Pattern, Union, Callable
import ast import ast
# Commander selection configuration # Commander selection configuration
# Format string for displaying duplicate cards in deck lists
DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}'
COMMANDER_CSV_PATH: Final[str] = 'csv_files/commander_cards.csv' COMMANDER_CSV_PATH: Final[str] = 'csv_files/commander_cards.csv'
FUZZY_MATCH_THRESHOLD: Final[int] = 90 # Threshold for fuzzy name matching FUZZY_MATCH_THRESHOLD: Final[int] = 90 # Threshold for fuzzy name matching
MAX_FUZZY_CHOICES: Final[int] = 5 # Maximum number of fuzzy match choices MAX_FUZZY_CHOICES: Final[int] = 5 # Maximum number of fuzzy match choices
COMMANDER_CONVERTERS: Final[Dict[str, str]] = {'themeTags': ast.literal_eval, 'creatureTypes': ast.literal_eval} # CSV loading converters COMMANDER_CONVERTERS: Final[Dict[str, str]] = {'themeTags': ast.literal_eval, 'creatureTypes': ast.literal_eval} # CSV loading converters
# Commander-related constants # Commander-related constants
COMMANDER_POWER_DEFAULT: Final[int] = 0 COMMANDER_POWER_DEFAULT: Final[int] = 0
COMMANDER_TOUGHNESS_DEFAULT: Final[int] = 0 COMMANDER_TOUGHNESS_DEFAULT: Final[int] = 0
@ -27,6 +29,148 @@ PRICE_CACHE_SIZE: Final[int] = 128 # Size of price check LRU cache
PRICE_CHECK_TIMEOUT: Final[int] = 30 # Timeout for price check requests in seconds PRICE_CHECK_TIMEOUT: Final[int] = 30 # Timeout for price check requests in seconds
PRICE_TOLERANCE_MULTIPLIER: Final[float] = 1.1 # Multiplier for price tolerance PRICE_TOLERANCE_MULTIPLIER: Final[float] = 1.1 # Multiplier for price tolerance
DEFAULT_MAX_CARD_PRICE: Final[float] = 20.0 # Default maximum price per card DEFAULT_MAX_CARD_PRICE: Final[float] = 20.0 # Default maximum price per card
# Deck composition defaults
DEFAULT_RAMP_COUNT: Final[int] = 8 # Default number of ramp pieces
DEFAULT_LAND_COUNT: Final[int] = 35 # Default total land count
DEFAULT_BASIC_LAND_COUNT: Final[int] = 20 # Default minimum basic lands
DEFAULT_NON_BASIC_LAND_SLOTS: Final[int] = 10 # Default number of non-basic land slots to reserve
DEFAULT_BASICS_PER_COLOR: Final[int] = 5 # Default number of basic lands to add per color
# Default fetch land count
FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include
# Basic land mappings
COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = {
'W': 'Plains',
'U': 'Island',
'B': 'Swamp',
'R': 'Mountain',
'G': 'Forest',
'C': 'Wastes'
}
SNOW_COVERED_BASIC_LANDS: Final[Dict[str, str]] = {
'W': 'Snow-Covered Plains',
'U': 'Snow-Covered Island',
'B': 'Snow-Covered Swamp',
'G': 'Snow-Covered Forest'
}
SNOW_BASIC_LAND_MAPPING: Final[Dict[str, str]] = {
'W': 'Snow-Covered Plains',
'U': 'Snow-Covered Island',
'B': 'Snow-Covered Swamp',
'R': 'Snow-Covered Mountain',
'G': 'Snow-Covered Forest',
'C': 'Wastes' # Note: No snow-covered version exists for Wastes
}
# Generic fetch lands list
GENERIC_FETCH_LANDS: Final[List[str]] = [
'Evolving Wilds',
'Terramorphic Expanse',
'Shire Terrace',
'Escape Tunnel',
'Promising Vein',
'Myriad Landscape',
'Fabled Passage',
'Terminal Moraine',
'Prismatic Vista'
]
# Kindred land constants
KINDRED_STAPLE_LANDS: Final[List[Dict[str, str]]] = [
{
'name': 'Path of Ancestry',
'type': 'Land'
},
{
'name': 'Three Tree City',
'type': 'Legendary Land'
},
{'name': 'Cavern of Souls', 'type': 'Land'}
]
# Color-specific fetch land mappings
COLOR_TO_FETCH_LANDS: Final[Dict[str, List[str]]] = {
'W': [
'Flooded Strand',
'Windswept Heath',
'Marsh Flats',
'Arid Mesa',
'Brokers Hideout',
'Obscura Storefront',
'Cabaretti Courtyard'
],
'U': [
'Flooded Strand',
'Polluted Delta',
'Scalding Tarn',
'Misty Rainforest',
'Brokers Hideout',
'Obscura Storefront',
'Maestros Theater'
],
'B': [
'Polluted Delta',
'Bloodstained Mire',
'Marsh Flats',
'Verdant Catacombs',
'Obscura Storefront',
'Maestros Theater',
'Riveteers Overlook'
],
'R': [
'Bloodstained Mire',
'Wooded Foothills',
'Scalding Tarn',
'Arid Mesa',
'Maestros Theater',
'Riveteers Overlook',
'Cabaretti Courtyard'
],
'G': [
'Wooded Foothills',
'Windswept Heath',
'Verdant Catacombs',
'Misty Rainforest',
'Brokers Hideout',
'Riveteers Overlook',
'Cabaretti Courtyard'
]
}
DEFAULT_CREATURE_COUNT: Final[int] = 25 # Default number of creatures
DEFAULT_REMOVAL_COUNT: Final[int] = 10 # Default number of spot removal spells
DEFAULT_WIPES_COUNT: Final[int] = 2 # Default number of board wipes
# Staple land conditions mapping
STAPLE_LAND_CONDITIONS: Final[Dict[str, Callable[[List[str], List[str], int], bool]]] = {
'Reliquary Tower': lambda commander_tags, colors, commander_power: True, # Always include
'Ash Barrens': lambda commander_tags, colors, commander_power: 'Landfall' not in commander_tags,
'Command Tower': lambda commander_tags, colors, commander_power: len(colors) > 1,
'Exotic Orchard': lambda commander_tags, colors, commander_power: len(colors) > 1,
'War Room': lambda commander_tags, colors, commander_power: len(colors) <= 2,
'Rogue\'s Passage': lambda commander_tags, colors, commander_power: commander_power >= 5
}
DEFAULT_CARD_ADVANTAGE_COUNT: Final[int] = 10 # Default number of card advantage pieces
DEFAULT_PROTECTION_COUNT: Final[int] = 8 # Default number of protection spells
# Deck composition prompts
DECK_COMPOSITION_PROMPTS: Final[Dict[str, str]] = {
'ramp': 'Enter desired number of ramp pieces (default: 8):',
'lands': 'Enter desired number of total lands (default: 35):',
'basic_lands': 'Enter minimum number of basic lands (default: 20):',
'creatures': 'Enter desired number of creatures (default: 25):',
'removal': 'Enter desired number of spot removal spells (default: 10):',
'wipes': 'Enter desired number of board wipes (default: 2):',
'card_advantage': 'Enter desired number of card advantage pieces (default: 10):',
'protection': 'Enter desired number of protection spells (default: 8):',
'max_deck_price': 'Enter maximum total deck price in dollars (default: 400.0):',
'max_card_price': 'Enter maximum price per card in dollars (default: 20.0):'
}
DEFAULT_MAX_DECK_PRICE: Final[float] = 400.0 # Default maximum total deck price DEFAULT_MAX_DECK_PRICE: Final[float] = 400.0 # Default maximum total deck price
BATCH_PRICE_CHECK_SIZE: Final[int] = 50 # Number of cards to check prices for in one batch BATCH_PRICE_CHECK_SIZE: Final[int] = 50 # Number of cards to check prices for in one batch
# Constants for input validation # Constants for input validation
@ -555,11 +699,31 @@ 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']
# Card type sorting order for organizing libraries
# This constant defines the order in which different card types should be sorted
# when organizing a deck library. The order is designed to group cards logically,
# starting with Planeswalkers and ending with Lands.
CARD_TYPE_SORT_ORDER: Final[List[str]] = [
'Planeswalker', 'Battle', 'Creature', 'Instant', 'Sorcery',
'Artifact', 'Enchantment', 'Land'
]
# Default counts for each card type
CARD_TYPE_COUNT_DEFAULTS: Final[Dict[str, int]] = {
'Artifact': 0,
'Battle': 0,
'Creature': 0,
'Enchantment': 0,
'Instant': 0,
'Kindred': 0,
'Land': 0,
'Planeswalker': 0,
'Sorcery': 0
}
# Mapping of card types to their corresponding theme tags # Mapping of card types to their corresponding theme tags
TYPE_TAG_MAPPING = { TYPE_TAG_MAPPING = {
'Artifact': ['Artifacts Matter'], 'Artifact': ['Artifacts Matter'],
@ -810,6 +974,21 @@ REQUIRED_COLUMNS: List[str] = [
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side' 'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
] ]
# Constants for theme weight management
THEME_WEIGHTS_DEFAULT: Final[Dict[str, float]] = {
'primary': 1.0,
'secondary': 0.6,
'tertiary': 0.3,
'hidden': 0.0
}
WEIGHT_ADJUSTMENT_FACTORS: Final[Dict[str, float]] = {
'kindred_primary': 1.5, # Boost for Kindred themes as primary
'kindred_secondary': 1.3, # Boost for Kindred themes as secondary
'kindred_tertiary': 1.2, # Boost for Kindred themes as tertiary
'theme_synergy': 1.2 # Boost for themes that work well together
}
DEFAULT_THEME_TAGS = [ DEFAULT_THEME_TAGS = [
'Aggro', 'Aristocrats', 'Artifacts Matter', 'Big Mana', 'Blink', 'Aggro', 'Aristocrats', 'Artifacts Matter', 'Big Mana', 'Blink',
'Board Wipes', 'Burn', 'Cantrips', 'Card Draw', 'Clones', 'Board Wipes', 'Burn', 'Cantrips', 'Card Draw', 'Clones',
@ -976,9 +1155,9 @@ DATAFRAME_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, boo
# Card category validation rules # Card category validation rules
CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { CREATURE_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = {
'power': {'type': ('str', 'int', 'float'), 'required': True}, 'power': {'type': ('str', 'int', 'float', 'object'), 'required': True},
'toughness': {'type': ('str', 'int', 'float'), 'required': True}, 'toughness': {'type': ('str', 'int', 'float', 'object'), 'required': True},
'creatureTypes': {'type': 'list', 'required': True} 'creatureTypes': {'type': ('list', 'object'), 'required': True}
} }
SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = { SPELL_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = {