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 """Utility module for MTG deck building operations.
import pandas as pd
from price_check import PriceChecker This module provides utility functions for various deck building operations including:
from input_handler import InputHandler - DataFrame validation and processing
import logging - 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 functools
import logging
import time import time
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union, cast
# Third-party imports
import pandas as pd import pandas as pd
from fuzzywuzzy import process from fuzzywuzzy import process
from settings import (
COMMANDER_CSV_PATH, # Local application imports
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
)
from exceptions import ( from exceptions import (
CSVValidationError,
DataFrameTimeoutError,
DataFrameValidationError,
DeckBuilderError, DeckBuilderError,
DuplicateCardError, DuplicateCardError,
CSVValidationError,
DataFrameValidationError,
DataFrameTimeoutError,
EmptyDataFrameError, EmptyDataFrameError,
FetchLandSelectionError, FetchLandSelectionError,
FetchLandValidationError, FetchLandValidationError,
@ -54,6 +53,24 @@ from exceptions import (
ThemeWeightError, ThemeWeightError,
CardTypeCountError 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( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -61,7 +78,6 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Type variables for generic functions # Type variables for generic functions
T = TypeVar('T') T = TypeVar('T')
DataFrame = TypeVar('DataFrame', bound=pd.DataFrame) DataFrame = TypeVar('DataFrame', bound=pd.DataFrame)
@ -431,7 +447,6 @@ def adjust_theme_weights(primary_theme: str,
if total_weight > 0: if total_weight > 0:
adjusted_weights = {k: round(v/total_weight, 2) for k, v in adjusted_weights.items()} adjusted_weights = {k: round(v/total_weight, 2) for k, v in adjusted_weights.items()}
print(adjusted_weights)
return adjusted_weights return adjusted_weights
except Exception as e: 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}") logger.error(f"Error selecting land for removal: {e}")
raise 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: 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. """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_CSV_PATH, FUZZY_MATCH_THRESHOLD, MAX_FUZZY_CHOICES, FETCH_LAND_DEFAULT_COUNT,
COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT, COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT,
COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT, COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT,
THEME_PRIORITY_BONUS, THEME_POOL_SIZE_MULTIPLIER,
COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_DEFAULT, COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_DEFAULT,
COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, DUAL_LAND_TYPE_MAP, COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, DUAL_LAND_TYPE_MAP,
CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS, CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS,
STAPLE_LAND_CONDITIONS, TRIPLE_LAND_TYPE_MAP, MISC_LAND_MAX_COUNT, MISC_LAND_MIN_COUNT, 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, 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 builder_utils
import setup_utils import setup_utils
from setup import determine_commanders
from input_handler import InputHandler from input_handler import InputHandler
from exceptions import ( from exceptions import (
BasicLandCountError, BasicLandCountError,
@ -37,7 +37,6 @@ from exceptions import (
CommanderMoveError, CommanderMoveError,
CardTypeCountError, CardTypeCountError,
CommanderColorError, CommanderColorError,
CommanderLoadError,
CommanderSelectionError, CommanderSelectionError,
CommanderValidationError, CommanderValidationError,
CSVError, CSVError,
@ -48,16 +47,12 @@ from exceptions import (
DuplicateCardError, DuplicateCardError,
DeckBuilderError, DeckBuilderError,
EmptyDataFrameError, EmptyDataFrameError,
EmptyInputError,
FetchLandSelectionError, FetchLandSelectionError,
FetchLandValidationError, FetchLandValidationError,
IdealDeterminationError, IdealDeterminationError,
InvalidNumberError,
InvalidQuestionTypeError,
LandRemovalError, LandRemovalError,
LibraryOrganizationError, LibraryOrganizationError,
LibrarySortError, LibrarySortError,
MaxAttemptsError,
PriceAPIError, PriceAPIError,
PriceConfigurationError, PriceConfigurationError,
PriceLimitError, PriceLimitError,
@ -66,18 +61,21 @@ from exceptions import (
ThemeSelectionError, ThemeSelectionError,
ThemeWeightError, ThemeWeightError,
StapleLandError, StapleLandError,
StapleLandError, ManaPipError,
ManaPipError ThemeTagError,
ThemeWeightingError,
ThemePoolError
) )
from type_definitions import ( from type_definitions import (
CardDict,
CommanderDict, CommanderDict,
CardLibraryDF, CardLibraryDF,
CommanderDF, CommanderDF,
LandDF, LandDF,
ArtifactDF, ArtifactDF,
CreatureDF, CreatureDF,
NonCreatureDF) NonCreatureDF,
PlaneswalkerDF,
NonPlaneswalkerDF)
# Try to import scrython and price_checker # Try to import scrython and price_checker
try: try:
@ -102,19 +100,6 @@ pd.set_option('display.max_columns', None)
pd.set_option('display.max_rows', None) pd.set_option('display.max_rows', None)
pd.set_option('display.max_colwidth', 50) 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: def new_line(num_lines: int = 1) -> None:
"""Print specified number of newlines for formatting output. """Print specified number of newlines for formatting output.
@ -149,9 +134,10 @@ class DeckBuilder:
self.artifact_df: ArtifactDF = pd.DataFrame() self.artifact_df: ArtifactDF = pd.DataFrame()
self.creature_df: CreatureDF = pd.DataFrame() self.creature_df: CreatureDF = pd.DataFrame()
self.noncreature_df: NonCreatureDF = pd.DataFrame() self.noncreature_df: NonCreatureDF = pd.DataFrame()
self.nonplaneswalker_df: NonPlaneswalkerDF = pd.DataFrame()
# Initialize other attributes with type hints # Initialize other attributes with type hints
self.commander_info: Dict = {} self.commander_info: Dict = {}
self.max_card_price: Optional[float] = None
self.commander_dict: CommanderDict = {} self.commander_dict: CommanderDict = {}
self.commander: str = '' self.commander: str = ''
self.commander_type: str = '' self.commander_type: str = ''
@ -764,7 +750,7 @@ class DeckBuilder:
# Remove lands from main DataFrame # Remove lands from main DataFrame
df = df[~df['type'].str.contains('Land')] 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 # Create specialized frames
self.artifact_df = df[df['type'].str.contains('Artifact')].copy() 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.enchantment_df = df[df['type'].str.contains('Enchantment')].copy()
self.instant_df = df[df['type'].str.contains('Instant')].copy() self.instant_df = df[df['type'].str.contains('Instant')].copy()
self.planeswalker_df = df[df['type'].str.contains('Planeswalker')].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.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 # Sort all frames
for frame in [self.artifact_df, self.battle_df, self.creature_df, for frame in [self.artifact_df, self.battle_df, self.creature_df,
@ -859,8 +846,9 @@ class DeckBuilder:
try: try:
# Load and combine data # Load and combine data
self.full_df = self._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.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 # Split into specialized frames
self._split_into_specialized_frames(self.full_df) self._split_into_specialized_frames(self.full_df)
@ -962,7 +950,6 @@ class DeckBuilder:
self.tertiary_theme, self.tertiary_theme,
self.weights self.weights
) )
print(self.weights)
self.primary_weight = self.weights['primary'] self.primary_weight = self.weights['primary']
self.secondary_weight = self.weights['secondary'] self.secondary_weight = self.weights['secondary']
self.tertiary_weight = self.weights['tertiary'] self.tertiary_weight = self.weights['tertiary']
@ -972,8 +959,7 @@ class DeckBuilder:
if self.secondary_theme: if self.secondary_theme:
self.themes.append(self.secondary_theme) self.themes.append(self.secondary_theme)
if self.tertiary_theme: if self.tertiary_theme:
self.themes.append(self.tertiary_theme) self.themes.append
print(self.weights)
self.determine_hidden_themes() self.determine_hidden_themes()
except (ThemeSelectionError, ThemeWeightError) as e: except (ThemeSelectionError, ThemeWeightError) as e:
@ -1323,7 +1309,7 @@ class DeckBuilder:
6. Add miscellaneous utility lands 6. Add miscellaneous utility lands
7. Adjust total land count to match ideal count 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 self.total_basics = 0
try: try:
@ -2032,222 +2018,395 @@ class DeckBuilder:
logger.error(f"Error calculating CMC: {e}") logger.error(f"Error calculating CMC: {e}")
self.cmc = 0.0 self.cmc = 0.0
def weight_by_theme(self, tag, ideal=1, weight=1, df=None): def weight_by_theme(self, tag: str, ideal: int = 1, weight: float = 1.0, df: Optional[pd.DataFrame] = None) -> None:
# First grab the first 50/30/20 cards that match each theme """Add cards with specific tag up to weighted ideal count.
"""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()
]
# Randomly select cards up to ideal value Args:
cards_to_add = [] tag: Theme tag to filter cards by
while len(cards_to_add) < ideal_value and card_pool: ideal: Target number of cards to add
card = random.choice(card_pool) weight: Theme weight factor (0.0-1.0)
card_pool.remove(card) 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 tag_df = builder_utils.filter_theme_cards(df, tags, pool_size)
if use_scrython and self.set_max_card_price: if tag_df.empty:
price = self.price_checker.get_card_price(card['name']) raise ThemePoolError(f"No cards found for theme {tag}")
if price > self.max_card_price * 1.1:
continue # 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 for _ in range(copies):
cards_added.append(card)
if card['name'] in multiple_copy_cards:
if card['name'] == 'Nazgûl': # Handle regular cards
for _ in range(9): elif card['name'] not in self.card_library['Card Name'].values:
cards_to_add.append(card) cards_added.append(card)
elif card['name'] == 'Seven Dwarves':
for _ in range(7):
cards_to_add.append(card)
else: else:
num_to_add = ideal_value - len(cards_to_add) logger.warning(f"{card['name']} already in Library, skipping it.")
for _ in range(num_to_add):
cards_to_add.append(card) # Add selected cards to library
for card in cards_added:
elif (card['name'] not in multiple_copy_cards self.add_card(
and card['name'] not in self.card_library['Card Name'].values): card['name'],
cards_to_add.append(card) card['type'],
card['manaCost'],
elif (card['name'] not in multiple_copy_cards card['manaValue'],
and card['name'] in self.card_library['Card Name'].values): card.get('creatureTypes'),
logger.warning(f"{card['name']} already in Library, skipping it.") card['themeTags']
continue )
# Add selected cards to library # Update DataFrames
for card in cards_to_add: used_cards = {card['name'] for card in selected_cards}
self.add_card(card['name'], card['type'], self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(used_cards)]
card['manaCost'], card['manaValue'],
card['creatureTypes'], card['themeTags']) logger.info(f'Added {len(cards_added)} {tag} cards')
for card in cards_added:
card_pool_names = [item['name'] for item in card_pool] print(card['name'])
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)] except (ThemeWeightingError, ThemePoolError) as e:
logger.info(f'Added {len(cards_to_add)} {tag} cards') logger.error(f"Error in weight_by_theme: {e}")
#tag_df.to_csv(f'{CSV_DIRECTORY}/test_{tag}.csv', index=False) 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): def add_by_tags(self, tag, ideal_value=1, df=None, ignore_existing=False):
"""Add cards with specific tag up to ideal_value count""" """Add cards with specific tag up to ideal_value count.
print(f'Finding {ideal_value} cards with the "{tag}" tag...') 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 Raises:
skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1 ThemeTagError: If there are issues with tag processing or card selection
tag_df = df.copy() """
tag_df.sort_values(by='edhrecRank', inplace=True) try:
tag_df = tag_df[tag_df['themeTags'].apply(lambda x: tag in x)] # Count existing cards with target tag
# Take top cards based on ideal value print()
pool_size = int(ideal_value * random.randint(2, 3)) if not ignore_existing:
tag_df = tag_df.head(pool_size) 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)
# 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'])
else: else:
continue existing_count = 0
remaining_slots = max(0, ideal_value - existing_count + 1)
card_pool_names = [item['name'] for item in card_pool] if remaining_slots == 0:
self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)] if not ignore_existing:
self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)] logger.info(f'Already have {existing_count} cards with tag "{tag}" - no additional cards needed')
logger.info(f'Added {len(cards_to_add)} {tag} cards') return
#tag_df.to_csv(f'{CSV_DIRECTORY}/test_{tag}.csv', index=False) 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): 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 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. creatures added will approximate the ideal_creature_count.
Themes are processed in order of importance (primary -> secondary -> tertiary) The method follows this process:
with error handling to ensure the deck building process continues even if 1. Process hidden theme if present
a particular theme encounters issues. 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: try:
if self.hidden_theme: 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) 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) self.weight_by_theme(self.primary_theme, self.ideal_creature_count, self.primary_weight, self.creature_df)
if self.secondary_theme: 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) self.weight_by_theme(self.secondary_theme, self.ideal_creature_count, self.secondary_weight, self.creature_df)
if self.tertiary_theme: 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) self.weight_by_theme(self.tertiary_theme, self.ideal_creature_count, self.tertiary_weight, self.creature_df)
except Exception as e: except Exception as e:
logger.error(f"Error while adding creatures: {e}") logger.error(f"Error while adding creatures: {e}")
finally: finally:
self.organize_library() self.organize_library()
logger.info(f'Creature addition complete. Total creatures (including commander): {self.creature_cards}')
def add_ramp(self): 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: try:
self.add_by_tags('Mana Rock', math.ceil(self.ideal_ramp / 3), self.noncreature_df) 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('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: except Exception as e:
logger.error(f"Error while adding Ramp: {e}") logger.error(f"Error while adding Ramp: {e}")
finally:
logger.info('Adding Ramp complete.')
def add_interaction(self): 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: try:
self.add_by_tags('Removal', self.ideal_removal, self.noncreature_nonplaneswaker_df) self.add_by_tags('Removal', self.ideal_removal, self.nonplaneswalker_df)
self.add_by_tags('Protection', self.ideal_protection, self.noncreature_nonplaneswaker_df) self.add_by_tags('Protection', self.ideal_protection, self.nonplaneswalker_df)
except Exception as e: except Exception as e:
logger.error(f"Error while adding Interaction: {e}") logger.error(f"Error while adding Interaction: {e}")
finally:
logger.info('Adding Interaction complete.')
def add_board_wipes(self): 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: try:
self.add_by_tags('Board Wipes', self.ideal_wipes, self.full_df) self.add_by_tags('Board Wipes', self.ideal_wipes, self.full_df)
except Exception as e: except Exception as e:
logger.error(f"Error while adding Board Wipes: {e}") logger.error(f"Error while adding Board Wipes: {e}")
finally:
logger.info('Adding Board Wipes complete.')
def add_card_advantage(self): 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: try:
self.add_by_tags('Conditional Draw', math.ceil(self.ideal_card_advantage * 0.2), self.full_df) 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: except Exception as e:
logger.error(f"Error while adding Card Draw: {e}") logger.error(f"Error while adding Card Draw: {e}")
finally:
logger.info('Adding Card Draw complete.')
def fill_out_deck(self): 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.') logger.info('Filling out the Library to 100 with cards fitting the themes.')
cards_needed = 100 - len(self.card_library) cards_needed = 100 - len(self.card_library)
if cards_needed <= 0: if cards_needed <= 0:
return return
@ -2274,38 +2433,57 @@ class DeckBuilder:
try: try:
# Add cards from each theme with adjusted self.weights # 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, self.add_by_tags(self.tertiary_theme,
math.ceil(self.tertiary_weight * 10 * weight_multiplier), math.ceil(weight_multiplier * 0.2),
self.noncreature_df) self.noncreature_df,
if self.secondary_theme: True)
if self.secondary_theme and remaining > 0:
self.add_by_tags(self.secondary_theme, self.add_by_tags(self.secondary_theme,
math.ceil(self.secondary_weight * 3 * weight_multiplier), math.ceil(weight_multiplier * 0.3),
self.noncreature_df) self.noncreature_df,
self.add_by_tags(self.primary_theme, True)
math.ceil(self.primary_weight * 2 * weight_multiplier), if remaining > 0:
self.noncreature_df) self.add_by_tags(self.primary_theme,
math.ceil(weight_multiplier * 0.5),
self.noncreature_df,
True)
# Check if we made progress # Check if we made progress
if len(self.card_library) == initial_count: if len(self.card_library) == initial_count:
attempts += 1 attempts += 1
if attempts % 5 == 0: if attempts % 5 == 0:
print()
logger.warning(f"Made {attempts} attempts, still need {100 - len(self.card_library)} cards") logger.warning(f"Made {attempts} attempts, still need {100 - len(self.card_library)} cards")
# Break early if we're stuck # Break early if we're stuck
if attempts >= MAX_ATTEMPTS / 2 and len(self.card_library) < initial_count + (cards_needed / 4): if attempts >= MAX_ATTEMPTS / 2 and len(self.card_library) < initial_count + (cards_needed / 4):
print()
logger.warning("Insufficient progress being made, breaking early") logger.warning("Insufficient progress being made, breaking early")
break break
except Exception as e: except Exception as e:
print()
logger.error(f"Error while adding cards: {e}") logger.error(f"Error while adding cards: {e}")
attempts += 1 attempts += 1
final_count = len(self.card_library) final_count = len(self.card_library)
if final_count < 100: if final_count < 100:
message = f"\nWARNING: Deck is incomplete with {final_count} cards. Manual additions may be needed." message = f"\nWARNING: Deck is incomplete with {final_count} cards. Manual additions may be needed."
print()
logger.warning(message) logger.warning(message)
else: else:
print()
logger.info(f"Successfully filled deck to {final_count} cards in {attempts} attempts") logger.info(f"Successfully filled deck to {final_count} cards in {attempts} attempts")
def main(): def main():
"""Main entry point for deck builder application.""" """Main entry point for deck builder application."""

View file

@ -1278,4 +1278,111 @@ class ManaPipError(DeckBuilderError):
message, message,
code="MANA_PIP_ERR", code="MANA_PIP_ERR",
details=details 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' 'returns target.*to.*hand'
] ]
REMOVAL_SPECIFIC_CARDS = [] # type: list REMOVAL_SPECIFIC_CARDS = ['from.*graveyard.*hand'] # type: list
REMOVAL_EXCLUSION_PATTERNS = [] # type: list REMOVAL_EXCLUSION_PATTERNS = [] # type: list
@ -1032,7 +1032,17 @@ REQUIRED_COLUMNS: List[str] = [
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side' '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]] = { THEME_WEIGHTS_DEFAULT: Final[Dict[str, float]] = {
'primary': 1.0, 'primary': 1.0,
'secondary': 0.6, 'secondary': 0.6,

View file

@ -1,26 +1,53 @@
from __future__ import annotations 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 # Standard library imports
import logging import logging
from enum import Enum from enum import Enum
from pathlib import Path
import os import os
from pathlib import Path
from typing import Union, List, Dict, Any from typing import Union, List, Dict, Any
# Third-party imports # Third-party imports
import pandas as pd
import inquirer import inquirer
import pandas as pd
# Local application imports # Local application imports
from settings import ( 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 ( 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 ( from exceptions import (
CSVFileNotFoundError, MTGJSONDownloadError, DataFrameProcessingError, CSVFileNotFoundError,
ColorFilterError, CommanderValidationError ColorFilterError,
CommanderValidationError,
DataFrameProcessingError,
MTGJSONDownloadError
) )
# Create logs directory if it doesn't exist # Create logs directory if it doesn't exist
if not os.path.exists('logs'): if not os.path.exists('logs'):

View file

@ -347,7 +347,7 @@ def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
) from e ) from e
def process_card_dataframe(df: CardLibraryDF, batch_size: int = 1000, columns_to_keep: Optional[List[str]] = None, 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. """Process DataFrame with common operations in batches.
Args: 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") raise TypeError("df must be a pandas DataFrame")
if not isinstance(color, str): if not isinstance(color, str):
raise TypeError("color must be a string") 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}") raise ValueError(f"Invalid color: {color}")
try: try:
@ -2633,6 +2633,24 @@ def create_token_modifier_mask(df: pd.DataFrame) -> pd.Series:
return has_modifier & has_effect & ~name_exclusions 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: def tag_for_tokens(df: pd.DataFrame, color: str) -> None:
"""Tag cards that create or modify tokens using vectorized operations. """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, tag_utils.apply_tag_vectorized(df, modifier_mask,
['Token Modification', 'Token Creation', 'Tokens Matter']) ['Token Modification', 'Token Creation', 'Tokens Matter'])
logger.info('Tagged %d cards that modify token creation', modifier_mask.sum()) 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() duration = (pd.Timestamp.now() - start_time).total_seconds()
logger.info('Completed token tagging in %.2fs', duration) 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)) logger.error('Error tagging Cantrips in %s_cards.csv: %s', color, str(e))
raise raise
## Magecraft
def create_magecraft_mask(df: pd.DataFrame) -> pd.Series: def create_magecraft_mask(df: pd.DataFrame) -> pd.Series:
"""Create a boolean mask for cards with magecraft effects. """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) 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: def tag_for_removal(df: pd.DataFrame, color: str) -> None:
"""Tag cards that provide spot removal using vectorized operations. """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(): def run_tagging():
start_time = pd.Timestamp.now() start_time = pd.Timestamp.now()
for color in settings.colors: for color in settings.COLORS:
load_dataframe(color) load_dataframe(color)
duration = (pd.Timestamp.now() - start_time).total_seconds() 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 EnchantmentDF = pd.DataFrame
InstantDF = pd.DataFrame InstantDF = pd.DataFrame
PlaneswalkerDF = pd.DataFrame PlaneswalkerDF = pd.DataFrame
NonPlaneswalkerDF = pd.DataFrame
SorceryDF = pd.DataFrame SorceryDF = pd.DataFrame