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

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

View file

@ -34,6 +34,9 @@ from settings import (
COLOR_TO_BASIC_LAND,
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

View file

@ -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

View file

@ -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
)

View file

@ -37,8 +37,14 @@ DEFAULT_BASIC_LAND_COUNT: Final[int] = 20 # Default minimum basic lands
DEFAULT_NON_BASIC_LAND_SLOTS: Final[int] = 10 # Default number of non-basic land slots to reserve
DEFAULT_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