mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
2497 lines
No EOL
102 KiB
Python
2497 lines
No EOL
102 KiB
Python
from __future__ import annotations
|
|
|
|
import math
|
|
import numpy as np
|
|
import os
|
|
import random
|
|
import time
|
|
from functools import lru_cache
|
|
from typing import Dict, List, Optional, Union
|
|
|
|
import inquirer.prompt
|
|
import keyboard
|
|
import pandas as pd
|
|
import pprint
|
|
from fuzzywuzzy import process
|
|
from tqdm import tqdm
|
|
|
|
from settings import CSV_DIRECTORY, MULTIPLE_COPY_CARDS
|
|
from .builder_constants import (
|
|
BASIC_LANDS, CARD_TYPES, DEFAULT_NON_BASIC_LAND_SLOTS,
|
|
COMMANDER_CSV_PATH, FUZZY_MATCH_THRESHOLD, MAX_FUZZY_CHOICES, FETCH_LAND_DEFAULT_COUNT,
|
|
COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT,
|
|
COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT,
|
|
THEME_PRIORITY_BONUS, THEME_POOL_SIZE_MULTIPLIER, DECK_DIRECTORY,
|
|
COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_DEFAULT,
|
|
COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, DUAL_LAND_TYPE_MAP,
|
|
CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS,
|
|
STAPLE_LAND_CONDITIONS, TRIPLE_LAND_TYPE_MAP, MISC_LAND_MAX_COUNT, MISC_LAND_MIN_COUNT,
|
|
MISC_LAND_POOL_SIZE, LAND_REMOVAL_MAX_ATTEMPTS, PROTECTED_LANDS,
|
|
MANA_COLORS, MANA_PIP_PATTERNS, THEME_WEIGHT_MULTIPLIER
|
|
)
|
|
from . import builder_utils
|
|
from file_setup import setup_utils
|
|
from input_handler import InputHandler
|
|
from exceptions import (
|
|
BasicLandCountError,
|
|
BasicLandError,
|
|
CommanderMoveError,
|
|
CardTypeCountError,
|
|
CommanderColorError,
|
|
CommanderSelectionError,
|
|
CommanderValidationError,
|
|
CSVError,
|
|
CSVReadError,
|
|
CSVTimeoutError,
|
|
CSVValidationError,
|
|
DataFrameValidationError,
|
|
DuplicateCardError,
|
|
DeckBuilderError,
|
|
EmptyDataFrameError,
|
|
FetchLandSelectionError,
|
|
FetchLandValidationError,
|
|
IdealDeterminationError,
|
|
LandRemovalError,
|
|
LibraryOrganizationError,
|
|
LibrarySortError,
|
|
PriceAPIError,
|
|
PriceConfigurationError,
|
|
PriceLimitError,
|
|
PriceTimeoutError,
|
|
PriceValidationError,
|
|
ThemeSelectionError,
|
|
ThemeWeightError,
|
|
StapleLandError,
|
|
ManaPipError,
|
|
ThemeTagError,
|
|
ThemeWeightingError,
|
|
ThemePoolError
|
|
)
|
|
from type_definitions import (
|
|
CommanderDict,
|
|
CardLibraryDF,
|
|
CommanderDF,
|
|
LandDF,
|
|
ArtifactDF,
|
|
CreatureDF,
|
|
NonCreatureDF,
|
|
PlaneswalkerDF,
|
|
NonPlaneswalkerDF)
|
|
|
|
import logging_util
|
|
|
|
# Create logger for this module
|
|
logger = logging_util.logging.getLogger(__name__)
|
|
logger.setLevel(logging_util.LOG_LEVEL)
|
|
logger.addHandler(logging_util.file_handler)
|
|
logger.addHandler(logging_util.stream_handler)
|
|
|
|
# Try to import scrython and price_checker
|
|
try:
|
|
import scrython
|
|
from price_check import PriceChecker
|
|
use_scrython = True
|
|
except ImportError:
|
|
scrython = None
|
|
PriceChecker = None
|
|
use_scrython = False
|
|
logger.warning("Scrython is not installed. Price checking features will be unavailable."
|
|
)
|
|
|
|
pd.set_option('display.max_columns', None)
|
|
pd.set_option('display.max_rows', None)
|
|
pd.set_option('display.max_colwidth', 50)
|
|
|
|
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) -> None:
|
|
"""Initialize DeckBuilder with empty dataframes and default attributes."""
|
|
# Initialize dataframes with type hints
|
|
self.card_library: CardLibraryDF = pd.DataFrame({
|
|
'Card Name': pd.Series(dtype='str'),
|
|
'Card Type': pd.Series(dtype='str'),
|
|
'Mana Cost': pd.Series(dtype='str'),
|
|
'Mana Value': pd.Series(dtype='int'),
|
|
'Creature Types': pd.Series(dtype='object'),
|
|
'Themes': pd.Series(dtype='object'),
|
|
'Commander': pd.Series(dtype='bool'),
|
|
})
|
|
|
|
# Initialize component dataframes
|
|
self.commander_df: CommanderDF = pd.DataFrame()
|
|
self.land_df: LandDF = pd.DataFrame()
|
|
self.artifact_df: ArtifactDF = pd.DataFrame()
|
|
self.creature_df: CreatureDF = pd.DataFrame()
|
|
self.noncreature_df: NonCreatureDF = pd.DataFrame()
|
|
self.nonplaneswalker_df: NonPlaneswalkerDF = pd.DataFrame()
|
|
# Initialize other attributes with type hints
|
|
self.commander_info: Dict = {}
|
|
self.max_card_price: Optional[float] = None
|
|
self.commander_dict: CommanderDict = {}
|
|
self.commander: str = ''
|
|
self.commander_type: str = ''
|
|
self.commander_text: str = ''
|
|
self.commander_power: int = 0
|
|
self.commander_toughness: int = 0
|
|
self.commander_mana_cost: str = ''
|
|
self.commander_mana_value: int = 0
|
|
self.color_identity: Union[str, List[str]] = ''
|
|
self.color_identity_full: str = ''
|
|
self.colors: List[str] = []
|
|
self.creature_types: str = ''
|
|
self.commander_tags: List[str] = []
|
|
self.themes: List[str] = []
|
|
|
|
# Initialize handlers
|
|
self.price_checker = PriceChecker() if PriceChecker else None
|
|
self.input_handler = InputHandler()
|
|
|
|
def pause_with_message(self, message: str = "Press Enter to continue...") -> None:
|
|
"""Display a message and wait for user input.
|
|
|
|
Args:
|
|
message: Message to display before pausing
|
|
"""
|
|
"""Helper function to pause execution with a message."""
|
|
print(f"\n{message}")
|
|
input()
|
|
|
|
# Determine and Validate commander
|
|
def determine_commander(self) -> None:
|
|
"""Main orchestrator method for commander selection and initialization process.
|
|
|
|
This method coordinates the commander selection workflow by:
|
|
1. Loading commander data
|
|
2. Facilitating commander selection
|
|
3. Confirming the selection
|
|
4. Initializing commander attributes
|
|
|
|
Raises:
|
|
CommanderLoadError: If commander data cannot be loaded
|
|
CommanderSelectionError: If commander selection fails
|
|
CommanderValidationError: If commander data is invalid
|
|
"""
|
|
logger.info("Starting commander selection process")
|
|
|
|
try:
|
|
# Load commander data using builder_utils
|
|
df = builder_utils.load_commander_data()
|
|
logger.debug("Commander data loaded successfully")
|
|
|
|
# Select commander
|
|
commander_name = self._select_commander(df)
|
|
logger.info(f"Commander selected: {commander_name}")
|
|
|
|
# Confirm selection
|
|
commander_data = self._confirm_commander(df, commander_name)
|
|
logger.info("Commander selection confirmed")
|
|
|
|
# Initialize commander
|
|
self._initialize_commander(commander_data)
|
|
logger.info("Commander initialization complete")
|
|
|
|
except DeckBuilderError as e:
|
|
logger.error(f"Commander selection failed: {e}")
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in commander selection: {e}")
|
|
raise DeckBuilderError(f"Commander selection failed: {str(e)}")
|
|
|
|
def _select_commander(self, df: pd.DataFrame) -> str:
|
|
"""Handle the commander selection process including fuzzy matching.
|
|
|
|
Args:
|
|
df: DataFrame containing commander data
|
|
|
|
Returns:
|
|
Selected commander name
|
|
|
|
Raises:
|
|
CommanderSelectionError: If commander selection fails
|
|
"""
|
|
while True:
|
|
try:
|
|
card_choice = self.input_handler.questionnaire(
|
|
'Text',
|
|
'Enter a card name to be your commander'
|
|
)
|
|
|
|
# Use builder_utils for fuzzy matching
|
|
match, choices, exact_match = builder_utils.process_fuzzy_matches(card_choice, df)
|
|
|
|
if exact_match:
|
|
return match
|
|
|
|
# Handle multiple matches
|
|
choices.append(('Neither', 0))
|
|
logger.info("Multiple commander matches found")
|
|
|
|
choice = self.input_handler.questionnaire(
|
|
'Choice',
|
|
'Multiple matches found. Please select:',
|
|
choices_list=[name for name, _ in choices]
|
|
)
|
|
|
|
if choice != 'Neither':
|
|
return choice
|
|
|
|
except DeckBuilderError as e:
|
|
logger.warning(f"Commander selection attempt failed: {e}")
|
|
continue
|
|
|
|
def _confirm_commander(self, df: pd.DataFrame, commander_name: str) -> Dict:
|
|
"""Confirm commander selection and validate data.
|
|
|
|
Args:
|
|
df: DataFrame containing commander data
|
|
commander_name: Name of selected commander
|
|
|
|
Returns:
|
|
Dictionary containing commander data
|
|
|
|
Raises:
|
|
CommanderValidationError: If commander data is invalid
|
|
"""
|
|
try:
|
|
# Validate commander data
|
|
commander_data = builder_utils.validate_commander_selection(df, commander_name)
|
|
|
|
# Store commander DataFrame
|
|
self.commander_df = pd.DataFrame(commander_data)
|
|
|
|
# Display commander info
|
|
print('\nSelected Commander:')
|
|
pprint.pprint(commander_data, sort_dicts=False)
|
|
|
|
# Confirm selection
|
|
if not self.input_handler.questionnaire('Confirm', 'Is this the commander you want?', True):
|
|
raise CommanderSelectionError("Commander selection cancelled by user")
|
|
|
|
# Check price if enabled
|
|
if self.price_checker:
|
|
self.price_checker.get_card_price(commander_name)
|
|
|
|
return commander_data
|
|
|
|
except DeckBuilderError as e:
|
|
logger.error(f"Commander confirmation failed: {e}")
|
|
raise
|
|
|
|
def _initialize_commander(self, commander_data: Dict) -> None:
|
|
"""Initialize commander attributes from validated data.
|
|
|
|
Args:
|
|
commander_data: Dictionary containing commander information
|
|
|
|
Raises:
|
|
CommanderValidationError: If required attributes are missing
|
|
"""
|
|
try:
|
|
# Store commander info
|
|
self.commander_info = commander_data
|
|
self.commander = commander_data['name'][0]
|
|
|
|
# Initialize commander attributes
|
|
self.commander_setup()
|
|
logger.debug("Commander attributes initialized successfully")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Commander initialization failed: {e}")
|
|
raise CommanderValidationError(f"Failed to initialize commander: {str(e)}")
|
|
|
|
# Setup Commander
|
|
def commander_setup(self) -> None:
|
|
"""Set up commander attributes and initialize deck building.
|
|
|
|
This method orchestrates the commander setup process by calling specialized
|
|
helper methods to handle different aspects of initialization.
|
|
|
|
Raises:
|
|
CommanderValidationError: If commander validation fails
|
|
DeckBuilderError: If deck building initialization fails
|
|
"""
|
|
try:
|
|
# Initialize commander attributes
|
|
self._initialize_commander_attributes()
|
|
|
|
# Set up commander components
|
|
self._setup_commander_type_and_text()
|
|
self._setup_commander_stats()
|
|
self._setup_color_identity()
|
|
self._setup_creature_types()
|
|
self._setup_commander_tags()
|
|
|
|
# Initialize commander dictionary and deck
|
|
self._initialize_commander_dict()
|
|
self._initialize_deck_building()
|
|
|
|
logger.info("Commander setup completed successfully")
|
|
|
|
except CommanderValidationError as e:
|
|
logger.error(f"Commander validation failed: {e}")
|
|
raise
|
|
except DeckBuilderError as e:
|
|
logger.error(f"Deck building initialization failed: {e}")
|
|
raise
|
|
|
|
def _initialize_commander_attributes(self) -> None:
|
|
"""Initialize basic commander attributes with defaults.
|
|
|
|
Uses settings.py constants for default values.
|
|
"""
|
|
self.commander_power = COMMANDER_POWER_DEFAULT
|
|
self.commander_toughness = COMMANDER_TOUGHNESS_DEFAULT
|
|
self.commander_mana_value = COMMANDER_MANA_VALUE_DEFAULT
|
|
self.commander_type = COMMANDER_TYPE_DEFAULT
|
|
self.commander_text = COMMANDER_TEXT_DEFAULT
|
|
self.commander_mana_cost = COMMANDER_MANA_COST_DEFAULT
|
|
self.color_identity = COMMANDER_COLOR_IDENTITY_DEFAULT
|
|
self.colors = COMMANDER_COLORS_DEFAULT.copy()
|
|
self.creature_types = COMMANDER_CREATURE_TYPES_DEFAULT
|
|
self.commander_tags = COMMANDER_TAGS_DEFAULT.copy()
|
|
self.themes = COMMANDER_THEMES_DEFAULT.copy()
|
|
|
|
def _setup_commander_type_and_text(self) -> None:
|
|
"""Set up and validate commander type line and text.
|
|
|
|
Raises:
|
|
CommanderTypeError: If type line validation fails
|
|
"""
|
|
df = self.commander_df
|
|
type_line = str(df.at[0, 'type'])
|
|
self.commander_type = self.input_handler.validate_commander_type(type_line)
|
|
self.commander_text = str(df.at[0, 'text'])
|
|
|
|
def _setup_commander_stats(self) -> None:
|
|
"""Set up and validate commander power, toughness, and mana values.
|
|
|
|
Raises:
|
|
CommanderStatsError: If stats validation fails
|
|
"""
|
|
df = self.commander_df
|
|
|
|
# Validate power and toughness
|
|
self.commander_power = self.input_handler.validate_commander_stats(
|
|
'power', str(df.at[0, 'power']))
|
|
self.commander_toughness = self.input_handler.validate_commander_stats(
|
|
'toughness', str(df.at[0, 'toughness']))
|
|
|
|
# Set mana cost and value
|
|
self.commander_mana_cost = str(df.at[0, 'manaCost'])
|
|
self.commander_mana_value = self.input_handler.validate_commander_stats(
|
|
'mana value', int(df.at[0, 'manaValue']))
|
|
|
|
def _setup_color_identity(self) -> None:
|
|
"""Set up and validate commander color identity.
|
|
|
|
Raises:
|
|
CommanderColorError: If color identity validation fails
|
|
"""
|
|
df = self.commander_df
|
|
try:
|
|
color_id = df.at[0, 'colorIdentity']
|
|
if pd.isna(color_id):
|
|
color_id = 'COLORLESS'
|
|
|
|
self.color_identity = self.input_handler.validate_commander_colors(color_id)
|
|
self.color_identity_full = ''
|
|
self.determine_color_identity()
|
|
print(self.color_identity_full)
|
|
|
|
# Set colors list
|
|
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()]
|
|
if not self.colors:
|
|
self.colors = ['COLORLESS']
|
|
else:
|
|
self.colors = ['COLORLESS']
|
|
|
|
except Exception as e:
|
|
raise CommanderColorError(f"Failed to set color identity: {str(e)}")
|
|
|
|
def _setup_creature_types(self) -> None:
|
|
"""Set up commander creature types."""
|
|
df = self.commander_df
|
|
self.creature_types = str(df.at[0, 'creatureTypes'])
|
|
|
|
def _setup_commander_tags(self) -> None:
|
|
"""Set up and validate commander theme tags.
|
|
|
|
Raises:
|
|
CommanderTagError: If tag validation fails
|
|
"""
|
|
df = self.commander_df
|
|
tags = list(df.at[0, 'themeTags'])
|
|
self.commander_tags = self.input_handler.validate_commander_tags(tags)
|
|
self.determine_themes()
|
|
|
|
def _initialize_commander_dict(self) -> None:
|
|
"""Initialize the commander dictionary with validated data."""
|
|
self.commander_dict: CommanderDict = {
|
|
'Commander Name': self.commander,
|
|
'Mana Cost': self.commander_mana_cost,
|
|
'Mana Value': self.commander_mana_value,
|
|
'Color Identity': self.color_identity_full,
|
|
'Colors': self.colors,
|
|
'Type': self.commander_type,
|
|
'Creature Types': self.creature_types,
|
|
'Text': self.commander_text,
|
|
'Power': self.commander_power,
|
|
'Toughness': self.commander_toughness,
|
|
'Themes': self.themes,
|
|
'CMC': 0.0
|
|
}
|
|
self.add_card(self.commander, self.commander_type,
|
|
self.commander_mana_cost, self.commander_mana_value,
|
|
self.creature_types, self.commander_tags, True)
|
|
|
|
def _initialize_deck_building(self) -> None:
|
|
"""Initialize deck building process.
|
|
|
|
Raises:
|
|
DeckBuilderError: If deck building initialization fails
|
|
"""
|
|
try:
|
|
# Set up initial deck structure
|
|
self.setup_dataframes()
|
|
self.determine_ideals()
|
|
|
|
# Add cards by category
|
|
self.add_lands()
|
|
self.add_creatures()
|
|
self.add_ramp()
|
|
self.add_board_wipes()
|
|
self.add_interaction()
|
|
self.add_card_advantage()
|
|
|
|
# Fill remaining slots if needed
|
|
if len(self.card_library) < 100:
|
|
self.fill_out_deck()
|
|
|
|
# Process and organize deck
|
|
self.organize_library()
|
|
|
|
# Log deck composition
|
|
self._log_deck_composition()
|
|
|
|
# Finalize deck
|
|
self.get_cmc()
|
|
self.count_pips()
|
|
self.concatenate_duplicates()
|
|
self.organize_library()
|
|
self.sort_library()
|
|
self.commander_to_top()
|
|
|
|
# Save final deck
|
|
FILE_TIME = time.strftime("%Y%m%d-%H%M%S")
|
|
DECK_FILE = f'{self.commander}_{FILE_TIME}.csv'
|
|
self.card_library.to_csv(f'{DECK_DIRECTORY}/{DECK_FILE}', index=False)
|
|
|
|
except Exception as e:
|
|
raise DeckBuilderError(f"Failed to initialize deck building: {str(e)}")
|
|
|
|
def _log_deck_composition(self) -> None:
|
|
"""Log the deck composition statistics."""
|
|
logger.info(f'Creature cards (including commander): {self.creature_cards}')
|
|
logger.info(f'Planeswalker cards: {self.planeswalker_cards}')
|
|
logger.info(f'Battle cards: {self.battle_cards}')
|
|
logger.info(f'Instant cards: {self.instant_cards}')
|
|
logger.info(f'Sorcery cards: {self.sorcery_cards}')
|
|
logger.info(f'Artifact cards: {self.artifact_cards}')
|
|
logger.info(f'Enchantment cards: {self.enchantment_cards}')
|
|
logger.info(f'Land cards cards: {self.land_cards}')
|
|
logger.info(f'Number of cards in Library: {len(self.card_library)}')
|
|
|
|
# Determine and validate color identity
|
|
def determine_color_identity(self) -> None:
|
|
"""Determine the deck's color identity and set related attributes.
|
|
|
|
This method orchestrates the color identity determination process by:
|
|
1. Validating the color identity input
|
|
2. Determining the appropriate color combination type
|
|
3. Setting color identity attributes based on the combination
|
|
|
|
Raises:
|
|
CommanderColorError: If color identity validation fails
|
|
"""
|
|
try:
|
|
# Validate color identity using input handler
|
|
validated_identity = self.input_handler.validate_commander_colors(self.color_identity)
|
|
|
|
# Determine color combination type and set attributes
|
|
if self._determine_mono_color(validated_identity):
|
|
return
|
|
|
|
if self._determine_dual_color(validated_identity):
|
|
return
|
|
|
|
if self._determine_tri_color(validated_identity):
|
|
return
|
|
|
|
if self._determine_other_color(validated_identity):
|
|
return
|
|
|
|
# Handle unknown color identity
|
|
logger.warning(f"Unknown color identity: {validated_identity}")
|
|
self.color_identity_full = 'Unknown'
|
|
self.files_to_load = ['colorless']
|
|
|
|
except CommanderColorError as e:
|
|
logger.error(f"Color identity validation failed: {e}")
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error in determine_color_identity: {e}")
|
|
raise CommanderColorError(f"Failed to determine color identity: {str(e)}")
|
|
|
|
def _determine_mono_color(self, color_identity: str) -> bool:
|
|
"""Handle single color identities.
|
|
|
|
Args:
|
|
color_identity: Validated color identity string
|
|
|
|
Returns:
|
|
True if color identity was handled, False otherwise
|
|
"""
|
|
from settings import MONO_COLOR_MAP
|
|
|
|
if color_identity in MONO_COLOR_MAP:
|
|
self.color_identity_full, self.files_to_load = MONO_COLOR_MAP[color_identity]
|
|
return True
|
|
return False
|
|
|
|
def _determine_dual_color(self, color_identity: str) -> bool:
|
|
"""Handle two-color combinations.
|
|
|
|
Args:
|
|
color_identity: Validated color identity string
|
|
|
|
Returns:
|
|
True if color identity was handled, False otherwise
|
|
"""
|
|
from settings import DUAL_COLOR_MAP
|
|
|
|
if color_identity in DUAL_COLOR_MAP:
|
|
identity_info = DUAL_COLOR_MAP[color_identity]
|
|
self.color_identity_full = identity_info[0]
|
|
self.color_identity_options = identity_info[1]
|
|
self.files_to_load = identity_info[2]
|
|
return True
|
|
return False
|
|
|
|
def _determine_tri_color(self, color_identity: str) -> bool:
|
|
"""Handle three-color combinations.
|
|
|
|
Args:
|
|
color_identity: Validated color identity string
|
|
|
|
Returns:
|
|
True if color identity was handled, False otherwise
|
|
"""
|
|
from settings import TRI_COLOR_MAP
|
|
|
|
if color_identity in TRI_COLOR_MAP:
|
|
identity_info = TRI_COLOR_MAP[color_identity]
|
|
self.color_identity_full = identity_info[0]
|
|
self.color_identity_options = identity_info[1]
|
|
self.files_to_load = identity_info[2]
|
|
return True
|
|
return False
|
|
|
|
def _determine_other_color(self, color_identity: str) -> bool:
|
|
"""Handle four and five color combinations.
|
|
|
|
Args:
|
|
color_identity: Validated color identity string
|
|
|
|
Returns:
|
|
True if color identity was handled, False otherwise
|
|
"""
|
|
from builder_constants import OTHER_COLOR_MAP
|
|
|
|
if color_identity in OTHER_COLOR_MAP:
|
|
identity_info = OTHER_COLOR_MAP[color_identity]
|
|
self.color_identity_full = identity_info[0]
|
|
self.color_identity_options = identity_info[1]
|
|
self.files_to_load = identity_info[2]
|
|
return True
|
|
return False
|
|
|
|
# CSV and dataframe functionality
|
|
def read_csv(self, filename: str, converters: dict | None = None) -> pd.DataFrame:
|
|
"""Read and validate CSV file with comprehensive error handling.
|
|
|
|
Args:
|
|
filename: Name of the CSV file without extension
|
|
converters: Dictionary of converters for specific columns
|
|
|
|
Returns:
|
|
pd.DataFrame: Validated and processed DataFrame
|
|
|
|
Raises:
|
|
CSVReadError: If file cannot be read
|
|
CSVValidationError: If data fails validation
|
|
CSVTimeoutError: If read operation times out
|
|
EmptyDataFrameError: If DataFrame is empty
|
|
"""
|
|
filepath = f'{CSV_DIRECTORY}/{filename}_cards.csv'
|
|
|
|
try:
|
|
# Read with timeout
|
|
df = pd.read_csv(
|
|
filepath,
|
|
converters=converters or {'themeTags': pd.eval, 'creatureTypes': pd.eval},
|
|
)
|
|
|
|
# Check for empty DataFrame
|
|
if df.empty:
|
|
raise EmptyDataFrameError(f"Empty DataFrame from {filename}_cards.csv")
|
|
|
|
# Validate required columns
|
|
missing_cols = set(CSV_REQUIRED_COLUMNS) - set(df.columns)
|
|
if missing_cols:
|
|
raise CSVValidationError(f"Missing required columns: {missing_cols}")
|
|
|
|
# Validate data rules
|
|
for col, rules in CSV_VALIDATION_RULES.items():
|
|
if rules.get('required', False) and df[col].isnull().any():
|
|
raise CSVValidationError(f"Missing required values in column: {col}")
|
|
if 'type' in rules:
|
|
expected_type = rules['type']
|
|
actual_type = df[col].dtype.name
|
|
if expected_type == 'str' and actual_type not in ['object', 'string']:
|
|
raise CSVValidationError(f"Invalid type for column {col}: expected {expected_type}, got {actual_type}")
|
|
elif expected_type != 'str' and not actual_type.startswith(expected_type):
|
|
raise CSVValidationError(f"Invalid type for column {col}: expected {expected_type}, got {actual_type}")
|
|
logger.debug(f"Successfully read and validated {filename}_cards.csv")
|
|
#print(df.columns)
|
|
return df
|
|
|
|
except pd.errors.EmptyDataError:
|
|
raise EmptyDataFrameError(f"Empty CSV file: {filename}_cards.csv")
|
|
|
|
except FileNotFoundError as e:
|
|
logger.error(f"File {filename}_cards.csv not found: {e}")
|
|
setup_utils.regenerate_csvs_all()
|
|
return self.read_csv(filename, converters)
|
|
|
|
except TimeoutError:
|
|
raise CSVTimeoutError(f"Timeout reading {filename}_cards.csv", CSV_READ_TIMEOUT)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error reading {filename}_cards.csv: {e}")
|
|
raise CSVReadError(f"Failed to read {filename}_cards.csv: {str(e)}")
|
|
|
|
def write_csv(self, df: pd.DataFrame, filename: str) -> None:
|
|
"""Write DataFrame to CSV with error handling and logger.
|
|
|
|
Args:
|
|
df: DataFrame to write
|
|
filename: Name of the CSV file without extension
|
|
"""
|
|
try:
|
|
filepath = f'{CSV_DIRECTORY}/{filename}.csv'
|
|
df.to_csv(filepath, index=False)
|
|
logger.debug(f"Successfully wrote {filename}.csv")
|
|
except Exception as e:
|
|
logger.error(f"Error writing {filename}.csv: {e}")
|
|
|
|
def _load_and_combine_data(self) -> pd.DataFrame:
|
|
"""Load and combine data from multiple CSV files.
|
|
|
|
Returns:
|
|
Combined DataFrame from all source files
|
|
|
|
Raises:
|
|
CSVError: If data loading or combining fails
|
|
EmptyDataFrameError: If no valid data is loaded
|
|
"""
|
|
logger.info("Loading and combining data from CSV files...")
|
|
all_df = []
|
|
|
|
try:
|
|
# Wrap files_to_load with tqdm for progress bar
|
|
for file in tqdm(self.files_to_load, desc="Loading card data files", leave=False):
|
|
df = self.read_csv(file)
|
|
if df.empty:
|
|
raise EmptyDataFrameError(f"Empty DataFrame from {file}")
|
|
all_df.append(df)
|
|
#print(df.columns)
|
|
return builder_utils.combine_dataframes(all_df)
|
|
|
|
except (CSVError, EmptyDataFrameError) as e:
|
|
logger.error(f"Error loading and combining data: {e}")
|
|
raise
|
|
|
|
def _split_into_specialized_frames(self, df: pd.DataFrame) -> None:
|
|
"""Split combined DataFrame into specialized component frames.
|
|
|
|
Args:
|
|
df: Source DataFrame to split
|
|
|
|
Raises:
|
|
DataFrameValidationError: If data splitting fails
|
|
"""
|
|
try:
|
|
# Extract lands
|
|
self.land_df = df[df['type'].str.contains('Land')].copy()
|
|
self.land_df.sort_values(by='edhrecRank', inplace=True)
|
|
|
|
# Remove lands from main DataFrame
|
|
df = df[~df['type'].str.contains('Land')]
|
|
df.to_csv(f'{CSV_DIRECTORY}/test_cards.csv', index=False)
|
|
|
|
# Create specialized frames
|
|
self.artifact_df = df[df['type'].str.contains('Artifact')].copy()
|
|
self.battle_df = df[df['type'].str.contains('Battle')].copy()
|
|
self.creature_df = df[df['type'].str.contains('Creature')].copy()
|
|
self.noncreature_df = df[~df['type'].str.contains('Creature')].copy()
|
|
self.enchantment_df = df[df['type'].str.contains('Enchantment')].copy()
|
|
self.instant_df = df[df['type'].str.contains('Instant')].copy()
|
|
self.planeswalker_df = df[df['type'].str.contains('Planeswalker')].copy()
|
|
self.nonplaneswalker_df = df[~df['type'].str.contains('Planeswalker')].copy()
|
|
self.sorcery_df = df[df['type'].str.contains('Sorcery')].copy()
|
|
|
|
self.battle_df.to_csv(f'{CSV_DIRECTORY}/test_battle_cards.csv', index=False)
|
|
|
|
# Sort all frames
|
|
for frame in [self.artifact_df, self.battle_df, self.creature_df,
|
|
self.noncreature_df, self.enchantment_df, self.instant_df,
|
|
self.planeswalker_df, self.sorcery_df]:
|
|
frame.sort_values(by='edhrecRank', inplace=True)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error splitting DataFrames: {e}")
|
|
raise DataFrameValidationError("DataFrame splitting failed", {}, {"error": str(e)})
|
|
|
|
def _validate_dataframes(self) -> None:
|
|
"""Validate all component DataFrames.
|
|
|
|
Raises:
|
|
DataFrameValidationError: If validation fails
|
|
"""
|
|
try:
|
|
frames_to_validate = {
|
|
'land': self.land_df,
|
|
'artifact': self.artifact_df,
|
|
'battle': self.battle_df,
|
|
'creature': self.creature_df,
|
|
'noncreature': self.noncreature_df,
|
|
'enchantment': self.enchantment_df,
|
|
'instant': self.instant_df,
|
|
'planeswalker': self.planeswalker_df,
|
|
'sorcery': self.sorcery_df
|
|
}
|
|
|
|
for name, frame in frames_to_validate.items():
|
|
rules = builder_utils.get_validation_rules(name)
|
|
if not builder_utils.validate_dataframe(frame, rules):
|
|
raise DataFrameValidationError(f"{name} validation failed", rules)
|
|
|
|
except Exception as e:
|
|
logger.error(f"DataFrame validation failed: {e}")
|
|
raise
|
|
|
|
def _save_intermediate_results(self) -> None:
|
|
"""Save intermediate DataFrames for debugging and analysis.
|
|
|
|
Raises:
|
|
CSVError: If saving fails
|
|
"""
|
|
try:
|
|
frames_to_save = {
|
|
'lands': self.land_df,
|
|
'artifacts': self.artifact_df,
|
|
'battles': self.battle_df,
|
|
'creatures': self.creature_df,
|
|
'noncreatures': self.noncreature_df,
|
|
'enchantments': self.enchantment_df,
|
|
'instants': self.instant_df,
|
|
'planeswalkers': self.planeswalker_df,
|
|
'sorcerys': self.sorcery_df
|
|
}
|
|
|
|
for name, frame in frames_to_save.items():
|
|
self.write_csv(frame, f'test_{name}')
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error saving intermediate results: {e}")
|
|
raise CSVError(f"Failed to save intermediate results: {str(e)}")
|
|
|
|
def setup_dataframes(self) -> None:
|
|
"""Initialize and validate all required DataFrames.
|
|
|
|
This method orchestrates the DataFrame setup process by:
|
|
1. Loading and combining data from CSV files
|
|
2. Splitting into specialized component frames
|
|
3. Validating all DataFrames
|
|
4. Saving intermediate results
|
|
|
|
Raises:
|
|
CSVError: If any CSV operations fail
|
|
EmptyDataFrameError: If any required DataFrame is empty
|
|
DataFrameValidationError: If validation fails
|
|
"""
|
|
try:
|
|
# Load and combine data
|
|
self.full_df = self._load_and_combine_data()
|
|
self.full_df = self.full_df[~self.full_df['name'].str.contains(self.commander)]
|
|
self.full_df.sort_values(by='edhrecRank', inplace=True)
|
|
self.full_df.to_csv(f'{CSV_DIRECTORY}/test_full_cards.csv', index=False)
|
|
|
|
# Split into specialized frames
|
|
self._split_into_specialized_frames(self.full_df)
|
|
# Validate all frames
|
|
self._validate_dataframes()
|
|
|
|
# Save intermediate results
|
|
self._save_intermediate_results()
|
|
|
|
logger.info("DataFrame setup completed successfully")
|
|
|
|
except (CSVError, EmptyDataFrameError, DataFrameValidationError) as e:
|
|
logger.error(f"Error in DataFrame setup: {e}")
|
|
raise
|
|
|
|
# Theme selection
|
|
def determine_themes(self) -> None:
|
|
"""Determine and set up themes for the deck building process.
|
|
|
|
This method handles:
|
|
1. Theme selection (primary, secondary, tertiary)
|
|
2. Theme weight calculations
|
|
3. Hidden theme detection and setup
|
|
|
|
Raises:
|
|
ThemeSelectionError: If theme selection fails
|
|
ThemeWeightError: If weight calculation fails
|
|
"""
|
|
try:
|
|
# Get available themes from commander tags
|
|
themes = self.commander_tags.copy()
|
|
|
|
# Get available themes from commander tags
|
|
themes = self.commander_tags.copy()
|
|
|
|
# Initialize theme flags
|
|
self.hidden_theme = False
|
|
self.secondary_theme = False
|
|
self.tertiary_theme = False
|
|
|
|
# Select primary theme (required)
|
|
self.primary_theme = builder_utils.select_theme(
|
|
themes,
|
|
'Choose a primary theme for your commander deck.\n'
|
|
'This will be the "focus" of the deck, in a kindred deck this will typically be a creature type for example.'
|
|
)
|
|
themes.remove(self.primary_theme)
|
|
|
|
# Initialize self.weights from settings
|
|
from settings import THEME_WEIGHTS_DEFAULT
|
|
self.weights = THEME_WEIGHTS_DEFAULT.copy()
|
|
# Set initial weights for primary-only case
|
|
self.weights['primary'] = 1.0
|
|
self.weights['secondary'] = 0.0
|
|
self.weights['tertiary'] = 0.0
|
|
self.primary_weight = 1.0
|
|
|
|
# Select secondary theme if desired
|
|
if themes:
|
|
self.secondary_theme = builder_utils.select_theme(
|
|
themes,
|
|
'Choose a secondary theme for your commander deck.\n'
|
|
'This will typically be a secondary focus, like card draw for Spellslinger, or +1/+1 counters for Aggro.',
|
|
optional=True
|
|
)
|
|
|
|
# Check for Stop Here before modifying themes list
|
|
if self.secondary_theme == 'Stop Here':
|
|
self.secondary_theme = False
|
|
elif self.secondary_theme:
|
|
themes.remove(self.secondary_theme)
|
|
self.weights['secondary'] = 0.6
|
|
self.weights = builder_utils.adjust_theme_weights(
|
|
self.primary_theme,
|
|
self.secondary_theme,
|
|
None, # No tertiary theme yet
|
|
self.weights
|
|
)
|
|
self.primary_weight = self.weights['primary']
|
|
self.secondary_weight = self.weights['secondary']
|
|
|
|
# Select tertiary theme if desired
|
|
if themes and self.secondary_theme and self.secondary_theme != 'Stop Here':
|
|
self.tertiary_theme = builder_utils.select_theme(
|
|
themes,
|
|
'Choose a tertiary theme for your commander deck.\n'
|
|
'This will typically be a tertiary focus, or just something else to do that your commander is good at.',
|
|
optional=True
|
|
)
|
|
|
|
# Check for Stop Here before modifying themes list
|
|
if self.tertiary_theme == 'Stop Here':
|
|
self.tertiary_theme = False
|
|
elif self.tertiary_theme:
|
|
self.weights['tertiary'] = 0.3
|
|
self.weights = builder_utils.adjust_theme_weights(
|
|
self.primary_theme,
|
|
self.secondary_theme,
|
|
self.tertiary_theme,
|
|
self.weights
|
|
)
|
|
self.primary_weight = self.weights['primary']
|
|
self.secondary_weight = self.weights['secondary']
|
|
self.tertiary_weight = self.weights['tertiary']
|
|
|
|
# Build final themes list
|
|
self.themes = [self.primary_theme]
|
|
if self.secondary_theme:
|
|
self.themes.append(self.secondary_theme)
|
|
if self.tertiary_theme:
|
|
self.themes.append
|
|
self.determine_hidden_themes()
|
|
|
|
except (ThemeSelectionError, ThemeWeightError) as e:
|
|
logger.error(f"Error in theme determination: {e}")
|
|
raise
|
|
|
|
def determine_hidden_themes(self) -> None:
|
|
"""
|
|
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 self.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 = ['B', 'B', 'R', 'W', 'B', 'B']
|
|
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):
|
|
logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?')
|
|
choice = self.input_handler.questionnaire('Confirm', message='', default_value=False)
|
|
if choice:
|
|
self.hidden_theme = theme_cards[i]
|
|
self.themes.append(self.hidden_theme)
|
|
self.weights['primary'] = round(self.weights['primary'] / 3, 2)
|
|
self.weights['secondary'] = round(self.weights['secondary'] / 2, 2)
|
|
self.weights['tertiary'] = self.weights['tertiary']
|
|
self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2)
|
|
self.primary_weight = self.weights['primary']
|
|
self.secondary_weight = self.weights['secondary']
|
|
self.tertiary_weight = self.weights['tertiary']
|
|
self.hidden_weight = self.weights['hidden']
|
|
else:
|
|
continue
|
|
|
|
elif (hidden_themes[i] in self.themes
|
|
and hidden_themes[i] == 'Rat Kindred'
|
|
and color[i] in self.colors):
|
|
logger.info(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.input_handler.questionnaire('Confirm', message='', default_value=False)
|
|
if choice:
|
|
print('Which one?')
|
|
choice = self.input_handler.questionnaire('Choice', choices_list=theme_cards[i], message='')
|
|
if choice:
|
|
self.hidden_theme = choice
|
|
self.themes.append(self.hidden_theme)
|
|
self.weights['primary'] = round(self.weights['primary'] / 3, 2)
|
|
self.weights['secondary'] = round(self.weights['secondary'] / 2, 2)
|
|
self.weights['tertiary'] = self.weights['tertiary']
|
|
self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2)
|
|
self.primary_weight = self.weights['primary']
|
|
self.secondary_weight = self.weights['secondary']
|
|
self.tertiary_weight = self.weights['tertiary']
|
|
self.hidden_weight = self.weights['hidden']
|
|
else:
|
|
continue
|
|
|
|
# Setting the hidden theme for non-Kindred themes
|
|
hidden_themes = ['Little Fellas', 'Mill', 'Spellslinger', 'Spells Matter', 'Spellslinger', 'Spells Matter',]
|
|
theme_cards = ['Hare Apparent', 'Persistent Petitions', 'Dragon\'s Approach', 'Dragon\'s Approach', 'Slime Against Humanity', 'Slime Against Humanity']
|
|
color = ['W', 'B', 'R', 'R', 'G', 'G']
|
|
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):
|
|
logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?')
|
|
choice = self.input_handler.questionnaire('Confirm', message='', default_value=False)
|
|
if choice:
|
|
self.hidden_theme = theme_cards[i]
|
|
self.themes.append(self.hidden_theme)
|
|
self.weights['primary'] = round(self.weights['primary'] / 3, 2)
|
|
self.weights['secondary'] = round(self.weights['secondary'] / 2, 2)
|
|
self.weights['tertiary'] = self.weights['tertiary']
|
|
self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2)
|
|
self.primary_weight = self.weights['primary']
|
|
self.secondary_weight = self.weights['secondary']
|
|
self.tertiary_weight = self.weights['tertiary']
|
|
self.hidden_weight = self.weights['hidden']
|
|
else:
|
|
continue
|
|
|
|
# Setting ideals
|
|
def determine_ideals(self):
|
|
"""Determine ideal card counts and price settings for the deck.
|
|
|
|
This method handles:
|
|
1. Price configuration (if price checking is enabled)
|
|
2. Setting ideal counts for different card types
|
|
3. Calculating remaining free slots
|
|
|
|
Raises:
|
|
PriceConfigurationError: If there are issues configuring price settings
|
|
IdealDeterminationError: If there are issues determining ideal counts
|
|
"""
|
|
try:
|
|
# Initialize free slots
|
|
self.free_slots = 99
|
|
|
|
# Configure price settings if enabled
|
|
if use_scrython:
|
|
try:
|
|
builder_utils.configure_price_settings(self.price_checker, self.input_handler)
|
|
except ValueError as e:
|
|
raise PriceConfigurationError(f"Failed to configure price settings: {str(e)}")
|
|
|
|
# Get deck composition values
|
|
try:
|
|
composition = builder_utils.get_deck_composition_values(self.input_handler)
|
|
except ValueError as e:
|
|
raise IdealDeterminationError(f"Failed to determine deck composition: {str(e)}")
|
|
|
|
# Update class attributes with composition values
|
|
self.ideal_ramp = composition['ramp']
|
|
self.ideal_land_count = composition['lands']
|
|
self.min_basics = composition['basic_lands']
|
|
self.ideal_creature_count = composition['creatures']
|
|
self.ideal_removal = composition['removal']
|
|
self.ideal_wipes = composition['wipes']
|
|
self.ideal_card_advantage = composition['card_advantage']
|
|
self.ideal_protection = composition['protection']
|
|
|
|
# Update free slots
|
|
for value in [self.ideal_ramp, self.ideal_land_count, self.ideal_creature_count,
|
|
self.ideal_removal, self.ideal_wipes, self.ideal_card_advantage,
|
|
self.ideal_protection]:
|
|
self.free_slots -= value
|
|
|
|
print(f'\nFree 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.')
|
|
|
|
except (PriceConfigurationError, IdealDeterminationError) as e:
|
|
logger.error(f"Error in determine_ideals: {e}")
|
|
raise
|
|
|
|
# Adding card to library
|
|
def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, creature_types: list = None, tags: list = None, is_commander: bool = False) -> None:
|
|
"""Add a card to the deck library with price checking if enabled.
|
|
Args:
|
|
card (str): Name of the card to add
|
|
card_type (str): Type of the card (e.g., 'Creature', 'Instant')
|
|
mana_cost (str): Mana cost string representation
|
|
mana_value (int): Converted mana cost/mana value
|
|
creature_types (list): List of creature types in the card (if any)
|
|
themes (list): List of themes the card has
|
|
is_commander (bool, optional): Whether this card is the commander. Defaults to False.
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
PriceLimitError: If card price exceeds maximum allowed price
|
|
PriceAPIError: If there is an error fetching the price
|
|
PriceTimeoutError: If the price check times out
|
|
PriceValidationError: If the price data is invalid
|
|
"""
|
|
multiple_copies = BASIC_LANDS + MULTIPLE_COPY_CARDS
|
|
|
|
# Skip if card already exists and isn't allowed multiple copies
|
|
if card in pd.Series(self.card_library['Card Name']).values and card not in multiple_copies:
|
|
return
|
|
|
|
# Handle price checking
|
|
card_price = 0.0
|
|
try:
|
|
# Get price and validate
|
|
card_price = self.price_checker.get_card_price(card)
|
|
self.price_checker.validate_card_price(card, card_price)
|
|
self.price_checker.update_deck_price(card_price)
|
|
except (PriceAPIError, PriceTimeoutError, PriceValidationError, PriceLimitError) as e:
|
|
logger.warning(str(e))
|
|
return
|
|
|
|
# Create card entry
|
|
card_entry = [card, card_type, mana_cost, mana_value, creature_types, tags, is_commander]
|
|
|
|
# Add to library
|
|
self.card_library.loc[len(self.card_library)] = card_entry
|
|
|
|
logger.debug(f"Added {card} to deck library")
|
|
|
|
# Get card counts, sort library, set commander at index 1, and combine duplicates into 1 entry
|
|
def organize_library(self):
|
|
"""Organize and count cards in the library by their types.
|
|
|
|
This method counts the number of cards for each card type in the library
|
|
and updates the corresponding instance variables. It uses the count_cards_by_type
|
|
helper function from builder_utils for efficient counting.
|
|
|
|
The method handles the following card types:
|
|
- Artifacts
|
|
- Battles
|
|
- Creatures
|
|
- Enchantments
|
|
- Instants
|
|
- Kindred (if applicable)
|
|
- Lands
|
|
- Planeswalkers
|
|
- Sorceries
|
|
|
|
Raises:
|
|
CardTypeCountError: If there are issues counting cards by type
|
|
LibraryOrganizationError: If library organization fails
|
|
"""
|
|
try:
|
|
# Get all card types to count, including Kindred if not already present
|
|
all_types = CARD_TYPES + ['Kindred'] if 'Kindred' not in CARD_TYPES else CARD_TYPES
|
|
|
|
# Use helper function to count cards by type
|
|
card_counters = builder_utils.count_cards_by_type(self.card_library, all_types)
|
|
|
|
# Update instance variables with counts
|
|
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.get('Kindred', 0)
|
|
self.land_cards = card_counters['Land']
|
|
self.planeswalker_cards = card_counters['Planeswalker']
|
|
self.sorcery_cards = card_counters['Sorcery']
|
|
|
|
logger.debug(f"Library organized successfully with {len(self.card_library)} total cards")
|
|
|
|
except (CardTypeCountError, Exception) as e:
|
|
logger.error(f"Error organizing library: {e}")
|
|
raise LibraryOrganizationError(f"Failed to organize library: {str(e)}")
|
|
|
|
def sort_library(self) -> None:
|
|
"""Sort the card library by card type and name.
|
|
|
|
This method sorts the card library first by card type according to the
|
|
CARD_TYPE_SORT_ORDER constant, and then alphabetically by card name.
|
|
It uses the assign_sort_order() helper function to ensure consistent
|
|
type-based sorting across the application.
|
|
|
|
The sorting order is:
|
|
1. Card type (Planeswalker -> Battle -> Creature -> Instant -> Sorcery ->
|
|
Artifact -> Enchantment -> Land)
|
|
2. Card name (alphabetically)
|
|
|
|
Raises:
|
|
LibrarySortError: If there are issues during the sorting process
|
|
"""
|
|
try:
|
|
# Use the assign_sort_order helper function to add sort order
|
|
sorted_library = builder_utils.assign_sort_order(self.card_library)
|
|
|
|
# Sort by Sort Order and Card Name
|
|
sorted_library = sorted_library.sort_values(
|
|
by=['Sort Order', 'Card Name'],
|
|
ascending=[True, True]
|
|
)
|
|
|
|
# Clean up and reset index
|
|
self.card_library = (
|
|
sorted_library
|
|
.drop(columns=['Sort Order'])
|
|
.reset_index(drop=True)
|
|
)
|
|
|
|
logger.debug("Card library sorted successfully")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error sorting library: {e}")
|
|
raise LibrarySortError(
|
|
"Failed to sort card library",
|
|
{"error": str(e)}
|
|
)
|
|
|
|
def commander_to_top(self) -> None:
|
|
"""Move commander card to the top of the library while preserving commander status.
|
|
|
|
This method identifies the commander card in the library using a boolean mask,
|
|
removes it from its current position, and prepends it to the top of the library.
|
|
The commander's status and attributes are preserved during the move.
|
|
|
|
Raises:
|
|
CommanderMoveError: If the commander cannot be found in the library or
|
|
if there are issues with the move operation.
|
|
"""
|
|
try:
|
|
# Create boolean mask to identify commander
|
|
commander_mask = self.card_library['Commander']
|
|
|
|
# Check if commander exists in library
|
|
if not commander_mask.any():
|
|
error_msg = "Commander not found in library"
|
|
logger.warning(error_msg)
|
|
raise CommanderMoveError(error_msg)
|
|
|
|
# Get commander row and name for logging
|
|
commander_row = self.card_library[commander_mask].copy()
|
|
commander_name = commander_row['Card Name'].iloc[0]
|
|
|
|
# Remove commander from current position
|
|
self.card_library = self.card_library[~commander_mask]
|
|
|
|
# Prepend commander to top of library
|
|
self.card_library = pd.concat([commander_row, self.card_library], ignore_index=True)
|
|
|
|
logger.info(f"Successfully moved commander '{commander_name}' to top of library")
|
|
|
|
except CommanderMoveError:
|
|
raise
|
|
except Exception as e:
|
|
error_msg = f"Error moving commander to top: {str(e)}"
|
|
logger.error(error_msg)
|
|
raise CommanderMoveError(error_msg)
|
|
|
|
def concatenate_duplicates(self):
|
|
"""Process duplicate cards in the library using the helper function.
|
|
|
|
This method consolidates duplicate cards (like basic lands and special cards
|
|
that can have multiple copies) into single entries with updated counts.
|
|
It uses the process_duplicate_cards helper function from builder_utils.
|
|
|
|
Raises:
|
|
DuplicateCardError: If there are issues processing duplicate cards
|
|
"""
|
|
try:
|
|
# Get list of cards that can have duplicates
|
|
duplicate_lists = BASIC_LANDS + MULTIPLE_COPY_CARDS
|
|
|
|
# Process duplicates using helper function
|
|
self.card_library = builder_utils.process_duplicate_cards(
|
|
self.card_library,
|
|
duplicate_lists
|
|
)
|
|
|
|
logger.info("Successfully processed duplicate cards")
|
|
|
|
except DuplicateCardError as e:
|
|
logger.error(f"Error processing duplicate cards: {e}")
|
|
raise
|
|
|
|
# Land Management
|
|
def add_lands(self):
|
|
"""
|
|
Add lands to the deck based on ideal count and deck requirements.
|
|
|
|
The process follows these steps:
|
|
1. Add basic lands distributed by color identity
|
|
2. Add utility/staple lands
|
|
3. Add fetch lands if requested
|
|
4. Add theme-specific lands (e.g., Kindred)
|
|
5. Add multi-color lands based on color count
|
|
6. Add miscellaneous utility lands
|
|
7. Adjust total land count to match ideal count
|
|
"""
|
|
MAX_ADJUSTMENT_ATTEMPTS = (self.ideal_land_count - self.min_basics) * 1.5
|
|
self.total_basics = 0
|
|
|
|
try:
|
|
# Add lands in sequence
|
|
self.add_basics()
|
|
self.check_basics()
|
|
self.add_standard_non_basics()
|
|
self.add_fetches()
|
|
|
|
# Add theme and color-specific lands
|
|
if any('Kindred' in theme for theme in self.themes):
|
|
self.add_kindred_lands()
|
|
if len(self.colors) >= 2:
|
|
self.add_dual_lands()
|
|
if len(self.colors) >= 3:
|
|
self.add_triple_lands()
|
|
|
|
self.add_misc_lands()
|
|
|
|
# Clean up land database
|
|
mask = self.land_df['name'].isin(self.card_library['Card Name'])
|
|
self.land_df = self.land_df[~mask]
|
|
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
|
|
|
# Adjust to ideal land count
|
|
self.check_basics()
|
|
print()
|
|
logger.info('Adjusting total land count to match ideal count...')
|
|
self.organize_library()
|
|
|
|
attempts = 0
|
|
while self.land_cards > int(self.ideal_land_count) and attempts < MAX_ADJUSTMENT_ATTEMPTS:
|
|
logger.info(f'Current lands: {self.land_cards}, Target: {self.ideal_land_count}')
|
|
self.remove_basic()
|
|
self.organize_library()
|
|
attempts += 1
|
|
|
|
if attempts >= MAX_ADJUSTMENT_ATTEMPTS:
|
|
logger.warning(f"Could not reach ideal land count after {MAX_ADJUSTMENT_ATTEMPTS} attempts")
|
|
|
|
logger.info(f'Final land count: {self.land_cards}')
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during land addition: {e}")
|
|
raise
|
|
|
|
def add_basics(self):
|
|
"""Add basic lands to the deck based on color identity and commander tags.
|
|
|
|
This method:
|
|
1. Calculates total basics needed based on ideal land count
|
|
2. Gets appropriate basic land mapping (normal or snow-covered)
|
|
3. Distributes basics across colors
|
|
4. Updates the land database
|
|
|
|
Raises:
|
|
BasicLandError: If there are issues with basic land addition
|
|
LandDistributionError: If land distribution fails
|
|
"""
|
|
try:
|
|
# Calculate total basics needed
|
|
total_basics = self.ideal_land_count - DEFAULT_NON_BASIC_LAND_SLOTS
|
|
if total_basics <= 0:
|
|
raise BasicLandError("Invalid basic land count calculation")
|
|
|
|
# Get appropriate basic land mapping
|
|
use_snow = 'Snow' in self.commander_tags
|
|
color_to_basic = builder_utils.get_basic_land_mapping(use_snow)
|
|
|
|
# Calculate distribution
|
|
basics_per_color, remaining = builder_utils.calculate_basics_per_color(
|
|
total_basics,
|
|
len(self.colors)
|
|
)
|
|
|
|
print()
|
|
logger.info(
|
|
f'Adding {total_basics} basic lands distributed across '
|
|
f'{len(self.colors)} colors'
|
|
)
|
|
|
|
# Initialize distribution dictionary
|
|
distribution = {color: basics_per_color for color in self.colors}
|
|
|
|
# Distribute remaining basics
|
|
if remaining > 0:
|
|
distribution = builder_utils.distribute_remaining_basics(
|
|
distribution,
|
|
remaining,
|
|
self.colors
|
|
)
|
|
|
|
# Add basics according to distribution
|
|
lands_to_remove = []
|
|
for color, count in distribution.items():
|
|
basic = color_to_basic.get(color)
|
|
if basic:
|
|
for _ in range(count):
|
|
self.add_card(basic, 'Basic Land', None, 0, is_commander=False)
|
|
lands_to_remove.append(basic)
|
|
|
|
# Update land database
|
|
self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)]
|
|
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error adding basic lands: {e}")
|
|
raise BasicLandError(f"Failed to add basic lands: {str(e)}")
|
|
|
|
def add_standard_non_basics(self):
|
|
"""Add staple utility lands to the deck based on predefined conditions and requirements.
|
|
|
|
This method processes the STAPLE_LAND_CONDITIONS from settings to add appropriate
|
|
utility lands to the deck. For each potential staple land, it:
|
|
|
|
1. Validates the land against deck requirements using:
|
|
- Commander tags
|
|
- Color identity
|
|
- Commander power level
|
|
- Other predefined conditions
|
|
|
|
2. Adds validated lands to the deck and tracks them in self.staples
|
|
|
|
3. Updates the land database to remove added lands
|
|
|
|
The method ensures no duplicate lands are added and maintains proper logging
|
|
of all additions.
|
|
|
|
Raises:
|
|
StapleLandError: If there are issues adding staple lands, such as
|
|
validation failures or database update errors.
|
|
"""
|
|
print()
|
|
logger.info('Adding staple non-basic lands')
|
|
self.staples = []
|
|
|
|
try:
|
|
for land in STAPLE_LAND_CONDITIONS:
|
|
if builder_utils.validate_staple_land_conditions(
|
|
land,
|
|
STAPLE_LAND_CONDITIONS,
|
|
self.commander_tags,
|
|
self.colors,
|
|
self.commander_power
|
|
):
|
|
if land not in self.card_library['Card Name'].values:
|
|
self.add_card(land, 'Land', None, 0)
|
|
self.staples.append(land)
|
|
logger.debug(f"Added staple land: {land}")
|
|
|
|
self.land_df = builder_utils.process_staple_lands(
|
|
self.staples, self.card_library, self.land_df
|
|
)
|
|
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
|
logger.info(f'Added {len(self.staples)} staple lands:')
|
|
print(*self.staples, sep='\n')
|
|
except Exception as e:
|
|
logger.error(f"Error adding staple lands: {e}")
|
|
raise StapleLandError(f"Failed to add staple lands: {str(e)}")
|
|
|
|
def add_fetches(self):
|
|
"""Add fetch lands to the deck based on user input and deck colors.
|
|
|
|
This method handles:
|
|
1. Getting user input for desired number of fetch lands
|
|
2. Validating the input
|
|
3. Getting available fetch lands based on deck colors
|
|
4. Selecting and adding appropriate fetch lands
|
|
5. Updating the land database
|
|
|
|
Raises:
|
|
FetchLandValidationError: If fetch land count is invalid
|
|
FetchLandSelectionError: If unable to select required fetch lands
|
|
PriceLimitError: If fetch lands exceed price limits
|
|
"""
|
|
try:
|
|
# Get user input for fetch lands
|
|
print()
|
|
logger.info('Adding fetch lands')
|
|
print('How many fetch lands would you like to include?\n'
|
|
'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.')
|
|
|
|
# Get and validate fetch count
|
|
fetch_count = self.input_handler.questionnaire('Number', default_value=FETCH_LAND_DEFAULT_COUNT, message='Default')
|
|
validated_count = builder_utils.validate_fetch_land_count(fetch_count)
|
|
|
|
# Get available fetch lands based on colors and budget
|
|
max_price = self.max_card_price if hasattr(self, 'max_card_price') else None
|
|
available_fetches = builder_utils.get_available_fetch_lands(
|
|
self.colors,
|
|
self.price_checker if use_scrython else None,
|
|
max_price
|
|
)
|
|
|
|
# Select fetch lands
|
|
selected_fetches = builder_utils.select_fetch_lands(
|
|
available_fetches,
|
|
validated_count
|
|
)
|
|
|
|
# Add selected fetch lands to deck
|
|
lands_to_remove = set()
|
|
for fetch in selected_fetches:
|
|
self.add_card(fetch, 'Land', None, 0)
|
|
lands_to_remove.add(fetch)
|
|
|
|
# Update land database
|
|
self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)]
|
|
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
|
|
|
logger.info(f'Added {len(selected_fetches)} fetch lands:')
|
|
print(*selected_fetches, sep='\n')
|
|
|
|
except (FetchLandValidationError, FetchLandSelectionError, PriceLimitError) as e:
|
|
logger.error(f"Error adding fetch lands: {e}")
|
|
raise
|
|
|
|
def add_kindred_lands(self):
|
|
"""Add Kindred-themed lands to the deck based on commander themes.
|
|
|
|
This method handles:
|
|
1. Getting available Kindred lands based on deck themes
|
|
2. Selecting and adding appropriate Kindred lands
|
|
3. Updating the land database
|
|
|
|
Raises:
|
|
KindredLandSelectionError: If unable to select required Kindred lands
|
|
PriceLimitError: If Kindred lands exceed price limits
|
|
"""
|
|
try:
|
|
print()
|
|
logger.info('Adding Kindred-themed lands')
|
|
|
|
# Get available Kindred lands based on themes and budget
|
|
max_price = self.max_card_price if hasattr(self, 'max_card_price') else None
|
|
available_lands = builder_utils.get_available_kindred_lands(
|
|
self.land_df,
|
|
self.colors,
|
|
self.commander_tags,
|
|
self.price_checker if use_scrython else None,
|
|
max_price
|
|
)
|
|
|
|
# Select Kindred lands
|
|
selected_lands = builder_utils.select_kindred_lands(
|
|
available_lands,
|
|
len(available_lands)
|
|
)
|
|
|
|
# Add selected Kindred lands to deck
|
|
lands_to_remove = set()
|
|
for land in selected_lands:
|
|
self.add_card(land, 'Land', None, 0)
|
|
lands_to_remove.add(land)
|
|
|
|
# Update land database
|
|
self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)]
|
|
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
|
|
|
logger.info(f'Added {len(selected_lands)} Kindred-themed lands:')
|
|
print(*selected_lands, sep='\n')
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error adding Kindred lands: {e}")
|
|
raise
|
|
|
|
def add_dual_lands(self):
|
|
"""Add dual lands to the deck based on color identity and user preference.
|
|
|
|
This method handles the addition of dual lands by:
|
|
1. Validating if dual lands should be added
|
|
2. Getting available dual lands based on deck colors
|
|
3. Selecting appropriate dual lands
|
|
4. Adding selected lands to the deck
|
|
5. Updating the land database
|
|
|
|
The process uses helper functions from builder_utils for modular operation.
|
|
"""
|
|
try:
|
|
# Check if we should add dual lands
|
|
print()
|
|
print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?')
|
|
use_duals = self.input_handler.questionnaire('Confirm', message='', default_value=True)
|
|
|
|
if not use_duals:
|
|
logger.info('Skipping adding Dual-type land cards.')
|
|
return
|
|
|
|
logger.info('Adding Dual-type lands')
|
|
# Get color pairs by checking DUAL_LAND_TYPE_MAP keys against files_to_load
|
|
color_pairs = []
|
|
for key in DUAL_LAND_TYPE_MAP:
|
|
if key in self.files_to_load:
|
|
color_pairs.extend([f'Land — {DUAL_LAND_TYPE_MAP[key]}', f'Snow Land — {DUAL_LAND_TYPE_MAP[key]}'])
|
|
|
|
# Validate dual lands for these color pairs
|
|
if not builder_utils.validate_dual_lands(color_pairs, 'Snow' in self.commander_tags):
|
|
logger.info('No valid dual lands available for this color combination.')
|
|
return
|
|
|
|
# Get available dual lands
|
|
dual_df = builder_utils.get_available_dual_lands(
|
|
self.land_df,
|
|
color_pairs,
|
|
'Snow' in self.commander_tags
|
|
)
|
|
|
|
# Select appropriate dual lands
|
|
selected_lands = builder_utils.select_dual_lands(
|
|
dual_df,
|
|
self.price_checker if use_scrython else None,
|
|
self.max_card_price if hasattr(self, 'max_card_price') else None
|
|
)
|
|
|
|
# Add selected lands to deck
|
|
for land in selected_lands:
|
|
self.add_card(land['name'], land['type'],
|
|
land['manaCost'], land['manaValue'])
|
|
|
|
# Update land database
|
|
self.land_df = builder_utils.process_dual_lands(
|
|
selected_lands,
|
|
self.card_library,
|
|
self.land_df
|
|
)
|
|
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
|
|
|
logger.info(f'Added {len(selected_lands)} Dual-type land cards:')
|
|
for card in selected_lands:
|
|
print(card['name'])
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error adding dual lands: {e}")
|
|
raise
|
|
|
|
def add_triple_lands(self):
|
|
"""Add triple lands to the deck based on color identity and user preference.
|
|
|
|
This method handles the addition of triple lands by:
|
|
1. Validating if triple lands should be added
|
|
2. Getting available triple lands based on deck colors
|
|
3. Selecting appropriate triple lands
|
|
4. Adding selected lands to the deck
|
|
5. Updating the land database
|
|
|
|
The process uses helper functions from builder_utils for modular operation.
|
|
"""
|
|
try:
|
|
# Check if we should add triple lands
|
|
print()
|
|
print('Would you like to include triple lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?')
|
|
use_triples = self.input_handler.questionnaire('Confirm', message='', default_value=True)
|
|
|
|
if not use_triples:
|
|
logger.info('Skipping adding triple lands.')
|
|
return
|
|
|
|
logger.info('Adding triple lands')
|
|
# Get color triplets by checking TRIPLE_LAND_TYPE_MAP keys against files_to_load
|
|
color_triplets = []
|
|
for key in TRIPLE_LAND_TYPE_MAP:
|
|
if key in self.files_to_load:
|
|
color_triplets.extend([f'Land — {TRIPLE_LAND_TYPE_MAP[key]}'])
|
|
|
|
# Validate triple lands for these color triplets
|
|
if not builder_utils.validate_triple_lands(color_triplets, 'Snow' in self.commander_tags):
|
|
logger.info('No valid triple lands available for this color combination.')
|
|
return
|
|
|
|
# Get available triple lands
|
|
triple_df = builder_utils.get_available_triple_lands(
|
|
self.land_df,
|
|
color_triplets,
|
|
'Snow' in self.commander_tags
|
|
)
|
|
|
|
# Select appropriate triple lands
|
|
selected_lands = builder_utils.select_triple_lands(
|
|
triple_df,
|
|
self.price_checker if use_scrython else None,
|
|
self.max_card_price if hasattr(self, 'max_card_price') else None
|
|
)
|
|
|
|
# Add selected lands to deck
|
|
for land in selected_lands:
|
|
self.add_card(land['name'], land['type'],
|
|
land['manaCost'], land['manaValue'])
|
|
|
|
# Update land database
|
|
self.land_df = builder_utils.process_triple_lands(
|
|
selected_lands,
|
|
self.card_library,
|
|
self.land_df
|
|
)
|
|
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
|
|
|
logger.info(f'Added {len(selected_lands)} triple lands:')
|
|
for card in selected_lands:
|
|
print(card['name'])
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error adding triple lands: {e}")
|
|
|
|
def add_misc_lands(self):
|
|
"""Add additional utility lands that fit the deck's color identity.
|
|
|
|
This method randomly selects a number of miscellaneous utility lands to add to the deck.
|
|
The number of lands is randomly determined between MISC_LAND_MIN_COUNT and MISC_LAND_MAX_COUNT.
|
|
Lands are selected from a filtered pool of the top MISC_LAND_POOL_SIZE lands by EDHREC rank.
|
|
|
|
The method handles price constraints if price checking is enabled and updates the land
|
|
database after adding lands to prevent duplicates.
|
|
|
|
Raises:
|
|
MiscLandSelectionError: If there are issues selecting appropriate misc lands
|
|
"""
|
|
print()
|
|
logger.info('Adding miscellaneous utility lands')
|
|
|
|
try:
|
|
# Get available misc lands
|
|
available_lands = builder_utils.get_available_misc_lands(
|
|
self.land_df,
|
|
MISC_LAND_POOL_SIZE
|
|
)
|
|
|
|
if not available_lands:
|
|
logger.warning("No eligible miscellaneous lands found")
|
|
return
|
|
|
|
# Select random number of lands
|
|
selected_lands = builder_utils.select_misc_lands(
|
|
available_lands,
|
|
MISC_LAND_MIN_COUNT,
|
|
MISC_LAND_MAX_COUNT,
|
|
self.price_checker if use_scrython else None,
|
|
self.max_card_price if hasattr(self, 'max_card_price') else None
|
|
)
|
|
|
|
# Add selected lands
|
|
lands_to_remove = set()
|
|
for card in selected_lands:
|
|
self.add_card(card['name'], card['type'],
|
|
card['manaCost'], card['manaValue'])
|
|
lands_to_remove.add(card['name'])
|
|
|
|
# Update land database
|
|
self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)]
|
|
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
|
|
|
logger.info(f'Added {len(selected_lands)} miscellaneous lands:')
|
|
for card in selected_lands:
|
|
print(card['name'])
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error adding misc lands: {e}")
|
|
raise
|
|
|
|
def check_basics(self):
|
|
"""Check and display counts of each basic land type in the deck.
|
|
|
|
This method analyzes the deck's basic land composition by:
|
|
1. Counting each type of basic land (including snow-covered)
|
|
2. Displaying the counts for each basic land type
|
|
3. Calculating and storing the total number of basic lands
|
|
|
|
The method uses helper functions from builder_utils for consistent
|
|
counting and display formatting.
|
|
|
|
Raises:
|
|
BasicLandCountError: If there are issues counting basic lands
|
|
|
|
Note:
|
|
Updates self.total_basics with the sum of all basic lands
|
|
"""
|
|
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
|
|
|
|
try:
|
|
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()
|
|
logger.info("Basic Land Counts:")
|
|
for land, count in basic_lands.items():
|
|
if count > 0:
|
|
print(f"{land}: {count}")
|
|
logger.info(f"Total basic lands: {self.total_basics}")
|
|
except BasicLandCountError as e:
|
|
logger.error(f"Error counting basic lands: {e}")
|
|
self.total_basics = 0
|
|
raise
|
|
|
|
def remove_basic(self, max_attempts: int = 3):
|
|
"""
|
|
Remove a basic land while maintaining color balance.
|
|
Attempts to remove from colors with more basics first.
|
|
|
|
Args:
|
|
max_attempts: Maximum number of removal attempts before falling back to non-basics
|
|
"""
|
|
print()
|
|
logger.info('Land count over ideal count, removing a basic land.')
|
|
|
|
color_to_basic = {
|
|
'W': 'Plains', 'U': 'Island', 'B': 'Swamp',
|
|
'R': 'Mountain', 'G': 'Forest'
|
|
}
|
|
|
|
# Get current basic land counts using vectorized operations
|
|
basic_counts = {
|
|
basic: len(self.card_library[self.card_library['Card Name'] == basic])
|
|
for color, basic in color_to_basic.items()
|
|
if color in self.colors
|
|
}
|
|
|
|
sum_basics = sum(basic_counts.values())
|
|
attempts = 0
|
|
|
|
while attempts < max_attempts and sum_basics > self.min_basics:
|
|
if not basic_counts:
|
|
logger.warning("No basic lands found to remove")
|
|
break
|
|
|
|
basic_land = max(basic_counts.items(), key=lambda x: x[1])[0]
|
|
try:
|
|
# Use boolean indexing for efficiency
|
|
mask = self.card_library['Card Name'] == basic_land
|
|
if not mask.any():
|
|
basic_counts.pop(basic_land)
|
|
continue
|
|
|
|
index_to_drop = self.card_library[mask].index[0]
|
|
self.card_library = self.card_library.drop(index_to_drop).reset_index(drop=True)
|
|
logger.info(f'{basic_land} removed successfully')
|
|
return
|
|
|
|
except (IndexError, KeyError) as e:
|
|
logger.error(f"Error removing {basic_land}: {e}")
|
|
basic_counts.pop(basic_land)
|
|
|
|
attempts += 1
|
|
|
|
# If we couldn't remove a basic land, try removing a non-basic
|
|
logger.warning("Could not remove basic land, attempting to remove non-basic")
|
|
self.remove_land()
|
|
|
|
def remove_land(self):
|
|
"""Remove a random non-basic, non-staple land from the deck.
|
|
|
|
This method attempts to remove a non-protected land from the deck up to
|
|
LAND_REMOVAL_MAX_ATTEMPTS times. It uses helper functions to filter removable
|
|
lands and select a land for removal.
|
|
|
|
Raises:
|
|
LandRemovalError: If no removable lands are found or removal fails
|
|
"""
|
|
print()
|
|
logger.info('Attempting to remove a non-protected land')
|
|
attempts = 0
|
|
|
|
while attempts < LAND_REMOVAL_MAX_ATTEMPTS:
|
|
try:
|
|
# Get removable lands
|
|
removable_lands = builder_utils.filter_removable_lands(self.card_library, PROTECTED_LANDS + self.staples)
|
|
|
|
# Select a land for removal
|
|
card_index, card_name = builder_utils.select_land_for_removal(removable_lands)
|
|
|
|
# Remove the selected land
|
|
logger.info(f"Removing {card_name}")
|
|
self.card_library.drop(card_index, inplace=True)
|
|
self.card_library.reset_index(drop=True, inplace=True)
|
|
logger.info("Land removed successfully")
|
|
return
|
|
|
|
except LandRemovalError as e:
|
|
logger.warning(f"Attempt {attempts + 1} failed: {e}")
|
|
attempts += 1
|
|
continue
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error removing land: {e}")
|
|
raise LandRemovalError(f"Failed to remove land: {str(e)}")
|
|
|
|
# If we reach here, we've exceeded max attempts
|
|
raise LandRemovalError(f"Could not find a removable land after {LAND_REMOVAL_MAX_ATTEMPTS} attempts")
|
|
# Count pips and get average CMC
|
|
def count_pips(self):
|
|
"""Analyze and display the distribution of colored mana symbols (pips) in card casting costs.
|
|
|
|
This method processes the mana costs of all cards in the deck to:
|
|
1. Count the number of colored mana symbols for each color
|
|
2. Calculate the percentage distribution of colors
|
|
3. Log detailed pip distribution information
|
|
|
|
The analysis uses helper functions from builder_utils for consistent counting
|
|
and percentage calculations. Results are logged with detailed breakdowns
|
|
of pip counts and distributions.
|
|
|
|
Dependencies:
|
|
- MANA_COLORS from settings.py for color iteration
|
|
- builder_utils.count_color_pips() for counting pips
|
|
- builder_utils.calculate_pip_percentages() for distribution calculation
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
ManaPipError: If there are issues with:
|
|
- Counting pips for specific colors
|
|
- Calculating pip percentages
|
|
- Unexpected errors during analysis
|
|
|
|
Logs:
|
|
- Warning if no colored mana symbols are found
|
|
- Info with detailed pip distribution and percentages
|
|
- Error details if analysis fails
|
|
"""
|
|
print()
|
|
logger.info('Analyzing color pip distribution...')
|
|
|
|
try:
|
|
# Get mana costs from card library
|
|
mana_costs = self.card_library['Mana Cost'].dropna()
|
|
|
|
# Count pips for each color using helper function
|
|
pip_counts = {}
|
|
for color in MANA_COLORS:
|
|
try:
|
|
pip_counts[color] = builder_utils.count_color_pips(mana_costs, color)
|
|
except (TypeError, ValueError) as e:
|
|
raise ManaPipError(
|
|
f"Error counting {color} pips",
|
|
{"color": color, "error": str(e)}
|
|
)
|
|
|
|
# Calculate percentages using helper function
|
|
try:
|
|
percentages = builder_utils.calculate_pip_percentages(pip_counts)
|
|
except (TypeError, ValueError) as e:
|
|
raise ManaPipError(
|
|
"Error calculating pip percentages",
|
|
{"error": str(e)}
|
|
)
|
|
|
|
# Log detailed pip distribution
|
|
total_pips = sum(pip_counts.values())
|
|
if total_pips == 0:
|
|
logger.warning("No colored mana symbols found in casting costs")
|
|
return
|
|
|
|
logger.info("Color Pip Distribution:")
|
|
for color in MANA_COLORS:
|
|
count = pip_counts[color]
|
|
if count > 0:
|
|
percentage = percentages[color]
|
|
print(f"{color}: {count} pips ({percentage:.1f}%)")
|
|
print()
|
|
logger.info(f"Total colored pips: {total_pips}")
|
|
# Filter out zero percentages
|
|
non_zero_percentages = {color: pct for color, pct in percentages.items() if pct > 0}
|
|
logger.info(f"Distribution ratios: {non_zero_percentages}\n")
|
|
|
|
except ManaPipError as e:
|
|
logger.error(f"Mana pip analysis failed: {e}")
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in pip analysis: {e}")
|
|
raise ManaPipError("Failed to analyze mana pips", {"error": str(e)})
|
|
|
|
def get_cmc(self):
|
|
"""Calculate average converted mana cost of non-land cards."""
|
|
logger.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:
|
|
logger.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)})
|
|
logger.info(f"Average CMC: {self.cmc}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error calculating CMC: {e}")
|
|
self.cmc = 0.0
|
|
|
|
def weight_by_theme(self, tag: str, ideal: int = 1, weight: float = 1.0, df: Optional[pd.DataFrame] = None) -> None:
|
|
"""Add cards with specific tag up to weighted ideal count.
|
|
|
|
Args:
|
|
tag: Theme tag to filter cards by
|
|
ideal: Target number of cards to add
|
|
weight: Theme weight factor (0.0-1.0)
|
|
df: Source DataFrame to filter cards from
|
|
|
|
Raises:
|
|
ThemeWeightingError: If weight calculation fails
|
|
ThemePoolError: If card pool is empty or insufficient
|
|
"""
|
|
try:
|
|
# Calculate target card count using weight and safety multiplier
|
|
target_count = math.ceil(ideal * weight * THEME_WEIGHT_MULTIPLIER)
|
|
logger.info(f'Finding {target_count} cards with the "{tag}" tag...')
|
|
|
|
# Handle Kindred theme special case
|
|
tags = [tag, 'Kindred Support'] if 'Kindred' in tag else [tag]
|
|
|
|
# Calculate initial pool size
|
|
pool_size = builder_utils.calculate_weighted_pool_size(target_count, weight)
|
|
|
|
# Filter cards by theme
|
|
if df is None:
|
|
raise ThemePoolError(f"No source DataFrame provided for theme {tag}")
|
|
|
|
tag_df = builder_utils.filter_theme_cards(df, tags, pool_size)
|
|
if tag_df.empty:
|
|
raise ThemePoolError(f"No cards found for theme {tag}")
|
|
|
|
# Select cards considering price and duplicates
|
|
selected_cards = builder_utils.select_weighted_cards(
|
|
tag_df,
|
|
target_count,
|
|
self.price_checker if use_scrython else None,
|
|
self.max_card_price if hasattr(self, 'max_card_price') else None
|
|
)
|
|
|
|
# Process selected cards
|
|
cards_added = []
|
|
for card in selected_cards:
|
|
# Handle multiple copy cards
|
|
if card['name'] in MULTIPLE_COPY_CARDS:
|
|
copies = {
|
|
'Nazgûl': 9,
|
|
'Seven Dwarves': 7
|
|
}.get(card['name'], target_count - len(cards_added))
|
|
|
|
for _ in range(copies):
|
|
cards_added.append(card)
|
|
|
|
# Handle regular cards
|
|
elif card['name'] not in self.card_library['Card Name'].values:
|
|
cards_added.append(card)
|
|
else:
|
|
logger.warning(f"{card['name']} already in Library, skipping it.")
|
|
|
|
# Add selected cards to library
|
|
for card in cards_added:
|
|
self.add_card(
|
|
card['name'],
|
|
card['type'],
|
|
card['manaCost'],
|
|
card['manaValue'],
|
|
card.get('creatureTypes'),
|
|
card['themeTags']
|
|
)
|
|
|
|
# Update DataFrames
|
|
used_cards = {card['name'] for card in selected_cards}
|
|
self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(used_cards)]
|
|
|
|
logger.info(f'Added {len(cards_added)} {tag} cards')
|
|
for card in cards_added:
|
|
print(card['name'])
|
|
|
|
except (ThemeWeightingError, ThemePoolError) as e:
|
|
logger.error(f"Error in weight_by_theme: {e}")
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error in weight_by_theme: {e}")
|
|
raise ThemeWeightingError(f"Failed to process theme {tag}: {str(e)}")
|
|
|
|
def add_by_tags(self, tag, ideal_value=1, df=None, ignore_existing=False):
|
|
"""Add cards with specific tag up to ideal_value count.
|
|
Args:
|
|
tag: The theme tag to filter cards by
|
|
ideal_value: Target number of cards to add
|
|
df: DataFrame containing candidate cards
|
|
|
|
Raises:
|
|
ThemeTagError: If there are issues with tag processing or card selection
|
|
"""
|
|
try:
|
|
# Count existing cards with target tag
|
|
print()
|
|
if not ignore_existing:
|
|
existing_count = len(self.card_library[self.card_library['Themes'].apply(lambda x: x is not None and tag in x)])
|
|
remaining_slots = max(0, ideal_value - existing_count + 1)
|
|
else:
|
|
existing_count = 0
|
|
remaining_slots = max(0, ideal_value - existing_count + 1)
|
|
|
|
if remaining_slots == 0:
|
|
if not ignore_existing:
|
|
logger.info(f'Already have {existing_count} cards with tag "{tag}" - no additional cards needed')
|
|
return
|
|
else:
|
|
logger.info(f'Already have {ideal_value} cards with tag "{tag}" - no additional cards needed')
|
|
return
|
|
|
|
logger.info(f'Finding {remaining_slots} additional cards with the "{tag}" tag...')
|
|
|
|
# Filter cards with the given tag
|
|
skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1
|
|
tag_df = df.copy()
|
|
tag_df.sort_values(by='edhrecRank', inplace=True)
|
|
tag_df = tag_df[tag_df['themeTags'].apply(lambda x: x is not None and tag in x)]
|
|
|
|
# Calculate initial pool size using THEME_POOL_SIZE_MULTIPLIER
|
|
pool_size = int(remaining_slots * THEME_POOL_SIZE_MULTIPLIER)
|
|
tag_df = tag_df.head(pool_size)
|
|
|
|
# Convert to list of card dictionaries with priority scores
|
|
card_pool = []
|
|
for _, row in tag_df.iterrows():
|
|
theme_tags = row['themeTags'] if row['themeTags'] is not None else []
|
|
priority = builder_utils.calculate_theme_priority(theme_tags, self.themes, THEME_PRIORITY_BONUS)
|
|
card_pool.append({
|
|
'name': row['name'],
|
|
'type': row['type'],
|
|
'manaCost': row['manaCost'],
|
|
'manaValue': row['manaValue'],
|
|
'creatureTypes': row['creatureTypes'],
|
|
'themeTags': theme_tags,
|
|
'priority': priority
|
|
})
|
|
|
|
# Sort card pool by priority score
|
|
card_pool.sort(key=lambda x: x['priority'], reverse=True)
|
|
|
|
# Select cards up to remaining slots
|
|
cards_to_add = []
|
|
for card in card_pool:
|
|
if len(cards_to_add) >= remaining_slots:
|
|
break
|
|
|
|
# Check price constraints if enabled
|
|
if use_scrython and hasattr(self, 'max_card_price') and self.max_card_price:
|
|
price = self.price_checker.get_card_price(card['name'])
|
|
if price > self.max_card_price * 1.1:
|
|
continue
|
|
|
|
# Handle multiple-copy cards
|
|
if card['name'] in MULTIPLE_COPY_CARDS:
|
|
existing_copies = len(self.card_library[self.card_library['Card Name'] == card['name']])
|
|
if existing_copies < ideal_value:
|
|
cards_to_add.append(card)
|
|
continue
|
|
|
|
# Add new cards if not already in library
|
|
if card['name'] not in self.card_library['Card Name'].values:
|
|
if 'Creature' in card['type'] and skip_creatures:
|
|
continue
|
|
else:
|
|
if 'Creature' in card['type']:
|
|
self.creature_cards += 1
|
|
skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1
|
|
cards_to_add.append(card)
|
|
|
|
# Add selected cards to library
|
|
for card in cards_to_add:
|
|
if len(self.card_library) < 100:
|
|
self.add_card(card['name'], card['type'],
|
|
card['manaCost'], card['manaValue'],
|
|
card['creatureTypes'], card['themeTags'])
|
|
else:
|
|
break
|
|
|
|
# Update DataFrames
|
|
card_pool_names = [item['name'] for item in card_pool]
|
|
self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)]
|
|
|
|
logger.info(f'Added {len(cards_to_add)} {tag} cards (total with tag: {existing_count + len(cards_to_add)})')
|
|
for card in cards_to_add:
|
|
print(card['name'])
|
|
|
|
except Exception as e:
|
|
raise ThemeTagError(f"Error processing tag '{tag}'", {"error": str(e)})
|
|
|
|
def add_creatures(self):
|
|
"""
|
|
Add creatures to the deck based on themes and weights.
|
|
|
|
This method processes the primary, secondary, and tertiary themes to add
|
|
creatures proportionally according to their weights. The total number of
|
|
creatures added will approximate the ideal_creature_count.
|
|
|
|
The method follows this process:
|
|
1. Process hidden theme if present
|
|
2. Process primary theme
|
|
3. Process secondary theme if present
|
|
4. Process tertiary theme if present
|
|
|
|
Each theme is weighted according to its importance:
|
|
- Hidden theme: Highest priority if present
|
|
- Primary theme: Main focus
|
|
- Secondary theme: Supporting focus
|
|
- Tertiary theme: Minor focus
|
|
|
|
Args:
|
|
None
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
ThemeWeightingError: If there are issues with theme weight calculations
|
|
ThemePoolError: If the card pool for a theme is insufficient
|
|
Exception: For any other unexpected errors during creature addition
|
|
|
|
Note:
|
|
The method uses error handling to ensure the deck building process
|
|
continues even if a particular theme encounters issues.
|
|
"""
|
|
print()
|
|
logger.info(f'Adding creatures to deck based on the ideal creature count of {self.ideal_creature_count}...')
|
|
|
|
try:
|
|
if 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)
|
|
|
|
logger.info(f'Processing primary theme: {self.primary_theme}')
|
|
self.weight_by_theme(self.primary_theme, self.ideal_creature_count, self.primary_weight, self.creature_df)
|
|
|
|
if self.secondary_theme:
|
|
print()
|
|
logger.info(f'Processing secondary theme: {self.secondary_theme}')
|
|
self.weight_by_theme(self.secondary_theme, self.ideal_creature_count, self.secondary_weight, self.creature_df)
|
|
|
|
if self.tertiary_theme:
|
|
print()
|
|
logger.info(f'Processing tertiary theme: {self.tertiary_theme}')
|
|
self.weight_by_theme(self.tertiary_theme, self.ideal_creature_count, self.tertiary_weight, self.creature_df)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error while adding creatures: {e}")
|
|
finally:
|
|
self.organize_library()
|
|
|
|
def add_ramp(self):
|
|
"""Add ramp cards to the deck based on ideal ramp count.
|
|
|
|
This method adds three categories of ramp cards:
|
|
1. Mana rocks (artifacts that produce mana) - ~1/3 of ideal ramp count
|
|
2. Mana dorks (creatures that produce mana) - ~1/4 of ideal ramp count
|
|
3. General ramp spells - remaining portion of ideal ramp count
|
|
|
|
The method uses the add_by_tags() helper to add cards from each category
|
|
while respecting the deck's themes and color identity.
|
|
|
|
Args:
|
|
None
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
ThemeTagError: If there are issues adding cards with ramp-related tags
|
|
"""
|
|
try:
|
|
self.add_by_tags('Mana Rock', math.ceil(self.ideal_ramp / 3), self.noncreature_df)
|
|
self.add_by_tags('Mana Dork', math.ceil(self.ideal_ramp / 4), self.creature_df)
|
|
self.add_by_tags('Ramp', self.ideal_ramp, self.noncreature_df)
|
|
except Exception as e:
|
|
logger.error(f"Error while adding Ramp: {e}")
|
|
|
|
def add_interaction(self):
|
|
"""Add interaction cards to the deck for removal and protection.
|
|
|
|
This method adds two categories of interaction cards:
|
|
1. Removal spells based on ideal_removal count
|
|
2. Protection spells based on ideal_protection count
|
|
|
|
Cards are selected from non-planeswalker cards to ensure appropriate
|
|
interaction types are added.
|
|
|
|
Args:
|
|
None
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
ThemeTagError: If there are issues adding cards with interaction-related tags
|
|
"""
|
|
try:
|
|
self.add_by_tags('Removal', self.ideal_removal, self.nonplaneswalker_df)
|
|
self.add_by_tags('Protection', self.ideal_protection, self.nonplaneswalker_df)
|
|
except Exception as e:
|
|
logger.error(f"Error while adding Interaction: {e}")
|
|
|
|
def add_board_wipes(self):
|
|
"""Add board wipe cards to the deck.
|
|
|
|
This method adds board wipe cards based on the ideal_wipes count.
|
|
Board wipes are selected from the full card pool to include all possible
|
|
options across different card types.
|
|
|
|
Args:
|
|
None
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
ThemeTagError: If there are issues adding cards with the 'Board Wipes' tag
|
|
"""
|
|
try:
|
|
self.add_by_tags('Board Wipes', self.ideal_wipes, self.full_df)
|
|
except Exception as e:
|
|
logger.error(f"Error while adding Board Wipes: {e}")
|
|
|
|
def add_card_advantage(self):
|
|
"""Add card advantage effects to the deck.
|
|
|
|
This method adds two categories of card draw effects:
|
|
1. Conditional draw effects (20% of ideal_card_advantage)
|
|
- Cards that draw based on specific conditions or triggers
|
|
2. Unconditional draw effects (80% of ideal_card_advantage)
|
|
- Cards that provide straightforward card draw
|
|
|
|
Cards are selected from appropriate pools while avoiding planeswalkers
|
|
for unconditional draw effects.
|
|
|
|
Args:
|
|
None
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
ThemeTagError: If there are issues adding cards with draw-related tags
|
|
"""
|
|
try:
|
|
self.add_by_tags('Conditional Draw', math.ceil(self.ideal_card_advantage * 0.2), self.full_df)
|
|
self.add_by_tags('Unconditional Draw', math.ceil(self.ideal_card_advantage * 0.8), self.nonplaneswalker_df)
|
|
except Exception as e:
|
|
logger.error(f"Error while adding Card Draw: {e}")
|
|
|
|
def fill_out_deck(self):
|
|
"""Fill out the deck to 100 cards with theme-appropriate cards.
|
|
|
|
This method completes the deck by adding remaining cards up to the 100-card
|
|
requirement, prioritizing cards that match the deck's themes. The process
|
|
follows these steps:
|
|
|
|
1. Calculate how many cards are needed to reach 100
|
|
2. Add cards from each theme with weighted distribution:
|
|
- Hidden theme (if present)
|
|
- Tertiary theme (20% weight if present)
|
|
- Secondary theme (30% weight if present)
|
|
- Primary theme (50% weight)
|
|
|
|
The method includes safeguards:
|
|
- Maximum attempts limit to prevent infinite loops
|
|
- Timeout to prevent excessive runtime
|
|
- Progress tracking to break early if insufficient progress
|
|
|
|
Args:
|
|
None
|
|
|
|
Returns:
|
|
None
|
|
|
|
Raises:
|
|
ThemeTagError: If there are issues adding cards with specific theme tags
|
|
TimeoutError: If the process exceeds the maximum allowed time
|
|
|
|
Note:
|
|
If the deck cannot be filled to 100 cards, a warning message is logged
|
|
indicating manual additions may be needed.
|
|
"""
|
|
print()
|
|
logger.info('Filling out the Library to 100 with cards fitting the themes.')
|
|
cards_needed = 100 - len(self.card_library)
|
|
if cards_needed <= 0:
|
|
return
|
|
|
|
logger.info(f"Need to add {cards_needed} more cards")
|
|
|
|
# Define maximum attempts and timeout
|
|
MAX_ATTEMPTS = max(20, cards_needed * 2)
|
|
MAX_TIME = 60 # Maximum time in seconds
|
|
start_time = time.time()
|
|
attempts = 0
|
|
|
|
while len(self.card_library) < 100 and attempts < MAX_ATTEMPTS:
|
|
# Check timeout
|
|
if time.time() - start_time > MAX_TIME:
|
|
logger.error("Timeout reached while filling deck")
|
|
break
|
|
|
|
initial_count = len(self.card_library)
|
|
remaining = 100 - len(self.card_library)
|
|
|
|
# Adjust self.weights based on remaining cards needed
|
|
weight_multiplier = remaining / cards_needed
|
|
|
|
try:
|
|
# Add cards from each theme with adjusted self.weights
|
|
if self.hidden_theme and remaining > 0:
|
|
self.add_by_tags(self.hidden_theme,
|
|
math.ceil(weight_multiplier),
|
|
self.full_df,
|
|
True)
|
|
|
|
# Adjust self.weights based on remaining cards needed
|
|
remaining = 100 - len(self.card_library)
|
|
weight_multiplier = remaining / cards_needed
|
|
if self.tertiary_theme and remaining > 0:
|
|
self.add_by_tags(self.tertiary_theme,
|
|
math.ceil(weight_multiplier * 0.2),
|
|
self.noncreature_df,
|
|
True)
|
|
|
|
if self.secondary_theme and remaining > 0:
|
|
self.add_by_tags(self.secondary_theme,
|
|
math.ceil(weight_multiplier * 0.3),
|
|
self.noncreature_df,
|
|
True)
|
|
if remaining > 0:
|
|
self.add_by_tags(self.primary_theme,
|
|
math.ceil(weight_multiplier * 0.5),
|
|
self.noncreature_df,
|
|
True)
|
|
|
|
# Check if we made progress
|
|
if len(self.card_library) == initial_count:
|
|
attempts += 1
|
|
if attempts % 5 == 0:
|
|
print()
|
|
logger.warning(f"Made {attempts} attempts, still need {100 - len(self.card_library)} cards")
|
|
|
|
# Break early if we're stuck
|
|
if attempts >= MAX_ATTEMPTS / 2 and len(self.card_library) < initial_count + (cards_needed / 4):
|
|
print()
|
|
logger.warning("Insufficient progress being made, breaking early")
|
|
break
|
|
|
|
except Exception as e:
|
|
print()
|
|
logger.error(f"Error while adding cards: {e}")
|
|
attempts += 1
|
|
|
|
final_count = len(self.card_library)
|
|
if final_count < 100:
|
|
message = f"\nWARNING: Deck is incomplete with {final_count} cards. Manual additions may be needed."
|
|
print()
|
|
logger.warning(message)
|
|
else:
|
|
print()
|
|
logger.info(f"Successfully filled deck to {final_count} cards in {attempts} attempts")
|
|
|
|
def main():
|
|
"""Main entry point for deck builder application."""
|
|
build_deck = DeckBuilder()
|
|
build_deck.determine_commander()
|
|
pprint.pprint(build_deck.commander_dict, sort_dicts=False)
|
|
|
|
if __name__ == '__main__':
|
|
main() |