Added logic to tagger and deck_builder for "hidden themes" related to multiple-copy cards, such as Hare Apparent or Dragon's Approach

This commit is contained in:
mwisnowski 2024-12-30 11:43:36 -08:00
parent c74d1ff03a
commit 0dfe53bb32
3 changed files with 561 additions and 304 deletions

View file

@ -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:
@ -187,8 +218,9 @@ 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
@ -209,12 +241,12 @@ 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...
self.commander_setup()
@ -240,9 +272,15 @@ class DeckBuilder:
self.commander_mana_value = int(df.at[0, 'manaValue'])
# Set 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():
@ -259,15 +297,7 @@ class DeckBuilder:
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,
@ -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,18 +927,8 @@ 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:
@ -887,7 +941,7 @@ class DeckBuilder:
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']
@ -903,41 +957,82 @@ class DeckBuilder:
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)
"""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
# Create a count column for duplicates
self.card_library['Card Count'] = 1
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}'
mask = self.card_library['Card Name'] == duplicate
count = mask.sum()
self.card_library = self.card_library.drop_duplicates(subset='Card Name', keep='first')
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: pd.DataFrame, index: int) -> None:
"""Safely drop a card from the dataframe by index.
def drop_card(self, dataframe, 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,11 +1143,10 @@ 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)]
self.land_df.to_csv(f'{csv_directory}/test_lands.csv', index=False)
@ -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:
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 * (random.randint(100, 110) / 100):
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 = []
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
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")
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,22 +1238,20 @@ class DeckBuilder:
def add_kindred_lands(self):
print('Adding lands that care about the commander having a Kindred theme.')
print('Adding general Kindred lands.')
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 = [
{'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
},
create_land('Path of Ancestry', 'Land'),
create_land('Three Tree City', 'Legendary Land'),
create_land('Cavern of Souls', 'Land')
]
for theme in self.themes:
@ -1333,26 +1433,31 @@ class DeckBuilder:
print(f'Added {len(cards_to_add)} land cards.')
def check_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)])
print(f'Number of basic lands: {self.total_basics}')
"""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
}
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):
"""
@ -1400,44 +1505,110 @@ class DeckBuilder:
except (IndexError, KeyError) as e:
logging.error(f"Error removing {basic_land}: {e}")
# Try next most numerous basic if available
# Iterative approach instead of recursion
while basic_counts:
basic_counts.pop(basic_land, None)
if basic_counts:
basic_land = max(basic_counts.items(), key=lambda x: x[1])[0]
self.remove_basic() # Recursive call with remaining basics
else:
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]
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',
# 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']
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)
'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.")
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
}
#keyboard.wait('space')
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")
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()
"""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.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
@ -1457,15 +1628,15 @@ 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 = []
@ -1531,15 +1702,15 @@ 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 = []
@ -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:
if self.tertiary_theme:
self.add_by_tags(self.tertiary_theme, math.ceil(self.tertiary_weight * 3))
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))
build_deck = DeckBuilder()
build_deck.determine_commander()
pprint.pprint(build_deck.commander_dict, sort_dicts = False)
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 * weight_multiplier))
if self.secondary_theme:
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()
#pprint.pprint(build_deck.card_library['Card Name'], sort_dicts = False)

View file

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

View file

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