mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
Finshed refactoring land addtiions, all that's left is adding cards by theme and other tags
This commit is contained in:
parent
47c2cee00f
commit
8936fa347f
4 changed files with 1089 additions and 222 deletions
497
builder_utils.py
497
builder_utils.py
|
|
@ -34,6 +34,9 @@ from settings import (
|
|||
COLOR_TO_BASIC_LAND,
|
||||
SNOW_BASIC_LAND_MAPPING,
|
||||
KINDRED_STAPLE_LANDS,
|
||||
DUAL_LAND_TYPE_MAP,
|
||||
MANA_COLORS,
|
||||
MANA_PIP_PATTERNS
|
||||
)
|
||||
from exceptions import (
|
||||
DeckBuilderError,
|
||||
|
|
@ -46,6 +49,7 @@ from exceptions import (
|
|||
FetchLandValidationError,
|
||||
KindredLandSelectionError,
|
||||
KindredLandValidationError,
|
||||
LandRemovalError,
|
||||
ThemeSelectionError,
|
||||
ThemeWeightError,
|
||||
CardTypeCountError
|
||||
|
|
@ -584,7 +588,7 @@ def process_duplicate_cards(card_library: pd.DataFrame, duplicate_lists: List[st
|
|||
# Process each allowed duplicate card
|
||||
for card_name in duplicate_lists:
|
||||
# Find all instances of the card
|
||||
card_mask = processed_library['name'] == card_name
|
||||
card_mask = processed_library['Card Name'] == card_name
|
||||
card_count = card_mask.sum()
|
||||
|
||||
if card_count > 1:
|
||||
|
|
@ -592,7 +596,7 @@ def process_duplicate_cards(card_library: pd.DataFrame, duplicate_lists: List[st
|
|||
first_instance = processed_library[card_mask].iloc[0]
|
||||
processed_library = processed_library[~card_mask]
|
||||
|
||||
first_instance['name'] = DUPLICATE_CARD_FORMAT.format(
|
||||
first_instance['Card Name'] = DUPLICATE_CARD_FORMAT.format(
|
||||
card_name=card_name,
|
||||
count=card_count
|
||||
)
|
||||
|
|
@ -624,7 +628,7 @@ def count_cards_by_type(card_library: pd.DataFrame, card_types: List[str]) -> Di
|
|||
for card_type in card_types:
|
||||
# Use pandas str.contains() for efficient type matching
|
||||
# Case-insensitive matching with na=False to handle missing values
|
||||
type_mask = card_library['type'].str.contains(
|
||||
type_mask = card_library['Card Type'].str.contains(
|
||||
card_type,
|
||||
case=False,
|
||||
na=False
|
||||
|
|
@ -633,6 +637,7 @@ def count_cards_by_type(card_library: pd.DataFrame, card_types: List[str]) -> Di
|
|||
|
||||
return type_counts
|
||||
except Exception as e:
|
||||
print(card_type)
|
||||
logger.error(f"Error counting cards by type: {e}")
|
||||
raise CardTypeCountError(f"Failed to count cards by type: {str(e)}")
|
||||
|
||||
|
|
@ -838,7 +843,6 @@ def get_available_fetch_lands(colors: List[str], price_checker: Optional[Any] =
|
|||
fetch for fetch in available_fetches
|
||||
if price_checker.get_card_price(fetch) <= max_price * 1.1
|
||||
]
|
||||
|
||||
return available_fetches
|
||||
|
||||
def select_fetch_lands(available_fetches: List[str], count: int,
|
||||
|
|
@ -915,8 +919,7 @@ def validate_kindred_lands(land_name: str, commander_tags: List[str], colors: Li
|
|||
f"Failed to validate Kindred land {land_name}",
|
||||
{"error": str(e), "tags": commander_tags, "colors": colors}
|
||||
)
|
||||
|
||||
def get_available_kindred_lands(colors: List[str], commander_tags: List[str],
|
||||
def get_available_kindred_lands(land_df: pd.DataFrame, colors: List[str], commander_tags: List[str],
|
||||
price_checker: Optional[Any] = None,
|
||||
max_price: Optional[float] = None) -> List[str]:
|
||||
"""Get list of Kindred lands available for the deck's colors and themes.
|
||||
|
|
@ -934,8 +937,35 @@ def get_available_kindred_lands(colors: List[str], commander_tags: List[str],
|
|||
>>> get_available_kindred_lands(['G'], ['Elf Kindred'])
|
||||
['Cavern of Souls', 'Path of Ancestry', ...]
|
||||
"""
|
||||
# Start with staple Kindred lands
|
||||
available_lands = [land['name'] for land in KINDRED_STAPLE_LANDS]
|
||||
# Only proceed if deck has tribal themes
|
||||
if not any('Kindred' in tag for tag in commander_tags):
|
||||
return []
|
||||
|
||||
available_lands = []
|
||||
|
||||
# Add staple Kindred lands first
|
||||
available_lands.extend([land['name'] for land in KINDRED_STAPLE_LANDS
|
||||
if validate_kindred_lands(land['name'], commander_tags, colors)])
|
||||
|
||||
# Extract creature types from Kindred themes
|
||||
creature_types = [tag.replace(' Kindred', '')
|
||||
for tag in commander_tags
|
||||
if 'Kindred' in tag]
|
||||
|
||||
# Find lands specific to each creature type
|
||||
for creature_type in creature_types:
|
||||
logging.info(f'Searching for {creature_type}-specific lands')
|
||||
|
||||
# Filter lands by creature type mentions in text or type
|
||||
type_specific = land_df[
|
||||
land_df['text'].notna() &
|
||||
(land_df['text'].str.contains(creature_type, case=False) |
|
||||
land_df['type'].str.contains(creature_type, case=False))
|
||||
]
|
||||
|
||||
# Add any found type-specific lands
|
||||
if not type_specific.empty:
|
||||
available_lands.extend(type_specific['name'].tolist())
|
||||
|
||||
# Filter by price if price checking is enabled
|
||||
if price_checker and max_price:
|
||||
|
|
@ -946,14 +976,12 @@ def get_available_kindred_lands(colors: List[str], commander_tags: List[str],
|
|||
|
||||
return available_lands
|
||||
|
||||
def select_kindred_lands(available_lands: List[str], count: int,
|
||||
def select_kindred_lands(available_lands: List[str], count: int = None,
|
||||
allow_duplicates: bool = False) -> List[str]:
|
||||
"""Select Kindred lands from the available pool.
|
||||
|
||||
Args:
|
||||
available_lands: List of available Kindred lands
|
||||
count: Number of Kindred lands to select
|
||||
allow_duplicates: Whether to allow duplicate selections
|
||||
|
||||
Returns:
|
||||
List of selected Kindred land names
|
||||
|
|
@ -962,11 +990,10 @@ def select_kindred_lands(available_lands: List[str], count: int,
|
|||
KindredLandSelectionError: If unable to select required number of lands
|
||||
|
||||
Example:
|
||||
>>> select_kindred_lands(['Cavern of Souls', 'Path of Ancestry'], 2)
|
||||
['Path of Ancestry', 'Cavern of Souls']
|
||||
>>> select_kindred_lands(['Cavern of Souls', 'Path of Ancestry'])
|
||||
['Cavern of Souls', 'Path of Ancestry']
|
||||
"""
|
||||
import random
|
||||
|
||||
if not available_lands:
|
||||
raise KindredLandSelectionError(
|
||||
"No Kindred lands available to select from",
|
||||
|
|
@ -1001,4 +1028,444 @@ def process_kindred_lands(lands_to_add: List[str], card_library: pd.DataFrame,
|
|||
DataFrame without 'Cavern of Souls' in the available lands
|
||||
"""
|
||||
updated_land_df = land_df[~land_df['name'].isin(lands_to_add)]
|
||||
return updated_land_df
|
||||
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
|
||||
|
|
|
|||
526
deck_builder.py
526
deck_builder.py
|
|
@ -21,11 +21,13 @@ from settings import (
|
|||
COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT,
|
||||
COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT,
|
||||
COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_DEFAULT,
|
||||
COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT,
|
||||
COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, DUAL_LAND_TYPE_MAP,
|
||||
CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS,
|
||||
STAPLE_LAND_CONDITIONS
|
||||
STAPLE_LAND_CONDITIONS, TRIPLE_LAND_TYPE_MAP, MISC_LAND_MAX_COUNT, MISC_LAND_MIN_COUNT,
|
||||
MISC_LAND_POOL_SIZE, LAND_REMOVAL_MAX_ATTEMPTS, PROTECTED_LANDS,
|
||||
MANA_COLORS, MANA_PIP_PATTERNS
|
||||
)
|
||||
import builder_utils
|
||||
import builder_utils
|
||||
import setup_utils
|
||||
from setup import determine_commanders
|
||||
from input_handler import InputHandler
|
||||
|
|
@ -52,6 +54,7 @@ from exceptions import (
|
|||
IdealDeterminationError,
|
||||
InvalidNumberError,
|
||||
InvalidQuestionTypeError,
|
||||
LandRemovalError,
|
||||
LibraryOrganizationError,
|
||||
LibrarySortError,
|
||||
MaxAttemptsError,
|
||||
|
|
@ -63,7 +66,8 @@ from exceptions import (
|
|||
ThemeSelectionError,
|
||||
ThemeWeightError,
|
||||
StapleLandError,
|
||||
StapleLandError
|
||||
StapleLandError,
|
||||
ManaPipError
|
||||
)
|
||||
from type_definitions import (
|
||||
CardDict,
|
||||
|
|
@ -134,7 +138,9 @@ class DeckBuilder:
|
|||
'Card Type': pd.Series(dtype='str'),
|
||||
'Mana Cost': pd.Series(dtype='str'),
|
||||
'Mana Value': pd.Series(dtype='int'),
|
||||
'Commander': pd.Series(dtype='bool')
|
||||
'Creature Types': pd.Series(dtype='object'),
|
||||
'Themes': pd.Series(dtype='object'),
|
||||
'Commander': pd.Series(dtype='bool'),
|
||||
})
|
||||
|
||||
# Initialize component dataframes
|
||||
|
|
@ -461,7 +467,8 @@ class DeckBuilder:
|
|||
'CMC': 0.0
|
||||
}
|
||||
self.add_card(self.commander, self.commander_type,
|
||||
self.commander_mana_cost, self.commander_mana_value, True)
|
||||
self.commander_mana_cost, self.commander_mana_value,
|
||||
self.creature_types, self.commander_tags, True)
|
||||
|
||||
def _initialize_deck_building(self) -> None:
|
||||
"""Initialize deck building process.
|
||||
|
|
@ -869,6 +876,7 @@ class DeckBuilder:
|
|||
logger.error(f"Error in DataFrame setup: {e}")
|
||||
raise
|
||||
|
||||
# Theme selection
|
||||
def determine_themes(self) -> None:
|
||||
"""Determine and set up themes for the deck building process.
|
||||
|
||||
|
|
@ -1046,7 +1054,8 @@ class DeckBuilder:
|
|||
self.hidden_weight = self.weights['hidden']
|
||||
else:
|
||||
continue
|
||||
|
||||
|
||||
# Setting ideals
|
||||
def determine_ideals(self):
|
||||
"""Determine ideal card counts and price settings for the deck.
|
||||
|
||||
|
|
@ -1099,13 +1108,16 @@ class DeckBuilder:
|
|||
logger.error(f"Error in determine_ideals: {e}")
|
||||
raise
|
||||
|
||||
def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, is_commander: bool = False) -> None:
|
||||
# Adding card to library
|
||||
def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, creature_types: list = None, tags: list = None, is_commander: bool = False) -> None:
|
||||
"""Add a card to the deck library with price checking if enabled.
|
||||
Args:
|
||||
card (str): Name of the card to add
|
||||
card_type (str): Type of the card (e.g., 'Creature', 'Instant')
|
||||
mana_cost (str): Mana cost string representation
|
||||
mana_value (int): Converted mana cost/mana value
|
||||
creature_types (list): List of creature types in the card (if any)
|
||||
themes (list): List of themes the card has
|
||||
is_commander (bool, optional): Whether this card is the commander. Defaults to False.
|
||||
|
||||
Returns:
|
||||
|
|
@ -1135,13 +1147,14 @@ class DeckBuilder:
|
|||
return
|
||||
|
||||
# Create card entry
|
||||
card_entry = [card, card_type, mana_cost, mana_value, is_commander]
|
||||
card_entry = [card, card_type, mana_cost, mana_value, creature_types, tags, is_commander]
|
||||
|
||||
# Add to library
|
||||
self.card_library.loc[len(self.card_library)] = card_entry
|
||||
|
||||
logger.debug(f"Added {card} to deck library")
|
||||
|
||||
# Get card counts, sort library, set commander at index 1, and combine duplicates into 1 entry
|
||||
def organize_library(self):
|
||||
"""Organize and count cards in the library by their types.
|
||||
|
||||
|
|
@ -1296,6 +1309,7 @@ class DeckBuilder:
|
|||
logger.error(f"Error processing duplicate cards: {e}")
|
||||
raise
|
||||
|
||||
# Land Management
|
||||
def add_lands(self):
|
||||
"""
|
||||
Add lands to the deck based on ideal count and deck requirements.
|
||||
|
|
@ -1336,6 +1350,7 @@ class DeckBuilder:
|
|||
|
||||
# Adjust to ideal land count
|
||||
self.check_basics()
|
||||
print()
|
||||
logger.info('Adjusting total land count to match ideal count...')
|
||||
self.organize_library()
|
||||
|
||||
|
|
@ -1546,6 +1561,7 @@ class DeckBuilder:
|
|||
# Get available Kindred lands based on themes and budget
|
||||
max_price = self.max_card_price if hasattr(self, 'max_card_price') else None
|
||||
available_lands = builder_utils.get_available_kindred_lands(
|
||||
self.land_df,
|
||||
self.colors,
|
||||
self.commander_tags,
|
||||
self.price_checker if use_scrython else None,
|
||||
|
|
@ -1554,7 +1570,8 @@ class DeckBuilder:
|
|||
|
||||
# Select Kindred lands
|
||||
selected_lands = builder_utils.select_kindred_lands(
|
||||
available_lands
|
||||
available_lands,
|
||||
len(available_lands)
|
||||
)
|
||||
|
||||
# Add selected Kindred lands to deck
|
||||
|
|
@ -1575,158 +1592,181 @@ class DeckBuilder:
|
|||
raise
|
||||
|
||||
def add_dual_lands(self):
|
||||
# Determine dual-color lands available
|
||||
"""Add dual lands to the deck based on color identity and user preference.
|
||||
|
||||
# Determine if using the dual-type lands
|
||||
print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?')
|
||||
choice = self.input_handler.questionnaire('Confirm', message='', default_value=True)
|
||||
color_filter = []
|
||||
color_dict = {
|
||||
'azorius': 'Plains Island',
|
||||
'dimir': 'Island Swamp',
|
||||
'rakdos': 'Swamp Mountain',
|
||||
'gruul': 'Mountain Forest',
|
||||
'selesnya': 'Forest Plains',
|
||||
'orzhov': 'Plains Swamp',
|
||||
'golgari': 'Swamp Forest',
|
||||
'simic': 'Forest Island',
|
||||
'izzet': 'Island Mountain',
|
||||
'boros': 'Mountain Plains'
|
||||
}
|
||||
|
||||
if choice:
|
||||
for key in color_dict:
|
||||
if key in self.files_to_load:
|
||||
color_filter.extend([f'Land — {color_dict[key]}', f'Snow Land — {color_dict[key]}'])
|
||||
|
||||
dual_df = self.land_df[self.land_df['type'].isin(color_filter)].copy()
|
||||
|
||||
# Convert to list of card dictionaries
|
||||
card_pool = []
|
||||
for _, row in dual_df.iterrows():
|
||||
card = {
|
||||
'name': row['name'],
|
||||
'type': row['type'],
|
||||
'manaCost': row['manaCost'],
|
||||
'manaValue': row['manaValue']
|
||||
}
|
||||
card_pool.append(card)
|
||||
|
||||
lands_to_remove = []
|
||||
for card in card_pool:
|
||||
self.add_card(card['name'], card['type'],
|
||||
card['manaCost'], card['manaValue'])
|
||||
lands_to_remove.append(card['name'])
|
||||
This method handles the addition of dual lands by:
|
||||
1. Validating if dual lands should be added
|
||||
2. Getting available dual lands based on deck colors
|
||||
3. Selecting appropriate dual lands
|
||||
4. Adding selected lands to the deck
|
||||
5. Updating the land database
|
||||
|
||||
self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)]
|
||||
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
||||
|
||||
logger.info(f'Added {len(card_pool)} Dual-type land cards.')
|
||||
|
||||
if not choice:
|
||||
logger.info('Skipping adding Dual-type land cards.')
|
||||
|
||||
def add_triple_lands(self):
|
||||
# Determine if using Triome lands
|
||||
print('Would you like to include triome lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?')
|
||||
choice = self.input_handler.questionnaire('Confirm', message='', default_value=True)
|
||||
|
||||
color_filter = []
|
||||
color_dict = {
|
||||
'bant': 'Forest Plains Island',
|
||||
'esper': 'Plains Island Swamp',
|
||||
'grixis': 'Island Swamp Mountain',
|
||||
'jund': 'Swamp Mountain Forest',
|
||||
'naya': 'Mountain Forest Plains',
|
||||
'mardu': 'Mountain Plains Swamp',
|
||||
'abzan': 'Plains Swamp Forest',
|
||||
'sultai': 'Swamp Forest Island',
|
||||
'temur': 'Forest Island Mountain',
|
||||
'jeska': 'Island Mountain Plains'
|
||||
}
|
||||
|
||||
if choice:
|
||||
for key in color_dict:
|
||||
if key in self.files_to_load:
|
||||
color_filter.extend([f'Land — {color_dict[key]}'])
|
||||
|
||||
triome_df = self.land_df[self.land_df['type'].isin(color_filter)].copy()
|
||||
|
||||
# Convert to list of card dictionaries
|
||||
card_pool = []
|
||||
for _, row in triome_df.iterrows():
|
||||
card = {
|
||||
'name': row['name'],
|
||||
'type': row['type'],
|
||||
'manaCost': row['manaCost'],
|
||||
'manaValue': row['manaValue']
|
||||
}
|
||||
card_pool.append(card)
|
||||
|
||||
lands_to_remove = []
|
||||
for card in card_pool:
|
||||
self.add_card(card['name'], card['type'],
|
||||
card['manaCost'], card['manaValue'])
|
||||
lands_to_remove.append(card['name'])
|
||||
|
||||
self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)]
|
||||
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
||||
|
||||
logger.info(f'Added {len(card_pool)} Triome land cards.')
|
||||
|
||||
if not choice:
|
||||
logger.info('Skipping adding Triome land cards.')
|
||||
|
||||
def add_misc_lands(self):
|
||||
"""Add additional utility lands that fit the deck's color identity."""
|
||||
logger.info('Adding miscellaneous utility lands')
|
||||
|
||||
MIN_MISC_LANDS = 5
|
||||
MAX_MISC_LANDS = 15
|
||||
MAX_POOL_SIZE = 100
|
||||
|
||||
The process uses helper functions from builder_utils for modular operation.
|
||||
"""
|
||||
try:
|
||||
# Create filtered pool of candidate lands
|
||||
land_pool = (self.land_df
|
||||
.head(MAX_POOL_SIZE)
|
||||
.copy()
|
||||
.reset_index(drop=True))
|
||||
# Check if we should add dual lands
|
||||
print()
|
||||
print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?')
|
||||
use_duals = self.input_handler.questionnaire('Confirm', message='', default_value=True)
|
||||
|
||||
# Convert to card dictionaries
|
||||
card_pool = [
|
||||
{
|
||||
'name': row['name'],
|
||||
'type': row['type'],
|
||||
'manaCost': row['manaCost'],
|
||||
'manaValue': row['manaValue']
|
||||
}
|
||||
for _, row in land_pool.iterrows()
|
||||
if row['name'] not in self.card_library['Card Name'].values
|
||||
]
|
||||
|
||||
if not card_pool:
|
||||
logger.warning("No eligible misc lands found")
|
||||
if not use_duals:
|
||||
logger.info('Skipping adding Dual-type land cards.')
|
||||
return
|
||||
|
||||
# Randomly select lands within constraints
|
||||
target_count = random.randint(MIN_MISC_LANDS, MAX_MISC_LANDS)
|
||||
cards_to_add = []
|
||||
logger.info('Adding Dual-type lands')
|
||||
# Get color pairs by checking DUAL_LAND_TYPE_MAP keys against files_to_load
|
||||
color_pairs = []
|
||||
for key in DUAL_LAND_TYPE_MAP:
|
||||
if key in self.files_to_load:
|
||||
color_pairs.extend([f'Land — {DUAL_LAND_TYPE_MAP[key]}', f'Snow Land — {DUAL_LAND_TYPE_MAP[key]}'])
|
||||
|
||||
while card_pool and len(cards_to_add) < target_count:
|
||||
card = random.choice(card_pool)
|
||||
card_pool.remove(card)
|
||||
|
||||
# Check price if enabled
|
||||
if use_scrython and self.set_max_card_price:
|
||||
price = self.price_checker.get_card_price(card['name'])
|
||||
if price > self.max_card_price * 1.1:
|
||||
continue
|
||||
|
||||
cards_to_add.append(card)
|
||||
# Validate dual lands for these color pairs
|
||||
if not builder_utils.validate_dual_lands(color_pairs, 'Snow' in self.commander_tags):
|
||||
logger.info('No valid dual lands available for this color combination.')
|
||||
return
|
||||
|
||||
# Get available dual lands
|
||||
dual_df = builder_utils.get_available_dual_lands(
|
||||
self.land_df,
|
||||
color_pairs,
|
||||
'Snow' in self.commander_tags
|
||||
)
|
||||
|
||||
# Select appropriate dual lands
|
||||
selected_lands = builder_utils.select_dual_lands(
|
||||
dual_df,
|
||||
self.price_checker if use_scrython else None,
|
||||
self.max_card_price if hasattr(self, 'max_card_price') else None
|
||||
)
|
||||
|
||||
# Add selected lands to deck
|
||||
for land in selected_lands:
|
||||
self.add_card(land['name'], land['type'],
|
||||
land['manaCost'], land['manaValue'])
|
||||
|
||||
# Update land database
|
||||
self.land_df = builder_utils.process_dual_lands(
|
||||
selected_lands,
|
||||
self.card_library,
|
||||
self.land_df
|
||||
)
|
||||
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
||||
|
||||
logger.info(f'Added {len(selected_lands)} Dual-type land cards:')
|
||||
for card in selected_lands:
|
||||
print(card['name'])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding dual lands: {e}")
|
||||
raise
|
||||
|
||||
def add_triple_lands(self):
|
||||
"""Add triple lands to the deck based on color identity and user preference.
|
||||
|
||||
This method handles the addition of triple lands by:
|
||||
1. Validating if triple lands should be added
|
||||
2. Getting available triple lands based on deck colors
|
||||
3. Selecting appropriate triple lands
|
||||
4. Adding selected lands to the deck
|
||||
5. Updating the land database
|
||||
|
||||
The process uses helper functions from builder_utils for modular operation.
|
||||
"""
|
||||
try:
|
||||
# Check if we should add triple lands
|
||||
print()
|
||||
print('Would you like to include triple lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?')
|
||||
use_triples = self.input_handler.questionnaire('Confirm', message='', default_value=True)
|
||||
|
||||
if not use_triples:
|
||||
logger.info('Skipping adding triple lands.')
|
||||
return
|
||||
|
||||
logger.info('Adding triple lands')
|
||||
# Get color triplets by checking TRIPLE_LAND_TYPE_MAP keys against files_to_load
|
||||
color_triplets = []
|
||||
for key in TRIPLE_LAND_TYPE_MAP:
|
||||
if key in self.files_to_load:
|
||||
color_triplets.extend([f'Land — {TRIPLE_LAND_TYPE_MAP[key]}'])
|
||||
|
||||
# Validate triple lands for these color triplets
|
||||
if not builder_utils.validate_triple_lands(color_triplets, 'Snow' in self.commander_tags):
|
||||
logger.info('No valid triple lands available for this color combination.')
|
||||
return
|
||||
|
||||
# Get available triple lands
|
||||
triple_df = builder_utils.get_available_triple_lands(
|
||||
self.land_df,
|
||||
color_triplets,
|
||||
'Snow' in self.commander_tags
|
||||
)
|
||||
|
||||
# Select appropriate triple lands
|
||||
selected_lands = builder_utils.select_triple_lands(
|
||||
triple_df,
|
||||
self.price_checker if use_scrython else None,
|
||||
self.max_card_price if hasattr(self, 'max_card_price') else None
|
||||
)
|
||||
|
||||
# Add selected lands to deck
|
||||
for land in selected_lands:
|
||||
self.add_card(land['name'], land['type'],
|
||||
land['manaCost'], land['manaValue'])
|
||||
|
||||
# Update land database
|
||||
self.land_df = builder_utils.process_triple_lands(
|
||||
selected_lands,
|
||||
self.card_library,
|
||||
self.land_df
|
||||
)
|
||||
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
||||
|
||||
logger.info(f'Added {len(selected_lands)} triple lands:')
|
||||
for card in selected_lands:
|
||||
print(card['name'])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding triple lands: {e}")
|
||||
|
||||
def add_misc_lands(self):
|
||||
"""Add additional utility lands that fit the deck's color identity.
|
||||
|
||||
This method randomly selects a number of miscellaneous utility lands to add to the deck.
|
||||
The number of lands is randomly determined between MISC_LAND_MIN_COUNT and MISC_LAND_MAX_COUNT.
|
||||
Lands are selected from a filtered pool of the top MISC_LAND_POOL_SIZE lands by EDHREC rank.
|
||||
|
||||
The method handles price constraints if price checking is enabled and updates the land
|
||||
database after adding lands to prevent duplicates.
|
||||
|
||||
Raises:
|
||||
MiscLandSelectionError: If there are issues selecting appropriate misc lands
|
||||
"""
|
||||
print()
|
||||
logger.info('Adding miscellaneous utility lands')
|
||||
|
||||
try:
|
||||
# Get available misc lands
|
||||
available_lands = builder_utils.get_available_misc_lands(
|
||||
self.land_df,
|
||||
MISC_LAND_POOL_SIZE
|
||||
)
|
||||
|
||||
if not available_lands:
|
||||
logger.warning("No eligible miscellaneous lands found")
|
||||
return
|
||||
|
||||
# Select random number of lands
|
||||
selected_lands = builder_utils.select_misc_lands(
|
||||
available_lands,
|
||||
MISC_LAND_MIN_COUNT,
|
||||
MISC_LAND_MAX_COUNT,
|
||||
self.price_checker if use_scrython else None,
|
||||
self.max_card_price if hasattr(self, 'max_card_price') else None
|
||||
)
|
||||
|
||||
# Add selected lands
|
||||
lands_to_remove = set()
|
||||
for card in cards_to_add:
|
||||
for card in selected_lands:
|
||||
self.add_card(card['name'], card['type'],
|
||||
card['manaCost'], card['manaValue'])
|
||||
lands_to_remove.add(card['name'])
|
||||
|
|
@ -1735,7 +1775,9 @@ class DeckBuilder:
|
|||
self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)]
|
||||
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
||||
|
||||
logger.info(f'Added {len(cards_to_add)} miscellaneous lands')
|
||||
logger.info(f'Added {len(selected_lands)} miscellaneous lands:')
|
||||
for card in selected_lands:
|
||||
print(card['name'])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding misc lands: {e}")
|
||||
|
|
@ -1778,7 +1820,7 @@ class DeckBuilder:
|
|||
count = len(self.card_library[self.card_library['Card Name'] == land])
|
||||
basic_lands[land] = count
|
||||
self.total_basics += count
|
||||
|
||||
print()
|
||||
logger.info("Basic Land Counts:")
|
||||
for land, count in basic_lands.items():
|
||||
if count > 0:
|
||||
|
|
@ -1797,6 +1839,7 @@ class DeckBuilder:
|
|||
Args:
|
||||
max_attempts: Maximum number of removal attempts before falling back to non-basics
|
||||
"""
|
||||
print()
|
||||
logger.info('Land count over ideal count, removing a basic land.')
|
||||
|
||||
color_to_basic = {
|
||||
|
|
@ -1843,62 +1886,127 @@ class DeckBuilder:
|
|||
self.remove_land()
|
||||
|
||||
def remove_land(self):
|
||||
"""Remove a random non-basic, non-staple land from the deck."""
|
||||
logger.info('Removing a random nonbasic land.')
|
||||
"""Remove a random non-basic, non-staple land from the deck.
|
||||
|
||||
# Define basic lands including snow-covered variants
|
||||
basic_lands = [
|
||||
'Plains', 'Island', 'Swamp', 'Mountain', 'Forest',
|
||||
'Snow-Covered Plains', 'Snow-Covered Island', 'Snow-Covered Swamp',
|
||||
'Snow-Covered Mountain', 'Snow-Covered Forest'
|
||||
]
|
||||
This method attempts to remove a non-protected land from the deck up to
|
||||
LAND_REMOVAL_MAX_ATTEMPTS times. It uses helper functions to filter removable
|
||||
lands and select a land for removal.
|
||||
|
||||
try:
|
||||
# Filter for non-basic, non-staple lands
|
||||
library_filter = self.card_library[
|
||||
(self.card_library['Card Type'].str.contains('Land')) &
|
||||
(~self.card_library['Card Name'].isin(basic_lands + self.staples))
|
||||
].copy()
|
||||
Raises:
|
||||
LandRemovalError: If no removable lands are found or removal fails
|
||||
"""
|
||||
print()
|
||||
logger.info('Attempting to remove a non-protected land')
|
||||
attempts = 0
|
||||
|
||||
if len(library_filter) == 0:
|
||||
logger.warning("No suitable non-basic lands found to remove.")
|
||||
while attempts < LAND_REMOVAL_MAX_ATTEMPTS:
|
||||
try:
|
||||
# Get removable lands
|
||||
removable_lands = builder_utils.filter_removable_lands(self.card_library, PROTECTED_LANDS + self.staples)
|
||||
|
||||
# Select a land for removal
|
||||
card_index, card_name = builder_utils.select_land_for_removal(removable_lands)
|
||||
|
||||
# Remove the selected land
|
||||
logger.info(f"Removing {card_name}")
|
||||
self.card_library.drop(card_index, inplace=True)
|
||||
self.card_library.reset_index(drop=True, inplace=True)
|
||||
logger.info("Land removed successfully")
|
||||
return
|
||||
|
||||
# Select random land to remove
|
||||
card_index = np.random.choice(library_filter.index)
|
||||
card_name = self.card_library.loc[card_index, 'Card Name']
|
||||
except LandRemovalError as e:
|
||||
logger.warning(f"Attempt {attempts + 1} failed: {e}")
|
||||
attempts += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error removing land: {e}")
|
||||
raise LandRemovalError(f"Failed to remove land: {str(e)}")
|
||||
|
||||
logger.info(f"Removing {card_name}")
|
||||
self.card_library.drop(card_index, inplace=True)
|
||||
self.card_library.reset_index(drop=True, inplace=True)
|
||||
logger.info("Card removed successfully.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing land: {e}")
|
||||
logger.warning("Failed to remove land card.")
|
||||
|
||||
# If we reach here, we've exceeded max attempts
|
||||
raise LandRemovalError(f"Could not find a removable land after {LAND_REMOVAL_MAX_ATTEMPTS} attempts")
|
||||
# Count pips and get average CMC
|
||||
def count_pips(self):
|
||||
"""Count and display the number of colored mana symbols in casting costs using vectorized operations."""
|
||||
"""Analyze and display the distribution of colored mana symbols (pips) in card casting costs.
|
||||
|
||||
This method processes the mana costs of all cards in the deck to:
|
||||
1. Count the number of colored mana symbols for each color
|
||||
2. Calculate the percentage distribution of colors
|
||||
3. Log detailed pip distribution information
|
||||
|
||||
The analysis uses helper functions from builder_utils for consistent counting
|
||||
and percentage calculations. Results are logged with detailed breakdowns
|
||||
of pip counts and distributions.
|
||||
|
||||
Dependencies:
|
||||
- MANA_COLORS from settings.py for color iteration
|
||||
- builder_utils.count_color_pips() for counting pips
|
||||
- builder_utils.calculate_pip_percentages() for distribution calculation
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
ManaPipError: If there are issues with:
|
||||
- Counting pips for specific colors
|
||||
- Calculating pip percentages
|
||||
- Unexpected errors during analysis
|
||||
|
||||
Logs:
|
||||
- Warning if no colored mana symbols are found
|
||||
- Info with detailed pip distribution and percentages
|
||||
- Error details if analysis fails
|
||||
"""
|
||||
print()
|
||||
logger.info('Analyzing color pip distribution...')
|
||||
|
||||
# Define colors to check
|
||||
colors = ['W', 'U', 'B', 'R', 'G']
|
||||
|
||||
# Use vectorized string operations
|
||||
mana_costs = self.card_library['Mana Cost'].dropna()
|
||||
pip_counts = {color: mana_costs.str.count(color).sum() for color in colors}
|
||||
|
||||
total_pips = sum(pip_counts.values())
|
||||
if total_pips == 0:
|
||||
logger.error("No colored mana symbols found in casting costs.")
|
||||
return
|
||||
|
||||
logger.info("\nColor Pip Distribution:")
|
||||
for color, count in pip_counts.items():
|
||||
if count > 0:
|
||||
percentage = (count / total_pips) * 100
|
||||
print(f"{color}: {count} pips ({percentage:.1f}%)")
|
||||
logger.info(f"Total colored pips: {total_pips}\n")
|
||||
try:
|
||||
# Get mana costs from card library
|
||||
mana_costs = self.card_library['Mana Cost'].dropna()
|
||||
|
||||
# Count pips for each color using helper function
|
||||
pip_counts = {}
|
||||
for color in MANA_COLORS:
|
||||
try:
|
||||
pip_counts[color] = builder_utils.count_color_pips(mana_costs, color)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ManaPipError(
|
||||
f"Error counting {color} pips",
|
||||
{"color": color, "error": str(e)}
|
||||
)
|
||||
|
||||
# Calculate percentages using helper function
|
||||
try:
|
||||
percentages = builder_utils.calculate_pip_percentages(pip_counts)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ManaPipError(
|
||||
"Error calculating pip percentages",
|
||||
{"error": str(e)}
|
||||
)
|
||||
|
||||
# Log detailed pip distribution
|
||||
total_pips = sum(pip_counts.values())
|
||||
if total_pips == 0:
|
||||
logger.warning("No colored mana symbols found in casting costs")
|
||||
return
|
||||
|
||||
logger.info("Color Pip Distribution:")
|
||||
for color in MANA_COLORS:
|
||||
count = pip_counts[color]
|
||||
if count > 0:
|
||||
percentage = percentages[color]
|
||||
print(f"{color}: {count} pips ({percentage:.1f}%)")
|
||||
print()
|
||||
logger.info(f"Total colored pips: {total_pips}")
|
||||
# Filter out zero percentages
|
||||
non_zero_percentages = {color: pct for color, pct in percentages.items() if pct > 0}
|
||||
logger.info(f"Distribution ratios: {non_zero_percentages}\n")
|
||||
|
||||
except ManaPipError as e:
|
||||
logger.error(f"Mana pip analysis failed: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in pip analysis: {e}")
|
||||
raise ManaPipError("Failed to analyze mana pips", {"error": str(e)})
|
||||
|
||||
def get_cmc(self):
|
||||
"""Calculate average converted mana cost of non-land cards."""
|
||||
|
|
@ -1947,7 +2055,9 @@ class DeckBuilder:
|
|||
'name': row['name'],
|
||||
'type': row['type'],
|
||||
'manaCost': row['manaCost'],
|
||||
'manaValue': row['manaValue']
|
||||
'manaValue': row['manaValue'],
|
||||
'creatureTypes': row['creatureTypes'],
|
||||
'themeTags': row['themeTags']
|
||||
}
|
||||
for _, row in tag_df.iterrows()
|
||||
]
|
||||
|
|
@ -1990,7 +2100,8 @@ class DeckBuilder:
|
|||
# Add selected cards to library
|
||||
for card in cards_to_add:
|
||||
self.add_card(card['name'], card['type'],
|
||||
card['manaCost'], card['manaValue'])
|
||||
card['manaCost'], card['manaValue'],
|
||||
card['creatureTypes'], card['themeTags'])
|
||||
|
||||
card_pool_names = [item['name'] for item in card_pool]
|
||||
self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)]
|
||||
|
|
@ -2017,7 +2128,9 @@ class DeckBuilder:
|
|||
'name': row['name'],
|
||||
'type': row['type'],
|
||||
'manaCost': row['manaCost'],
|
||||
'manaValue': row['manaValue']
|
||||
'manaValue': row['manaValue'],
|
||||
'creatureTypes': row['creatureTypes'],
|
||||
'themeTags': row['themeTags']
|
||||
}
|
||||
for _, row in tag_df.iterrows()
|
||||
]
|
||||
|
|
@ -2047,8 +2160,9 @@ class DeckBuilder:
|
|||
# Add selected cards to library
|
||||
for card in cards_to_add:
|
||||
if len(self.card_library) < 100:
|
||||
self.add_card(card['name'], card['type'],
|
||||
card['manaCost'], card['manaValue'])
|
||||
self.add_card(card['name'], card['type'],
|
||||
card['manaCost'], card['manaValue'],
|
||||
card['creatureTypes'], card['themeTags'])
|
||||
else:
|
||||
continue
|
||||
|
||||
|
|
|
|||
229
exceptions.py
229
exceptions.py
|
|
@ -1051,4 +1051,231 @@ class FetchLandSelectionError(FetchLandError):
|
|||
message: Description of the selection failure
|
||||
details: Additional context about the error
|
||||
"""
|
||||
super().__init__(message, code="FETCH_SELECT_ERR", details=details)
|
||||
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
|
||||
)
|
||||
59
settings.py
59
settings.py
|
|
@ -37,8 +37,14 @@ DEFAULT_BASIC_LAND_COUNT: Final[int] = 20 # Default minimum basic lands
|
|||
DEFAULT_NON_BASIC_LAND_SLOTS: Final[int] = 10 # Default number of non-basic land slots to reserve
|
||||
DEFAULT_BASICS_PER_COLOR: Final[int] = 5 # Default number of basic lands to add per color
|
||||
|
||||
# Miscellaneous land configuration
|
||||
MISC_LAND_MIN_COUNT: Final[int] = 5 # Minimum number of miscellaneous lands to add
|
||||
MISC_LAND_MAX_COUNT: Final[int] = 10 # Maximum number of miscellaneous lands to add
|
||||
MISC_LAND_POOL_SIZE: Final[int] = 100 # Maximum size of initial land pool to select from
|
||||
|
||||
# Default fetch land count
|
||||
FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include
|
||||
|
||||
# Basic land mappings
|
||||
COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = {
|
||||
'W': 'Plains',
|
||||
|
|
@ -49,6 +55,41 @@ COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = {
|
|||
'C': 'Wastes'
|
||||
}
|
||||
|
||||
# Dual land type mappings
|
||||
DUAL_LAND_TYPE_MAP: Final[Dict[str, str]] = {
|
||||
'azorius': 'Plains Island',
|
||||
'dimir': 'Island Swamp',
|
||||
'rakdos': 'Swamp Mountain',
|
||||
'gruul': 'Mountain Forest',
|
||||
'selesnya': 'Forest Plains',
|
||||
'orzhov': 'Plains Swamp',
|
||||
'golgari': 'Swamp Forest',
|
||||
'simic': 'Forest Island',
|
||||
'izzet': 'Island Mountain',
|
||||
'boros': 'Mountain Plains'
|
||||
}
|
||||
|
||||
# Triple land type mappings
|
||||
TRIPLE_LAND_TYPE_MAP: Final[Dict[str, str]] = {
|
||||
'bant': 'Forest Plains Island',
|
||||
'esper': 'Plains Island Swamp',
|
||||
'grixis': 'Island Swamp Mountain',
|
||||
'jund': 'Swamp Mountain Forest',
|
||||
'naya': 'Mountain Forest Plains',
|
||||
'mardu': 'Mountain Plains Swamp',
|
||||
'abzan': 'Plains Swamp Forest',
|
||||
'sultai': 'Swamp Forest Island',
|
||||
'temur': 'Forest Island Mountain',
|
||||
'jeska': 'Island Mountain Plains'
|
||||
}
|
||||
|
||||
# Default preference for including dual lands
|
||||
DEFAULT_DUAL_LAND_ENABLED: Final[bool] = True
|
||||
|
||||
# Default preference for including triple lands
|
||||
DEFAULT_TRIPLE_LAND_ENABLED: Final[bool] = True
|
||||
|
||||
|
||||
SNOW_COVERED_BASIC_LANDS: Final[Dict[str, str]] = {
|
||||
'W': 'Snow-Covered Plains',
|
||||
'U': 'Snow-Covered Island',
|
||||
|
|
@ -226,6 +267,12 @@ banned_cards = [# in commander
|
|||
|
||||
BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']
|
||||
|
||||
# Constants for land removal functionality
|
||||
LAND_REMOVAL_MAX_ATTEMPTS: Final[int] = 3
|
||||
|
||||
# Protected lands that cannot be removed during land removal process
|
||||
PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + [land['name'] for land in KINDRED_STAPLE_LANDS]
|
||||
|
||||
# Constants for lands matter functionality
|
||||
LANDS_MATTER_PATTERNS: Dict[str, List[str]] = {
|
||||
'land_play': [
|
||||
|
|
@ -427,6 +474,7 @@ ARISTOCRAT_EXCLUSION_PATTERNS = [
|
|||
'from your library',
|
||||
'into your hand'
|
||||
]
|
||||
|
||||
STAX_TEXT_PATTERNS = [
|
||||
'an opponent controls'
|
||||
'can\'t attack',
|
||||
|
|
@ -699,6 +747,7 @@ BOARD_WIPE_EXCLUSION_PATTERNS = [
|
|||
'target player\'s library',
|
||||
'that player\'s library'
|
||||
]
|
||||
|
||||
CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery',
|
||||
'Kindred', 'Dungeon', 'Battle']
|
||||
|
||||
|
|
@ -741,6 +790,15 @@ TYPE_TAG_MAPPING = {
|
|||
CSV_DIRECTORY = 'csv_files'
|
||||
|
||||
# Color identity constants and mappings
|
||||
|
||||
# Basic mana colors
|
||||
MANA_COLORS: Final[List[str]] = ['W', 'U', 'B', 'R', 'G']
|
||||
|
||||
# Mana pip patterns for each color
|
||||
MANA_PIP_PATTERNS: Final[Dict[str, str]] = {
|
||||
color: f'{{{color}}}' for color in MANA_COLORS
|
||||
}
|
||||
|
||||
MONO_COLOR_MAP: Final[Dict[str, Tuple[str, List[str]]]] = {
|
||||
'COLORLESS': ('Colorless', ['colorless']),
|
||||
'W': ('White', ['colorless', 'white']),
|
||||
|
|
@ -1033,6 +1091,7 @@ AURA_SPECIFIC_CARDS = [
|
|||
'Ivy, Gleeful Spellthief', # Copies spells that have single target
|
||||
'Killian, Ink Duelist', # Targetted spell cost reduction
|
||||
]
|
||||
|
||||
# Equipment-related constants
|
||||
EQUIPMENT_EXCLUSIONS = [
|
||||
'Bruenor Battlehammer', # Equipment cost reduction
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue