From 0dfe53bb326a67731e6e60715b43c09d2869c04c Mon Sep 17 00:00:00 2001 From: mwisnowski Date: Mon, 30 Dec 2024 11:43:36 -0800 Subject: [PATCH] Added logic to tagger and deck_builder for "hidden themes" related to multiple-copy cards, such as Hare Apparent or Dragon's Approach --- deck_builder.py | 812 ++++++++++++++++++++++++++++++------------------ settings.py | 4 +- tagger.py | 49 ++- 3 files changed, 561 insertions(+), 304 deletions(-) diff --git a/deck_builder.py b/deck_builder.py index dd1d305..9912453 100644 --- a/deck_builder.py +++ b/deck_builder.py @@ -46,10 +46,21 @@ Land spread will ideally be handled based on pips and some adjustment is planned based on mana curve and ramp added. """ -def new_line(): - print('\n') +def new_line(num_lines: int = 1) -> None: + """Print specified number of newlines for formatting output. + + Args: + num_lines (int): Number of newlines to print. Defaults to 1. + + Returns: + None + """ + if num_lines < 0: + raise ValueError("Number of lines cannot be negative") + print('\n' * num_lines) class DeckBuilder: + def __init__(self): self.card_library = pd.DataFrame() self.card_library['Card Name'] = pd.Series(dtype='str') @@ -62,15 +73,35 @@ class DeckBuilder: self.set_max_card_price = False self.card_prices = {} if use_scrython else None - def validate_text(self, result): + def pause_with_message(self, message="Press Enter to continue..."): + """Helper function to pause execution with a message.""" + print(f"\n{message}") + input() + + def validate_text(self, result: str) -> bool: + """Validate text input is not empty. + + Args: + result (str): Text input to validate + + Returns: + bool: True if text is not empty after stripping whitespace + """ return bool(result and result.strip()) - def validate_number(self, result): + def validate_number(self, result: str) -> float | None: + """Validate and convert string input to float. + + Args: + result (str): Number input to validate + + Returns: + float | None: Converted float value or None if invalid + """ try: return float(result) - except ValueError: + except (ValueError, TypeError): return None - def validate_confirm(self, result): return bool(result) @@ -144,7 +175,7 @@ class DeckBuilder: logging.error(f"Invalid price format for '{card_name}': {card_price}") return 0.0 return 0.0 - except scrython.foundation.ScryfallError as e: + except (scrython.foundation.ScryfallError, scrython.foundation.ScryfallRequestError) as e: logging.error(f"Scryfall API error for '{card_name}': {e}") return 0.0 except TimeoutError: @@ -169,7 +200,7 @@ class DeckBuilder: while not commander_chosen: print('Enter a card name to be your commander, note that at this time only cards that have the \'Creature\' type may be chosen') card_choice = self.questionnaire('Text', '') - + # Logic to find the card in the commander_cards csv, then display it's information # If the card can't be found, or doesn't have enough of a match score, display a # list to choose from @@ -187,20 +218,21 @@ class DeckBuilder: fuzzy_card_choices.append('Neither') print(fuzzy_card_choices) fuzzy_card_choice = self.questionnaire('Choice', choices_list=fuzzy_card_choices) - if fuzzy_card_choice != 'Neither': + if isinstance(fuzzy_card_choice, tuple): fuzzy_card_choice = fuzzy_card_choice[0] + if fuzzy_card_choice != 'Neither': print(fuzzy_card_choice) fuzzy_chosen = True - + else: break - + filtered_df = df[df['name'] == fuzzy_card_choice] df_dict = filtered_df.to_dict('list') print('Is this the card you chose?') pprint.pprint(df_dict, sort_dicts=False) self.commander_df = pd.DataFrame(df_dict) - + # Confirm if card entered was correct commander_confirmed = self.questionnaire('Confirm', True) # If correct, set it as the commander @@ -209,11 +241,11 @@ class DeckBuilder: self.commander_info = df_dict self.commander = self.commander_df.at[0, 'name'] self.price_check(self.commander) + logging.info(f"Commander selected: {self.commander}") break #print(self.commander) else: commander_chosen = False - # Send commander info to setup commander, including extracting info on colors, color identity, # creature types, and other information, like keywords, abilities, etc... @@ -222,28 +254,34 @@ class DeckBuilder: def commander_setup(self): # Load commander info into a dataframe df = self.commander_df - + # Set type line self.commander_type = str(df.at[0, 'type']) - + # Set text line self.commander_text = str(df.at[0, 'text']) - + # Set Power self.commander_power = int(df.at[0, 'power']) - + # Set Toughness self.commander_toughness = int(df.at[0, 'toughness']) - + # Set Mana Cost self.commander_mana_cost = str(df.at[0, 'manaCost']) self.commander_mana_value = int(df.at[0, 'manaValue']) - + # Set color identity - self.color_identity = df.at[0, 'colorIdentity'] - self.color_identity_full = '' - self.determine_color_identity() - + try: + self.color_identity = df.at[0, 'colorIdentity'] + if pd.isna(self.color_identity): + self.color_identity = 'COLORLESS' + self.color_identity_full = '' + self.determine_color_identity() + except Exception as e: + logging.error(f"Failed to set color identity: {e}") + raise ValueError("Could not determine color identity") from e + # Set creature colors if pd.notna(df.at[0, 'colors']) and df.at[0, 'colors'].strip(): self.colors = [color.strip() for color in df.at[0, 'colors'].split(',') if color.strip()] @@ -251,24 +289,16 @@ class DeckBuilder: self.colors = ['COLORLESS'] else: self.colors = ['COLORLESS'] - + # Set creature types self.creature_types = str(df.at[0, 'creatureTypes']) - + # Set deck theme tags self.commander_tags = list(df.at[0, 'themeTags']) - + self.determine_themes() - self.themes = [self.primary_theme] - if not self.secondary_theme: - pass - else: - self.themes.append(self.secondary_theme) - if not self.tertiary_theme: - pass - else: - self.themes.append(self.tertiary_theme) + self.commander_dict = { 'Commander Name': self.commander, 'Mana Cost': self.commander_mana_cost, @@ -307,69 +337,17 @@ class DeckBuilder: print(f'Enchantment cards: {self.enchantment_cards}') print(f'Land cards cards: {self.land_cards}') print(f'Number of cards in Library: {len(self.card_library)}') + self.get_cmc() + self.count_pips() self.concatenate_duplicates() self.organize_library() self.sort_library() - self.get_cmc() self.commander_to_top() self.card_library.to_csv(f'{csv_directory}/test_deck_done.csv', index=False) self.full_df.to_csv(f'{csv_directory}/test_all_after_done.csv', index=False) def determine_color_identity(self): # Determine the color identity for later - color_dict = [ - {'color_identity': 'COLORLESS', - 'color_identity_full': 'Colorless', - 'color_identity_options': ['Colorless'], - 'files_to_load': ['colorless']}, - {'color_identity': 'B', - 'color_identity_full': 'Black', - 'color_identity_options': ['Black'], - 'files_to_load': ['colorless', 'black']}, - {'color_identity': 'G', - 'color_identity_full': 'Green', - 'color_identity_options': ['Green'], - 'files_to_load': ['colorless', 'green']}, - {'color_identity': 'R', - 'color_identity_full': 'Red', - 'color_identity_options': ['Red'], - 'files_to_load': ['colorless', 'red']}, - {'color_identity': 'U', - 'color_identity_full': 'Blue', - 'color_identity_options': ['Blue'], - 'files_to_load': ['colorless', 'blue']}, - {'color_identity': 'W', - 'color_identity_full': 'White', - 'files_to_load': ['colorless', 'white']}, - {'color_identity': 'B, G', - 'color_identity_full': 'Golgari: Black/Green', - 'files_to_load': ['colorless', 'black', 'green', 'golgari']}, - {'color_identity': 'B, R', - 'color_identity_full': 'Rakdos: Black/Red', - 'files_to_load': ['colorless', 'black', 'red', 'rakdos']}, - {'color_identity': 'B, U', - 'color_identity_full': 'Dimir: Black/Blue', - 'files_to_load': ['colorless', 'black', 'blue', 'dimir']}, - {'color_identity': 'B, W', - 'color_identity_full': 'Orzhov: Black/White', - 'files_to_load': ['colorless', 'black', 'white', 'orzhov']}, - {'color_identity': 'G, R', - 'color_identity_full': 'Gruul: Green/Red', - 'files_to_load': ['colorless', 'green', 'red', 'gruul']}, - {'color_identity': 'G, U', - 'color_identity_full': 'Gruul: Green/Blue', - 'files_to_load': ['colorless', 'green', 'blue', 'simic']}, - {'color_identity': 'G, W', - 'color_identity_full': 'Selesnya: Green/White', - 'files_to_load': ['colorless', 'green', 'white', 'selesnya']}, - {'color_identity': 'G, R', - 'color_identity_full': 'Gruul: Black/White', - 'files_to_load': ['colorless', 'green', 'red', 'gruul']}, - {'color_identity': 'U, R', - 'color_identity_full': 'Izzet Blue/Red', - 'color_identity_options': ['U', 'R', 'U, R'], - 'files_to_load': ['colorless', 'blue', 'red', 'azorius']} - ] # Mono color if self.color_identity == 'COLORLESS': self.color_identity_full = 'Colorless' @@ -603,13 +581,19 @@ class DeckBuilder: 'This will be the "focus" of the deck, in a kindred deck this will typically be a creature type for example.') choice = self.questionnaire('Choice', choices_list=themes) self.primary_theme = choice - self.primary_weight = 1.0 - + weights_default = { + 'primary': 1.0, + 'secondary': 0.0, + 'tertiary': 0.0, + 'hidden': 0.0 + } + weights = weights_default themes.remove(choice) themes.append('Stop Here') secondary_theme_chosen = False tertiary_theme_chosen = False + self.hidden_theme = False while not secondary_theme_chosen: # Secondary theme @@ -631,25 +615,20 @@ class DeckBuilder: pass else: + weights = weights_default # primary = 1.0, secondary = 0.0, tertiary = 0.0 self.secondary_theme = choice themes.remove(choice) secondary_theme_chosen = True # Set weights for primary/secondary themes - if 'Kindred' in self.primary_theme: - weights = { - 'primary': 0.75, - 'secondary': 0.25 - } + if 'Kindred' in self.primary_theme and 'Kindred' not in self.secondary_theme: + weights['primary'] -= 0.25 # 0.75 + weights['secondary'] += 0.25 # 0.25 elif 'Kindred' in self.primary_theme and 'Kindred' in self.secondary_theme: - weights = { - 'primary': 0.55, - 'secondary': 0.45 - } + weights['primary'] -= 0.45 # 0.55 + weights['secondary'] += 0.45 # 0.45 else: - weights = { - 'primary': 0.6, - 'secondary': 0.4 - } + weights['primary'] -= 0.4 # 0.6 + weights['secondary'] += 0.4 # 0.4 self.primary_weight = weights['primary'] self.secondary_weight = weights['secondary'] break @@ -672,37 +651,115 @@ class DeckBuilder: pass else: + weights = weights_default # primary = 1.0, secondary = 0.0, tertiary = 0.0 self.tertiary_theme = choice tertiary_theme_chosen = True - if 'Kindred' in self.primary_theme: - weights = { - 'primary': 0.7, - 'secondary': 0.2, - 'tertiary': 0.1 - } - elif 'Kindred' in self.primary_theme and 'Kindred' in self.secondary_theme: - weights = { - 'primary': 0.55, - 'secondary': 0.35, - 'tertiary': 0.1 - } + # Set weights for themes: + if 'Kindred' in self.primary_theme and 'Kindred' not in self.secondary_theme and 'Kindred' not in self.tertiary_theme: + weights['primary'] -= 0.3 # 0.7 + weights['secondary'] += 0.2 # 0.2 + weights['tertiary'] += 0.1 # 0.1 + elif 'Kindred' in self.primary_theme and 'Kindred' in self.secondary_theme and 'Kindred' not in self.tertiary_theme: + weights['primary'] -= 0.45 # 0.55 + weights['secondary'] += 0.35 # 0.35 + weights['tertiary'] += 0.1 # 0.1 elif 'Kindred' in self.primary_theme and 'Kindred' in self.secondary_theme and 'Kindred' in self.tertiary_theme: - weights = { - 'primary': 0.5, - 'secondary': 0.3, - 'tertiary': 0.2 - } + weights['primary'] -= 0.5 # 0.5 + weights['secondary'] += 0.3 # 0.3 + weights['tertiary'] += 0.2 # 0.2 else: - weights = { - 'primary': 0.4, - 'secondary': 0.3, - 'tertiary': 0.3 - } + weights['primary'] -= 0.6 # 0.4 + weights['secondary'] += 0.3 # 0.3 + weights['tertiary'] += 0.3 # 0.3 self.primary_weight = weights['primary'] self.secondary_weight = weights['secondary'] self.tertiary_weight = weights['tertiary'] break + + self.themes = [self.primary_theme] + if not self.secondary_theme: + pass + else: + self.themes.append(self.secondary_theme) + if not self.tertiary_theme: + pass + else: + self.themes.append(self.tertiary_theme) + + """ + Setting 'Hidden' themes for multiple-copy cards, such as 'Hare Apparent' or 'Shadowborn Apostle'. + These are themes that will be prompted for under specific conditions, such as a matching Kindred theme or a matching color combination and Spellslinger theme for example. + Typically a hidden theme won't come up, but if it does, it will take priority with theme weights to ensure a decent number of the specialty cards are added. + """ + # Setting hidden theme for Kindred-specific themes + hidden_themes = ['Advisor Kindred', 'Demon Kindred', 'Dwarf Kindred', 'Rabbit Kindred', 'Rat Kindred', 'Wraith Kindred'] + theme_cards = ['Persistent Petitioners', 'Shadowborn Apostle', 'Seven Dwarves', 'Hare Apparent', ['Rat Colony', 'Relentless Rats'], 'Nazgûl'] + color = ['Blue', 'Black', 'Red', 'White', 'Black', 'Black'] + for i in range(min(len(hidden_themes), len(theme_cards), len(color))): + if (hidden_themes[i] in self.themes + and hidden_themes[i] != 'Rat Kindred' + and color[i] in self.colors): + print(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') + choice = self.questionnaire('Confirm', False) + if choice: + self.hidden_theme = theme_cards[i] + self.themes.append(self.hidden_theme) + weights['primary'] -= weights['primary'] / 2 # 0.3 + weights['secondary'] += weights['secondary'] / 2 # 0.2 + weights['tertiary'] += weights['tertiary'] / 2 # 0.1 + weights['hidden'] = 1.0 - weights['primary'] - weights['secondary'] - weights['tertiary'] + self.primary_weight = weights['primary'] + self.secondary_weight = weights['secondary'] + self.tertiary_weight = weights['tertiary'] + self.hidden_weight = weights['hidden'] + else: + continue + + elif (hidden_themes[i] in self.themes + and hidden_themes[i] == 'Rat Kindred' + and color[i] in self.colors): + print(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i][0]} or {theme_cards[i][1]} deck?') + choice = self.questionnaire('Confirm', False) + if choice: + print('Which one?') + choice = self.questionnaire('Choice', choices_list=theme_cards[i]) + if choice: + self.hidden_theme = choice + self.themes.append(self.hidden_theme) + weights['primary'] -= weights['primary'] / 2 # 0.3 + weights['secondary'] += weights['secondary'] / 2 # 0.2 + weights['tertiary'] += weights['tertiary'] / 2 # 0.1 + weights['hidden'] = 1.0 - weights['primary'] - weights['secondary'] - weights['tertiary'] + self.primary_weight = weights['primary'] + self.secondary_weight = weights['secondary'] + self.tertiary_weight = weights['tertiary'] + self.hidden_weight = weights['hidden'] + else: + continue + + # Setting the hidden theme for non-Kindred themes + hidden_themes = ['Little Fellas', 'Mill', 'Spellslinger', 'Spellslinger'] + theme_cards = ['Hare Apparent', 'Persistent Petitions', 'Dragon\'s Approach', 'Slime Against Humanity'] + color = ['White', 'Blue', 'Red', 'Green'] + for i in range(min(len(hidden_themes), len(theme_cards), len(color))): + if (hidden_themes[i] in self.themes + and color[i] in self.colors): + print(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') + choice = self.questionnaire('Confirm', False) + if choice: + self.hidden_theme = theme_cards[i] + self.themes.append(self.hidden_theme) + weights['primary'] -= weights['primary'] / 2 # 0.3 + weights['secondary'] += weights['secondary'] / 2 # 0.2 + weights['tertiary'] += weights['tertiary'] / 2 # 0.1 + weights['hidden'] = 1.0 - weights['primary'] - weights['secondary'] - weights['tertiary'] + self.primary_weight = weights['primary'] + self.secondary_weight = weights['secondary'] + self.tertiary_weight = weights['tertiary'] + self.hidden_weight = weights['hidden'] + else: + continue break def determine_ideals(self): @@ -781,7 +838,7 @@ class DeckBuilder: # Determine spot/targetted removal print('How many spot removal pieces would you like to include?\n' 'A good starting point is about 8-12 pieces of spot removal.\n' - 'Counterspells can be consisdered proactive removal and protection.\n' + 'Counterspells can be considered proactive removal and protection.\n' 'If you\'re going spellslinger, more would be a good idea as you might have less cretaures.\n' 'Default: 10') answer = self.questionnaire('Number', 10) @@ -823,14 +880,21 @@ class DeckBuilder: print(f'Free slots that aren\'t part of the ideals: {self.free_slots}') print('Keep in mind that many of the ideals can also cover multiple roles, but this will give a baseline POV.') - def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, is_commander=False) -> None: + def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, is_commander: bool = False) -> None: """Add a card to the deck library with price checking if enabled. - + Args: - card (str): Name of the card - card_type (str): Type of the card - mana_cost (str): Mana cost of the card - mana_value (int): Converted mana cost + card (str): Name of the card to add + card_type (str): Type of the card (e.g., 'Creature', 'Instant') + mana_cost (str): Mana cost string representation + mana_value (int): Converted mana cost/mana value + is_commander (bool, optional): Whether this card is the commander. Defaults to False. + + Returns: + None + + Raises: + ValueError: If card price exceeds maximum allowed price when price checking is enabled """ multiple_copies = basic_lands + multiple_copy_cards @@ -863,31 +927,21 @@ class DeckBuilder: logging.debug(f"Added {card} to deck library") def organize_library(self): - # Initialize counters dictionary - card_counters = { - 'Artifact': 0, - 'Battle': 0, - 'Creature': 0, - 'Enchantment': 0, - 'Instant': 0, - 'Kindred': 0, - 'Land': 0, - 'Planeswalker': 0, - 'Sorcery': 0 - } - + # Initialize counters dictionary dynamically from card_types + card_counters = {card_type: 0 for card_type in card_types} + # Count cards by type for card_type in card_types: type_df = self.card_library[self.card_library['Card Type'].apply(lambda x: card_type in x)] card_counters[card_type] = len(type_df) - + # Assign counts to instance variables self.artifact_cards = card_counters['Artifact'] self.battle_cards = card_counters['Battle'] self.creature_cards = card_counters['Creature'] self.enchantment_cards = card_counters['Enchantment'] self.instant_cards = card_counters['Instant'] - self.kindred_cards = card_counters['Kindred'] + self.theme_cards = card_counters['Kindred'] self.land_cards = card_counters['Land'] self.planeswalker_cards = card_counters['Planeswalker'] self.sorcery_cards = card_counters['Sorcery'] @@ -901,43 +955,84 @@ class DeckBuilder: continue if row['Sort Order'] != 'Creature': self.card_library.loc[index, 'Sort Order'] = card_type - + custom_order = ['Planeswalker', 'Battle', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land'] - self.card_library['Sort Order'] = pd.Categorical(self.card_library['Sort Order'], categories=custom_order, ordered=True) - self.card_library = self.card_library.sort_values(by=['Sort Order', 'Card Name'], ascending=[True, True]) - #self.card_library = self.card_library.reset_index(drop=True) - self.card_library = self.card_library.drop(columns=['Sort Order']) - + self.card_library['Sort Order'] = pd.Categorical( + self.card_library['Sort Order'], + categories=custom_order, + ordered=True + ) + self.card_library = (self.card_library + .sort_values(by=['Sort Order', 'Card Name'], ascending=[True, True]) + .drop(columns=['Sort Order']) + .reset_index(drop=True) + ) + def commander_to_top(self): - target_index = self.card_library[self.card_library['Commander']].index.to_list() - row_to_move = self.card_library.loc[target_index] - row_to_move.loc[1.5] = ['-'] * len(row_to_move.columns) - row_to_move = row_to_move.sort_index().reset_index(drop=True) - self.card_library = self.card_library.drop(target_index) - self.card_library = pd.concat([row_to_move, self.card_library], ignore_index = False) - self.card_library = self.card_library.reset_index(drop=True) - self.card_library = self.card_library.drop(columns=['Commander']) - + """Move commander card to the top of the library.""" + try: + # Extract commander row + commander_row = self.card_library[self.card_library['Commander']].copy() + if commander_row.empty: + logging.warning("No commander found in library") + return + + # Remove commander from main library + self.card_library = self.card_library[~self.card_library['Commander']] + + # Concatenate with commander at top + self.card_library = pd.concat([commander_row, self.card_library], ignore_index=True) + self.card_library = self.card_library.drop(columns=['Commander']) + + logging.info(f"Successfully moved commander '{commander_row['Card Name'].iloc[0]}' to top") + except Exception as e: + logging.error(f"Error moving commander to top: {e}") + def concatenate_duplicates(self): + """Handle duplicate cards in the library while maintaining data integrity.""" duplicate_lists = basic_lands + multiple_copy_cards - for duplicate in duplicate_lists: - duplicate_search = self.card_library[self.card_library['Card Name'] == duplicate] - num_duplicates = len(duplicate_search) - if num_duplicates > 0: - print(f'Found {num_duplicates} copies of {duplicate}') - print(f'Dropping {num_duplicates -1} duplicate copies of {duplicate}') - print(f'Setting remaining {duplicate} to be called "{duplicate} x {num_duplicates}"') - self.card_library.loc[self.card_library['Card Name'] == duplicate, 'Card Name'] = f'{duplicate} x {num_duplicates}' - self.card_library = self.card_library.drop_duplicates(subset='Card Name', keep='first') + # Create a count column for duplicates + self.card_library['Card Count'] = 1 + + for duplicate in duplicate_lists: + mask = self.card_library['Card Name'] == duplicate + count = mask.sum() + + if count > 0: + logging.info(f'Found {count} copies of {duplicate}') + + # Keep first occurrence with updated count + first_idx = mask.idxmax() + self.card_library.loc[first_idx, 'Card Count'] = count + + # Drop other occurrences + self.card_library = self.card_library.drop( + self.card_library[mask & (self.card_library.index != first_idx)].index + ) + + # Update card names with counts where applicable + mask = self.card_library['Card Count'] > 1 + self.card_library.loc[mask, 'Card Name'] = ( + self.card_library.loc[mask, 'Card Name'] + + ' x ' + + self.card_library.loc[mask, 'Card Count'].astype(str) + ) + + # Clean up + self.card_library = self.card_library.drop(columns=['Card Count']) self.card_library = self.card_library.reset_index(drop=True) - - def drop_card(self, dataframe, index): + def drop_card(self, dataframe: pd.DataFrame, index: int) -> None: + """Safely drop a card from the dataframe by index. + + Args: + dataframe: DataFrame to modify + index: Index to drop + """ try: dataframe.drop(index, inplace=True) except KeyError: - pass # Index already dropped or does not exist - + logging.warning(f"Attempted to drop non-existent index {index}") def add_lands(self): """ Begin the process to add lands, the number will depend on ideal land count, ramp, @@ -977,10 +1072,7 @@ class DeckBuilder: self.remove_basic() self.organize_library() - #if self.card_library < self.ideal_land_count: - # pass print(f'Total lands: {self.land_cards}') - #print(self.total_basics) def add_basics(self): base_basics = self.ideal_land_count - 10 # Reserve 10 slots for non-basic lands @@ -1013,14 +1105,14 @@ class DeckBuilder: basic = color_to_basic.get(color) if basic: for _ in range(basics_per_color): - self.add_card(basic, 'Basic Land', '', 0) + self.add_card(basic, 'Basic Land', None, 0) # Distribute remaining basics based on color requirements if remaining_basics > 0: for color in self.colors[:remaining_basics]: basic = color_to_basic.get(color) if basic: - self.add_card(basic, 'Basic Land', '', 0) + self.add_card(basic, 'Basic Land', None, 0) lands_to_remove = [] for key in color_to_basic: @@ -1051,10 +1143,9 @@ class DeckBuilder: for card in self.staples: if card not in self.card_library: - self.add_card(card, 'Land', '', 0) + self.add_card(card, 'Land', None, 0) else: pass - lands_to_remove = self.staples self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] @@ -1066,17 +1157,22 @@ class DeckBuilder: 'For most decks you\'ll likely be good with 3 or 4, just enough to thin the deck and help ensure the color availability.\n' 'If you\'re doing Landfall, more fetches would be recommended just to get as many Landfall triggers per turn.') answer = self.questionnaire('Number', 2) - + MAX_ATTEMPTS = 50 # Maximum attempts to prevent infinite loops + attempt_count = 0 desired_fetches = int(answer) chosen_fetches = [] - generic_fetches = ['Evolving Wilds', 'Terramorphic Expanse', 'Shire Terrace', 'Escape Tunnel', 'Promising Vein','Myriad Landscape', 'Fabled Passage', 'Terminal Moraine'] - fetches = generic_fetches - lands_to_remove = generic_fetches + generic_fetches = [ + 'Evolving Wilds', 'Terramorphic Expanse', 'Shire Terrace', + 'Escape Tunnel', 'Promising Vein', 'Myriad Landscape', + 'Fabled Passage', 'Terminal Moraine' + ] + fetches = generic_fetches.copy() + lands_to_remove = generic_fetches.copy() # Adding in expensive fetches if (use_scrython and self.set_max_card_price): - if self.price_check('Prismatic Vista') <= self.max_card_price * (random.randint(100, 110) / 100): + if self.price_check('Prismatic Vista') <= self.max_card_price * 1.1: lands_to_remove.append('Prismatic Vista') fetches.append('Prismatic Vista') else: @@ -1107,28 +1203,34 @@ class DeckBuilder: if fetch not in lands_to_remove: lands_to_remove.extend(fetch) - fetches_chosen = False # Randomly choose fetches up to the desired number - while not fetches_chosen: - while len(chosen_fetches) < desired_fetches + 3: - fetch_choice = random.choice(fetches) - if use_scrython and self.set_max_card_price: - if self.price_check(fetch_choice) <= self.max_card_price * (random.randint(100, 110) / 100): - chosen_fetches.append(fetch_choice) - fetches.remove(fetch_choice) - else: + while len(chosen_fetches) < desired_fetches + 3 and attempt_count < MAX_ATTEMPTS: + if not fetches: # If we run out of fetches to choose from + break + + fetch_choice = random.choice(fetches) + if use_scrython and self.set_max_card_price: + if self.price_check(fetch_choice) <= self.max_card_price * 1.1: chosen_fetches.append(fetch_choice) fetches.remove(fetch_choice) + else: + chosen_fetches.append(fetch_choice) + fetches.remove(fetch_choice) + + attempt_count += 1 + + # Select final fetches to add + fetches_to_add = [] + available_fetches = chosen_fetches[:desired_fetches] + for fetch in available_fetches: + if fetch not in fetches_to_add: + fetches_to_add.append(fetch) + + if attempt_count >= MAX_ATTEMPTS: + logging.warning(f"Reached maximum attempts ({MAX_ATTEMPTS}) while selecting fetch lands") - fetches_to_add = [] - while len(fetches_to_add) < desired_fetches: - card = random.choice(fetches) - if card not in fetches_to_add: - fetches_to_add.append(card) - fetches_chosen = True - for card in fetches_to_add: - self.add_card(card, 'Land', '',0) + self.add_card(card, 'Land', None, 0) self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False) @@ -1136,24 +1238,22 @@ class DeckBuilder: def add_kindred_lands(self): print('Adding lands that care about the commander having a Kindred theme.') print('Adding general Kindred lands.') - kindred_lands = [ - {'name': 'Path of Ancestry', - 'type': 'Land', - 'manaCost': '', - 'manaValue': 0 - }, - {'name': 'Three Tree City', - 'type': 'Legendary Land', - 'manaCost': '', - 'manaValue': 0 - }, - {'name': 'Cavern of Souls', - 'type': 'Land', - 'manaCost': '', - 'manaValue': 0 - }, - ] + + def create_land(name: str, land_type: str) -> dict: + """Helper function to create land card dictionaries""" + return { + 'name': name, + 'type': land_type, + 'manaCost': None, + 'manaValue': 0 + } + kindred_lands = [ + create_land('Path of Ancestry', 'Land'), + create_land('Three Tree City', 'Legendary Land'), + create_land('Cavern of Souls', 'Land') + ] + for theme in self.themes: if 'Kindred' in theme: kindred = theme.replace(' Kindred', '') @@ -1171,7 +1271,7 @@ class DeckBuilder: continue if (kindred in row['text']) or (kindred in row['type']): kindred_lands.append(card) - + lands_to_remove = [] for card in kindred_lands: self.add_card(card['name'], card['type'], @@ -1333,34 +1433,39 @@ class DeckBuilder: print(f'Added {len(cards_to_add)} land cards.') def check_basics(self): - basic_lands = ['Plains', 'Island', 'Swamp', 'Forest', 'Mountain'] + """Check and display counts of each basic land type.""" + basic_lands = { + 'Plains': 0, + 'Island': 0, + 'Swamp': 0, + 'Mountain': 0, + 'Forest': 0, + 'Snow-Covered Plains': 0, + 'Snow-Covered Island': 0, + 'Snow-Covered Swamp': 0, + 'Snow-Covered Mountain': 0, + 'Snow-Covered Forest': 0 + } + self.total_basics = 0 - self.total_basics += len(self.card_library[self.card_library['Card Name'].isin(basic_lands)]) - print(f'Number of basic lands: {self.total_basics}') - - def concatenate_basics(self): - basic_lands = ['Plains', 'Island', 'Swamp', 'Forest', 'Mountain'] - self.total_basics = 0 - self.total_basics += len(self.card_library[self.card_library['Card Name'].isin(basic_lands)]) - for basic_land in basic_lands: - basic_count = len(self.card_library[self.card_library['Card Name'] == basic_land]) - if basic_count > 0: - # Keep first occurrence and update its name to show count - mask = self.card_library['Card Name'] == basic_land - first_occurrence = mask.idxmax() - self.card_library.loc[first_occurrence, 'Card Name'] = f'{basic_land} x {basic_count}' - # Drop other occurrences - indices_to_drop = self.card_library[mask].index[1:] - self.card_library.drop(indices_to_drop, inplace=True) - self.card_library.reset_index(drop=True, inplace=True) - + for land in basic_lands: + count = len(self.card_library[self.card_library['Card Name'] == land]) + basic_lands[land] = count + self.total_basics += count + + print("\nBasic Land Counts:") + for land, count in basic_lands.items(): + if count > 0: + print(f"{land}: {count}") + print(f"Total basic lands: {self.total_basics}\n") + def remove_basic(self): """ Remove a basic land while maintaining color balance. Attempts to remove from colors with more basics first. """ logging.info('Land count over ideal count, removing a basic land.') - + # Map colors to basic land names color_to_basic = { 'W': 'Plains', @@ -1369,7 +1474,7 @@ class DeckBuilder: 'R': 'Mountain', 'G': 'Forest' } - + # Count basics of each type basic_counts = {} for color in self.colors: @@ -1378,12 +1483,12 @@ class DeckBuilder: count = len(self.card_library[self.card_library['Card Name'] == basic]) if count > 0: basic_counts[basic] = count - + if not basic_counts: logging.warning("No basic lands found to remove") return sum_basics = sum(basic_counts.values()) - + # Try to remove from color with most basics basic_land = max(basic_counts.items(), key=lambda x: x[1])[0] if sum_basics > self.min_basics: @@ -1391,54 +1496,120 @@ class DeckBuilder: logging.info(f'Attempting to remove {basic_land}') condition = self.card_library['Card Name'] == basic_land index_to_drop = self.card_library[condition].index[0] - + self.card_library = self.card_library.drop(index_to_drop) self.card_library = self.card_library.reset_index(drop=True) - + logging.info(f'{basic_land} removed successfully') self.check_basics() - + except (IndexError, KeyError) as e: logging.error(f"Error removing {basic_land}: {e}") - # Try next most numerous basic if available - basic_counts.pop(basic_land, None) - if basic_counts: + # Iterative approach instead of recursion + while basic_counts: + basic_counts.pop(basic_land, None) + if not basic_counts: + logging.error("Failed to remove any basic land") + break + basic_land = max(basic_counts.items(), key=lambda x: x[1])[0] - self.remove_basic() # Recursive call with remaining basics - else: - logging.error("Failed to remove any basic land") + try: + condition = self.card_library['Card Name'] == basic_land + index_to_drop = self.card_library[condition].index[0] + self.card_library = self.card_library.drop(index_to_drop) + self.card_library = self.card_library.reset_index(drop=True) + logging.info(f'{basic_land} removed successfully') + self.check_basics() + break + except (IndexError, KeyError): + continue else: print(f'Not enough basic lands to keep the minimum of {self.min_basics}.') self.remove_land() def remove_land(self): + """Remove a random non-basic, non-staple land from the deck.""" print('Removing a random nonbasic land.') - basic_lands = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest', - 'Snow-Covered Plains', 'Snow-Covered Island', 'Snow-Covered Swamp', - 'Snow-Covered Mountain', 'Snow-Covered Forest'] - library_filter = self.card_library[self.card_library['Card Type'].str.contains('Land')].copy() - library_filter = library_filter[~library_filter['Card Name'].isin((basic_lands + self.staples))] - card = np.random.choice(library_filter.index, 1, replace=False) - print(library_filter.loc[card, 'Card Name'].to_string(index=False)) - self.card_library.drop(card, inplace=True) - self.card_library.reset_index(drop=True, inplace=True) - print("Card removed.") + # Define basic lands including snow-covered variants + basic_lands = [ + 'Plains', 'Island', 'Swamp', 'Mountain', 'Forest', + 'Snow-Covered Plains', 'Snow-Covered Island', 'Snow-Covered Swamp', + 'Snow-Covered Mountain', 'Snow-Covered Forest' + ] + + try: + # Filter for non-basic, non-staple lands + library_filter = self.card_library[ + (self.card_library['Card Type'].str.contains('Land')) & + (~self.card_library['Card Name'].isin(basic_lands + self.staples)) + ].copy() + + if len(library_filter) == 0: + print("No suitable non-basic lands found to remove.") + return + + # Select random land to remove + card_index = np.random.choice(library_filter.index) + card_name = self.card_library.loc[card_index, 'Card Name'] + + print(f"Removing {card_name}") + self.card_library.drop(card_index, inplace=True) + self.card_library.reset_index(drop=True, inplace=True) + print("Card removed successfully.") + + except Exception as e: + logging.error(f"Error removing land: {e}") + print("Failed to remove land card.") + def count_pips(self): - print('Checking the number of color pips in each color.') - mana_cost_list = self.card_library['Mana Cost'].tolist() - print(mana_cost_list) + """Count and display the number of colored mana symbols in casting costs.""" + print('Analyzing color pip distribution...') + pip_counts = { + 'W': 0, 'U': 0, 'B': 0, 'R': 0, 'G': 0 + } + + for cost in self.card_library['Mana Cost'].dropna(): + for color in pip_counts: + pip_counts[color] += cost.count(color) + + total_pips = sum(pip_counts.values()) + if total_pips == 0: + print("No colored mana symbols found in casting costs.") + return + + print("\nColor Pip Distribution:") + for color, count in pip_counts.items(): + if count > 0: + percentage = (count / total_pips) * 100 + print(f"{color}: {count} pips ({percentage:.1f}%)") + print(f"Total colored pips: {total_pips}\n") - #keyboard.wait('space') - def get_cmc(self): - print('Getting the combined mana value of non-land cards.') - non_land = self.card_library[~self.card_library['Card Type'].str.contains('Land')].copy() - total_cmc = non_land['Mana Value'].sum() - self.cmc = round((total_cmc / len(non_land)), 2) - self.commander_dict.update({'CMC': float(self.cmc)}) - + """Calculate average converted mana cost of non-land cards.""" + logging.info('Calculating average mana value of non-land cards.') + + try: + # Filter non-land cards + non_land = self.card_library[ + ~self.card_library['Card Type'].str.contains('Land') + ].copy() + + if non_land.empty: + logging.warning("No non-land cards found") + self.cmc = 0.0 + else: + total_cmc = non_land['Mana Value'].sum() + self.cmc = round(total_cmc / len(non_land), 2) + + self.commander_dict.update({'CMC': float(self.cmc)}) + logging.info(f"Average CMC: {self.cmc}") + + except Exception as e: + logging.error(f"Error calculating CMC: {e}") + self.cmc = 0.0 + def weight_by_theme(self, tag, ideal=1, weight=1): # First grab the first 50/30/20 cards that match each theme """Add cards with specific tag up to ideal_value count""" @@ -1457,16 +1628,16 @@ class DeckBuilder: tag_df = tag_df.head(pool_size) # Convert to list of card dictionaries - card_pool = [] - for _, row in tag_df.iterrows(): - card = { + card_pool = [ + { 'name': row['name'], 'type': row['type'], 'manaCost': row['manaCost'], 'manaValue': row['manaValue'] } - card_pool.append(card) - + 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: @@ -1517,7 +1688,7 @@ class DeckBuilder: def add_by_tags(self, tag, ideal_value=1): """Add cards with specific tag up to ideal_value count""" print(f'Finding {ideal_value} cards with the "{tag}" tag...') - + # Filter cards with the given tag skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1 if skip_creatures: @@ -1529,30 +1700,30 @@ class DeckBuilder: # 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 = [] - for _, row in tag_df.iterrows(): - card = { + card_pool = [ + { 'name': row['name'], 'type': row['type'], 'manaCost': row['manaCost'], 'manaValue': row['manaValue'] } - card_pool.append(card) - + 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_check(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: @@ -1562,7 +1733,7 @@ class DeckBuilder: 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: @@ -1570,7 +1741,7 @@ class DeckBuilder: card['manaCost'], card['manaValue']) else: continue - + 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)] @@ -1592,6 +1763,10 @@ class DeckBuilder: print(f'Adding creatures to deck based on the ideal creature count of {self.ideal_creature_count}...') try: + if self.hidden_theme: + print(f'Processing primary theme: {self.hidden_theme}') + self.weight_by_theme(self.hidden_theme, self.ideal_creature_count, self.hidden_weight) + print(f'Processing primary theme: {self.primary_theme}') self.weight_by_theme(self.primary_theme, self.ideal_creature_count, self.primary_weight) @@ -1626,15 +1801,52 @@ class DeckBuilder: self.add_by_tags('Unconditional Draw', math.ceil(self.ideal_card_advantage * 0.8)) def fill_out_deck(self): + """Fill out the deck to 100 cards with theme-appropriate cards.""" print('Filling out the Library to 100 with cards fitting the themes.') - while len(self.card_library) < 100: + + cards_needed = 100 - len(self.card_library) + if cards_needed <= 0: + return + + logging.info(f"Need to add {cards_needed} more cards") + MAX_ATTEMPTS = max(20, cards_needed * 2) # Scale attempts with cards needed + attempts = 0 + + while len(self.card_library) < 100 and attempts < MAX_ATTEMPTS: + initial_count = len(self.card_library) + remaining = 100 - len(self.card_library) + + # Adjust weights based on remaining cards needed + weight_multiplier = remaining / cards_needed + if self.tertiary_theme: - self.add_by_tags(self.tertiary_theme, math.ceil(self.tertiary_weight * 3)) + self.add_by_tags(self.tertiary_theme, + math.ceil(self.tertiary_weight * 3 * weight_multiplier)) if self.secondary_theme: - self.add_by_tags(self.secondary_theme, math.ceil(self.secondary_weight)) - self.add_by_tags(self.primary_theme, math.ceil(self.primary_weight / 5)) + self.add_by_tags(self.secondary_theme, + math.ceil(self.secondary_weight * weight_multiplier)) + self.add_by_tags(self.primary_theme, + math.ceil(self.primary_weight * weight_multiplier)) + + if len(self.card_library) == initial_count: + attempts += 1 + if attempts % 5 == 0: # Log progress every 5 failed attempts + logging.warning(f"Made {attempts} attempts, still need {100 - len(self.card_library)} cards") + + final_count = len(self.card_library) + if final_count < 100: + logging.warning(f"Could not reach 100 cards after {attempts} attempts. Current count: {final_count}") + print(f"\nWARNING: Deck is incomplete with {final_count} cards. Manual additions may be needed.") + else: + logging.info(f"Successfully filled deck to {final_count} cards in {attempts} attempts") + +def main(): + """Main entry point for deck builder application.""" + build_deck = DeckBuilder() + build_deck.determine_commander() + pprint.pprint(build_deck.commander_dict, sort_dicts=False) + +if __name__ == '__main__': + main() -build_deck = DeckBuilder() -build_deck.determine_commander() -pprint.pprint(build_deck.commander_dict, sort_dicts = False) #pprint.pprint(build_deck.card_library['Card Name'], sort_dicts = False) \ No newline at end of file diff --git a/settings.py b/settings.py index c617c2f..142a11e 100644 --- a/settings.py +++ b/settings.py @@ -114,8 +114,8 @@ enchantment_tokens = ['Cursed Role', 'Monster Role', 'Royal Role', 'Sorcerer Rol 'Virtuous Role', 'Wicked Role', 'Young Hero Role', 'Shard'] multiple_copy_cards = ['Dragon\'s Approach', 'Hare Apparent', 'Nazgûl', 'Persistent Petitioners', - 'Rat Colony','Relentless Rars', 'Seven Dwarves', 'Shadowborn Apostle', - 'Slime Against Humanity', 'Templar Knights'] + 'Rat Colony', 'Relentless Rats', 'Seven Dwarves', 'Shadowborn Apostle', + 'Slime Against Humanity', 'Templar Knight'] non_creature_types = ['Legendary', 'Creature', 'Enchantment', 'Artifact', 'Battle', 'Sorcery', 'Instant', 'Land', '-', '—', diff --git a/tagger.py b/tagger.py index c70ede7..02e7dec 100644 --- a/tagger.py +++ b/tagger.py @@ -5,7 +5,7 @@ import pandas as pd # type: ignore import settings -from settings import artifact_tokens, csv_directory, colors, counter_types, enchantment_tokens, num_to_search, triggers +from settings import artifact_tokens, csv_directory, colors, counter_types, enchantment_tokens, multiple_copy_cards, num_to_search, triggers from setup import regenerate_csv_by_color from utility import pluralize, sort_list @@ -3176,10 +3176,14 @@ def tag_for_themes(df, color): print('==========\n') search_for_legends(df, color) print('==========\n') + tag_for_little_guys(df, color) + print('==========\n') tag_for_mill(df, color) print('==========\n') tag_for_monarch(df, color) print('==========\n') + tag_for_multiple_copies(df, color) + print('==========\n') tag_for_planeswalkers(df, color) print('==========\n') tag_for_reanimate(df, color) @@ -3703,6 +3707,32 @@ def search_for_legends(df, color): print(f'"Legends Matter" and "Historics Matter" cards in {color}_cards.csv have been tagged.\n') +## Little Fellas +def tag_for_little_guys(df, color): + print(f'Tagging cards in {color}_cards.csv that are or care about low-power (2 or less) creatures.') + for index, row in df.iterrows(): + theme_tags = row['themeTags'] + if pd.notna(row['power']): + if '*' in row['power']: + continue + if (int(row['power']) <= 2): + tag_type = ['Little Fellas'] + for tag in tag_type: + if tag not in theme_tags: + theme_tags.extend([tag]) + df.at[index, 'themeTags'] = theme_tags + + if pd.notna(row['text']): + if ('power 2 or less' in row['text'].lower() + ): + tag_type = ['Little Fellas'] + for tag in tag_type: + if tag not in theme_tags: + theme_tags.extend([tag]) + df.at[index, 'themeTags'] = theme_tags + + print(f'Low-power (2 or less) creature cards in {color}_cards.csv have been tagged.\n') + ## Mill def tag_for_mill(df, color): print(f'Tagging cards in {color}_cards.csv that have a "Mill" theme.') @@ -3780,6 +3810,21 @@ def tag_for_monarch(df, color): print(f'"Monarch" cards in {color}_cards.csv have been tagged.\n') +## Multi-copy cards +def tag_for_multiple_copies(df, color): + print(f'Tagging cards in {color}_cards.csv that allow having multiple copies.') + for index, row in df.iterrows(): + theme_tags = row['themeTags'] + if (row['name'] in multiple_copy_cards + ): + tag_type = ['Multiple Copies', row['name']] + for tag in tag_type: + if tag not in theme_tags: + theme_tags.extend([tag]) + df.at[index, 'themeTags'] = theme_tags + + print(f'"Multiple-copy" cards in {color}_cards.csv have been tagged.\n') + ## Planeswalkers def tag_for_planeswalkers(df, color): print(f'Tagging cards in {color}_cards.csv that fit the "Planeswalkers/Super Friends" theme.') @@ -4819,4 +4864,4 @@ def tag_for_removal(df, color): #regenerate_csv_by_color('colorless') for color in colors: - load_dataframe(color) \ No newline at end of file + load_dataframe(color)