mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
2194 lines
No EOL
93 KiB
Python
2194 lines
No EOL
93 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import math
|
|
import numpy as np
|
|
import random
|
|
import time
|
|
from functools import lru_cache
|
|
from typing import Dict, List, Optional, Union
|
|
|
|
import inquirer.prompt # type: ignore
|
|
import keyboard # type: ignore
|
|
import pandas as pd # type: ignore
|
|
import pprint # type: ignore
|
|
from fuzzywuzzy import process # type: ignore
|
|
|
|
from settings import (
|
|
BASIC_LANDS, CARD_TYPES, CSV_DIRECTORY, multiple_copy_cards,
|
|
COMMANDER_CSV_PATH, FUZZY_MATCH_THRESHOLD, MAX_FUZZY_CHOICES,
|
|
COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT,
|
|
COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT,
|
|
COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_DEFAULT,
|
|
COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT,
|
|
CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS
|
|
)
|
|
import builder_utils
|
|
import setup_utils
|
|
from setup import determine_commanders
|
|
from input_handler import InputHandler
|
|
from exceptions import (
|
|
CommanderColorError,
|
|
CommanderLoadError,
|
|
CommanderSelectionError,
|
|
CommanderValidationError,
|
|
CSVError,
|
|
CSVReadError,
|
|
CSVTimeoutError,
|
|
CSVValidationError,
|
|
DataFrameValidationError,
|
|
DeckBuilderError,
|
|
EmptyDataFrameError,
|
|
EmptyInputError,
|
|
InvalidNumberError,
|
|
InvalidQuestionTypeError,
|
|
MaxAttemptsError,
|
|
PriceAPIError,
|
|
PriceLimitError,
|
|
PriceTimeoutError,
|
|
PriceValidationError
|
|
)
|
|
from type_definitions import (
|
|
CardDict,
|
|
CommanderDict,
|
|
CardLibraryDF,
|
|
CommanderDF,
|
|
LandDF,
|
|
ArtifactDF,
|
|
CreatureDF,
|
|
NonCreatureDF)
|
|
|
|
# Try to import scrython and price_checker
|
|
try:
|
|
import scrython # type: ignore
|
|
from price_check import PriceChecker
|
|
use_scrython = True
|
|
except ImportError:
|
|
scrython = None
|
|
PriceChecker = None
|
|
use_scrython = False
|
|
logging.warning("Scrython is not installed. Price checking features will be unavailable."
|
|
)
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s'
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
pd.set_option('display.max_columns', None)
|
|
pd.set_option('display.max_rows', None)
|
|
pd.set_option('display.max_colwidth', 50)
|
|
|
|
"""
|
|
Basic deck builder, primarily intended for building Kindred decks.
|
|
Logic for other themes (such as Spellslinger or Wheels), is added.
|
|
I plan to also implement having it recommend a commander or themes.
|
|
|
|
Currently, the script will ask questions to determine number of
|
|
creatures, lands, interaction, ramp, etc... then add cards and
|
|
adjust from there.
|
|
|
|
Land spread will ideally be handled based on pips and some adjustment
|
|
is planned based on mana curve and ramp added.
|
|
"""
|
|
|
|
def new_line(num_lines: int = 1) -> None:
|
|
"""Print specified number of newlines for formatting output.
|
|
|
|
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'),
|
|
'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()
|
|
|
|
# Initialize other attributes with type hints
|
|
self.commander_info: Dict = {}
|
|
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 = {
|
|
'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
|
|
}
|
|
self.add_card(self.commander, self.commander_type,
|
|
self.commander_mana_cost, self.commander_mana_value, 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.card_library.to_csv(f'{CSV_DIRECTORY}/test_deck_presort.csv', index=False)
|
|
self.organize_library()
|
|
self.card_library.to_csv(f'{CSV_DIRECTORY}/test_deck_preconcat.csv', index=False)
|
|
|
|
# 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
|
|
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)
|
|
|
|
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 settings 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}")
|
|
|
|
# Process in batches
|
|
processed_dfs = []
|
|
for i in range(0, len(df), CSV_PROCESSING_BATCH_SIZE):
|
|
batch = df.iloc[i:i + CSV_PROCESSING_BATCH_SIZE]
|
|
processed_batch = setup_utils.process_card_dataframe(batch, skip_availability_checks=True)
|
|
processed_dfs.append(processed_batch)
|
|
|
|
df = pd.concat(processed_dfs, ignore_index=True)
|
|
|
|
# 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 not actual_type 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:
|
|
for file in self.files_to_load:
|
|
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')]
|
|
|
|
# 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.sorcery_df = df[df['type'].str.contains('Sorcery')].copy()
|
|
|
|
# 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.sort_values(by='edhrecRank', inplace=True)
|
|
|
|
# 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
|
|
def determine_themes(self):
|
|
themes = self.commander_tags
|
|
print('Your commander deck will likely have a number of viable themes, but you\'ll want to narrow it down for focus.\n'
|
|
'This will go through the process of choosing up to three themes for the deck.\n')
|
|
while True:
|
|
# Choose a primary theme
|
|
print('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.')
|
|
choice = self.input_handler.questionnaire('Choice', choices_list=themes)
|
|
self.primary_theme = choice
|
|
weights_default = {
|
|
'primary': 1.0,
|
|
'secondary': 0.0,
|
|
'tertiary': 0.0,
|
|
'hidden': 0.0
|
|
}
|
|
weights = weights_default.copy()
|
|
themes.remove(choice)
|
|
themes.append('Stop Here')
|
|
self.primary_weight = weights['primary']
|
|
|
|
secondary_theme_chosen = False
|
|
tertiary_theme_chosen = False
|
|
self.hidden_theme = False
|
|
|
|
while not secondary_theme_chosen:
|
|
# Secondary theme
|
|
print('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.')
|
|
choice = self.input_handler.questionnaire('Choice', choices_list=themes)
|
|
while True:
|
|
if choice == 'Stop Here':
|
|
logger.warning('You\'ve only selected one theme, are you sure you want to stop?\n')
|
|
confirm_done = self.input_handler.questionnaire('Confirm', False)
|
|
if confirm_done:
|
|
secondary_theme_chosen = True
|
|
self.secondary_theme = False
|
|
tertiary_theme_chosen = True
|
|
self.tertiary_theme = False
|
|
themes.remove(choice)
|
|
break
|
|
else:
|
|
pass
|
|
|
|
else:
|
|
weights = weights_default.copy() # 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 and 'Kindred' not in self.secondary_theme:
|
|
weights['primary'] -= 0.1 # 0.8
|
|
weights['secondary'] += 0.1 # 0.1
|
|
elif 'Kindred' in self.primary_theme and 'Kindred' in self.secondary_theme:
|
|
weights['primary'] -= 0.7 # 0.7
|
|
weights['secondary'] += 0.3 # 0.3
|
|
else:
|
|
weights['primary'] -= 0.4 # 0.6
|
|
weights['secondary'] += 0.4 # 0.4
|
|
self.primary_weight = weights['primary']
|
|
self.secondary_weight = weights['secondary']
|
|
break
|
|
|
|
while not tertiary_theme_chosen:
|
|
# Tertiary theme
|
|
print('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.')
|
|
choice = self.input_handler.questionnaire('Choice', choices_list=themes)
|
|
while True:
|
|
if choice == 'Stop Here':
|
|
logger.warning('You\'ve only selected two themes, are you sure you want to stop?\n')
|
|
confirm_done = self.input_handler.questionnaire('Confirm', False)
|
|
if confirm_done:
|
|
tertiary_theme_chosen = True
|
|
self.tertiary_theme = False
|
|
themes.remove(choice)
|
|
break
|
|
else:
|
|
pass
|
|
|
|
else:
|
|
weights = weights_default.copy() # primary = 1.0, secondary = 0.0, tertiary = 0.0
|
|
self.tertiary_theme = choice
|
|
tertiary_theme_chosen = True
|
|
|
|
# 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.2 # 0.8
|
|
weights['secondary'] += 0.1 # 0.1
|
|
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.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' in self.tertiary_theme:
|
|
weights['primary'] -= 0.5 # 0.5
|
|
weights['secondary'] += 0.3 # 0.3
|
|
weights['tertiary'] += 0.2 # 0.2
|
|
else:
|
|
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 = ['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', False)
|
|
if choice:
|
|
self.hidden_theme = theme_cards[i]
|
|
self.themes.append(self.hidden_theme)
|
|
weights['primary'] = round(weights['primary'] / 3, 2)
|
|
weights['secondary'] = round(weights['secondary'] / 2, 2)
|
|
weights['tertiary'] = weights['tertiary']
|
|
weights['hidden'] = round(1.0 - weights['primary'] - weights['secondary'] - weights['tertiary'], 2)
|
|
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):
|
|
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', False)
|
|
if choice:
|
|
print('Which one?')
|
|
choice = self.input_handler.questionnaire('Choice', choices_list=theme_cards[i])
|
|
if choice:
|
|
self.hidden_theme = choice
|
|
self.themes.append(self.hidden_theme)
|
|
weights['primary'] = round(weights['primary'] / 3, 2)
|
|
weights['secondary'] = round(weights['secondary'] / 2, 2)
|
|
weights['tertiary'] = weights['tertiary']
|
|
weights['hidden'] = round(1.0 - weights['primary'] - weights['secondary'] - weights['tertiary'], 2)
|
|
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', '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', False)
|
|
if choice:
|
|
self.hidden_theme = theme_cards[i]
|
|
self.themes.append(self.hidden_theme)
|
|
weights['primary'] = round(weights['primary'] / 3, 2)
|
|
weights['secondary'] = round(weights['secondary'] / 2, 2)
|
|
weights['tertiary'] = weights['tertiary']
|
|
weights['hidden'] = round(1.0 - weights['primary'] - weights['secondary'] - weights['tertiary'], 2)
|
|
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):
|
|
# "Free" slots that can be used for anything that isn't the ideals
|
|
self.free_slots = 99
|
|
|
|
if use_scrython:
|
|
print('Would you like to set an intended max price of the deck?\n'
|
|
'There will be some leeway of ~10%, with a couple alternative options provided.')
|
|
choice = self.input_handler.questionnaire('Confirm', False)
|
|
if choice:
|
|
print('What would you like the max price to be?')
|
|
max_deck_price = float(self.input_handler.questionnaire('Number', 400))
|
|
self.price_checker.max_deck_price = max_deck_price
|
|
new_line()
|
|
else:
|
|
new_line()
|
|
|
|
print('Would you like to set a max price per card?\n'
|
|
'There will be some leeway of ~10% when choosing cards and you can choose to keep it or not.')
|
|
choice = self.input_handler.questionnaire('Confirm', False)
|
|
if choice:
|
|
print('What would you like the max price to be?')
|
|
answer = float(self.input_handler.questionnaire('Number', 20))
|
|
self.price_checker.max_card_price = answer
|
|
new_line()
|
|
else:
|
|
new_line()
|
|
|
|
# Determine ramp
|
|
print('How many pieces of ramp would you like to include?\n'
|
|
'This includes mana rocks, mana dorks, and land ramp spells.\n'
|
|
'A good baseline is 8-12 pieces, scaling up with higher average CMC\n'
|
|
'Default: 8')
|
|
answer = self.input_handler.questionnaire('Number', 8)
|
|
self.ideal_ramp = int(answer)
|
|
self.free_slots -= self.ideal_ramp
|
|
new_line()
|
|
|
|
# Determine ideal land count
|
|
print('How many total lands would you like to include?\n'
|
|
'Before ramp is considered, 38-40 lands is typical for most decks.\n'
|
|
"For landfall decks, consider starting at 40 lands before ramp.\n"
|
|
'As a guideline, each mana source from ramp can reduce land count by ~1.\n'
|
|
'Default: 35')
|
|
answer = self.input_handler.questionnaire('Number', 35)
|
|
self.ideal_land_count = int(answer)
|
|
self.free_slots -= self.ideal_land_count
|
|
new_line()
|
|
|
|
# Determine minimum basics to have
|
|
print('How many basic lands would you like to have at minimum?\n'
|
|
'This can vary widely depending on your commander, colors in color identity, and what you want to do.\n'
|
|
'Some decks may be fine with as low as 10, others may want 25.\n'
|
|
'Default: 20')
|
|
answer = self.input_handler.questionnaire('Number', 20)
|
|
self.min_basics = int(answer)
|
|
new_line()
|
|
|
|
# Determine ideal creature count
|
|
print('How many creatures would you like to include?\n'
|
|
'Something like 25-30 would be a good starting point.\n'
|
|
"If you're going for a kindred theme, going past 30 is likely normal.\n"
|
|
"Also be sure to take into account token generation, but remember you'll want enough to stay safe\n"
|
|
'Default: 25')
|
|
answer = self.input_handler.questionnaire('Number', 25)
|
|
self.ideal_creature_count = int(answer)
|
|
self.free_slots -= self.ideal_creature_count
|
|
new_line()
|
|
|
|
# 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 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.input_handler.questionnaire('Number', 10)
|
|
self.ideal_removal = int(answer)
|
|
self.free_slots -= self.ideal_removal
|
|
new_line()
|
|
|
|
# Determine board wipes
|
|
print('How many board wipes would you like to include?\n'
|
|
'Somewhere around 2-3 is good to help eliminate threats, but also prevent the game from running long\n.'
|
|
'This can include damaging wipes like "Blasphemous Act" or toughness reduction like "Meathook Massacre".\n'
|
|
'Default: 2')
|
|
answer = self.input_handler.questionnaire('Number', 2)
|
|
self.ideal_wipes = int(answer)
|
|
self.free_slots -= self.ideal_wipes
|
|
new_line()
|
|
|
|
# Determine card advantage
|
|
print('How many pieces of card advantage would you like to include?\n'
|
|
'10 pieces of card advantage is good, up to 14 is better.\n'
|
|
'Try to have a majority of it be non-conditional, and only have a couple of "Rhystic Study" style effects.\n'
|
|
'Default: 10')
|
|
answer = self.input_handler.questionnaire('Number', 10)
|
|
self.ideal_card_advantage = int(answer)
|
|
self.free_slots -= self.ideal_card_advantage
|
|
new_line()
|
|
|
|
# Determine how many protection spells
|
|
print('How many protection spells would you like to include?\n'
|
|
'This can be individual protection, board protection, fogs, or similar effects.\n'
|
|
'Things that grant indestructible, hexproof, phase out, or even just counterspells.\n'
|
|
'It\'s recommended to have 5 to 15, depending on your commander and preferred strategy.\n'
|
|
'Default: 8')
|
|
answer = self.input_handler.questionnaire('Number', 8)
|
|
self.ideal_protection = int(answer)
|
|
self.free_slots -= self.ideal_protection
|
|
new_line()
|
|
|
|
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: 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
|
|
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, is_commander]
|
|
|
|
# Add to library
|
|
self.card_library.loc[len(self.card_library)] = card_entry
|
|
|
|
logger.debug(f"Added {card} to deck library")
|
|
|
|
def organize_library(self):
|
|
# Initialize counters dictionary dynamically from CARD_TYPES including Kindred
|
|
all_types = CARD_TYPES + ['Kindred'] if 'Kindred' not in CARD_TYPES else CARD_TYPES
|
|
card_counters = {card_type: 0 for card_type in all_types}
|
|
|
|
# Count cards by type
|
|
for card_type in CARD_TYPES:
|
|
type_df = self.card_library[self.card_library['Card Type'].apply(lambda x: card_type in x)]
|
|
card_counters[card_type] = len(type_df)
|
|
|
|
# Assign counts to instance variables
|
|
self.artifact_cards = card_counters['Artifact']
|
|
self.battle_cards = card_counters['Battle']
|
|
self.creature_cards = card_counters['Creature']
|
|
self.enchantment_cards = card_counters['Enchantment']
|
|
self.instant_cards = card_counters['Instant']
|
|
self.kindred_cards = card_counters.get('Kindred', 0) # Use get() with default value
|
|
self.land_cards = card_counters['Land']
|
|
self.planeswalker_cards = card_counters['Planeswalker']
|
|
self.sorcery_cards = card_counters['Sorcery']
|
|
|
|
def sort_library(self):
|
|
self.card_library['Sort Order'] = pd.Series(dtype='str')
|
|
for index, row in self.card_library.iterrows():
|
|
for card_type in CARD_TYPES:
|
|
if card_type in row['Card Type']:
|
|
if row['Sort Order'] == 'Creature':
|
|
continue
|
|
if row['Sort Order'] != 'Creature':
|
|
self.card_library.loc[index, 'Sort Order'] = card_type
|
|
|
|
custom_order = ['Planeswalker', 'Battle', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Land']
|
|
self.card_library['Sort Order'] = pd.Categorical(
|
|
self.card_library['Sort Order'],
|
|
categories=custom_order,
|
|
ordered=True
|
|
)
|
|
self.card_library = (self.card_library
|
|
.sort_values(by=['Sort Order', 'Card Name'], ascending=[True, True])
|
|
.drop(columns=['Sort Order'])
|
|
.reset_index(drop=True)
|
|
)
|
|
|
|
def commander_to_top(self) -> None:
|
|
"""Move commander card to the top of the library while preserving commander status."""
|
|
try:
|
|
commander_row = self.card_library[self.card_library['Commander']].copy()
|
|
if commander_row.empty:
|
|
logger.warning("No commander found in library")
|
|
return
|
|
|
|
self.card_library = self.card_library[~self.card_library['Commander']]
|
|
|
|
self.card_library = pd.concat([commander_row, self.card_library], ignore_index=True)
|
|
|
|
commander_name = commander_row['Card Name'].iloc[0]
|
|
logger.info(f"Successfully moved commander '{commander_name}' to top")
|
|
except Exception as e:
|
|
logger.error(f"Error moving commander to top: {str(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:
|
|
mask = self.card_library['Card Name'] == duplicate
|
|
count = mask.sum()
|
|
|
|
if count > 0:
|
|
logger.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.
|
|
|
|
Args:
|
|
dataframe: DataFrame to modify
|
|
index: Index to drop
|
|
"""
|
|
try:
|
|
dataframe.drop(index, inplace=True)
|
|
except KeyError:
|
|
logger.warning(f"Attempted to drop non-existent index {index}")
|
|
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 = 10
|
|
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()
|
|
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):
|
|
base_basics = self.ideal_land_count - 10 # Reserve 10 slots for non-basic lands
|
|
basics_per_color = base_basics // len(self.colors)
|
|
remaining_basics = base_basics % len(self.colors)
|
|
|
|
color_to_basic = {
|
|
'W': 'Plains',
|
|
'U': 'Island',
|
|
'B': 'Swamp',
|
|
'R': 'Mountain',
|
|
'G': 'Forest',
|
|
'COLORLESS': 'Wastes'
|
|
}
|
|
|
|
if 'Snow' in self.commander_tags:
|
|
color_to_basic = {
|
|
'W': 'Snow-Covered Plains',
|
|
'U': 'Snow-Covered Island',
|
|
'B': 'Snow-Covered Swamp',
|
|
'R': 'Snow-Covered Mountain',
|
|
'G': 'Snow-Covered Forest',
|
|
'COLORLESS': 'Snow-Covered Wastes'
|
|
}
|
|
|
|
print(f'Adding {base_basics} basic lands distributed across {len(self.colors)} colors')
|
|
|
|
# Add equal distribution first
|
|
for color in self.colors:
|
|
basic = color_to_basic.get(color)
|
|
if basic:
|
|
# Add basics with explicit commander flag and track count
|
|
for _ in range(basics_per_color):
|
|
self.add_card(basic, 'Basic Land', None, 0, is_commander=False)
|
|
|
|
# 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', None, 0, is_commander=False)
|
|
|
|
lands_to_remove = []
|
|
for key in color_to_basic:
|
|
basic = color_to_basic.get(key)
|
|
lands_to_remove.append(basic)
|
|
|
|
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)
|
|
|
|
def add_standard_non_basics(self):
|
|
"""Add staple utility lands based on deck requirements."""
|
|
logger.info('Adding staple non-basic lands')
|
|
|
|
# Define staple lands and their conditions
|
|
staple_lands = {
|
|
'Reliquary Tower': lambda: True, # Always include
|
|
'Ash Barrens': lambda: 'Landfall' not in self.commander_tags,
|
|
'Command Tower': lambda: len(self.colors) > 1,
|
|
'Exotic Orchard': lambda: len(self.colors) > 1,
|
|
'War Room': lambda: len(self.colors) <= 2,
|
|
'Rogue\'s Passage': lambda: self.commander_power >= 5
|
|
}
|
|
|
|
self.staples = []
|
|
try:
|
|
# Add lands that meet their conditions
|
|
for land, condition in staple_lands.items():
|
|
if condition():
|
|
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}")
|
|
|
|
# Update land database
|
|
self.land_df = self.land_df[~self.land_df['name'].isin(self.staples)]
|
|
self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False)
|
|
|
|
logger.info(f'Added {len(self.staples)} staple lands')
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error adding staple lands: {e}")
|
|
raise
|
|
def add_fetches(self):
|
|
# Determine how many fetches in total
|
|
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.')
|
|
answer = self.input_handler.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.copy()
|
|
lands_to_remove = generic_fetches.copy()
|
|
|
|
# Adding in expensive fetches
|
|
if (use_scrython and self.set_max_card_price):
|
|
if self.price_checker.get_card_price('Prismatic Vista') <= self.max_card_price * 1.1:
|
|
lands_to_remove.append('Prismatic Vista')
|
|
fetches.append('Prismatic Vista')
|
|
else:
|
|
lands_to_remove.append('Prismatic Vista')
|
|
pass
|
|
else:
|
|
lands_to_remove.append('Prismatic Vista')
|
|
fetches.append('Prismatic Vista')
|
|
|
|
color_to_fetch = {
|
|
'W': ['Flooded Strand', 'Windswept Heath', 'Marsh Flats', 'Arid Mesa', 'Brokers Hideout', 'Obscura Storefront', 'Cabaretti Courtyard'],
|
|
'U': ['Flooded Strand', 'Polluted Delta', 'Scalding Tarn', 'Misty Rainforest', 'Brokers Hideout', 'Obscura Storefront', 'Maestros Theater'],
|
|
'B': ['Polluted Delta', 'Bloodstained Mire', 'Marsh Flats', 'Verdant Catacombs', 'Obscura Storefront', 'Maestros Theater', 'Riveteers Overlook'],
|
|
'R': ['Bloodstained Mire', 'Wooded Foothills', 'Scalding Tarn', 'Arid Mesa', 'Maestros Theater', 'Riveteers Overlook', 'Cabaretti Courtyard'],
|
|
'G': ['Wooded Foothills', 'Windswept Heath', 'Verdant Catacombs', 'Misty Rainforest', 'Brokers Hideout', 'Riveteers Overlook', 'Cabaretti Courtyard']
|
|
}
|
|
|
|
for color in self.colors:
|
|
fetch = color_to_fetch.get(color)
|
|
if fetch not in fetches:
|
|
fetches.extend(fetch)
|
|
if fetch not in lands_to_remove:
|
|
lands_to_remove.extend(fetch)
|
|
for color in color_to_fetch:
|
|
fetch = color_to_fetch.get(color)
|
|
if fetch not in fetches:
|
|
fetches.extend(fetch)
|
|
if fetch not in lands_to_remove:
|
|
lands_to_remove.extend(fetch)
|
|
|
|
# Randomly choose fetches up to the desired number
|
|
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_checker.get_card_price(fetch_choice) <= self.max_card_price * 1.1:
|
|
chosen_fetches.append(fetch_choice)
|
|
fetches.remove(fetch_choice)
|
|
else:
|
|
chosen_fetches.append(fetch_choice)
|
|
fetches.remove(fetch_choice)
|
|
|
|
attempt_count += 1
|
|
|
|
# Select final fetches to add
|
|
fetches_to_add = []
|
|
available_fetches = chosen_fetches[:desired_fetches]
|
|
for fetch in available_fetches:
|
|
if fetch not in fetches_to_add:
|
|
fetches_to_add.append(fetch)
|
|
|
|
if attempt_count >= MAX_ATTEMPTS:
|
|
logger.warning(f"Reached maximum attempts ({MAX_ATTEMPTS}) while selecting fetch lands")
|
|
|
|
for card in fetches_to_add:
|
|
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)
|
|
|
|
def add_kindred_lands(self):
|
|
"""Add lands that support tribal/kindred themes."""
|
|
logger.info('Adding Kindred-themed lands')
|
|
|
|
# Standard Kindred support lands
|
|
KINDRED_STAPLES = [
|
|
{'name': 'Path of Ancestry', 'type': 'Land'},
|
|
{'name': 'Three Tree City', 'type': 'Legendary Land'},
|
|
{'name': 'Cavern of Souls', 'type': 'Land'}
|
|
]
|
|
|
|
kindred_lands = KINDRED_STAPLES.copy()
|
|
lands_to_remove = set()
|
|
|
|
try:
|
|
# Process each Kindred theme
|
|
for theme in self.themes:
|
|
if 'Kindred' in theme:
|
|
creature_type = theme.replace(' Kindred', '')
|
|
logger.info(f'Searching for {creature_type}-specific lands')
|
|
|
|
# Filter lands by creature type
|
|
type_specific = self.land_df[
|
|
self.land_df['text'].notna() &
|
|
(self.land_df['text'].str.contains(creature_type, case=False) |
|
|
self.land_df['type'].str.contains(creature_type, case=False))
|
|
]
|
|
|
|
# Add matching lands to pool
|
|
for _, row in type_specific.iterrows():
|
|
kindred_lands.append({
|
|
'name': row['name'],
|
|
'type': row['type'],
|
|
'manaCost': row['manaCost'],
|
|
'manaValue': row['manaValue']
|
|
})
|
|
lands_to_remove.add(row['name'])
|
|
|
|
# Add lands to deck
|
|
for card in kindred_lands:
|
|
if card['name'] not in self.card_library['Card Name'].values:
|
|
self.add_card(card['name'], card['type'],
|
|
None, 0)
|
|
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(lands_to_remove)} Kindred-themed lands')
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error adding Kindred lands: {e}")
|
|
raise
|
|
def add_dual_lands(self):
|
|
# Determine dual-color lands available
|
|
|
|
# Determine if using the dual-type lands
|
|
print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?')
|
|
choice = self.input_handler.questionnaire('Confirm', True)
|
|
color_filter = []
|
|
color_dict = {
|
|
'azorius': 'Plains Island',
|
|
'dimir': 'Island Swamp',
|
|
'rakdos': 'Swamp Mountain',
|
|
'gruul': 'Mountain Forest',
|
|
'selesnya': 'Forest Plains',
|
|
'orzhov': 'Plains Swamp',
|
|
'golgari': 'Swamp Forest',
|
|
'simic': 'Forest Island',
|
|
'izzet': 'Island Mountain',
|
|
'boros': 'Mountain Plains'
|
|
}
|
|
|
|
if choice:
|
|
for key in color_dict:
|
|
if key in self.files_to_load:
|
|
color_filter.extend([f'Land — {color_dict[key]}', f'Snow Land — {color_dict[key]}'])
|
|
|
|
dual_df = self.land_df[self.land_df['type'].isin(color_filter)].copy()
|
|
|
|
# Convert to list of card dictionaries
|
|
card_pool = []
|
|
for _, row in dual_df.iterrows():
|
|
card = {
|
|
'name': row['name'],
|
|
'type': row['type'],
|
|
'manaCost': row['manaCost'],
|
|
'manaValue': row['manaValue']
|
|
}
|
|
card_pool.append(card)
|
|
|
|
lands_to_remove = []
|
|
for card in card_pool:
|
|
self.add_card(card['name'], card['type'],
|
|
card['manaCost'], card['manaValue'])
|
|
lands_to_remove.append(card['name'])
|
|
|
|
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(card_pool)} Dual-type land cards.')
|
|
|
|
if not choice:
|
|
logger.info('Skipping adding Dual-type land cards.')
|
|
|
|
def add_triple_lands(self):
|
|
# Determine if using Triome lands
|
|
print('Would you like to include triome lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?')
|
|
choice = self.input_handler.questionnaire('Confirm', True)
|
|
|
|
color_filter = []
|
|
color_dict = {
|
|
'bant': 'Forest Plains Island',
|
|
'esper': 'Plains Island Swamp',
|
|
'grixis': 'Island Swamp Mountain',
|
|
'jund': 'Swamp Mountain Forest',
|
|
'naya': 'Mountain Forest Plains',
|
|
'mardu': 'Mountain Plains Swamp',
|
|
'abzan': 'Plains Swamp Forest',
|
|
'sultai': 'Swamp Forest Island',
|
|
'temur': 'Forest Island Mountain',
|
|
'jeska': 'Island Mountain Plains'
|
|
}
|
|
|
|
if choice:
|
|
for key in color_dict:
|
|
if key in self.files_to_load:
|
|
color_filter.extend([f'Land — {color_dict[key]}'])
|
|
|
|
triome_df = self.land_df[self.land_df['type'].isin(color_filter)].copy()
|
|
|
|
# Convert to list of card dictionaries
|
|
card_pool = []
|
|
for _, row in triome_df.iterrows():
|
|
card = {
|
|
'name': row['name'],
|
|
'type': row['type'],
|
|
'manaCost': row['manaCost'],
|
|
'manaValue': row['manaValue']
|
|
}
|
|
card_pool.append(card)
|
|
|
|
lands_to_remove = []
|
|
for card in card_pool:
|
|
self.add_card(card['name'], card['type'],
|
|
card['manaCost'], card['manaValue'])
|
|
lands_to_remove.append(card['name'])
|
|
|
|
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(card_pool)} Triome land cards.')
|
|
|
|
if not choice:
|
|
logger.info('Skipping adding Triome land cards.')
|
|
|
|
def add_misc_lands(self):
|
|
"""Add additional utility lands that fit the deck's color identity."""
|
|
logger.info('Adding miscellaneous utility lands')
|
|
|
|
MIN_MISC_LANDS = 5
|
|
MAX_MISC_LANDS = 15
|
|
MAX_POOL_SIZE = 100
|
|
|
|
try:
|
|
# Create filtered pool of candidate lands
|
|
land_pool = (self.land_df
|
|
.head(MAX_POOL_SIZE)
|
|
.copy()
|
|
.reset_index(drop=True))
|
|
|
|
# Convert to card dictionaries
|
|
card_pool = [
|
|
{
|
|
'name': row['name'],
|
|
'type': row['type'],
|
|
'manaCost': row['manaCost'],
|
|
'manaValue': row['manaValue']
|
|
}
|
|
for _, row in land_pool.iterrows()
|
|
if row['name'] not in self.card_library['Card Name'].values
|
|
]
|
|
|
|
if not card_pool:
|
|
logger.warning("No eligible misc lands found")
|
|
return
|
|
|
|
# Randomly select lands within constraints
|
|
target_count = random.randint(MIN_MISC_LANDS, MAX_MISC_LANDS)
|
|
cards_to_add = []
|
|
|
|
while card_pool and len(cards_to_add) < target_count:
|
|
card = random.choice(card_pool)
|
|
card_pool.remove(card)
|
|
|
|
# Check price if enabled
|
|
if use_scrython and self.set_max_card_price:
|
|
price = self.price_checker.get_card_price(card['name'])
|
|
if price > self.max_card_price * 1.1:
|
|
continue
|
|
|
|
cards_to_add.append(card)
|
|
|
|
# Add selected lands
|
|
lands_to_remove = set()
|
|
for card in cards_to_add:
|
|
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(cards_to_add)} miscellaneous lands')
|
|
|
|
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."""
|
|
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
|
|
for land in basic_lands:
|
|
count = len(self.card_library[self.card_library['Card Name'] == land])
|
|
basic_lands[land] = count
|
|
self.total_basics += count
|
|
|
|
logger.info("\nBasic Land Counts:")
|
|
for land, count in basic_lands.items():
|
|
if count > 0:
|
|
logger.info(f"{land}: {count}")
|
|
logger.info(f"Total basic lands: {self.total_basics}\n")
|
|
|
|
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
|
|
"""
|
|
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."""
|
|
logger.info('Removing a random nonbasic land.')
|
|
|
|
# Define basic lands including snow-covered variants
|
|
basic_lands = [
|
|
'Plains', 'Island', 'Swamp', 'Mountain', 'Forest',
|
|
'Snow-Covered Plains', 'Snow-Covered Island', 'Snow-Covered Swamp',
|
|
'Snow-Covered Mountain', 'Snow-Covered Forest'
|
|
]
|
|
|
|
try:
|
|
# Filter for non-basic, non-staple lands
|
|
library_filter = self.card_library[
|
|
(self.card_library['Card Type'].str.contains('Land')) &
|
|
(~self.card_library['Card Name'].isin(basic_lands + self.staples))
|
|
].copy()
|
|
|
|
if len(library_filter) == 0:
|
|
logger.warning("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']
|
|
|
|
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("Card removed successfully.")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error removing land: {e}")
|
|
logger.warning("Failed to remove land card.")
|
|
|
|
def count_pips(self):
|
|
"""Count and display the number of colored mana symbols in casting costs using vectorized operations."""
|
|
logger.info('Analyzing color pip distribution...')
|
|
|
|
# Define colors to check
|
|
colors = ['W', 'U', 'B', 'R', 'G']
|
|
|
|
# Use vectorized string operations
|
|
mana_costs = self.card_library['Mana Cost'].dropna()
|
|
pip_counts = {color: mana_costs.str.count(color).sum() for color in colors}
|
|
|
|
total_pips = sum(pip_counts.values())
|
|
if total_pips == 0:
|
|
logger.error("No colored mana symbols found in casting costs.")
|
|
return
|
|
|
|
logger.info("\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}%)")
|
|
logger.info(f"Total colored pips: {total_pips}\n")
|
|
|
|
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, ideal=1, weight=1, df=None):
|
|
# First grab the first 50/30/20 cards that match each theme
|
|
"""Add cards with specific tag up to ideal_value count"""
|
|
ideal_value = math.ceil(ideal * weight * 0.9)
|
|
print(f'Finding {ideal_value} cards with the "{tag}" tag...')
|
|
if 'Kindred' in tag:
|
|
tags = [tag, 'Kindred Support']
|
|
else:
|
|
tags = [tag]
|
|
# Filter cards with the given tag
|
|
tag_df = df.copy()
|
|
tag_df.sort_values(by='edhrecRank', inplace=True)
|
|
tag_df = tag_df[tag_df['themeTags'].apply(lambda x: any(tag in x for tag in tags))]
|
|
# Take top cards based on ideal value
|
|
pool_size = int(ideal_value * random.randint(15, 20) /10)
|
|
tag_df = tag_df.head(pool_size)
|
|
|
|
# Convert to list of card dictionaries
|
|
card_pool = [
|
|
{
|
|
'name': row['name'],
|
|
'type': row['type'],
|
|
'manaCost': row['manaCost'],
|
|
'manaValue': row['manaValue']
|
|
}
|
|
for _, row in tag_df.iterrows()
|
|
]
|
|
|
|
# Randomly select cards up to ideal value
|
|
cards_to_add = []
|
|
while len(cards_to_add) < ideal_value and card_pool:
|
|
card = random.choice(card_pool)
|
|
card_pool.remove(card)
|
|
|
|
# Check price constraints if enabled
|
|
if use_scrython and self.set_max_card_price:
|
|
price = self.price_checker.get_card_price(card['name'])
|
|
if price > self.max_card_price * 1.1:
|
|
continue
|
|
|
|
# Add card if not already in library
|
|
|
|
if card['name'] in multiple_copy_cards:
|
|
if card['name'] == 'Nazgûl':
|
|
for _ in range(9):
|
|
cards_to_add.append(card)
|
|
elif card['name'] == 'Seven Dwarves':
|
|
for _ in range(7):
|
|
cards_to_add.append(card)
|
|
else:
|
|
num_to_add = ideal_value - len(cards_to_add)
|
|
for _ in range(num_to_add):
|
|
cards_to_add.append(card)
|
|
|
|
elif (card['name'] not in multiple_copy_cards
|
|
and card['name'] not in self.card_library['Card Name'].values):
|
|
cards_to_add.append(card)
|
|
|
|
elif (card['name'] not in multiple_copy_cards
|
|
and card['name'] in self.card_library['Card Name'].values):
|
|
logger.warning(f"{card['name']} already in Library, skipping it.")
|
|
continue
|
|
|
|
# Add selected cards to library
|
|
for card in cards_to_add:
|
|
self.add_card(card['name'], card['type'],
|
|
card['manaCost'], card['manaValue'])
|
|
|
|
card_pool_names = [item['name'] for item in card_pool]
|
|
self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)]
|
|
self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)]
|
|
logger.info(f'Added {len(cards_to_add)} {tag} cards')
|
|
#tag_df.to_csv(f'{CSV_DIRECTORY}/test_{tag}.csv', index=False)
|
|
|
|
def add_by_tags(self, tag, ideal_value=1, df=None):
|
|
"""Add cards with specific tag up to ideal_value count"""
|
|
print(f'Finding {ideal_value} cards with the "{tag}" tag...')
|
|
|
|
# Filter cards with the given tag
|
|
skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1
|
|
tag_df = df.copy()
|
|
tag_df.sort_values(by='edhrecRank', inplace=True)
|
|
tag_df = tag_df[tag_df['themeTags'].apply(lambda x: tag in x)]
|
|
# Take top cards based on ideal value
|
|
pool_size = int(ideal_value * random.randint(2, 3))
|
|
tag_df = tag_df.head(pool_size)
|
|
|
|
# Convert to list of card dictionaries
|
|
card_pool = [
|
|
{
|
|
'name': row['name'],
|
|
'type': row['type'],
|
|
'manaCost': row['manaCost'],
|
|
'manaValue': row['manaValue']
|
|
}
|
|
for _, row in tag_df.iterrows()
|
|
]
|
|
|
|
# Randomly select cards up to ideal value
|
|
cards_to_add = []
|
|
while len(cards_to_add) < ideal_value and card_pool:
|
|
card = random.choice(card_pool)
|
|
card_pool.remove(card)
|
|
|
|
# Check price constraints if enabled
|
|
if use_scrython and self.set_max_card_price:
|
|
price = self.price_checker.get_card_price(card['name'])
|
|
if price > self.max_card_price * 1.1:
|
|
continue
|
|
|
|
# Add card if not already in library
|
|
if card['name'] not in self.card_library['Card Name'].values:
|
|
if 'Creature' in card['type'] and skip_creatures:
|
|
continue
|
|
else:
|
|
if 'Creature' in card['type']:
|
|
self.creature_cards += 1
|
|
skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1
|
|
cards_to_add.append(card)
|
|
|
|
# Add selected cards to library
|
|
for card in cards_to_add:
|
|
if len(self.card_library) < 100:
|
|
self.add_card(card['name'], card['type'],
|
|
card['manaCost'], card['manaValue'])
|
|
else:
|
|
continue
|
|
|
|
card_pool_names = [item['name'] for item in card_pool]
|
|
self.full_df = self.full_df[~self.full_df['name'].isin(card_pool_names)]
|
|
self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)]
|
|
logger.info(f'Added {len(cards_to_add)} {tag} cards')
|
|
#tag_df.to_csv(f'{CSV_DIRECTORY}/test_{tag}.csv', index=False)
|
|
|
|
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.
|
|
|
|
Themes are processed in order of importance (primary -> secondary -> tertiary)
|
|
with error handling to ensure the deck building process continues even if
|
|
a particular theme encounters issues.
|
|
"""
|
|
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 Hidden theme: {self.hidden_theme}')
|
|
self.weight_by_theme(self.hidden_theme, self.ideal_creature_count, self.hidden_weight, self.creature_df)
|
|
|
|
print(f'Processing primary theme: {self.primary_theme}')
|
|
self.weight_by_theme(self.primary_theme, self.ideal_creature_count, self.primary_weight, self.creature_df)
|
|
|
|
if self.secondary_theme:
|
|
print(f'Processing secondary theme: {self.secondary_theme}')
|
|
self.weight_by_theme(self.secondary_theme, self.ideal_creature_count, self.secondary_weight, self.creature_df)
|
|
|
|
if self.tertiary_theme:
|
|
print(f'Processing tertiary theme: {self.tertiary_theme}')
|
|
self.weight_by_theme(self.tertiary_theme, self.ideal_creature_count, self.tertiary_weight, self.creature_df)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error while adding creatures: {e}")
|
|
finally:
|
|
self.organize_library()
|
|
logger.info(f'Creature addition complete. Total creatures (including commander): {self.creature_cards}')
|
|
|
|
def add_ramp(self):
|
|
try:
|
|
self.add_by_tags('Mana Rock', math.ceil(self.ideal_ramp / 3), self.noncreature_df)
|
|
self.add_by_tags('Mana Dork', math.ceil(self.ideal_ramp / 4), self.creature_df)
|
|
self.add_by_tags('Ramp', math.ceil(self.ideal_ramp / 2), self.noncreature_df)
|
|
except Exception as e:
|
|
logger.error(f"Error while adding Ramp: {e}")
|
|
finally:
|
|
logger.info('Adding Ramp complete.')
|
|
|
|
def add_interaction(self):
|
|
try:
|
|
self.add_by_tags('Removal', self.ideal_removal, self.noncreature_nonplaneswaker_df)
|
|
self.add_by_tags('Protection', self.ideal_protection, self.noncreature_nonplaneswaker_df)
|
|
except Exception as e:
|
|
logger.error(f"Error while adding Interaction: {e}")
|
|
finally:
|
|
logger.info('Adding Interaction complete.')
|
|
|
|
def add_board_wipes(self):
|
|
try:
|
|
self.add_by_tags('Board Wipes', self.ideal_wipes, self.full_df)
|
|
except Exception as e:
|
|
logger.error(f"Error while adding Board Wipes: {e}")
|
|
finally:
|
|
logger.info('Adding Board Wipes complete.')
|
|
|
|
def add_card_advantage(self):
|
|
try:
|
|
self.add_by_tags('Conditional Draw', math.ceil(self.ideal_card_advantage * 0.2), self.full_df)
|
|
self.add_by_tags('Unconditional Draw', math.ceil(self.ideal_card_advantage * 0.8), self.noncreature_nonplaneswaker_df)
|
|
except Exception as e:
|
|
logger.error(f"Error while adding Card Draw: {e}")
|
|
finally:
|
|
logger.info('Adding Card Draw complete.')
|
|
|
|
def fill_out_deck(self):
|
|
"""Fill out the deck to 100 cards with theme-appropriate cards."""
|
|
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 weights based on remaining cards needed
|
|
weight_multiplier = remaining / cards_needed
|
|
|
|
try:
|
|
# Add cards from each theme with adjusted weights
|
|
if self.tertiary_theme:
|
|
self.add_by_tags(self.tertiary_theme,
|
|
math.ceil(self.tertiary_weight * 10 * weight_multiplier),
|
|
self.noncreature_df)
|
|
if self.secondary_theme:
|
|
self.add_by_tags(self.secondary_theme,
|
|
math.ceil(self.secondary_weight * 3 * weight_multiplier),
|
|
self.noncreature_df)
|
|
self.add_by_tags(self.primary_theme,
|
|
math.ceil(self.primary_weight * 2 * weight_multiplier),
|
|
self.noncreature_df)
|
|
|
|
# Check if we made progress
|
|
if len(self.card_library) == initial_count:
|
|
attempts += 1
|
|
if attempts % 5 == 0:
|
|
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):
|
|
logger.warning("Insufficient progress being made, breaking early")
|
|
break
|
|
|
|
except Exception as e:
|
|
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."
|
|
logger.warning(message)
|
|
else:
|
|
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() |