Finished refactoring and adding docstrings functions.

Added module-level docstrings to modules and cleaned up imports
This commit is contained in:
mwisnowski 2025-01-17 11:39:27 -08:00
parent 8936fa347f
commit c628b054ea
8 changed files with 784 additions and 252 deletions

View file

@ -1,49 +1,48 @@
from typing import Dict, List, Tuple, Optional, Any, Callable, TypeVar, Union, cast
import pandas as pd
from price_check import PriceChecker
from input_handler import InputHandler
import logging
"""Utility module for MTG deck building operations.
This module provides utility functions for various deck building operations including:
- DataFrame validation and processing
- Card type counting and validation
- Land selection and management
- Theme processing and weighting
- Price checking integration
- Mana pip analysis
The module serves as a central collection of helper functions used throughout the
deck building process, handling data validation, card selection, and various
deck composition calculations.
Key Features:
- DataFrame validation with timeout handling
- Card type counting and categorization
- Land type validation and selection (basic, fetch, dual, etc.)
- Theme tag processing and weighting calculations
- Mana pip counting and color distribution analysis
Typical usage example:
>>> df = load_commander_data()
>>> validate_dataframe(df, DATAFRAME_VALIDATION_RULES)
>>> process_dataframe_batch(df)
>>> count_cards_by_type(df, ['Creature', 'Instant', 'Sorcery'])
"""
# Standard library imports
import functools
import logging
import time
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union, cast
# Third-party imports
import pandas as pd
from fuzzywuzzy import process
from settings import (
COMMANDER_CSV_PATH,
FUZZY_MATCH_THRESHOLD,
MAX_FUZZY_CHOICES,
COMMANDER_CONVERTERS,
DATAFRAME_VALIDATION_RULES,
DATAFRAME_VALIDATION_TIMEOUT,
DATAFRAME_BATCH_SIZE,
DATAFRAME_TRANSFORM_TIMEOUT,
DATAFRAME_REQUIRED_COLUMNS,
WEIGHT_ADJUSTMENT_FACTORS,
DEFAULT_MAX_DECK_PRICE,
DEFAULT_MAX_CARD_PRICE,
DECK_COMPOSITION_PROMPTS,
DEFAULT_RAMP_COUNT,
DEFAULT_LAND_COUNT,
DEFAULT_BASIC_LAND_COUNT,
DEFAULT_CREATURE_COUNT,
DEFAULT_REMOVAL_COUNT,
DEFAULT_CARD_ADVANTAGE_COUNT,
DEFAULT_PROTECTION_COUNT,
DEFAULT_WIPES_COUNT,
CARD_TYPE_SORT_ORDER,
DUPLICATE_CARD_FORMAT,
COLOR_TO_BASIC_LAND,
SNOW_BASIC_LAND_MAPPING,
KINDRED_STAPLE_LANDS,
DUAL_LAND_TYPE_MAP,
MANA_COLORS,
MANA_PIP_PATTERNS
)
# Local application imports
from exceptions import (
CSVValidationError,
DataFrameTimeoutError,
DataFrameValidationError,
DeckBuilderError,
DuplicateCardError,
CSVValidationError,
DataFrameValidationError,
DataFrameTimeoutError,
EmptyDataFrameError,
FetchLandSelectionError,
FetchLandValidationError,
@ -54,6 +53,24 @@ from exceptions import (
ThemeWeightError,
CardTypeCountError
)
from input_handler import InputHandler
from price_check import PriceChecker
from settings import (
CARD_TYPE_SORT_ORDER, COLOR_TO_BASIC_LAND, COMMANDER_CONVERTERS,
COMMANDER_CSV_PATH, DATAFRAME_BATCH_SIZE,
DATAFRAME_REQUIRED_COLUMNS, DATAFRAME_TRANSFORM_TIMEOUT,
DATAFRAME_VALIDATION_RULES, DATAFRAME_VALIDATION_TIMEOUT,
DECK_COMPOSITION_PROMPTS, DEFAULT_BASIC_LAND_COUNT,
DEFAULT_CARD_ADVANTAGE_COUNT, DEFAULT_CREATURE_COUNT,
DEFAULT_LAND_COUNT, DEFAULT_MAX_CARD_PRICE, DEFAULT_MAX_DECK_PRICE,
DEFAULT_PROTECTION_COUNT, DEFAULT_RAMP_COUNT,
DEFAULT_REMOVAL_COUNT, DEFAULT_WIPES_COUNT, DUAL_LAND_TYPE_MAP,
DUPLICATE_CARD_FORMAT, FUZZY_MATCH_THRESHOLD, KINDRED_STAPLE_LANDS,
MANA_COLORS, MANA_PIP_PATTERNS, MAX_FUZZY_CHOICES,
SNOW_BASIC_LAND_MAPPING, THEME_POOL_SIZE_MULTIPLIER,
WEIGHT_ADJUSTMENT_FACTORS
)
from type_definitions import CardLibraryDF, CommanderDF, LandDF
logging.basicConfig(
level=logging.INFO,
@ -61,7 +78,6 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
# Type variables for generic functions
T = TypeVar('T')
DataFrame = TypeVar('DataFrame', bound=pd.DataFrame)
@ -431,7 +447,6 @@ def adjust_theme_weights(primary_theme: str,
if total_weight > 0:
adjusted_weights = {k: round(v/total_weight, 2) for k, v in adjusted_weights.items()}
print(adjusted_weights)
return adjusted_weights
except Exception as e:
@ -1404,6 +1419,162 @@ def select_land_for_removal(filtered_lands: pd.DataFrame) -> Tuple[int, str]:
logger.error(f"Error selecting land for removal: {e}")
raise
def get_card_theme_overlap(card_tags: List[str], deck_themes: List[str]) -> int:
"""Count how many deck themes a given card matches.
Args:
card_tags: List of tags associated with the card
deck_themes: List of themes in the deck
Returns:
Number of deck themes that match the card's tags
Example:
>>> card_tags = ['Artifacts Matter', 'Token Creation', 'Sacrifice']
>>> deck_themes = ['Artifacts Matter', 'Sacrifice Matters']
>>> get_card_theme_overlap(card_tags, deck_themes)
2
"""
if not card_tags or not deck_themes:
return 0
# Convert to sets for efficient intersection
card_tag_set = set(card_tags)
deck_theme_set = set(deck_themes)
# Count overlapping themes
return len(card_tag_set.intersection(deck_theme_set))
def calculate_theme_priority(card_tags: List[str], deck_themes: List[str], THEME_PRIORITY_BONUS: float) -> float:
"""Calculate priority score for a card based on theme overlap.
Args:
card_tags: List of tags associated with the card
deck_themes: List of themes in the deck
THEME_PRIORITY_BONUS: Bonus multiplier for each additional theme match
Returns:
Priority score for the card (higher means more theme overlap)
Example:
>>> card_tags = ['Artifacts Matter', 'Token Creation', 'Sacrifice']
>>> deck_themes = ['Artifacts Matter', 'Sacrifice Matters']
>>> calculate_theme_priority(card_tags, deck_themes, 1.2)
1.44 # Base score of 1.0 * (1.2 ^ 2) for two theme matches
"""
overlap_count = get_card_theme_overlap(card_tags, deck_themes)
if overlap_count == 0:
return 0.0
# Calculate priority score with exponential bonus for multiple matches
return pow(THEME_PRIORITY_BONUS, overlap_count)
def calculate_weighted_pool_size(ideal_count: int, weight: float, multiplier: float = THEME_POOL_SIZE_MULTIPLIER) -> int:
"""Calculate the size of the initial card pool based on ideal count and weight.
Args:
ideal_count: Target number of cards to select
weight: Theme weight factor (0.0-1.0)
multiplier: Pool size multiplier (default from settings)
Returns:
Calculated pool size
Example:
>>> calculate_weighted_pool_size(10, 0.8, 2.0)
16
"""
return int(ideal_count * weight * multiplier)
def filter_theme_cards(df: pd.DataFrame, themes: List[str], pool_size: int) -> pd.DataFrame:
"""Filter cards by theme and return top cards by EDHREC rank.
Args:
df: Source DataFrame to filter
themes: List of theme tags to filter by
pool_size: Number of cards to return
Returns:
Filtered DataFrame with top cards
Raises:
ValueError: If themes is None or contains invalid values
TypeError: If themes is not a list
Example:
>>> filtered_df = filter_theme_cards(cards_df, ['Artifacts Matter', 'Token Creation'], 20)
"""
# Input validation
if themes is None:
raise ValueError("themes parameter cannot be None")
if not isinstance(themes, list):
raise TypeError("themes must be a list of strings")
if not all(isinstance(theme, str) for theme in themes):
raise ValueError("all themes must be strings")
if not themes:
return pd.DataFrame() # Return empty DataFrame for empty themes list
# Create copy to avoid modifying original
filtered_df = df.copy()
# Filter by theme
filtered_df = filtered_df[filtered_df['themeTags'].apply(
lambda x: any(theme in x for theme in themes) if isinstance(x, list) else False
)]
# Sort by EDHREC rank and take top cards
filtered_df.sort_values('edhrecRank', inplace=True)
return filtered_df.head(pool_size)
def select_weighted_cards(
card_pool: pd.DataFrame,
target_count: int,
price_checker: Optional[Any] = None,
max_price: Optional[float] = None
) -> List[Dict[str, Any]]:
"""Select cards from pool considering price constraints.
Args:
card_pool: DataFrame of candidate cards
target_count: Number of cards to select
price_checker: Optional price checker instance
max_price: Maximum allowed price per card
Returns:
List of selected card dictionaries
Example:
>>> selected = select_weighted_cards(pool_df, 5, price_checker, 10.0)
"""
selected_cards = []
for _, card in card_pool.iterrows():
if len(selected_cards) >= target_count:
break
# 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_cards.append({
'name': card['name'],
'type': card['type'],
'manaCost': card['manaCost'],
'manaValue': card['manaValue'],
'themeTags': card['themeTags']
})
return selected_cards
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.

View file

@ -20,16 +20,16 @@ from settings import (
COMMANDER_CSV_PATH, FUZZY_MATCH_THRESHOLD, MAX_FUZZY_CHOICES, FETCH_LAND_DEFAULT_COUNT,
COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT,
COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT,
THEME_PRIORITY_BONUS, THEME_POOL_SIZE_MULTIPLIER,
COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_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, 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
MANA_COLORS, MANA_PIP_PATTERNS, THEME_WEIGHT_MULTIPLIER
)
import builder_utils
import setup_utils
from setup import determine_commanders
from input_handler import InputHandler
from exceptions import (
BasicLandCountError,
@ -37,7 +37,6 @@ from exceptions import (
CommanderMoveError,
CardTypeCountError,
CommanderColorError,
CommanderLoadError,
CommanderSelectionError,
CommanderValidationError,
CSVError,
@ -48,16 +47,12 @@ from exceptions import (
DuplicateCardError,
DeckBuilderError,
EmptyDataFrameError,
EmptyInputError,
FetchLandSelectionError,
FetchLandValidationError,
IdealDeterminationError,
InvalidNumberError,
InvalidQuestionTypeError,
LandRemovalError,
LibraryOrganizationError,
LibrarySortError,
MaxAttemptsError,
PriceAPIError,
PriceConfigurationError,
PriceLimitError,
@ -66,18 +61,21 @@ from exceptions import (
ThemeSelectionError,
ThemeWeightError,
StapleLandError,
StapleLandError,
ManaPipError
ManaPipError,
ThemeTagError,
ThemeWeightingError,
ThemePoolError
)
from type_definitions import (
CardDict,
CommanderDict,
CardLibraryDF,
CommanderDF,
LandDF,
ArtifactDF,
CreatureDF,
NonCreatureDF)
NonCreatureDF,
PlaneswalkerDF,
NonPlaneswalkerDF)
# Try to import scrython and price_checker
try:
@ -102,19 +100,6 @@ pd.set_option('display.max_columns', None)
pd.set_option('display.max_rows', None)
pd.set_option('display.max_colwidth', 50)
"""
Basic deck builder, primarily intended for building Kindred decks.
Logic for other themes (such as Spellslinger or Wheels), is added.
I plan to also implement having it recommend a commander or themes.
Currently, the script will ask questions to determine number of
creatures, lands, interaction, ramp, etc... then add cards and
adjust from there.
Land spread will ideally be handled based on pips and some adjustment
is planned based on mana curve and ramp added.
"""
def new_line(num_lines: int = 1) -> None:
"""Print specified number of newlines for formatting output.
@ -149,9 +134,10 @@ class DeckBuilder:
self.artifact_df: ArtifactDF = pd.DataFrame()
self.creature_df: CreatureDF = pd.DataFrame()
self.noncreature_df: NonCreatureDF = pd.DataFrame()
self.nonplaneswalker_df: NonPlaneswalkerDF = pd.DataFrame()
# Initialize other attributes with type hints
self.commander_info: Dict = {}
self.max_card_price: Optional[float] = None
self.commander_dict: CommanderDict = {}
self.commander: str = ''
self.commander_type: str = ''
@ -764,7 +750,7 @@ class DeckBuilder:
# Remove lands from main DataFrame
df = df[~df['type'].str.contains('Land')]
df.to_csv(f'{CSV_DIRECTORY}/test_cards.csv')
df.to_csv(f'{CSV_DIRECTORY}/test_cards.csv', index=False)
# Create specialized frames
self.artifact_df = df[df['type'].str.contains('Artifact')].copy()
@ -774,9 +760,10 @@ class DeckBuilder:
self.enchantment_df = df[df['type'].str.contains('Enchantment')].copy()
self.instant_df = df[df['type'].str.contains('Instant')].copy()
self.planeswalker_df = df[df['type'].str.contains('Planeswalker')].copy()
self.nonplaneswalker_df = df[~df['type'].str.contains('Planeswalker')].copy()
self.sorcery_df = df[df['type'].str.contains('Sorcery')].copy()
self.battle_df.to_csv(f'{CSV_DIRECTORY}/test_battle_cards.csv')
self.battle_df.to_csv(f'{CSV_DIRECTORY}/test_battle_cards.csv', index=False)
# Sort all frames
for frame in [self.artifact_df, self.battle_df, self.creature_df,
@ -859,8 +846,9 @@ class DeckBuilder:
try:
# Load and combine data
self.full_df = self._load_and_combine_data()
self.full_df = self.full_df[~self.full_df['name'].str.contains(self.commander)]
self.full_df.sort_values(by='edhrecRank', inplace=True)
self.full_df.to_csv(f'{CSV_DIRECTORY}/test_full_cards.csv')
self.full_df.to_csv(f'{CSV_DIRECTORY}/test_full_cards.csv', index=False)
# Split into specialized frames
self._split_into_specialized_frames(self.full_df)
@ -962,7 +950,6 @@ class DeckBuilder:
self.tertiary_theme,
self.weights
)
print(self.weights)
self.primary_weight = self.weights['primary']
self.secondary_weight = self.weights['secondary']
self.tertiary_weight = self.weights['tertiary']
@ -972,8 +959,7 @@ class DeckBuilder:
if self.secondary_theme:
self.themes.append(self.secondary_theme)
if self.tertiary_theme:
self.themes.append(self.tertiary_theme)
print(self.weights)
self.themes.append
self.determine_hidden_themes()
except (ThemeSelectionError, ThemeWeightError) as e:
@ -1323,7 +1309,7 @@ class DeckBuilder:
6. Add miscellaneous utility lands
7. Adjust total land count to match ideal count
"""
MAX_ADJUSTMENT_ATTEMPTS = 10
MAX_ADJUSTMENT_ATTEMPTS = (self.ideal_land_count - self.min_basics) * 1.5
self.total_basics = 0
try:
@ -2032,222 +2018,395 @@ class DeckBuilder:
logger.error(f"Error calculating CMC: {e}")
self.cmc = 0.0
def weight_by_theme(self, tag, ideal=1, weight=1, df=None):
# First grab the first 50/30/20 cards that match each theme
"""Add cards with specific tag up to ideal_value count"""
ideal_value = math.ceil(ideal * weight * 0.9)
print(f'Finding {ideal_value} cards with the "{tag}" tag...')
if 'Kindred' in tag:
tags = [tag, 'Kindred Support']
else:
tags = [tag]
# Filter cards with the given tag
tag_df = df.copy()
tag_df.sort_values(by='edhrecRank', inplace=True)
tag_df = tag_df[tag_df['themeTags'].apply(lambda x: any(tag in x for tag in tags))]
# Take top cards based on ideal value
pool_size = int(ideal_value * random.randint(15, 20) /10)
tag_df = tag_df.head(pool_size)
# Convert to list of card dictionaries
card_pool = [
{
'name': row['name'],
'type': row['type'],
'manaCost': row['manaCost'],
'manaValue': row['manaValue'],
'creatureTypes': row['creatureTypes'],
'themeTags': row['themeTags']
}
for _, row in tag_df.iterrows()
]
def weight_by_theme(self, tag: str, ideal: int = 1, weight: float = 1.0, df: Optional[pd.DataFrame] = None) -> None:
"""Add cards with specific tag up to weighted ideal count.
# Randomly select cards up to ideal value
cards_to_add = []
while len(cards_to_add) < ideal_value and card_pool:
card = random.choice(card_pool)
card_pool.remove(card)
Args:
tag: Theme tag to filter cards by
ideal: Target number of cards to add
weight: Theme weight factor (0.0-1.0)
df: Source DataFrame to filter cards from
Raises:
ThemeWeightingError: If weight calculation fails
ThemePoolError: If card pool is empty or insufficient
"""
try:
# Calculate target card count using weight and safety multiplier
target_count = math.ceil(ideal * weight * THEME_WEIGHT_MULTIPLIER)
logger.info(f'Finding {target_count} cards with the "{tag}" tag...')
# Handle Kindred theme special case
tags = [tag, 'Kindred Support'] if 'Kindred' in tag else [tag]
# Calculate initial pool size
pool_size = builder_utils.calculate_weighted_pool_size(target_count, weight)
# Filter cards by theme
if df is None:
raise ThemePoolError(f"No source DataFrame provided for theme {tag}")
# Check price constraints 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
tag_df = builder_utils.filter_theme_cards(df, tags, pool_size)
if tag_df.empty:
raise ThemePoolError(f"No cards found for theme {tag}")
# Select cards considering price and duplicates
selected_cards = builder_utils.select_weighted_cards(
tag_df,
target_count,
self.price_checker if use_scrython else None,
self.max_card_price if hasattr(self, 'max_card_price') else None
)
# Process selected cards
cards_added = []
for card in selected_cards:
# Handle multiple copy cards
if card['name'] in multiple_copy_cards:
copies = {
'Nazgûl': 9,
'Seven Dwarves': 7
}.get(card['name'], target_count - len(cards_added))
# Add card if not already in library
if card['name'] in multiple_copy_cards:
if card['name'] == 'Nazgûl':
for _ in range(9):
cards_to_add.append(card)
elif card['name'] == 'Seven Dwarves':
for _ in range(7):
cards_to_add.append(card)
for _ in range(copies):
cards_added.append(card)
# Handle regular cards
elif card['name'] not in self.card_library['Card Name'].values:
cards_added.append(card)
else:
num_to_add = ideal_value - len(cards_to_add)
for _ in range(num_to_add):
cards_to_add.append(card)
elif (card['name'] not in multiple_copy_cards
and card['name'] not in self.card_library['Card Name'].values):
cards_to_add.append(card)
elif (card['name'] not in multiple_copy_cards
and card['name'] in self.card_library['Card Name'].values):
logger.warning(f"{card['name']} already in Library, skipping it.")
continue
# Add selected cards to library
for card in cards_to_add:
self.add_card(card['name'], card['type'],
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)]
self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)]
logger.info(f'Added {len(cards_to_add)} {tag} cards')
#tag_df.to_csv(f'{CSV_DIRECTORY}/test_{tag}.csv', index=False)
logger.warning(f"{card['name']} already in Library, skipping it.")
# Add selected cards to library
for card in cards_added:
self.add_card(
card['name'],
card['type'],
card['manaCost'],
card['manaValue'],
card.get('creatureTypes'),
card['themeTags']
)
# Update DataFrames
used_cards = {card['name'] for card in selected_cards}
self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(used_cards)]
logger.info(f'Added {len(cards_added)} {tag} cards')
for card in cards_added:
print(card['name'])
except (ThemeWeightingError, ThemePoolError) as e:
logger.error(f"Error in weight_by_theme: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error in weight_by_theme: {e}")
raise ThemeWeightingError(f"Failed to process theme {tag}: {str(e)}")
def add_by_tags(self, tag, ideal_value=1, df=None):
"""Add cards with specific tag up to ideal_value count"""
print(f'Finding {ideal_value} cards with the "{tag}" tag...')
def add_by_tags(self, tag, ideal_value=1, df=None, ignore_existing=False):
"""Add cards with specific tag up to ideal_value count.
Args:
tag: The theme tag to filter cards by
ideal_value: Target number of cards to add
df: DataFrame containing candidate cards
# Filter cards with the given tag
skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1
tag_df = df.copy()
tag_df.sort_values(by='edhrecRank', inplace=True)
tag_df = tag_df[tag_df['themeTags'].apply(lambda x: tag in x)]
# Take top cards based on ideal value
pool_size = int(ideal_value * random.randint(2, 3))
tag_df = tag_df.head(pool_size)
# Convert to list of card dictionaries
card_pool = [
{
'name': row['name'],
'type': row['type'],
'manaCost': row['manaCost'],
'manaValue': row['manaValue'],
'creatureTypes': row['creatureTypes'],
'themeTags': row['themeTags']
}
for _, row in tag_df.iterrows()
]
# Randomly select cards up to ideal value
cards_to_add = []
while len(cards_to_add) < ideal_value and card_pool:
card = random.choice(card_pool)
card_pool.remove(card)
# Check price constraints 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
# Add card if not already in library
if card['name'] not in self.card_library['Card Name'].values:
if 'Creature' in card['type'] and skip_creatures:
continue
else:
if 'Creature' in card['type']:
self.creature_cards += 1
skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1
cards_to_add.append(card)
# 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'],
card['creatureTypes'], card['themeTags'])
Raises:
ThemeTagError: If there are issues with tag processing or card selection
"""
try:
# Count existing cards with target tag
print()
if not ignore_existing:
existing_count = len(self.card_library[self.card_library['Themes'].apply(lambda x: x is not None and tag in x)])
remaining_slots = max(0, ideal_value - existing_count + 1)
else:
continue
existing_count = 0
remaining_slots = max(0, ideal_value - existing_count + 1)
card_pool_names = [item['name'] for item in card_pool]
self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)]
self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)]
logger.info(f'Added {len(cards_to_add)} {tag} cards')
#tag_df.to_csv(f'{CSV_DIRECTORY}/test_{tag}.csv', index=False)
if remaining_slots == 0:
if not ignore_existing:
logger.info(f'Already have {existing_count} cards with tag "{tag}" - no additional cards needed')
return
else:
logger.info(f'Already have {ideal_value} cards with tag "{tag}" - no additional cards needed')
return
logger.info(f'Finding {remaining_slots} additional cards with the "{tag}" tag...')
# Filter cards with the given tag
skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1
tag_df = df.copy()
tag_df.sort_values(by='edhrecRank', inplace=True)
tag_df = tag_df[tag_df['themeTags'].apply(lambda x: x is not None and tag in x)]
# Calculate initial pool size using THEME_POOL_SIZE_MULTIPLIER
pool_size = int(remaining_slots * THEME_POOL_SIZE_MULTIPLIER)
tag_df = tag_df.head(pool_size)
# Convert to list of card dictionaries with priority scores
card_pool = []
for _, row in tag_df.iterrows():
theme_tags = row['themeTags'] if row['themeTags'] is not None else []
priority = builder_utils.calculate_theme_priority(theme_tags, self.themes, THEME_PRIORITY_BONUS)
card_pool.append({
'name': row['name'],
'type': row['type'],
'manaCost': row['manaCost'],
'manaValue': row['manaValue'],
'creatureTypes': row['creatureTypes'],
'themeTags': theme_tags,
'priority': priority
})
# Sort card pool by priority score
card_pool.sort(key=lambda x: x['priority'], reverse=True)
# Select cards up to remaining slots
cards_to_add = []
for card in card_pool:
if len(cards_to_add) >= remaining_slots:
break
# Check price constraints if enabled
if use_scrython and hasattr(self, 'max_card_price') and self.max_card_price:
price = self.price_checker.get_card_price(card['name'])
if price > self.max_card_price * 1.1:
continue
# Handle multiple-copy cards
if card['name'] in multiple_copy_cards:
existing_copies = len(self.card_library[self.card_library['Card Name'] == card['name']])
if existing_copies < ideal_value:
cards_to_add.append(card)
continue
# Add new cards if not already in library
if card['name'] not in self.card_library['Card Name'].values:
if 'Creature' in card['type'] and skip_creatures:
continue
else:
if 'Creature' in card['type']:
self.creature_cards += 1
skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1
cards_to_add.append(card)
# 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'],
card['creatureTypes'], card['themeTags'])
else:
break
# Update DataFrames
card_pool_names = [item['name'] for item in card_pool]
self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)]
logger.info(f'Added {len(cards_to_add)} {tag} cards (total with tag: {existing_count + len(cards_to_add)})')
for card in cards_to_add:
print(card['name'])
except Exception as e:
raise ThemeTagError(f"Error processing tag '{tag}'", {"error": str(e)})
def add_creatures(self):
"""
Add creatures to the deck based on themes and self.weights.
Add creatures to the deck based on themes and weights.
This method processes the primary, secondary, and tertiary themes to add
creatures proportionally according to their self.weights. The total number of
creatures proportionally according to their weights. The total number of
creatures added will approximate the ideal_creature_count.
Themes are processed in order of importance (primary -> secondary -> tertiary)
with error handling to ensure the deck building process continues even if
a particular theme encounters issues.
The method follows this process:
1. Process hidden theme if present
2. Process primary theme
3. Process secondary theme if present
4. Process tertiary theme if present
Each theme is weighted according to its importance:
- Hidden theme: Highest priority if present
- Primary theme: Main focus
- Secondary theme: Supporting focus
- Tertiary theme: Minor focus
Args:
None
Returns:
None
Raises:
ThemeWeightingError: If there are issues with theme weight calculations
ThemePoolError: If the card pool for a theme is insufficient
Exception: For any other unexpected errors during creature addition
Note:
The method uses error handling to ensure the deck building process
continues even if a particular theme encounters issues.
"""
print(f'Adding creatures to deck based on the ideal creature count of {self.ideal_creature_count}...')
print()
logger.info(f'Adding creatures to deck based on the ideal creature count of {self.ideal_creature_count}...')
try:
if self.hidden_theme:
print(f'Processing Hidden theme: {self.hidden_theme}')
print()
logger.info(f'Processing Hidden theme: {self.hidden_theme}')
self.weight_by_theme(self.hidden_theme, self.ideal_creature_count, self.hidden_weight, self.creature_df)
print(f'Processing primary theme: {self.primary_theme}')
logger.info(f'Processing primary theme: {self.primary_theme}')
self.weight_by_theme(self.primary_theme, self.ideal_creature_count, self.primary_weight, self.creature_df)
if self.secondary_theme:
print(f'Processing secondary theme: {self.secondary_theme}')
print()
logger.info(f'Processing secondary theme: {self.secondary_theme}')
self.weight_by_theme(self.secondary_theme, self.ideal_creature_count, self.secondary_weight, self.creature_df)
if self.tertiary_theme:
print(f'Processing tertiary theme: {self.tertiary_theme}')
print()
logger.info(f'Processing tertiary theme: {self.tertiary_theme}')
self.weight_by_theme(self.tertiary_theme, self.ideal_creature_count, self.tertiary_weight, self.creature_df)
except Exception as e:
logger.error(f"Error while adding creatures: {e}")
finally:
self.organize_library()
logger.info(f'Creature addition complete. Total creatures (including commander): {self.creature_cards}')
def add_ramp(self):
"""Add ramp cards to the deck based on ideal ramp count.
This method adds three categories of ramp cards:
1. Mana rocks (artifacts that produce mana) - ~1/3 of ideal ramp count
2. Mana dorks (creatures that produce mana) - ~1/4 of ideal ramp count
3. General ramp spells - remaining portion of ideal ramp count
The method uses the add_by_tags() helper to add cards from each category
while respecting the deck's themes and color identity.
Args:
None
Returns:
None
Raises:
ThemeTagError: If there are issues adding cards with ramp-related tags
"""
try:
self.add_by_tags('Mana Rock', math.ceil(self.ideal_ramp / 3), self.noncreature_df)
self.add_by_tags('Mana Dork', math.ceil(self.ideal_ramp / 4), self.creature_df)
self.add_by_tags('Ramp', math.ceil(self.ideal_ramp / 2), self.noncreature_df)
self.add_by_tags('Ramp', self.ideal_ramp, self.noncreature_df)
except Exception as e:
logger.error(f"Error while adding Ramp: {e}")
finally:
logger.info('Adding Ramp complete.')
def add_interaction(self):
"""Add interaction cards to the deck for removal and protection.
This method adds two categories of interaction cards:
1. Removal spells based on ideal_removal count
2. Protection spells based on ideal_protection count
Cards are selected from non-planeswalker cards to ensure appropriate
interaction types are added.
Args:
None
Returns:
None
Raises:
ThemeTagError: If there are issues adding cards with interaction-related tags
"""
try:
self.add_by_tags('Removal', self.ideal_removal, self.noncreature_nonplaneswaker_df)
self.add_by_tags('Protection', self.ideal_protection, self.noncreature_nonplaneswaker_df)
self.add_by_tags('Removal', self.ideal_removal, self.nonplaneswalker_df)
self.add_by_tags('Protection', self.ideal_protection, self.nonplaneswalker_df)
except Exception as e:
logger.error(f"Error while adding Interaction: {e}")
finally:
logger.info('Adding Interaction complete.')
def add_board_wipes(self):
"""Add board wipe cards to the deck.
This method adds board wipe cards based on the ideal_wipes count.
Board wipes are selected from the full card pool to include all possible
options across different card types.
Args:
None
Returns:
None
Raises:
ThemeTagError: If there are issues adding cards with the 'Board Wipes' tag
"""
try:
self.add_by_tags('Board Wipes', self.ideal_wipes, self.full_df)
except Exception as e:
logger.error(f"Error while adding Board Wipes: {e}")
finally:
logger.info('Adding Board Wipes complete.')
def add_card_advantage(self):
"""Add card advantage effects to the deck.
This method adds two categories of card draw effects:
1. Conditional draw effects (20% of ideal_card_advantage)
- Cards that draw based on specific conditions or triggers
2. Unconditional draw effects (80% of ideal_card_advantage)
- Cards that provide straightforward card draw
Cards are selected from appropriate pools while avoiding planeswalkers
for unconditional draw effects.
Args:
None
Returns:
None
Raises:
ThemeTagError: If there are issues adding cards with draw-related tags
"""
try:
self.add_by_tags('Conditional Draw', math.ceil(self.ideal_card_advantage * 0.2), self.full_df)
self.add_by_tags('Unconditional Draw', math.ceil(self.ideal_card_advantage * 0.8), self.noncreature_nonplaneswaker_df)
self.add_by_tags('Unconditional Draw', math.ceil(self.ideal_card_advantage * 0.8), self.nonplaneswalker_df)
except Exception as e:
logger.error(f"Error while adding Card Draw: {e}")
finally:
logger.info('Adding Card Draw complete.')
def fill_out_deck(self):
"""Fill out the deck to 100 cards with theme-appropriate cards."""
"""Fill out the deck to 100 cards with theme-appropriate cards.
This method completes the deck by adding remaining cards up to the 100-card
requirement, prioritizing cards that match the deck's themes. The process
follows these steps:
1. Calculate how many cards are needed to reach 100
2. Add cards from each theme with weighted distribution:
- Hidden theme (if present)
- Tertiary theme (20% weight if present)
- Secondary theme (30% weight if present)
- Primary theme (50% weight)
The method includes safeguards:
- Maximum attempts limit to prevent infinite loops
- Timeout to prevent excessive runtime
- Progress tracking to break early if insufficient progress
Args:
None
Returns:
None
Raises:
ThemeTagError: If there are issues adding cards with specific theme tags
TimeoutError: If the process exceeds the maximum allowed time
Note:
If the deck cannot be filled to 100 cards, a warning message is logged
indicating manual additions may be needed.
"""
print()
logger.info('Filling out the Library to 100 with cards fitting the themes.')
cards_needed = 100 - len(self.card_library)
if cards_needed <= 0:
return
@ -2274,38 +2433,57 @@ class DeckBuilder:
try:
# Add cards from each theme with adjusted self.weights
if self.tertiary_theme:
if self.hidden_theme and remaining > 0:
self.add_by_tags(self.hidden_theme,
math.ceil(weight_multiplier),
self.full_df,
True)
# Adjust self.weights based on remaining cards needed
remaining = 100 - len(self.card_library)
weight_multiplier = remaining / cards_needed
if self.tertiary_theme and remaining > 0:
self.add_by_tags(self.tertiary_theme,
math.ceil(self.tertiary_weight * 10 * weight_multiplier),
self.noncreature_df)
if self.secondary_theme:
math.ceil(weight_multiplier * 0.2),
self.noncreature_df,
True)
if self.secondary_theme and remaining > 0:
self.add_by_tags(self.secondary_theme,
math.ceil(self.secondary_weight * 3 * weight_multiplier),
self.noncreature_df)
self.add_by_tags(self.primary_theme,
math.ceil(self.primary_weight * 2 * weight_multiplier),
self.noncreature_df)
math.ceil(weight_multiplier * 0.3),
self.noncreature_df,
True)
if remaining > 0:
self.add_by_tags(self.primary_theme,
math.ceil(weight_multiplier * 0.5),
self.noncreature_df,
True)
# Check if we made progress
if len(self.card_library) == initial_count:
attempts += 1
if attempts % 5 == 0:
print()
logger.warning(f"Made {attempts} attempts, still need {100 - len(self.card_library)} cards")
# Break early if we're stuck
if attempts >= MAX_ATTEMPTS / 2 and len(self.card_library) < initial_count + (cards_needed / 4):
print()
logger.warning("Insufficient progress being made, breaking early")
break
except Exception as e:
print()
logger.error(f"Error while adding cards: {e}")
attempts += 1
final_count = len(self.card_library)
if final_count < 100:
message = f"\nWARNING: Deck is incomplete with {final_count} cards. Manual additions may be needed."
print()
logger.warning(message)
else:
print()
logger.info(f"Successfully filled deck to {final_count} cards in {attempts} attempts")
def main():
"""Main entry point for deck builder application."""

View file

@ -1278,4 +1278,111 @@ class ManaPipError(DeckBuilderError):
message,
code="MANA_PIP_ERR",
details=details
)
class ThemeTagError(DeckBuilderError):
"""Raised when there are issues processing theme tags.
This exception is used when there are problems processing or validating theme tags,
such as invalid tag formats, missing required tags, or tag validation failures.
Examples:
>>> raise ThemeTagError(
... "Invalid theme tag format",
... {"tag": "invalid#tag", "expected_format": "theme:subtheme"}
... )
>>> raise ThemeTagError(
... "Missing required theme tags",
... {"card_name": "Example Card", "required_tags": ["theme:tribal", "theme:synergy"]}
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize theme tag error.
Args:
message: Description of the theme tag processing failure
details: Additional context about the error
"""
super().__init__(
message,
code="THEME_TAG_ERR",
details=details
)
class ThemeWeightingError(DeckBuilderError):
"""Raised when there are issues with theme-based card weighting.
This exception is used when there are problems calculating or validating
theme weights, such as invalid weight values, calculation errors, or
inconsistencies in theme weight distribution.
Examples:
>>> raise ThemeWeightingError(
... "Invalid theme weight value",
... {"theme": "tribal", "weight": -1, "valid_range": "0-100"}
... )
>>> raise ThemeWeightingError(
... "Theme weight calculation error",
... {"theme": "artifacts", "error": "Division by zero in weight normalization"}
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize theme weighting error.
Args:
message: Description of the theme weighting failure
details: Additional context about the error
"""
super().__init__(
message,
code="THEME_WEIGHT_ERR",
details=details
)
class ThemePoolError(DeckBuilderError):
"""Raised when there are issues with the theme card pool.
This exception is used when there are problems creating or managing the theme
card pool, such as empty pools, insufficient cards, or invalid pool configurations.
Examples:
>>> raise ThemePoolError(
... "Empty theme card pool",
... {"theme": "spellslinger", "required_cards": 30}
... )
>>> raise ThemePoolError(
... "Insufficient cards in theme pool",
... {
... "theme": "artifacts",
... "available_cards": 15,
... "required_cards": 25
... }
... )
>>> raise ThemePoolError(
... "Invalid card pool configuration",
... {
... "theme": "tribal",
... "creature_type": "Dragon",
... "error": "No cards match creature type"
... }
... )
"""
def __init__(self, message: str, details: dict | None = None):
"""Initialize theme pool error.
Args:
message: Description of the theme pool failure
details: Additional context about the error
"""
super().__init__(
message,
code="THEME_POOL_ERR",
details=details
)

View file

@ -544,7 +544,7 @@ REMOVAL_TEXT_PATTERNS = [
'returns target.*to.*hand'
]
REMOVAL_SPECIFIC_CARDS = [] # type: list
REMOVAL_SPECIFIC_CARDS = ['from.*graveyard.*hand'] # type: list
REMOVAL_EXCLUSION_PATTERNS = [] # type: list
@ -1032,7 +1032,17 @@ REQUIRED_COLUMNS: List[str] = [
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
]
# Constants for theme weight management
# Constants for theme weight management and selection
# Multiplier for initial card pool size during theme-based selection
THEME_POOL_SIZE_MULTIPLIER: Final[float] = 2.0
# Bonus multiplier for cards that match multiple deck themes
THEME_PRIORITY_BONUS: Final[float] = 1.2
# Safety multiplier to avoid overshooting target counts
THEME_WEIGHT_MULTIPLIER: Final[float] = 0.9
THEME_WEIGHTS_DEFAULT: Final[Dict[str, float]] = {
'primary': 1.0,
'secondary': 0.6,

View file

@ -1,26 +1,53 @@
from __future__ import annotations
"""MTG Python Deckbuilder setup module.
This module provides the main setup functionality for the MTG Python Deckbuilder
application. It handles initial setup tasks such as downloading card data,
creating color-filtered card lists, and generating commander-eligible card lists.
Key Features:
- Initial setup and configuration
- Card data download and processing
- Color-based card filtering
- Commander card list generation
- CSV file management and validation
The module works in conjunction with setup_utils.py for utility functions and
exceptions.py for error handling.
"""
# Standard library imports
import logging
from enum import Enum
from pathlib import Path
import os
from pathlib import Path
from typing import Union, List, Dict, Any
# Third-party imports
import pandas as pd
import inquirer
import pandas as pd
# Local application imports
from settings import (
banned_cards, CSV_DIRECTORY, SETUP_COLORS, COLOR_ABRV, MTGJSON_API_URL
banned_cards,
COLOR_ABRV,
CSV_DIRECTORY,
MTGJSON_API_URL,
SETUP_COLORS
)
from setup_utils import (
download_cards_csv, filter_dataframe, process_legendary_cards, filter_by_color_identity
download_cards_csv,
filter_by_color_identity,
filter_dataframe,
process_legendary_cards
)
from exceptions import (
CSVFileNotFoundError, MTGJSONDownloadError, DataFrameProcessingError,
ColorFilterError, CommanderValidationError
CSVFileNotFoundError,
ColorFilterError,
CommanderValidationError,
DataFrameProcessingError,
MTGJSONDownloadError
)
# Create logs directory if it doesn't exist
if not os.path.exists('logs'):

View file

@ -347,7 +347,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
) from e
def process_card_dataframe(df: CardLibraryDF, batch_size: int = 1000, columns_to_keep: Optional[List[str]] = None,
include_commander_cols: bool = False, skip_availability_checks: bool = False) -> pd.DataFrame:
include_commander_cols: bool = False, skip_availability_checks: bool = False) -> CardLibraryDF:
"""Process DataFrame with common operations in batches.
Args:

View file

@ -293,7 +293,7 @@ def create_theme_tags(df: pd.DataFrame, color: str) -> None:
raise TypeError("df must be a pandas DataFrame")
if not isinstance(color, str):
raise TypeError("color must be a string")
if color not in settings.colors:
if color not in settings.COLORS:
raise ValueError(f"Invalid color: {color}")
try:
@ -2633,6 +2633,24 @@ def create_token_modifier_mask(df: pd.DataFrame) -> pd.Series:
return has_modifier & has_effect & ~name_exclusions
def create_tokens_matter_mask(df: pd.DataFrame) -> pd.Series:
"""Create a boolean mask for cards that care about tokens.
Args:
df: DataFrame to search
Returns:
Boolean Series indicating which cards care about tokens
"""
# Create patterns for token matters
text_patterns = [
'tokens.*you.*control',
'that\'s a token',
]
text_mask = tag_utils.create_text_mask(df, text_patterns)
return text_mask
def tag_for_tokens(df: pd.DataFrame, color: str) -> None:
"""Tag cards that create or modify tokens using vectorized operations.
@ -2670,6 +2688,13 @@ def tag_for_tokens(df: pd.DataFrame, color: str) -> None:
tag_utils.apply_tag_vectorized(df, modifier_mask,
['Token Modification', 'Token Creation', 'Tokens Matter'])
logger.info('Tagged %d cards that modify token creation', modifier_mask.sum())
# Create tokens matter mask
matters_mask = create_tokens_matter_mask(df)
if matters_mask.any():
tag_utils.apply_tag_vectorized(df, matters_mask,
['Tokens Matter'])
logger.info('Tagged %d cards that care about tokens', modifier_mask.sum())
duration = (pd.Timestamp.now() - start_time).total_seconds()
logger.info('Completed token tagging in %.2fs', duration)
@ -3638,7 +3663,7 @@ def tag_for_cantrips(df: pd.DataFrame, color: str) -> None:
logger.error('Error tagging Cantrips in %s_cards.csv: %s', color, str(e))
raise
## Magecraft
def create_magecraft_mask(df: pd.DataFrame) -> pd.Series:
"""Create a boolean mask for cards with magecraft effects.
@ -6369,6 +6394,18 @@ def create_removal_text_mask(df: pd.DataFrame) -> pd.Series:
"""
return tag_utils.create_text_mask(df, settings.REMOVAL_TEXT_PATTERNS)
def create_removal_exclusion_mask(df: pd.DataFrame) -> pd.Series:
"""Create a boolean mask for cards that should be excluded from removal effects.
Args:
df: DataFrame to search
Returns:
Boolean Series indicating which cards should be excluded
"""
return tag_utils.create_text_mask(df, settings.REMOVAL_EXCLUSION_PATTERNS)
def tag_for_removal(df: pd.DataFrame, color: str) -> None:
"""Tag cards that provide spot removal using vectorized operations.
@ -6422,7 +6459,8 @@ def tag_for_removal(df: pd.DataFrame, color: str) -> None:
def run_tagging():
start_time = pd.Timestamp.now()
for color in settings.colors:
for color in settings.COLORS:
load_dataframe(color)
duration = (pd.Timestamp.now() - start_time).total_seconds()
logger.info(f'Tagged cards in {duration:.2f}s')
logger.info(f'Tagged cards in {duration:.2f}s')

View file

@ -46,4 +46,5 @@ NonCreatureDF = pd.DataFrame
EnchantmentDF = pd.DataFrame
InstantDF = pd.DataFrame
PlaneswalkerDF = pd.DataFrame
NonPlaneswalkerDF = pd.DataFrame
SorceryDF = pd.DataFrame