mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
Moved the builder, tagger, and setup modules into their own folders, along with constants to help provide better clarity and readability. Additionally added a missing call for the tag_for_artifcact_triggers() function
This commit is contained in:
parent
3a5beebfe2
commit
dbbc8bc66e
20 changed files with 1525 additions and 1737 deletions
7
code/deck_builder/__init__.py
Normal file
7
code/deck_builder/__init__.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from .builder import DeckBuilder
|
||||
from .builder_utils import *
|
||||
from .builder_constants import *
|
||||
|
||||
__all__ = [
|
||||
'DeckBuilder',
|
||||
]
|
2497
code/deck_builder/builder.py
Normal file
2497
code/deck_builder/builder.py
Normal file
File diff suppressed because it is too large
Load diff
437
code/deck_builder/builder_constants.py
Normal file
437
code/deck_builder/builder_constants.py
Normal file
|
@ -0,0 +1,437 @@
|
|||
from typing import Dict, List, Optional, Final, Tuple, Pattern, Union, Callable
|
||||
import ast
|
||||
|
||||
# Commander selection configuration
|
||||
# Format string for displaying duplicate cards in deck lists
|
||||
FUZZY_MATCH_THRESHOLD: Final[int] = 90 # Threshold for fuzzy name matching
|
||||
MAX_FUZZY_CHOICES: Final[int] = 5 # Maximum number of fuzzy match choices
|
||||
|
||||
# Commander-related constants
|
||||
DUPLICATE_CARD_FORMAT: Final[str] = '{card_name} x {count}'
|
||||
COMMANDER_CSV_PATH: Final[str] = 'csv_files/commander_cards.csv'
|
||||
DECK_DIRECTORY = '../deck_files'
|
||||
COMMANDER_CONVERTERS: Final[Dict[str, str]] = {'themeTags': ast.literal_eval, 'creatureTypes': ast.literal_eval} # CSV loading converters
|
||||
COMMANDER_POWER_DEFAULT: Final[int] = 0
|
||||
COMMANDER_TOUGHNESS_DEFAULT: Final[int] = 0
|
||||
COMMANDER_MANA_VALUE_DEFAULT: Final[int] = 0
|
||||
COMMANDER_TYPE_DEFAULT: Final[str] = ''
|
||||
COMMANDER_TEXT_DEFAULT: Final[str] = ''
|
||||
COMMANDER_MANA_COST_DEFAULT: Final[str] = ''
|
||||
COMMANDER_COLOR_IDENTITY_DEFAULT: Final[str] = ''
|
||||
COMMANDER_COLORS_DEFAULT: Final[List[str]] = []
|
||||
COMMANDER_CREATURE_TYPES_DEFAULT: Final[str] = ''
|
||||
COMMANDER_TAGS_DEFAULT: Final[List[str]] = []
|
||||
COMMANDER_THEMES_DEFAULT: Final[List[str]] = []
|
||||
|
||||
CARD_TYPES = ['Artifact','Creature', 'Enchantment', 'Instant', 'Land', 'Planeswalker', 'Sorcery',
|
||||
'Kindred', 'Dungeon', 'Battle']
|
||||
|
||||
# Basic mana colors
|
||||
MANA_COLORS: Final[List[str]] = ['W', 'U', 'B', 'R', 'G']
|
||||
|
||||
# Mana pip patterns for each color
|
||||
MANA_PIP_PATTERNS: Final[Dict[str, str]] = {
|
||||
color: f'{{{color}}}' for color in MANA_COLORS
|
||||
}
|
||||
|
||||
MONO_COLOR_MAP: Final[Dict[str, Tuple[str, List[str]]]] = {
|
||||
'COLORLESS': ('Colorless', ['colorless']),
|
||||
'W': ('White', ['colorless', 'white']),
|
||||
'U': ('Blue', ['colorless', 'blue']),
|
||||
'B': ('Black', ['colorless', 'black']),
|
||||
'R': ('Red', ['colorless', 'red']),
|
||||
'G': ('Green', ['colorless', 'green'])
|
||||
}
|
||||
|
||||
DUAL_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
|
||||
'B, G': ('Golgari: Black/Green', ['B', 'G', 'B, G'], ['colorless', 'black', 'green', 'golgari']),
|
||||
'B, R': ('Rakdos: Black/Red', ['B', 'R', 'B, R'], ['colorless', 'black', 'red', 'rakdos']),
|
||||
'B, U': ('Dimir: Black/Blue', ['B', 'U', 'B, U'], ['colorless', 'black', 'blue', 'dimir']),
|
||||
'B, W': ('Orzhov: Black/White', ['B', 'W', 'B, W'], ['colorless', 'black', 'white', 'orzhov']),
|
||||
'G, R': ('Gruul: Green/Red', ['G', 'R', 'G, R'], ['colorless', 'green', 'red', 'gruul']),
|
||||
'G, U': ('Simic: Green/Blue', ['G', 'U', 'G, U'], ['colorless', 'green', 'blue', 'simic']),
|
||||
'G, W': ('Selesnya: Green/White', ['G', 'W', 'G, W'], ['colorless', 'green', 'white', 'selesnya']),
|
||||
'R, U': ('Izzet: Blue/Red', ['U', 'R', 'U, R'], ['colorless', 'blue', 'red', 'izzet']),
|
||||
'U, W': ('Azorius: Blue/White', ['U', 'W', 'U, W'], ['colorless', 'blue', 'white', 'azorius']),
|
||||
'R, W': ('Boros: Red/White', ['R', 'W', 'R, W'], ['colorless', 'red', 'white', 'boros'])
|
||||
}
|
||||
|
||||
TRI_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
|
||||
'B, G, U': ('Sultai: Black/Blue/Green', ['B', 'G', 'U', 'B, G', 'B, U', 'G, U', 'B, G, U'],
|
||||
['colorless', 'black', 'blue', 'green', 'dimir', 'golgari', 'simic', 'sultai']),
|
||||
'B, G, R': ('Jund: Black/Red/Green', ['B', 'G', 'R', 'B, G', 'B, R', 'G, R', 'B, G, R'],
|
||||
['colorless', 'black', 'green', 'red', 'golgari', 'rakdos', 'gruul', 'jund']),
|
||||
'B, G, W': ('Abzan: Black/Green/White', ['B', 'G', 'W', 'B, G', 'B, W', 'G, W', 'B, G, W'],
|
||||
['colorless', 'black', 'green', 'white', 'golgari', 'orzhov', 'selesnya', 'abzan']),
|
||||
'B, R, U': ('Grixis: Black/Blue/Red', ['B', 'R', 'U', 'B, R', 'B, U', 'R, U', 'B, R, U'],
|
||||
['colorless', 'black', 'blue', 'red', 'dimir', 'rakdos', 'izzet', 'grixis']),
|
||||
'B, R, W': ('Mardu: Black/Red/White', ['B', 'R', 'W', 'B, R', 'B, W', 'R, W', 'B, R, W'],
|
||||
['colorless', 'black', 'red', 'white', 'rakdos', 'orzhov', 'boros', 'mardu']),
|
||||
'B, U, W': ('Esper: Black/Blue/White', ['B', 'U', 'W', 'B, U', 'B, W', 'U, W', 'B, U, W'],
|
||||
['colorless', 'black', 'blue', 'white', 'dimir', 'orzhov', 'azorius', 'esper']),
|
||||
'G, R, U': ('Temur: Blue/Green/Red', ['G', 'R', 'U', 'G, R', 'G, U', 'R, U', 'G, R, U'],
|
||||
['colorless', 'green', 'red', 'blue', 'simic', 'izzet', 'gruul', 'temur']),
|
||||
'G, R, W': ('Naya: Green/Red/White', ['G', 'R', 'W', 'G, R', 'G, W', 'R, W', 'G, R, W'],
|
||||
['colorless', 'green', 'red', 'white', 'gruul', 'selesnya', 'boros', 'naya']),
|
||||
'G, U, W': ('Bant: Blue/Green/White', ['G', 'U', 'W', 'G, U', 'G, W', 'U, W', 'G, U, W'],
|
||||
['colorless', 'green', 'blue', 'white', 'simic', 'azorius', 'selesnya', 'bant']),
|
||||
'R, U, W': ('Jeskai: Blue/Red/White', ['R', 'U', 'W', 'R, U', 'U, W', 'R, W', 'R, U, W'],
|
||||
['colorless', 'blue', 'red', 'white', 'izzet', 'azorius', 'boros', 'jeskai'])
|
||||
}
|
||||
|
||||
OTHER_COLOR_MAP: Final[Dict[str, Tuple[str, List[str], List[str]]]] = {
|
||||
'B, G, R, U': ('Glint: Black/Blue/Green/Red',
|
||||
['B', 'G', 'R', 'U', 'B, G', 'B, R', 'B, U', 'G, R', 'G, U', 'R, U', 'B, G, R',
|
||||
'B, G, U', 'B, R, U', 'G, R, U', 'B, G, R, U'],
|
||||
['colorless', 'black', 'blue', 'green', 'red', 'golgari', 'rakdos', 'dimir',
|
||||
'gruul', 'simic', 'izzet', 'jund', 'sultai', 'grixis', 'temur', 'glint']),
|
||||
'B, G, R, W': ('Dune: Black/Green/Red/White',
|
||||
['B', 'G', 'R', 'W', 'B, G', 'B, R', 'B, W', 'G, R', 'G, W', 'R, W', 'B, G, R',
|
||||
'B, G, W', 'B, R, W', 'G, R, W', 'B, G, R, W'],
|
||||
['colorless', 'black', 'green', 'red', 'white', 'golgari', 'rakdos', 'orzhov',
|
||||
'gruul', 'selesnya', 'boros', 'jund', 'abzan', 'mardu', 'naya', 'dune']),
|
||||
'B, G, U, W': ('Witch: Black/Blue/Green/White',
|
||||
['B', 'G', 'U', 'W', 'B, G', 'B, U', 'B, W', 'G, U', 'G, W', 'U, W', 'B, G, U',
|
||||
'B, G, W', 'B, U, W', 'G, U, W', 'B, G, U, W'],
|
||||
['colorless', 'black', 'blue', 'green', 'white', 'golgari', 'dimir', 'orzhov',
|
||||
'simic', 'selesnya', 'azorius', 'sultai', 'abzan', 'esper', 'bant', 'witch']),
|
||||
'B, R, U, W': ('Yore: Black/Blue/Red/White',
|
||||
['B', 'R', 'U', 'W', 'B, R', 'B, U', 'B, W', 'R, U', 'R, W', 'U, W', 'B, R, U',
|
||||
'B, R, W', 'B, U, W', 'R, U, W', 'B, R, U, W'],
|
||||
['colorless', 'black', 'blue', 'red', 'white', 'rakdos', 'dimir', 'orzhov',
|
||||
'izzet', 'boros', 'azorius', 'grixis', 'mardu', 'esper', 'jeskai', 'yore']),
|
||||
'G, R, U, W': ('Ink: Blue/Green/Red/White',
|
||||
['G', 'R', 'U', 'W', 'G, R', 'G, U', 'G, W', 'R, U', 'R, W', 'U, W', 'G, R, U',
|
||||
'G, R, W', 'G, U, W', 'R, U, W', 'G, R, U, W'],
|
||||
['colorless', 'blue', 'green', 'red', 'white', 'gruul', 'simic', 'selesnya',
|
||||
'izzet', 'boros', 'azorius', 'temur', 'naya', 'bant', 'jeskai', 'ink']),
|
||||
'B, G, R, U, W': ('WUBRG: All colors',
|
||||
['B', 'G', 'R', 'U', 'W', 'B, G', 'B, R', 'B, U', 'B, W', 'G, R', 'G, U',
|
||||
'G, W', 'R, U', 'R, W', 'U, W', 'B, G, R', 'B, G, U', 'B, G, W', 'B, R, U',
|
||||
'B, R, W', 'B, U, W', 'G, R, U', 'G, R, W', 'G, U, W', 'R, U, W',
|
||||
'B, G, R, U', 'B, G, R, W', 'B, G, U, W', 'B, R, U, W', 'G, R, U, W',
|
||||
'B, G, R, U, W'],
|
||||
['colorless', 'black', 'green', 'red', 'blue', 'white', 'golgari', 'rakdos',
|
||||
'dimir', 'orzhov', 'gruul', 'simic', 'selesnya', 'izzet', 'boros', 'azorius',
|
||||
'jund', 'sultai', 'abzan', 'grixis', 'mardu', 'esper', 'temur', 'naya',
|
||||
'bant', 'jeskai', 'glint', 'dune', 'witch', 'yore', 'ink', 'wubrg'])
|
||||
}
|
||||
|
||||
# Price checking configuration
|
||||
DEFAULT_PRICE_DELAY: Final[float] = 0.1 # Delay between price checks in seconds
|
||||
MAX_PRICE_CHECK_ATTEMPTS: Final[int] = 3 # Maximum attempts for price checking
|
||||
PRICE_CACHE_SIZE: Final[int] = 128 # Size of price check LRU cache
|
||||
PRICE_CHECK_TIMEOUT: Final[int] = 30 # Timeout for price check requests in seconds
|
||||
PRICE_TOLERANCE_MULTIPLIER: Final[float] = 1.1 # Multiplier for price tolerance
|
||||
DEFAULT_MAX_CARD_PRICE: Final[float] = 20.0 # Default maximum price per card
|
||||
|
||||
# Deck composition defaults
|
||||
DEFAULT_RAMP_COUNT: Final[int] = 8 # Default number of ramp pieces
|
||||
DEFAULT_LAND_COUNT: Final[int] = 35 # Default total land count
|
||||
DEFAULT_BASIC_LAND_COUNT: Final[int] = 20 # Default minimum basic lands
|
||||
DEFAULT_NON_BASIC_LAND_SLOTS: Final[int] = 10 # Default number of non-basic land slots to reserve
|
||||
DEFAULT_BASICS_PER_COLOR: Final[int] = 5 # Default number of basic lands to add per color
|
||||
|
||||
# Miscellaneous land configuration
|
||||
MISC_LAND_MIN_COUNT: Final[int] = 5 # Minimum number of miscellaneous lands to add
|
||||
MISC_LAND_MAX_COUNT: Final[int] = 10 # Maximum number of miscellaneous lands to add
|
||||
MISC_LAND_POOL_SIZE: Final[int] = 100 # Maximum size of initial land pool to select from
|
||||
|
||||
# Default fetch land count
|
||||
FETCH_LAND_DEFAULT_COUNT: Final[int] = 3 # Default number of fetch lands to include
|
||||
|
||||
# Basic Lands
|
||||
BASIC_LANDS = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']
|
||||
|
||||
# Basic land mappings
|
||||
COLOR_TO_BASIC_LAND: Final[Dict[str, str]] = {
|
||||
'W': 'Plains',
|
||||
'U': 'Island',
|
||||
'B': 'Swamp',
|
||||
'R': 'Mountain',
|
||||
'G': 'Forest',
|
||||
'C': 'Wastes'
|
||||
}
|
||||
|
||||
# Dual land type mappings
|
||||
DUAL_LAND_TYPE_MAP: Final[Dict[str, str]] = {
|
||||
'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'
|
||||
}
|
||||
|
||||
# Triple land type mappings
|
||||
TRIPLE_LAND_TYPE_MAP: Final[Dict[str, str]] = {
|
||||
'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',
|
||||
'jeskai': 'Island Mountain Plains'
|
||||
}
|
||||
|
||||
# Default preference for including dual lands
|
||||
DEFAULT_DUAL_LAND_ENABLED: Final[bool] = True
|
||||
|
||||
# Default preference for including triple lands
|
||||
DEFAULT_TRIPLE_LAND_ENABLED: Final[bool] = True
|
||||
|
||||
SNOW_COVERED_BASIC_LANDS: Final[Dict[str, str]] = {
|
||||
'W': 'Snow-Covered Plains',
|
||||
'U': 'Snow-Covered Island',
|
||||
'B': 'Snow-Covered Swamp',
|
||||
'G': 'Snow-Covered Forest'
|
||||
}
|
||||
|
||||
SNOW_BASIC_LAND_MAPPING: Final[Dict[str, str]] = {
|
||||
'W': 'Snow-Covered Plains',
|
||||
'U': 'Snow-Covered Island',
|
||||
'B': 'Snow-Covered Swamp',
|
||||
'R': 'Snow-Covered Mountain',
|
||||
'G': 'Snow-Covered Forest',
|
||||
'C': 'Wastes' # Note: No snow-covered version exists for Wastes
|
||||
}
|
||||
|
||||
# Generic fetch lands list
|
||||
GENERIC_FETCH_LANDS: Final[List[str]] = [
|
||||
'Evolving Wilds',
|
||||
'Terramorphic Expanse',
|
||||
'Shire Terrace',
|
||||
'Escape Tunnel',
|
||||
'Promising Vein',
|
||||
'Myriad Landscape',
|
||||
'Fabled Passage',
|
||||
'Terminal Moraine',
|
||||
'Prismatic Vista'
|
||||
]
|
||||
|
||||
# Kindred land constants
|
||||
KINDRED_STAPLE_LANDS: Final[List[Dict[str, str]]] = [
|
||||
{
|
||||
'name': 'Path of Ancestry',
|
||||
'type': 'Land'
|
||||
},
|
||||
{
|
||||
'name': 'Three Tree City',
|
||||
'type': 'Legendary Land'
|
||||
},
|
||||
{'name': 'Cavern of Souls', 'type': 'Land'}
|
||||
]
|
||||
|
||||
# Color-specific fetch land mappings
|
||||
COLOR_TO_FETCH_LANDS: Final[Dict[str, List[str]]] = {
|
||||
'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'
|
||||
]
|
||||
}
|
||||
|
||||
# Staple land conditions mapping
|
||||
STAPLE_LAND_CONDITIONS: Final[Dict[str, Callable[[List[str], List[str], int], bool]]] = {
|
||||
'Reliquary Tower': lambda commander_tags, colors, commander_power: True, # Always include
|
||||
'Ash Barrens': lambda commander_tags, colors, commander_power: 'Landfall' not in commander_tags,
|
||||
'Command Tower': lambda commander_tags, colors, commander_power: len(colors) > 1,
|
||||
'Exotic Orchard': lambda commander_tags, colors, commander_power: len(colors) > 1,
|
||||
'War Room': lambda commander_tags, colors, commander_power: len(colors) <= 2,
|
||||
'Rogue\'s Passage': lambda commander_tags, colors, commander_power: commander_power >= 5
|
||||
}
|
||||
|
||||
# Constants for land removal functionality
|
||||
LAND_REMOVAL_MAX_ATTEMPTS: Final[int] = 3
|
||||
|
||||
# Protected lands that cannot be removed during land removal process
|
||||
PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + [land['name'] for land in KINDRED_STAPLE_LANDS]
|
||||
|
||||
# Other defaults
|
||||
DEFAULT_CREATURE_COUNT: Final[int] = 25 # Default number of creatures
|
||||
DEFAULT_REMOVAL_COUNT: Final[int] = 10 # Default number of spot removal spells
|
||||
DEFAULT_WIPES_COUNT: Final[int] = 2 # Default number of board wipes
|
||||
|
||||
DEFAULT_CARD_ADVANTAGE_COUNT: Final[int] = 10 # Default number of card advantage pieces
|
||||
DEFAULT_PROTECTION_COUNT: Final[int] = 8 # Default number of protection spells
|
||||
|
||||
# Deck composition prompts
|
||||
DECK_COMPOSITION_PROMPTS: Final[Dict[str, str]] = {
|
||||
'ramp': 'Enter desired number of ramp pieces (default: 8):',
|
||||
'lands': 'Enter desired number of total lands (default: 35):',
|
||||
'basic_lands': 'Enter minimum number of basic lands (default: 20):',
|
||||
'creatures': 'Enter desired number of creatures (default: 25):',
|
||||
'removal': 'Enter desired number of spot removal spells (default: 10):',
|
||||
'wipes': 'Enter desired number of board wipes (default: 2):',
|
||||
'card_advantage': 'Enter desired number of card advantage pieces (default: 10):',
|
||||
'protection': 'Enter desired number of protection spells (default: 8):',
|
||||
'max_deck_price': 'Enter maximum total deck price in dollars (default: 400.0):',
|
||||
'max_card_price': 'Enter maximum price per card in dollars (default: 20.0):'
|
||||
}
|
||||
DEFAULT_MAX_DECK_PRICE: Final[float] = 400.0 # Default maximum total deck price
|
||||
BATCH_PRICE_CHECK_SIZE: Final[int] = 50 # Number of cards to check prices for in one batch
|
||||
# Constants for input validation
|
||||
|
||||
# Type aliases
|
||||
CardName = str
|
||||
CardType = str
|
||||
ThemeTag = str
|
||||
ColorIdentity = str
|
||||
ColorList = List[str]
|
||||
ColorInfo = Tuple[str, List[str], List[str]]
|
||||
|
||||
INPUT_VALIDATION = {
|
||||
'max_attempts': 3,
|
||||
'default_text_message': 'Please enter a valid text response.',
|
||||
'default_number_message': 'Please enter a valid number.',
|
||||
'default_confirm_message': 'Please enter Y/N or Yes/No.',
|
||||
'default_choice_message': 'Please select a valid option from the list.'
|
||||
}
|
||||
|
||||
QUESTION_TYPES = [
|
||||
'Text',
|
||||
'Number',
|
||||
'Confirm',
|
||||
'Choice'
|
||||
]
|
||||
|
||||
# Constants for theme weight management and selection
|
||||
# Multiplier for initial card pool size during theme-based selection
|
||||
THEME_POOL_SIZE_MULTIPLIER: Final[float] = 2.0
|
||||
|
||||
# Bonus multiplier for cards that match multiple deck themes
|
||||
THEME_PRIORITY_BONUS: Final[float] = 1.2
|
||||
|
||||
# Safety multiplier to avoid overshooting target counts
|
||||
THEME_WEIGHT_MULTIPLIER: Final[float] = 0.9
|
||||
|
||||
THEME_WEIGHTS_DEFAULT: Final[Dict[str, float]] = {
|
||||
'primary': 1.0,
|
||||
'secondary': 0.6,
|
||||
'tertiary': 0.3,
|
||||
'hidden': 0.0
|
||||
}
|
||||
|
||||
WEIGHT_ADJUSTMENT_FACTORS: Final[Dict[str, float]] = {
|
||||
'kindred_primary': 1.5, # Boost for Kindred themes as primary
|
||||
'kindred_secondary': 1.3, # Boost for Kindred themes as secondary
|
||||
'kindred_tertiary': 1.2, # Boost for Kindred themes as tertiary
|
||||
'theme_synergy': 1.2 # Boost for themes that work well together
|
||||
}
|
||||
|
||||
DEFAULT_THEME_TAGS = [
|
||||
'Aggro', 'Aristocrats', 'Artifacts Matter', 'Big Mana', 'Blink',
|
||||
'Board Wipes', 'Burn', 'Cantrips', 'Card Draw', 'Clones',
|
||||
'Combat Matters', 'Control', 'Counters Matter', 'Energy',
|
||||
'Enter the Battlefield', 'Equipment', 'Exile Matters', 'Infect',
|
||||
'Interaction', 'Lands Matter', 'Leave the Battlefield', 'Legends Matter',
|
||||
'Life Matters', 'Mill', 'Monarch', 'Protection', 'Ramp', 'Reanimate',
|
||||
'Removal', 'Sacrifice Matters', 'Spellslinger', 'Stax', 'Super Friends',
|
||||
'Theft', 'Token Creation', 'Tokens Matter', 'Voltron', 'X Spells'
|
||||
]
|
||||
|
||||
# CSV processing configuration
|
||||
CSV_READ_TIMEOUT: Final[int] = 30 # Timeout in seconds for CSV read operations
|
||||
CSV_PROCESSING_BATCH_SIZE: Final[int] = 1000 # Number of rows to process in each batch
|
||||
|
||||
# CSV validation configuration
|
||||
CSV_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float]]]] = {
|
||||
'name': {'type': ('str', 'object'), 'required': True, 'unique': True},
|
||||
'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000},
|
||||
'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20},
|
||||
'power': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'},
|
||||
'toughness': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'}
|
||||
}
|
||||
|
||||
# Required columns for CSV validation
|
||||
CSV_REQUIRED_COLUMNS: Final[List[str]] = [
|
||||
'name', 'faceName', 'edhrecRank', 'colorIdentity', 'colors',
|
||||
'manaCost', 'manaValue', 'type', 'creatureTypes', 'text',
|
||||
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
|
||||
]
|
||||
|
||||
# DataFrame processing configuration
|
||||
BATCH_SIZE: Final[int] = 1000 # Number of records to process at once
|
||||
DATAFRAME_BATCH_SIZE: Final[int] = 500 # Batch size for DataFrame operations
|
||||
TRANSFORM_BATCH_SIZE: Final[int] = 250 # Batch size for data transformations
|
||||
CSV_DOWNLOAD_TIMEOUT: Final[int] = 30 # Timeout in seconds for CSV downloads
|
||||
PROGRESS_UPDATE_INTERVAL: Final[int] = 100 # Number of records between progress updates
|
||||
|
||||
# DataFrame operation timeouts
|
||||
DATAFRAME_READ_TIMEOUT: Final[int] = 30 # Timeout for DataFrame read operations
|
||||
DATAFRAME_WRITE_TIMEOUT: Final[int] = 30 # Timeout for DataFrame write operations
|
||||
DATAFRAME_TRANSFORM_TIMEOUT: Final[int] = 45 # Timeout for DataFrame transformations
|
||||
DATAFRAME_VALIDATION_TIMEOUT: Final[int] = 20 # Timeout for DataFrame validation
|
||||
|
||||
# Required DataFrame columns
|
||||
DATAFRAME_REQUIRED_COLUMNS: Final[List[str]] = [
|
||||
'name', 'type', 'colorIdentity', 'manaValue', 'text',
|
||||
'edhrecRank', 'themeTags', 'keywords'
|
||||
]
|
||||
|
||||
# DataFrame validation rules
|
||||
DATAFRAME_VALIDATION_RULES: Final[Dict[str, Dict[str, Union[str, int, float, bool]]]] = {
|
||||
'name': {'type': ('str', 'object'), 'required': True, 'unique': True},
|
||||
'edhrecRank': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 100000},
|
||||
'manaValue': {'type': ('str', 'int', 'float', 'object'), 'min': 0, 'max': 20},
|
||||
'power': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'},
|
||||
'toughness': {'type': ('str', 'int', 'float', 'object'), 'pattern': r'^[\d*+-]+$'},
|
||||
'colorIdentity': {'type': ('str', 'object'), 'required': True},
|
||||
'text': {'type': ('str', 'object'), 'required': False}
|
||||
}
|
||||
|
||||
# Card type sorting order for organizing libraries
|
||||
# This constant defines the order in which different card types should be sorted
|
||||
# when organizing a deck library. The order is designed to group cards logically,
|
||||
# starting with Planeswalkers and ending with Lands.
|
||||
CARD_TYPE_SORT_ORDER: Final[List[str]] = [
|
||||
'Planeswalker', 'Battle', 'Creature', 'Instant', 'Sorcery',
|
||||
'Artifact', 'Enchantment', 'Land'
|
||||
]
|
1642
code/deck_builder/builder_utils.py
Normal file
1642
code/deck_builder/builder_utils.py
Normal file
File diff suppressed because it is too large
Load diff
1388
code/exceptions.py
Normal file
1388
code/exceptions.py
Normal file
File diff suppressed because it is too large
Load diff
8
code/file_setup/__init__.py
Normal file
8
code/file_setup/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
"""Initialize the file_setup package."""
|
||||
|
||||
from .setup import setup, regenerate_csv_by_color
|
||||
|
||||
__all__ = [
|
||||
'setup',
|
||||
'regenerate_csv_by_color'
|
||||
]
|
337
code/file_setup/setup.py
Normal file
337
code/file_setup/setup.py
Normal file
|
@ -0,0 +1,337 @@
|
|||
"""MTG Python Deckbuilder setup module.
|
||||
|
||||
This module provides the main setup functionality for the MTG Python Deckbuilder
|
||||
application. It handles initial setup tasks such as downloading card data,
|
||||
creating color-filtered card lists, and generating commander-eligible card lists.
|
||||
|
||||
Key Features:
|
||||
- Initial setup and configuration
|
||||
- Card data download and processing
|
||||
- Color-based card filtering
|
||||
- Commander card list generation
|
||||
- CSV file management and validation
|
||||
|
||||
The module works in conjunction with setup_utils.py for utility functions and
|
||||
exceptions.py for error handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Standard library imports
|
||||
import logging
|
||||
from enum import Enum
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Union, List, Dict, Any
|
||||
|
||||
# Third-party imports
|
||||
import inquirer
|
||||
import pandas as pd
|
||||
|
||||
# Local imports
|
||||
import logging_util
|
||||
from settings import CSV_DIRECTORY
|
||||
from .setup_constants import BANNED_CARDS, SETUP_COLORS, COLOR_ABRV, MTGJSON_API_URL
|
||||
from .setup_utils import (
|
||||
download_cards_csv,
|
||||
filter_by_color_identity,
|
||||
filter_dataframe,
|
||||
process_legendary_cards
|
||||
)
|
||||
from exceptions import (
|
||||
CSVFileNotFoundError,
|
||||
ColorFilterError,
|
||||
CommanderValidationError,
|
||||
DataFrameProcessingError,
|
||||
MTGJSONDownloadError
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
# Create CSV directory if it doesn't exist
|
||||
if not os.path.exists(CSV_DIRECTORY):
|
||||
os.makedirs(CSV_DIRECTORY)
|
||||
|
||||
def check_csv_exists(file_path: Union[str, Path]) -> bool:
|
||||
"""Check if a CSV file exists at the specified path.
|
||||
|
||||
Args:
|
||||
file_path: Path to the CSV file to check
|
||||
|
||||
Returns:
|
||||
bool: True if file exists, False otherwise
|
||||
|
||||
Raises:
|
||||
CSVFileNotFoundError: If there are issues accessing the file path
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8'):
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except Exception as e:
|
||||
raise CSVFileNotFoundError(f'Error checking CSV file: {str(e)}')
|
||||
|
||||
def initial_setup() -> None:
|
||||
"""Perform initial setup by downloading card data and creating filtered CSV files.
|
||||
|
||||
Downloads the latest card data from MTGJSON if needed, creates color-filtered CSV files,
|
||||
and generates commander-eligible cards list. Uses utility functions from setup_utils.py
|
||||
for file operations and data processing.
|
||||
|
||||
Raises:
|
||||
CSVFileNotFoundError: If required CSV files cannot be found
|
||||
MTGJSONDownloadError: If card data download fails
|
||||
DataFrameProcessingError: If data processing fails
|
||||
ColorFilterError: If color filtering fails
|
||||
"""
|
||||
logger.info('Checking for cards.csv file')
|
||||
|
||||
try:
|
||||
cards_file = f'{CSV_DIRECTORY}/cards.csv'
|
||||
try:
|
||||
with open(cards_file, 'r', encoding='utf-8'):
|
||||
logger.info('cards.csv exists')
|
||||
except FileNotFoundError:
|
||||
logger.info('cards.csv not found, downloading from mtgjson')
|
||||
download_cards_csv(MTGJSON_API_URL, cards_file)
|
||||
|
||||
df = pd.read_csv(cards_file, low_memory=False)
|
||||
df['colorIdentity'] = df['colorIdentity'].fillna('Colorless')
|
||||
|
||||
logger.info('Checking for color identity sorted files')
|
||||
|
||||
for i in range(min(len(SETUP_COLORS), len(COLOR_ABRV))):
|
||||
logger.info(f'Checking for {SETUP_COLORS[i]}_cards.csv')
|
||||
try:
|
||||
with open(f'{CSV_DIRECTORY}/{SETUP_COLORS[i]}_cards.csv', 'r', encoding='utf-8'):
|
||||
logger.info(f'{SETUP_COLORS[i]}_cards.csv exists')
|
||||
except FileNotFoundError:
|
||||
logger.info(f'{SETUP_COLORS[i]}_cards.csv not found, creating one')
|
||||
filter_by_color(df, 'colorIdentity', COLOR_ABRV[i], f'{CSV_DIRECTORY}/{SETUP_COLORS[i]}_cards.csv')
|
||||
|
||||
# Generate commander list
|
||||
determine_commanders()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error during initial setup: {str(e)}')
|
||||
raise
|
||||
|
||||
def filter_by_color(df: pd.DataFrame, column_name: str, value: str, new_csv_name: Union[str, Path]) -> None:
|
||||
"""Filter DataFrame by color identity and save to CSV.
|
||||
|
||||
Args:
|
||||
df: DataFrame to filter
|
||||
column_name: Column to filter on (should be 'colorIdentity')
|
||||
value: Color identity value to filter for
|
||||
new_csv_name: Path to save filtered CSV
|
||||
|
||||
Raises:
|
||||
ColorFilterError: If filtering fails
|
||||
DataFrameProcessingError: If DataFrame processing fails
|
||||
CSVFileNotFoundError: If CSV file operations fail
|
||||
"""
|
||||
try:
|
||||
# Check if target CSV already exists
|
||||
if check_csv_exists(new_csv_name):
|
||||
logger.info(f'{new_csv_name} already exists, will be overwritten')
|
||||
|
||||
filtered_df = filter_by_color_identity(df, value)
|
||||
filtered_df.to_csv(new_csv_name, index=False)
|
||||
logger.info(f'Successfully created {new_csv_name}')
|
||||
except (ColorFilterError, DataFrameProcessingError, CSVFileNotFoundError) as e:
|
||||
logger.error(f'Failed to filter by color {value}: {str(e)}')
|
||||
raise
|
||||
|
||||
def determine_commanders() -> None:
|
||||
"""Generate commander_cards.csv containing all cards eligible to be commanders.
|
||||
|
||||
This function processes the card database to identify and validate commander-eligible cards,
|
||||
applying comprehensive validation steps and filtering criteria.
|
||||
|
||||
Raises:
|
||||
CSVFileNotFoundError: If cards.csv is missing and cannot be downloaded
|
||||
MTGJSONDownloadError: If downloading cards data fails
|
||||
CommanderValidationError: If commander validation fails
|
||||
DataFrameProcessingError: If data processing operations fail
|
||||
"""
|
||||
logger.info('Starting commander card generation process')
|
||||
|
||||
try:
|
||||
# Check for cards.csv with progress tracking
|
||||
cards_file = f'{CSV_DIRECTORY}/cards.csv'
|
||||
if not check_csv_exists(cards_file):
|
||||
logger.info('cards.csv not found, initiating download')
|
||||
download_cards_csv(MTGJSON_API_URL, cards_file)
|
||||
else:
|
||||
logger.info('cards.csv found, proceeding with processing')
|
||||
|
||||
# Load and process cards data
|
||||
logger.info('Loading card data from CSV')
|
||||
df = pd.read_csv(cards_file, low_memory=False)
|
||||
df['colorIdentity'] = df['colorIdentity'].fillna('Colorless')
|
||||
|
||||
# Process legendary cards with validation
|
||||
logger.info('Processing and validating legendary cards')
|
||||
try:
|
||||
filtered_df = process_legendary_cards(df)
|
||||
except CommanderValidationError as e:
|
||||
logger.error(f'Commander validation failed: {str(e)}')
|
||||
raise
|
||||
|
||||
# Apply standard filters
|
||||
logger.info('Applying standard card filters')
|
||||
filtered_df = filter_dataframe(filtered_df, BANNED_CARDS)
|
||||
|
||||
# Save commander cards
|
||||
logger.info('Saving validated commander cards')
|
||||
filtered_df.to_csv(f'{CSV_DIRECTORY}/commander_cards.csv', index=False)
|
||||
|
||||
logger.info('Commander card generation completed successfully')
|
||||
|
||||
except (CSVFileNotFoundError, MTGJSONDownloadError) as e:
|
||||
logger.error(f'File operation error: {str(e)}')
|
||||
raise
|
||||
except CommanderValidationError as e:
|
||||
logger.error(f'Commander validation error: {str(e)}')
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Unexpected error during commander generation: {str(e)}')
|
||||
raise
|
||||
|
||||
def regenerate_csvs_all() -> None:
|
||||
"""Regenerate all color-filtered CSV files from latest card data.
|
||||
|
||||
Downloads fresh card data and recreates all color-filtered CSV files.
|
||||
Useful for updating the card database when new sets are released.
|
||||
|
||||
Raises:
|
||||
MTGJSONDownloadError: If card data download fails
|
||||
DataFrameProcessingError: If data processing fails
|
||||
ColorFilterError: If color filtering fails
|
||||
"""
|
||||
try:
|
||||
logger.info('Downloading latest card data from MTGJSON')
|
||||
download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv')
|
||||
|
||||
logger.info('Loading and processing card data')
|
||||
df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
|
||||
df['colorIdentity'] = df['colorIdentity'].fillna('Colorless')
|
||||
|
||||
logger.info('Regenerating color identity sorted files')
|
||||
for i in range(min(len(SETUP_COLORS), len(COLOR_ABRV))):
|
||||
color = SETUP_COLORS[i]
|
||||
color_id = COLOR_ABRV[i]
|
||||
logger.info(f'Processing {color} cards')
|
||||
filter_by_color(df, 'colorIdentity', color_id, f'{CSV_DIRECTORY}/{color}_cards.csv')
|
||||
|
||||
logger.info('Regenerating commander cards')
|
||||
determine_commanders()
|
||||
|
||||
logger.info('Card database regeneration complete')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to regenerate card database: {str(e)}')
|
||||
raise
|
||||
# Once files are regenerated, create a new legendary list
|
||||
determine_commanders()
|
||||
|
||||
def regenerate_csv_by_color(color: str) -> None:
|
||||
"""Regenerate CSV file for a specific color identity.
|
||||
|
||||
Args:
|
||||
color: Color name to regenerate CSV for (e.g. 'white', 'blue')
|
||||
|
||||
Raises:
|
||||
ValueError: If color is not valid
|
||||
MTGJSONDownloadError: If card data download fails
|
||||
DataFrameProcessingError: If data processing fails
|
||||
ColorFilterError: If color filtering fails
|
||||
"""
|
||||
try:
|
||||
if color not in SETUP_COLORS:
|
||||
raise ValueError(f'Invalid color: {color}')
|
||||
|
||||
color_abv = COLOR_ABRV[SETUP_COLORS.index(color)]
|
||||
|
||||
logger.info(f'Downloading latest card data for {color} cards')
|
||||
download_cards_csv(MTGJSON_API_URL, f'{CSV_DIRECTORY}/cards.csv')
|
||||
|
||||
logger.info('Loading and processing card data')
|
||||
df = pd.read_csv(f'{CSV_DIRECTORY}/cards.csv', low_memory=False)
|
||||
df['colorIdentity'] = df['colorIdentity'].fillna('Colorless')
|
||||
|
||||
logger.info(f'Regenerating {color} cards CSV')
|
||||
filter_by_color(df, 'colorIdentity', color_abv, f'{CSV_DIRECTORY}/{color}_cards.csv')
|
||||
|
||||
logger.info(f'Successfully regenerated {color} cards database')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to regenerate {color} cards: {str(e)}')
|
||||
raise
|
||||
|
||||
class SetupOption(Enum):
|
||||
"""Enum for setup menu options."""
|
||||
INITIAL_SETUP = 'Initial Setup'
|
||||
REGENERATE_CSV = 'Regenerate CSV Files'
|
||||
BACK = 'Back'
|
||||
|
||||
def _display_setup_menu() -> SetupOption:
|
||||
"""Display the setup menu and return the selected option.
|
||||
|
||||
Returns:
|
||||
SetupOption: The selected menu option
|
||||
"""
|
||||
question: List[Dict[str, Any]] = [
|
||||
inquirer.List(
|
||||
'menu',
|
||||
choices=[option.value for option in SetupOption],
|
||||
carousel=True)]
|
||||
answer = inquirer.prompt(question)
|
||||
return SetupOption(answer['menu'])
|
||||
|
||||
def setup() -> bool:
|
||||
"""Run the setup process for the MTG Python Deckbuilder.
|
||||
|
||||
This function provides a menu-driven interface to:
|
||||
1. Perform initial setup by downloading and processing card data
|
||||
2. Regenerate CSV files with updated card data
|
||||
3. Perform all tagging processes on the color-sorted csv files
|
||||
|
||||
The function handles errors gracefully and provides feedback through logging.
|
||||
|
||||
Returns:
|
||||
bool: True if setup completed successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
print('Which setup operation would you like to perform?\n'
|
||||
'If this is your first time setting up, do the initial setup.\n'
|
||||
'If you\'ve done the basic setup before, you can regenerate the CSV files\n')
|
||||
|
||||
choice = _display_setup_menu()
|
||||
|
||||
if choice == SetupOption.INITIAL_SETUP:
|
||||
logger.info('Starting initial setup')
|
||||
initial_setup()
|
||||
logger.info('Initial setup completed successfully')
|
||||
return True
|
||||
|
||||
elif choice == SetupOption.REGENERATE_CSV:
|
||||
logger.info('Starting CSV regeneration')
|
||||
regenerate_csvs_all()
|
||||
logger.info('CSV regeneration completed successfully')
|
||||
return True
|
||||
|
||||
elif choice == SetupOption.BACK:
|
||||
logger.info('Setup cancelled by user')
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error during setup: {e}')
|
||||
raise
|
||||
|
||||
return False
|
118
code/file_setup/setup_constants.py
Normal file
118
code/file_setup/setup_constants.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
from typing import Dict, List, Optional, Final, Tuple, Pattern, Union, Callable
|
||||
|
||||
BANNED_CARDS: List[str] = [# in commander
|
||||
'Ancestral Recall', 'Balance', 'Biorhythm', 'Black Lotus',
|
||||
'Braids, Cabal Minion', 'Chaos Orb', 'Coalition Victory',
|
||||
'Channel', 'Dockside Extortionist', 'Emrakul, the Aeons Torn',
|
||||
'Erayo, Soratami Ascendant', 'Falling Star', 'Fastbond',
|
||||
'Flash', 'Gifts Ungiven', 'Golos, Tireless Pilgrim',
|
||||
'Griselbrand', 'Hullbreacher', 'Iona, Shield of Emeria',
|
||||
'Karakas', 'Jeweled Lotus', 'Leovold, Emissary of Trest',
|
||||
'Library of Alexandria', 'Limited Resources', 'Lutri, the Spellchaser',
|
||||
'Mana Crypt', 'Mox Emerald', 'Mox Jet', 'Mox Pearl', 'Mox Ruby',
|
||||
'Mox Sapphire', 'Nadu, Winged Wisdom', 'Panoptic Mirror',
|
||||
'Paradox Engine', 'Primeval Titan', 'Prophet of Kruphix',
|
||||
'Recurring Nightmare', 'Rofellos, Llanowar Emissary', 'Shahrazad',
|
||||
'Sundering Titan', 'Sway of the Stars', 'Sylvan Primordial',
|
||||
'Time Vault', 'Time Walk', 'Tinker', 'Tolarian Academy',
|
||||
'Trade Secrets', 'Upheaval', 'Yawgmoth\'s Bargain',
|
||||
|
||||
# In constructed
|
||||
'Invoke Prejudice', 'Cleanse', 'Stone-Throwing Devils', 'Pradesh Gypsies',
|
||||
'Jihad', 'Imprison', 'Crusade'
|
||||
]
|
||||
|
||||
SETUP_COLORS: List[str] = ['colorless', 'white', 'blue', 'black', 'green', 'red',
|
||||
'azorius', 'orzhov', 'selesnya', 'boros', 'dimir',
|
||||
'simic', 'izzet', 'golgari', 'rakdos', 'gruul',
|
||||
'bant', 'esper', 'grixis', 'jund', 'naya',
|
||||
'abzan', 'jeskai', 'mardu', 'sultai', 'temur',
|
||||
'dune', 'glint', 'ink', 'witch', 'yore', 'wubrg']
|
||||
|
||||
COLOR_ABRV: List[str] = ['Colorless', 'W', 'U', 'B', 'G', 'R',
|
||||
'U, W', 'B, W', 'G, W', 'R, W', 'B, U',
|
||||
'G, U', 'R, U', 'B, G', 'B, R', 'G, R',
|
||||
'G, U, W', 'B, U, W', 'B, R, U', 'B, G, R', 'G, R, W',
|
||||
'B, G, W', 'R, U, W', 'B, R, W', 'B, G, U', 'G, R, U',
|
||||
'B, G, R, W', 'B, G, R, U', 'G, R, U, W', 'B, G, U, W',
|
||||
'B, R, U, W', 'B, G, R, U, W']
|
||||
|
||||
# Constants for setup and CSV processing
|
||||
MTGJSON_API_URL: str = 'https://mtgjson.com/api/v5/csv/cards.csv'
|
||||
|
||||
LEGENDARY_OPTIONS: List[str] = [
|
||||
'Legendary Creature',
|
||||
'Legendary Artifact',
|
||||
'Legendary Artifact Creature',
|
||||
'Legendary Enchantment Creature',
|
||||
'Legendary Planeswalker'
|
||||
]
|
||||
|
||||
NON_LEGAL_SETS: List[str] = [
|
||||
'PHTR', 'PH17', 'PH18', 'PH19', 'PH20', 'PH21',
|
||||
'UGL', 'UND', 'UNH', 'UST'
|
||||
]
|
||||
|
||||
CARD_TYPES_TO_EXCLUDE: List[str] = [
|
||||
'Plane —',
|
||||
'Conspiracy',
|
||||
'Vanguard',
|
||||
'Scheme',
|
||||
'Phenomenon',
|
||||
'Stickers',
|
||||
'Attraction',
|
||||
'Hero',
|
||||
'Contraption'
|
||||
]
|
||||
|
||||
# Columns to keep when processing CSV files
|
||||
CSV_PROCESSING_COLUMNS: List[str] = [
|
||||
'name', # Card name
|
||||
'faceName', # Name of specific face for multi-faced cards
|
||||
'edhrecRank', # Card's rank on EDHREC
|
||||
'colorIdentity', # Color identity for Commander format
|
||||
'colors', # Actual colors in card's mana cost
|
||||
'manaCost', # Mana cost string
|
||||
'manaValue', # Converted mana cost
|
||||
'type', # Card type line
|
||||
'layout', # Card layout (normal, split, etc)
|
||||
'text', # Card text/rules
|
||||
'power', # Power (for creatures)
|
||||
'toughness', # Toughness (for creatures)
|
||||
'keywords', # Card's keywords
|
||||
'side' # Side identifier for multi-faced cards
|
||||
]
|
||||
|
||||
# Configuration for DataFrame sorting operations
|
||||
SORT_CONFIG = {
|
||||
'columns': ['name', 'side'], # Columns to sort by
|
||||
'case_sensitive': False # Ignore case when sorting
|
||||
}
|
||||
|
||||
# Configuration for DataFrame filtering operations
|
||||
FILTER_CONFIG: Dict[str, Dict[str, List[str]]] = {
|
||||
'layout': {
|
||||
'exclude': ['reversible_card']
|
||||
},
|
||||
'availability': {
|
||||
'require': ['paper']
|
||||
},
|
||||
'promoTypes': {
|
||||
'exclude': ['playtest']
|
||||
},
|
||||
'securityStamp': {
|
||||
'exclude': ['Heart', 'Acorn']
|
||||
}
|
||||
}
|
||||
|
||||
COLUMN_ORDER: List[str] = [
|
||||
'name', 'faceName', 'edhrecRank', 'colorIdentity', 'colors',
|
||||
'manaCost', 'manaValue', 'type', 'creatureTypes', 'text',
|
||||
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
|
||||
]
|
||||
|
||||
TAGGED_COLUMN_ORDER: List[str] = [
|
||||
'name', 'faceName', 'edhrecRank', 'colorIdentity', 'colors',
|
||||
'manaCost', 'manaValue', 'type', 'creatureTypes', 'text',
|
||||
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
|
||||
]
|
408
code/file_setup/setup_utils.py
Normal file
408
code/file_setup/setup_utils.py
Normal file
|
@ -0,0 +1,408 @@
|
|||
"""MTG Python Deckbuilder setup utilities.
|
||||
|
||||
This module provides utility functions for setting up and managing the MTG Python Deckbuilder
|
||||
application. It handles tasks such as downloading card data, filtering cards by various criteria,
|
||||
and processing legendary creatures for commander format.
|
||||
|
||||
Key Features:
|
||||
- Card data download from MTGJSON
|
||||
- DataFrame filtering and processing
|
||||
- Color identity filtering
|
||||
- Commander validation
|
||||
- CSV file management
|
||||
|
||||
The module integrates with settings.py for configuration and exceptions.py for error handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Standard library imports
|
||||
import logging
|
||||
import os
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union, TypedDict
|
||||
|
||||
# Third-party imports
|
||||
import pandas as pd
|
||||
from tqdm import tqdm
|
||||
|
||||
# Local application imports
|
||||
from .setup_constants import (
|
||||
CSV_PROCESSING_COLUMNS,
|
||||
CARD_TYPES_TO_EXCLUDE,
|
||||
NON_LEGAL_SETS,
|
||||
LEGENDARY_OPTIONS,
|
||||
SORT_CONFIG,
|
||||
FILTER_CONFIG,
|
||||
COLUMN_ORDER,
|
||||
TAGGED_COLUMN_ORDER
|
||||
)
|
||||
from exceptions import (
|
||||
MTGJSONDownloadError,
|
||||
DataFrameProcessingError,
|
||||
ColorFilterError,
|
||||
CommanderValidationError
|
||||
)
|
||||
from type_definitions import CardLibraryDF
|
||||
from settings import FILL_NA_COLUMNS
|
||||
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)
|
||||
|
||||
# Type definitions
|
||||
class FilterRule(TypedDict):
|
||||
"""Type definition for filter rules configuration."""
|
||||
exclude: Optional[List[str]]
|
||||
require: Optional[List[str]]
|
||||
|
||||
class FilterConfig(TypedDict):
|
||||
"""Type definition for complete filter configuration."""
|
||||
layout: FilterRule
|
||||
availability: FilterRule
|
||||
promoTypes: FilterRule
|
||||
securityStamp: FilterRule
|
||||
def download_cards_csv(url: str, output_path: Union[str, Path]) -> None:
|
||||
"""Download cards data from MTGJSON and save to CSV.
|
||||
|
||||
Downloads card data from the specified MTGJSON URL and saves it to a local CSV file.
|
||||
Shows a progress bar during download using tqdm.
|
||||
|
||||
Args:
|
||||
url: URL to download cards data from (typically MTGJSON API endpoint)
|
||||
output_path: Path where the downloaded CSV file will be saved
|
||||
|
||||
Raises:
|
||||
MTGJSONDownloadError: If download fails due to network issues or invalid response
|
||||
|
||||
Example:
|
||||
>>> download_cards_csv('https://mtgjson.com/api/v5/cards.csv', 'cards.csv')
|
||||
"""
|
||||
try:
|
||||
response = requests.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
with tqdm(total=total_size, unit='iB', unit_scale=True, desc='Downloading cards data') as pbar:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
size = f.write(chunk)
|
||||
pbar.update(size)
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f'Failed to download cards data from {url}')
|
||||
raise MTGJSONDownloadError(
|
||||
"Failed to download cards data",
|
||||
url,
|
||||
getattr(e.response, 'status_code', None) if hasattr(e, 'response') else None
|
||||
) from e
|
||||
def check_csv_exists(filepath: Union[str, Path]) -> bool:
|
||||
"""Check if a CSV file exists at the specified path.
|
||||
|
||||
Verifies the existence of a CSV file at the given path. This function is used
|
||||
to determine if card data needs to be downloaded or if it already exists locally.
|
||||
|
||||
Args:
|
||||
filepath: Path to the CSV file to check
|
||||
|
||||
Returns:
|
||||
bool: True if the file exists, False otherwise
|
||||
|
||||
Example:
|
||||
>>> if not check_csv_exists('cards.csv'):
|
||||
... download_cards_csv(MTGJSON_API_URL, 'cards.csv')
|
||||
"""
|
||||
return Path(filepath).is_file()
|
||||
|
||||
def filter_dataframe(df: pd.DataFrame, banned_cards: List[str]) -> pd.DataFrame:
|
||||
"""Apply standard filters to the cards DataFrame using configuration from settings.
|
||||
|
||||
Applies a series of filters to the cards DataFrame based on configuration from settings.py.
|
||||
This includes handling null values, applying basic filters, removing illegal sets and banned cards,
|
||||
and processing special card types.
|
||||
|
||||
Args:
|
||||
df: pandas DataFrame containing card data to filter
|
||||
banned_cards: List of card names that are banned and should be excluded
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: A new DataFrame containing only the cards that pass all filters
|
||||
|
||||
Raises:
|
||||
DataFrameProcessingError: If any filtering operation fails
|
||||
|
||||
Example:
|
||||
>>> filtered_df = filter_dataframe(cards_df, ['Channel', 'Black Lotus'])
|
||||
"""
|
||||
try:
|
||||
logger.info('Starting standard DataFrame filtering')
|
||||
|
||||
# Fill null values according to configuration
|
||||
for col, fill_value in FILL_NA_COLUMNS.items():
|
||||
if col == 'faceName':
|
||||
fill_value = df['name']
|
||||
df[col] = df[col].fillna(fill_value)
|
||||
logger.debug(f'Filled NA values in {col} with {fill_value}')
|
||||
|
||||
# Apply basic filters from configuration
|
||||
filtered_df = df.copy()
|
||||
filter_config: FilterConfig = FILTER_CONFIG # Type hint for configuration
|
||||
for field, rules in filter_config.items():
|
||||
for rule_type, values in rules.items():
|
||||
if rule_type == 'exclude':
|
||||
for value in values:
|
||||
filtered_df = filtered_df[~filtered_df[field].str.contains(value, na=False)]
|
||||
elif rule_type == 'require':
|
||||
for value in values:
|
||||
filtered_df = filtered_df[filtered_df[field].str.contains(value, na=False)]
|
||||
logger.debug(f'Applied {rule_type} filter for {field}: {values}')
|
||||
|
||||
# Remove illegal sets
|
||||
for set_code in NON_LEGAL_SETS:
|
||||
filtered_df = filtered_df[~filtered_df['printings'].str.contains(set_code, na=False)]
|
||||
logger.debug('Removed illegal sets')
|
||||
|
||||
# Remove banned cards
|
||||
for card in banned_cards:
|
||||
filtered_df = filtered_df[~filtered_df['name'].str.contains(card, na=False)]
|
||||
logger.debug('Removed banned cards')
|
||||
|
||||
# Remove special card types
|
||||
for card_type in CARD_TYPES_TO_EXCLUDE:
|
||||
filtered_df = filtered_df[~filtered_df['type'].str.contains(card_type, na=False)]
|
||||
logger.debug('Removed special card types')
|
||||
|
||||
# Select columns, sort, and drop duplicates
|
||||
filtered_df = filtered_df[CSV_PROCESSING_COLUMNS]
|
||||
filtered_df = filtered_df.sort_values(
|
||||
by=SORT_CONFIG['columns'],
|
||||
key=lambda col: col.str.lower() if not SORT_CONFIG['case_sensitive'] else col
|
||||
)
|
||||
filtered_df = filtered_df.drop_duplicates(subset='faceName', keep='first')
|
||||
logger.info('Completed standard DataFrame filtering')
|
||||
|
||||
return filtered_df
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to filter DataFrame: {str(e)}')
|
||||
raise DataFrameProcessingError(
|
||||
"Failed to filter DataFrame",
|
||||
"standard_filtering",
|
||||
str(e)
|
||||
) from e
|
||||
def filter_by_color_identity(df: pd.DataFrame, color_identity: str) -> pd.DataFrame:
|
||||
"""Filter DataFrame by color identity with additional color-specific processing.
|
||||
|
||||
This function extends the base filter_dataframe functionality with color-specific
|
||||
filtering logic. It is used by setup.py's filter_by_color function but provides
|
||||
a more robust and configurable implementation.
|
||||
|
||||
Args:
|
||||
df: DataFrame to filter
|
||||
color_identity: Color identity to filter by (e.g., 'W', 'U,B', 'Colorless')
|
||||
|
||||
Returns:
|
||||
DataFrame filtered by color identity
|
||||
|
||||
Raises:
|
||||
ColorFilterError: If color identity is invalid or filtering fails
|
||||
DataFrameProcessingError: If general filtering operations fail
|
||||
"""
|
||||
try:
|
||||
logger.info(f'Filtering cards for color identity: {color_identity}')
|
||||
|
||||
# Validate color identity
|
||||
with tqdm(total=1, desc='Validating color identity') as pbar:
|
||||
if not isinstance(color_identity, str):
|
||||
raise ColorFilterError(
|
||||
"Invalid color identity type",
|
||||
str(color_identity),
|
||||
"Color identity must be a string"
|
||||
)
|
||||
pbar.update(1)
|
||||
|
||||
# Apply base filtering
|
||||
with tqdm(total=1, desc='Applying base filtering') as pbar:
|
||||
filtered_df = filter_dataframe(df, [])
|
||||
pbar.update(1)
|
||||
|
||||
# Filter by color identity
|
||||
with tqdm(total=1, desc='Filtering by color identity') as pbar:
|
||||
filtered_df = filtered_df[filtered_df['colorIdentity'] == color_identity]
|
||||
logger.debug(f'Applied color identity filter: {color_identity}')
|
||||
pbar.update(1)
|
||||
|
||||
# Additional color-specific processing
|
||||
with tqdm(total=1, desc='Performing color-specific processing') as pbar:
|
||||
# Placeholder for future color-specific processing
|
||||
pbar.update(1)
|
||||
logger.info(f'Completed color identity filtering for {color_identity}')
|
||||
return filtered_df
|
||||
|
||||
except DataFrameProcessingError as e:
|
||||
raise ColorFilterError(
|
||||
"Color filtering failed",
|
||||
color_identity,
|
||||
str(e)
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise ColorFilterError(
|
||||
"Unexpected error during color filtering",
|
||||
color_identity,
|
||||
str(e)
|
||||
) from e
|
||||
|
||||
def process_legendary_cards(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Process and filter legendary cards for commander eligibility with comprehensive validation.
|
||||
|
||||
Args:
|
||||
df: DataFrame containing all cards
|
||||
|
||||
Returns:
|
||||
DataFrame containing only commander-eligible cards
|
||||
|
||||
Raises:
|
||||
CommanderValidationError: If validation fails for legendary status, special cases, or set legality
|
||||
DataFrameProcessingError: If general processing fails
|
||||
"""
|
||||
try:
|
||||
logger.info('Starting commander validation process')
|
||||
|
||||
filtered_df = df.copy()
|
||||
# Step 1: Check legendary status
|
||||
try:
|
||||
with tqdm(total=1, desc='Checking legendary status') as pbar:
|
||||
mask = filtered_df['type'].str.contains('|'.join(LEGENDARY_OPTIONS), na=False)
|
||||
if not mask.any():
|
||||
raise CommanderValidationError(
|
||||
"No legendary creatures found",
|
||||
"legendary_check",
|
||||
"DataFrame contains no cards matching legendary criteria"
|
||||
)
|
||||
filtered_df = filtered_df[mask].copy()
|
||||
logger.debug(f'Found {len(filtered_df)} legendary cards')
|
||||
pbar.update(1)
|
||||
except Exception as e:
|
||||
raise CommanderValidationError(
|
||||
"Legendary status check failed",
|
||||
"legendary_check",
|
||||
str(e)
|
||||
) from e
|
||||
|
||||
# Step 2: Validate special cases
|
||||
try:
|
||||
with tqdm(total=1, desc='Validating special cases') as pbar:
|
||||
special_cases = df['text'].str.contains('can be your commander', na=False)
|
||||
special_commanders = df[special_cases].copy()
|
||||
filtered_df = pd.concat([filtered_df, special_commanders]).drop_duplicates()
|
||||
logger.debug(f'Added {len(special_commanders)} special commander cards')
|
||||
pbar.update(1)
|
||||
except Exception as e:
|
||||
raise CommanderValidationError(
|
||||
"Special case validation failed",
|
||||
"special_cases",
|
||||
str(e)
|
||||
) from e
|
||||
|
||||
# Step 3: Verify set legality
|
||||
try:
|
||||
with tqdm(total=1, desc='Verifying set legality') as pbar:
|
||||
initial_count = len(filtered_df)
|
||||
for set_code in NON_LEGAL_SETS:
|
||||
filtered_df = filtered_df[
|
||||
~filtered_df['printings'].str.contains(set_code, na=False)
|
||||
]
|
||||
removed_count = initial_count - len(filtered_df)
|
||||
logger.debug(f'Removed {removed_count} cards from illegal sets')
|
||||
pbar.update(1)
|
||||
except Exception as e:
|
||||
raise CommanderValidationError(
|
||||
"Set legality verification failed",
|
||||
"set_legality",
|
||||
str(e)
|
||||
) from e
|
||||
logger.info(f'Commander validation complete. {len(filtered_df)} valid commanders found')
|
||||
return filtered_df
|
||||
|
||||
except CommanderValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise DataFrameProcessingError(
|
||||
"Failed to process legendary cards",
|
||||
"commander_processing",
|
||||
str(e)
|
||||
) from e
|
||||
|
||||
def process_card_dataframe(df: CardLibraryDF, batch_size: int = 1000, columns_to_keep: Optional[List[str]] = None,
|
||||
include_commander_cols: bool = False, skip_availability_checks: bool = False) -> CardLibraryDF:
|
||||
"""Process DataFrame with common operations in batches.
|
||||
|
||||
Args:
|
||||
df: DataFrame to process
|
||||
batch_size: Size of batches for processing
|
||||
columns_to_keep: List of columns to keep (default: COLUMN_ORDER)
|
||||
include_commander_cols: Whether to include commander-specific columns
|
||||
skip_availability_checks: Whether to skip availability and security checks (default: False)
|
||||
|
||||
Args:
|
||||
df: DataFrame to process
|
||||
batch_size: Size of batches for processing
|
||||
columns_to_keep: List of columns to keep (default: COLUMN_ORDER)
|
||||
include_commander_cols: Whether to include commander-specific columns
|
||||
|
||||
Returns:
|
||||
CardLibraryDF: Processed DataFrame with standardized structure
|
||||
"""
|
||||
logger.info("Processing card DataFrame...")
|
||||
|
||||
if columns_to_keep is None:
|
||||
columns_to_keep = TAGGED_COLUMN_ORDER.copy()
|
||||
if include_commander_cols:
|
||||
commander_cols = ['printings', 'text', 'power', 'toughness', 'keywords']
|
||||
columns_to_keep.extend(col for col in commander_cols if col not in columns_to_keep)
|
||||
|
||||
# Fill NA values
|
||||
df.loc[:, 'colorIdentity'] = df['colorIdentity'].fillna('Colorless')
|
||||
df.loc[:, 'faceName'] = df['faceName'].fillna(df['name'])
|
||||
|
||||
# Process in batches
|
||||
total_batches = len(df) // batch_size + 1
|
||||
processed_dfs = []
|
||||
|
||||
for i in tqdm(range(total_batches), desc="Processing batches"):
|
||||
start_idx = i * batch_size
|
||||
end_idx = min((i + 1) * batch_size, len(df))
|
||||
batch = df.iloc[start_idx:end_idx].copy()
|
||||
|
||||
if not skip_availability_checks:
|
||||
columns_to_keep = COLUMN_ORDER.copy()
|
||||
logger.debug("Performing column checks...")
|
||||
# Common processing steps
|
||||
batch = batch[batch['availability'].str.contains('paper', na=False)]
|
||||
batch = batch.loc[batch['layout'] != 'reversible_card']
|
||||
batch = batch.loc[batch['promoTypes'] != 'playtest']
|
||||
batch = batch.loc[batch['securityStamp'] != 'heart']
|
||||
batch = batch.loc[batch['securityStamp'] != 'acorn']
|
||||
# Keep only specified columns
|
||||
batch = batch[columns_to_keep]
|
||||
processed_dfs.append(batch)
|
||||
else:
|
||||
logger.debug("Skipping column checks...")
|
||||
|
||||
# Keep only specified columns
|
||||
batch = batch[columns_to_keep]
|
||||
processed_dfs.append(batch)
|
||||
|
||||
# Combine processed batches
|
||||
result = pd.concat(processed_dfs, ignore_index=True)
|
||||
|
||||
# Final processing
|
||||
result.drop_duplicates(subset='faceName', keep='first', inplace=True)
|
||||
result.sort_values(by=['name', 'side'], key=lambda col: col.str.lower(), inplace=True)
|
||||
|
||||
logger.info("DataFrame processing completed")
|
||||
return result
|
445
code/input_handler.py
Normal file
445
code/input_handler.py
Normal file
|
@ -0,0 +1,445 @@
|
|||
"""Input handling and validation module for MTG Python Deckbuilder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, List, Optional, Tuple, Union
|
||||
|
||||
import inquirer.prompt
|
||||
from settings import (
|
||||
COLORS, COLOR_ABRV
|
||||
)
|
||||
from deck_builder.builder_constants import (DEFAULT_MAX_CARD_PRICE,
|
||||
DEFAULT_MAX_DECK_PRICE, DEFAULT_THEME_TAGS, MONO_COLOR_MAP,
|
||||
DUAL_COLOR_MAP, TRI_COLOR_MAP, OTHER_COLOR_MAP
|
||||
)
|
||||
|
||||
from exceptions import (
|
||||
CommanderColorError,
|
||||
CommanderStatsError,
|
||||
CommanderTagError,
|
||||
CommanderThemeError,
|
||||
CommanderTypeError,
|
||||
DeckBuilderError,
|
||||
EmptyInputError,
|
||||
InvalidNumberError,
|
||||
InvalidQuestionTypeError,
|
||||
MaxAttemptsError,
|
||||
PriceError,
|
||||
PriceLimitError,
|
||||
PriceValidationError
|
||||
)
|
||||
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)
|
||||
|
||||
class InputHandler:
|
||||
"""Handles user input operations with validation and error handling.
|
||||
|
||||
This class provides methods for collecting and validating different types
|
||||
of user input including text, numbers, confirmations, and choices.
|
||||
|
||||
Attributes:
|
||||
max_attempts (int): Maximum number of retry attempts for invalid input
|
||||
default_text (str): Default value for text input
|
||||
default_number (float): Default value for number input
|
||||
default_confirm (bool): Default value for confirmation input
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_attempts: int = 3,
|
||||
default_text: str = '',
|
||||
default_number: float = 0.0,
|
||||
default_confirm: bool = True
|
||||
):
|
||||
"""Initialize input handler with configuration.
|
||||
|
||||
Args:
|
||||
max_attempts: Maximum number of retry attempts
|
||||
default_text: Default value for text input
|
||||
default_number: Default value for number input
|
||||
default_confirm: Default value for confirmation input
|
||||
"""
|
||||
self.max_attempts = max_attempts
|
||||
self.default_text = default_text
|
||||
self.default_number = default_number
|
||||
self.default_confirm = default_confirm
|
||||
|
||||
def validate_text(self, result: str) -> bool:
|
||||
"""Validate text input is not empty.
|
||||
|
||||
Args:
|
||||
result: Text input to validate
|
||||
|
||||
Returns:
|
||||
True if text is not empty after stripping whitespace
|
||||
|
||||
Raises:
|
||||
EmptyInputError: If input is empty or whitespace only
|
||||
"""
|
||||
if not result or not result.strip():
|
||||
raise EmptyInputError()
|
||||
return True
|
||||
|
||||
def validate_number(self, result: str) -> float:
|
||||
"""Validate and convert string input to float.
|
||||
|
||||
Args:
|
||||
result: Number input to validate
|
||||
|
||||
Returns:
|
||||
Converted float value
|
||||
|
||||
Raises:
|
||||
InvalidNumberError: If input cannot be converted to float
|
||||
"""
|
||||
try:
|
||||
return float(result)
|
||||
except (ValueError, TypeError):
|
||||
raise InvalidNumberError(result)
|
||||
|
||||
def validate_price(self, result: str) -> Tuple[float, bool]:
|
||||
"""Validate and convert price input to float with format checking.
|
||||
|
||||
Args:
|
||||
result: Price input to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (price value, is_unlimited flag)
|
||||
|
||||
Raises:
|
||||
PriceValidationError: If price format is invalid
|
||||
"""
|
||||
result = result.strip().lower()
|
||||
|
||||
# Check for unlimited budget
|
||||
if result in ['unlimited', 'any']:
|
||||
return (float('inf'), True)
|
||||
|
||||
# Remove currency symbol if present
|
||||
if result.startswith('$'):
|
||||
result = result[1:]
|
||||
|
||||
try:
|
||||
price = float(result)
|
||||
if price < 0:
|
||||
raise PriceValidationError('Price cannot be negative')
|
||||
return (price, False)
|
||||
except ValueError:
|
||||
raise PriceValidationError(f"Invalid price format: '{result}'")
|
||||
|
||||
def validate_price_threshold(self, price: float, threshold: float = DEFAULT_MAX_CARD_PRICE) -> bool:
|
||||
"""Validate price against maximum threshold.
|
||||
|
||||
Args:
|
||||
price: Price value to check
|
||||
threshold: Maximum allowed price (default from settings)
|
||||
|
||||
Returns:
|
||||
True if price is within threshold
|
||||
|
||||
Raises:
|
||||
PriceLimitError: If price exceeds threshold
|
||||
"""
|
||||
if price > threshold and price != float('inf'):
|
||||
raise PriceLimitError('Card', price, threshold)
|
||||
return True
|
||||
|
||||
def validate_confirm(self, result: bool) -> bool:
|
||||
"""Validate confirmation input.
|
||||
|
||||
Args:
|
||||
result: Boolean confirmation input
|
||||
|
||||
Returns:
|
||||
The boolean input value
|
||||
"""
|
||||
return bool(result)
|
||||
|
||||
def questionnaire(
|
||||
self,
|
||||
question_type: str,
|
||||
message: str = '',
|
||||
default_value: Any = None,
|
||||
choices_list: List[str] = None
|
||||
) -> Union[str, float, bool]:
|
||||
"""Present questions to user and handle input validation.
|
||||
|
||||
Args:
|
||||
question_type: Type of question ('Text', 'Number', 'Confirm', 'Choice')
|
||||
message: Question message to display
|
||||
default_value: Default value for the question
|
||||
choices_list: List of choices for Choice type questions
|
||||
|
||||
Returns:
|
||||
Validated user input of appropriate type
|
||||
|
||||
Raises:
|
||||
InvalidQuestionTypeError: If question_type is not supported
|
||||
MaxAttemptsError: If maximum retry attempts are exceeded
|
||||
"""
|
||||
attempts = 0
|
||||
|
||||
while attempts < self.max_attempts:
|
||||
try:
|
||||
if question_type == 'Text':
|
||||
question = [
|
||||
inquirer.Text(
|
||||
'text',
|
||||
message=f'{message}' or 'Enter text',
|
||||
default=default_value or self.default_text
|
||||
)
|
||||
]
|
||||
result = inquirer.prompt(question)['text']
|
||||
if self.validate_text(result):
|
||||
return str(result)
|
||||
|
||||
elif question_type == 'Price':
|
||||
question = [
|
||||
inquirer.Text(
|
||||
'price',
|
||||
message=f'{message}' or 'Enter price (or "unlimited")',
|
||||
default=str(default_value or DEFAULT_MAX_CARD_PRICE)
|
||||
)
|
||||
]
|
||||
result = inquirer.prompt(question)['price']
|
||||
price, is_unlimited = self.validate_price(result)
|
||||
if not is_unlimited:
|
||||
self.validate_price_threshold(price)
|
||||
return float(price)
|
||||
|
||||
elif question_type == 'Number':
|
||||
question = [
|
||||
inquirer.Text(
|
||||
'number',
|
||||
message=f'{message}' or 'Enter number',
|
||||
default=str(default_value or self.default_number)
|
||||
)
|
||||
]
|
||||
result = inquirer.prompt(question)['number']
|
||||
return self.validate_number(result)
|
||||
|
||||
elif question_type == 'Confirm':
|
||||
question = [
|
||||
inquirer.Confirm(
|
||||
'confirm',
|
||||
message=f'{message}' or 'Confirm?',
|
||||
default=default_value if default_value is not None else self.default_confirm
|
||||
)
|
||||
]
|
||||
result = inquirer.prompt(question)['confirm']
|
||||
return self.validate_confirm(result)
|
||||
|
||||
elif question_type == 'Choice':
|
||||
if not choices_list:
|
||||
raise ValueError("Choices list cannot be empty for Choice type")
|
||||
question = [
|
||||
inquirer.List(
|
||||
'selection',
|
||||
message=f'{message}' or 'Select an option',
|
||||
choices=choices_list,
|
||||
carousel=True
|
||||
)
|
||||
]
|
||||
return inquirer.prompt(question)['selection']
|
||||
|
||||
else:
|
||||
raise InvalidQuestionTypeError(question_type)
|
||||
|
||||
except DeckBuilderError as e:
|
||||
logger.warning(f"Input validation failed: {e}")
|
||||
attempts += 1
|
||||
if attempts >= self.max_attempts:
|
||||
raise MaxAttemptsError(
|
||||
self.max_attempts,
|
||||
question_type.lower(),
|
||||
{"last_error": str(e)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in questionnaire: {e}")
|
||||
raise
|
||||
|
||||
raise MaxAttemptsError(self.max_attempts, question_type.lower())
|
||||
|
||||
def validate_commander_type(self, type_line: str) -> str:
|
||||
"""Validate commander type line requirements.
|
||||
|
||||
Args:
|
||||
type_line: Commander's type line to validate
|
||||
|
||||
Returns:
|
||||
Validated type line
|
||||
|
||||
Raises:
|
||||
CommanderTypeError: If type line validation fails
|
||||
"""
|
||||
if not type_line:
|
||||
raise CommanderTypeError("Type line cannot be empty")
|
||||
|
||||
type_line = type_line.strip()
|
||||
|
||||
# Check for legendary creature requirement
|
||||
if not ('Legendary' in type_line and 'Creature' in type_line):
|
||||
# Check for 'can be your commander' text
|
||||
if 'can be your commander' not in type_line.lower():
|
||||
raise CommanderTypeError(
|
||||
"Commander must be a legendary creature or have 'can be your commander' text"
|
||||
)
|
||||
|
||||
return type_line
|
||||
|
||||
def validate_commander_stats(self, stat_name: str, value: str) -> int:
|
||||
"""Validate commander numerical statistics.
|
||||
|
||||
Args:
|
||||
stat_name: Name of the stat (power, toughness, mana value)
|
||||
value: Value to validate
|
||||
|
||||
Returns:
|
||||
Validated integer value
|
||||
|
||||
Raises:
|
||||
CommanderStatsError: If stat validation fails
|
||||
"""
|
||||
try:
|
||||
stat_value = int(value)
|
||||
if stat_value < 0 and stat_name != 'power':
|
||||
raise CommanderStatsError(f"{stat_name} cannot be negative")
|
||||
return stat_value
|
||||
except ValueError:
|
||||
raise CommanderStatsError(
|
||||
f"Invalid {stat_name} value: '{value}'. Must be a number."
|
||||
)
|
||||
|
||||
def _normalize_color_string(self, colors: str) -> str:
|
||||
"""Helper method to standardize color string format.
|
||||
|
||||
Args:
|
||||
colors: Raw color string to normalize
|
||||
|
||||
Returns:
|
||||
Normalized color string
|
||||
"""
|
||||
if not colors:
|
||||
return 'colorless'
|
||||
|
||||
# Remove whitespace and sort color symbols
|
||||
colors = colors.strip().upper()
|
||||
color_symbols = [c for c in colors if c in 'WUBRG']
|
||||
return ', '.join(sorted(color_symbols))
|
||||
|
||||
def _validate_color_combination(self, colors: str) -> bool:
|
||||
"""Helper method to validate color combinations.
|
||||
|
||||
Args:
|
||||
colors: Normalized color string to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if colors == 'colorless':
|
||||
return True
|
||||
|
||||
# Check against valid combinations from settings
|
||||
return (colors in COLOR_ABRV or
|
||||
any(colors in combo for combo in [MONO_COLOR_MAP, DUAL_COLOR_MAP,
|
||||
TRI_COLOR_MAP, OTHER_COLOR_MAP]))
|
||||
|
||||
def validate_color_identity(self, colors: str) -> str:
|
||||
"""Validate commander color identity using settings constants.
|
||||
|
||||
Args:
|
||||
colors: Color identity string to validate
|
||||
|
||||
Returns:
|
||||
Validated color identity string
|
||||
|
||||
Raises:
|
||||
CommanderColorError: If color validation fails
|
||||
"""
|
||||
# Normalize the color string
|
||||
normalized = self._normalize_color_string(colors)
|
||||
|
||||
# Validate the combination
|
||||
if not self._validate_color_combination(normalized):
|
||||
raise CommanderColorError(
|
||||
f"Invalid color identity: '{colors}'. Must be a valid color combination."
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
def validate_commander_colors(self, colors: str) -> str:
|
||||
"""Validate commander color identity.
|
||||
|
||||
Args:
|
||||
colors: Color identity string to validate
|
||||
|
||||
Returns:
|
||||
Validated color identity string
|
||||
|
||||
Raises:
|
||||
CommanderColorError: If color validation fails
|
||||
"""
|
||||
try:
|
||||
return self.validate_color_identity(colors)
|
||||
except CommanderColorError as e:
|
||||
logger.error(f"Color validation failed: {e}")
|
||||
raise
|
||||
def validate_commander_tags(self, tags: List[str]) -> List[str]:
|
||||
"""Validate commander theme tags.
|
||||
|
||||
Args:
|
||||
tags: List of theme tags to validate
|
||||
|
||||
Returns:
|
||||
Validated list of theme tags
|
||||
|
||||
Raises:
|
||||
CommanderTagError: If tag validation fails
|
||||
"""
|
||||
if not isinstance(tags, list):
|
||||
raise CommanderTagError("Tags must be provided as a list")
|
||||
|
||||
validated_tags = []
|
||||
for tag in tags:
|
||||
if not isinstance(tag, str):
|
||||
raise CommanderTagError(f"Invalid tag type: {type(tag)}. Must be string.")
|
||||
tag = tag.strip()
|
||||
if tag:
|
||||
validated_tags.append(tag)
|
||||
|
||||
return validated_tags
|
||||
|
||||
def validate_commander_themes(self, themes: List[str]) -> List[str]:
|
||||
"""Validate commander themes.
|
||||
|
||||
Args:
|
||||
themes: List of themes to validate
|
||||
|
||||
Returns:
|
||||
Validated list of themes
|
||||
|
||||
Raises:
|
||||
CommanderThemeError: If theme validation fails
|
||||
"""
|
||||
if not isinstance(themes, list):
|
||||
raise CommanderThemeError("Themes must be provided as a list")
|
||||
|
||||
validated_themes = []
|
||||
for theme in themes:
|
||||
if not isinstance(theme, str):
|
||||
raise CommanderThemeError(f"Invalid theme type: {type(theme)}. Must be string.")
|
||||
theme = theme.strip()
|
||||
if theme and theme in DEFAULT_THEME_TAGS:
|
||||
validated_themes.append(theme)
|
||||
else:
|
||||
raise CommanderThemeError(f"Invalid theme: '{theme}'")
|
||||
|
||||
return validated_themes
|
29
code/logging_util.py
Normal file
29
code/logging_util.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from settings import os
|
||||
import logging
|
||||
|
||||
# Create logs directory if it doesn't exist
|
||||
if not os.path.exists('logs'):
|
||||
os.makedirs('logs')
|
||||
|
||||
# Logging configuration
|
||||
LOG_DIR = 'logs'
|
||||
LOG_FILE = os.path.join(LOG_DIR, 'deck_builder.log')
|
||||
LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
LOG_LEVEL = logging.INFO
|
||||
|
||||
# Create formatters and handlers
|
||||
# Create a formatter that removes double underscores
|
||||
class NoDunderFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
record.name = record.name.replace("__", "")
|
||||
return super().format(record)
|
||||
|
||||
# File handler
|
||||
file_handler = logging.FileHandler(LOG_FILE, mode='w', encoding='utf-8')
|
||||
file_handler.setFormatter(NoDunderFormatter(LOG_FORMAT))
|
||||
|
||||
# Stream handler
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(NoDunderFormatter(LOG_FORMAT))
|
120
code/main.py
Normal file
120
code/main.py
Normal file
|
@ -0,0 +1,120 @@
|
|||
"""Command-line interface for the MTG Python Deckbuilder application.
|
||||
|
||||
This module provides the main menu and user interaction functionality for the
|
||||
MTG Python Deckbuilder. It handles menu display, user input processing, and
|
||||
routing to different application features like setup, deck building, card info
|
||||
lookup and CSV file tagging.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Standard library imports
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NoReturn, Optional
|
||||
|
||||
# Third-party imports
|
||||
import inquirer.prompt
|
||||
|
||||
# Local imports
|
||||
from deck_builder import DeckBuilder
|
||||
from file_setup import setup
|
||||
from tagging import tagger
|
||||
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)
|
||||
|
||||
# Menu constants
|
||||
MENU_SETUP = 'Setup'
|
||||
MAIN_TAG = 'Tag CSV Files'
|
||||
MENU_BUILD_DECK = 'Build a Deck'
|
||||
MENU_QUIT = 'Quit'
|
||||
|
||||
MENU_CHOICES = [MENU_SETUP, MAIN_TAG, MENU_BUILD_DECK, MENU_QUIT]
|
||||
|
||||
builder = DeckBuilder()
|
||||
def get_menu_choice() -> Optional[str]:
|
||||
"""Display the main menu and get user choice.
|
||||
|
||||
Presents a menu of options to the user using inquirer and returns their selection.
|
||||
Handles potential errors from inquirer gracefully.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The selected menu option or None if cancelled/error occurs
|
||||
|
||||
Example:
|
||||
>>> choice = get_menu_choice()
|
||||
>>> if choice == MENU_SETUP:
|
||||
... setup.setup()
|
||||
"""
|
||||
question = [
|
||||
inquirer.List('menu',
|
||||
choices=MENU_CHOICES,
|
||||
carousel=True)
|
||||
]
|
||||
try:
|
||||
answer = inquirer.prompt(question)
|
||||
return answer['menu'] if answer else None
|
||||
except (KeyError, TypeError) as e:
|
||||
logger.error(f"Error getting menu choice: {e}")
|
||||
return None
|
||||
|
||||
def run_menu() -> NoReturn:
|
||||
"""Main menu loop with improved error handling and logger.
|
||||
|
||||
Provides the main application loop that displays the menu and handles user selections.
|
||||
Creates required directories, processes menu choices, and handles errors gracefully.
|
||||
Never returns normally - exits via sys.exit().
|
||||
|
||||
Returns:
|
||||
NoReturn: Function never returns normally
|
||||
|
||||
Raises:
|
||||
SystemExit: When user selects Quit option
|
||||
|
||||
Example:
|
||||
>>> run_menu()
|
||||
What would you like to do?
|
||||
1. Setup
|
||||
2. Build a Deck
|
||||
3. Get Card Info
|
||||
4. Tag CSV Files
|
||||
5. Quit
|
||||
"""
|
||||
logger.info("Starting MTG Python Deckbuilder")
|
||||
Path('csv_files').mkdir(parents=True, exist_ok=True)
|
||||
Path('deck_files').mkdir(parents=True, exist_ok=True)
|
||||
Path('logs').mkdir(parents=True, exist_ok=True)
|
||||
|
||||
while True:
|
||||
try:
|
||||
print('What would you like to do?')
|
||||
choice = get_menu_choice()
|
||||
|
||||
if choice is None:
|
||||
logger.info("Menu operation cancelled")
|
||||
continue
|
||||
|
||||
logger.info(f"User selected: {choice}")
|
||||
|
||||
match choice:
|
||||
case 'Setup':
|
||||
setup()
|
||||
case 'Tag CSV Files':
|
||||
tagger.run_tagging()
|
||||
case 'Build a Deck':
|
||||
builder.determine_commander()
|
||||
case 'Quit':
|
||||
logger.info("Exiting application")
|
||||
sys.exit(0)
|
||||
case _:
|
||||
logger.warning(f"Invalid menu choice: {choice}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in main menu: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_menu()
|
207
code/price_check.py
Normal file
207
code/price_check.py
Normal file
|
@ -0,0 +1,207 @@
|
|||
"""Price checking functionality for MTG Python Deckbuilder.
|
||||
|
||||
This module provides functionality to check card prices using the Scryfall API
|
||||
through the scrython library. It includes caching and error handling for reliable
|
||||
price lookups.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Standard library imports
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
# Third-party imports
|
||||
import scrython
|
||||
from scrython.cards import Named as ScryfallCard
|
||||
|
||||
# Local imports
|
||||
from exceptions import (
|
||||
PriceAPIError,
|
||||
PriceLimitError,
|
||||
PriceTimeoutError,
|
||||
PriceValidationError
|
||||
)
|
||||
from deck_builder.builder_constants import (
|
||||
BATCH_PRICE_CHECK_SIZE,
|
||||
DEFAULT_MAX_CARD_PRICE,
|
||||
DEFAULT_MAX_DECK_PRICE,
|
||||
DEFAULT_PRICE_DELAY,
|
||||
MAX_PRICE_CHECK_ATTEMPTS,
|
||||
PRICE_CACHE_SIZE,
|
||||
PRICE_CHECK_TIMEOUT,
|
||||
PRICE_TOLERANCE_MULTIPLIER
|
||||
)
|
||||
from type_definitions import PriceCache
|
||||
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)
|
||||
|
||||
class PriceChecker:
|
||||
"""Class for handling MTG card price checking and validation.
|
||||
|
||||
This class provides functionality for checking card prices via the Scryfall API,
|
||||
validating prices against thresholds, and managing price caching.
|
||||
|
||||
Attributes:
|
||||
price_cache (Dict[str, float]): Cache of card prices
|
||||
max_card_price (float): Maximum allowed price per card
|
||||
max_deck_price (float): Maximum allowed total deck price
|
||||
current_deck_price (float): Current total price of the deck
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_card_price: float = DEFAULT_MAX_CARD_PRICE,
|
||||
max_deck_price: float = DEFAULT_MAX_DECK_PRICE
|
||||
) -> None:
|
||||
"""Initialize the PriceChecker.
|
||||
|
||||
Args:
|
||||
max_card_price: Maximum allowed price per card
|
||||
max_deck_price: Maximum allowed total deck price
|
||||
"""
|
||||
self.price_cache: PriceCache = {}
|
||||
self.max_card_price: float = max_card_price
|
||||
self.max_deck_price: float = max_deck_price
|
||||
self.current_deck_price: float = 0.0
|
||||
|
||||
@lru_cache(maxsize=PRICE_CACHE_SIZE)
|
||||
def get_card_price(self, card_name: str, attempts: int = 0) -> float:
|
||||
"""Get the price of a card with caching and retry logic.
|
||||
|
||||
Args:
|
||||
card_name: Name of the card to check
|
||||
attempts: Current number of retry attempts
|
||||
|
||||
Returns:
|
||||
Float price of the card in USD
|
||||
|
||||
Raises:
|
||||
PriceAPIError: If price lookup fails after max attempts
|
||||
PriceTimeoutError: If request times out
|
||||
PriceValidationError: If received price data is invalid
|
||||
"""
|
||||
# Check cache first
|
||||
if card_name in self.price_cache:
|
||||
return self.price_cache[card_name]
|
||||
|
||||
try:
|
||||
# Add delay between API calls
|
||||
time.sleep(DEFAULT_PRICE_DELAY)
|
||||
|
||||
# Make API request with type hints
|
||||
card: ScryfallCard = scrython.cards.Named(fuzzy=card_name, timeout=PRICE_CHECK_TIMEOUT)
|
||||
price: Optional[str] = card.prices('usd')
|
||||
|
||||
# Handle None or empty string cases
|
||||
if price is None or price == "":
|
||||
return 0.0
|
||||
|
||||
# Validate and cache price
|
||||
if isinstance(price, (int, float, str)):
|
||||
try:
|
||||
# Convert string or numeric price to float
|
||||
price_float = float(price)
|
||||
self.price_cache[card_name] = price_float
|
||||
return price_float
|
||||
except ValueError:
|
||||
raise PriceValidationError(card_name, str(price))
|
||||
return 0.0
|
||||
|
||||
except scrython.foundation.ScryfallError as e:
|
||||
if attempts < MAX_PRICE_CHECK_ATTEMPTS:
|
||||
logger.warning(f"Retrying price check for {card_name} (attempt {attempts + 1})")
|
||||
return self.get_card_price(card_name, attempts + 1)
|
||||
raise PriceAPIError(card_name, {"error": str(e)})
|
||||
|
||||
except TimeoutError:
|
||||
raise PriceTimeoutError(card_name, PRICE_CHECK_TIMEOUT)
|
||||
|
||||
except Exception as e:
|
||||
if attempts < MAX_PRICE_CHECK_ATTEMPTS:
|
||||
logger.warning(f"Unexpected error checking price for {card_name}, retrying")
|
||||
return self.get_card_price(card_name, attempts + 1)
|
||||
raise PriceAPIError(card_name, {"error": str(e)})
|
||||
|
||||
def validate_card_price(self, card_name: str, price: float) -> bool | None:
|
||||
"""Validate if a card's price is within allowed limits.
|
||||
|
||||
Args:
|
||||
card_name: Name of the card to validate
|
||||
price: Price to validate
|
||||
|
||||
Returns:
|
||||
True if price is valid, False otherwise
|
||||
|
||||
Raises:
|
||||
PriceLimitError: If price exceeds maximum allowed
|
||||
"""
|
||||
if price > self.max_card_price * PRICE_TOLERANCE_MULTIPLIER:
|
||||
raise PriceLimitError(card_name, price, self.max_card_price)
|
||||
return True
|
||||
|
||||
def validate_deck_price(self) -> bool | None:
|
||||
"""Validate if the current deck price is within allowed limits.
|
||||
|
||||
Returns:
|
||||
True if deck price is valid, False otherwise
|
||||
|
||||
Raises:
|
||||
PriceLimitError: If deck price exceeds maximum allowed
|
||||
"""
|
||||
if self.current_deck_price > self.max_deck_price * PRICE_TOLERANCE_MULTIPLIER:
|
||||
raise PriceLimitError("deck", self.current_deck_price, self.max_deck_price)
|
||||
return True
|
||||
|
||||
def batch_check_prices(self, card_names: List[str]) -> Dict[str, float]:
|
||||
"""Check prices for multiple cards efficiently.
|
||||
|
||||
Args:
|
||||
card_names: List of card names to check prices for
|
||||
|
||||
Returns:
|
||||
Dictionary mapping card names to their prices
|
||||
|
||||
Raises:
|
||||
PriceAPIError: If batch price lookup fails
|
||||
"""
|
||||
results: Dict[str, float] = {}
|
||||
errors: List[Tuple[str, Exception]] = []
|
||||
|
||||
# Process in batches
|
||||
for i in range(0, len(card_names), BATCH_PRICE_CHECK_SIZE):
|
||||
batch = card_names[i:i + BATCH_PRICE_CHECK_SIZE]
|
||||
|
||||
for card_name in batch:
|
||||
try:
|
||||
price = self.get_card_price(card_name)
|
||||
results[card_name] = price
|
||||
except Exception as e:
|
||||
errors.append((card_name, e))
|
||||
logger.error(f"Error checking price for {card_name}: {e}")
|
||||
|
||||
if errors:
|
||||
logger.warning(f"Failed to get prices for {len(errors)} cards")
|
||||
|
||||
return results
|
||||
|
||||
def update_deck_price(self, price: float) -> None:
|
||||
"""Update the current deck price.
|
||||
|
||||
Args:
|
||||
price: Price to add to current deck total
|
||||
"""
|
||||
self.current_deck_price += price
|
||||
logger.debug(f"Updated deck price to ${self.current_deck_price:.2f}")
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the price cache."""
|
||||
self.price_cache.clear()
|
||||
self.get_card_price.cache_clear()
|
||||
logger.info("Price cache cleared")
|
40
code/settings.py
Normal file
40
code/settings.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
# Standard library imports
|
||||
import os
|
||||
from sys import exit
|
||||
from typing import Dict, List, Optional, Final, Tuple, Pattern, Union, Callable
|
||||
|
||||
# Third-party imports
|
||||
|
||||
COLORS = ['colorless', 'white', 'blue', 'black', 'red', 'green',
|
||||
'azorius', 'orzhov', 'selesnya', 'boros', 'dimir',
|
||||
'simic', 'izzet', 'golgari', 'rakdos', 'gruul',
|
||||
'bant', 'esper', 'grixis', 'jund', 'naya',
|
||||
'abzan', 'jeskai', 'mardu', 'sultai', 'temur',
|
||||
'dune', 'glint', 'ink', 'witch', 'yore', 'wubrg',
|
||||
'commander']
|
||||
|
||||
COLOR_ABRV: List[str] = ['Colorless', 'W', 'U', 'B', 'G', 'R',
|
||||
'U, W', 'B, W', 'G, W', 'R, W', 'B, U',
|
||||
'G, U', 'R, U', 'B, G', 'B, R', 'G, R',
|
||||
'G, U, W', 'B, U, W', 'B, R, U', 'B, G, R', 'G, R, W',
|
||||
'B, G, W', 'R, U, W', 'B, R, W', 'B, G, U', 'G, R, U',
|
||||
'B, G, R, W', 'B, G, R, U', 'G, R, U, W', 'B, G, U, W',
|
||||
'B, R, U, W', 'B, G, R, U, W']
|
||||
|
||||
MAIN_MENU_ITEMS: List[str] = ['Build A Deck', 'Setup CSV Files', 'Tag CSV Files', 'Quit']
|
||||
|
||||
SETUP_MENU_ITEMS: List[str] = ['Initial Setup', 'Regenerate CSV', 'Main Menu']
|
||||
|
||||
CSV_DIRECTORY: str = 'csv_files'
|
||||
|
||||
# Configuration for handling null/NA values in DataFrame columns
|
||||
FILL_NA_COLUMNS: Dict[str, Optional[str]] = {
|
||||
'colorIdentity': 'Colorless', # Default color identity for cards without one
|
||||
'faceName': None # Use card's name column value when face name is not available
|
||||
}
|
||||
|
||||
MULTIPLE_COPY_CARDS = ['Dragon\'s Approach', 'Hare Apparent', 'Nazgûl', 'Persistent Petitioners',
|
||||
'Rat Colony', 'Relentless Rats', 'Seven Dwarves', 'Shadowborn Apostle',
|
||||
'Slime Against Humanity', 'Templar Knight']
|
4
code/tagging/__init__.py
Normal file
4
code/tagging/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
This module initializes the tagging package, which contains functionality for
|
||||
handling card tagging and related operations in the MTG deck builder application.
|
||||
"""
|
718
code/tagging/tag_constants.py
Normal file
718
code/tagging/tag_constants.py
Normal file
|
@ -0,0 +1,718 @@
|
|||
from typing import Dict, List, Optional, Final, Tuple, Pattern, Union, Callable
|
||||
|
||||
TRIGGERS: List[str] = ['when', 'whenever', 'at']
|
||||
|
||||
NUM_TO_SEARCH: List[str] = ['a', 'an', 'one', '1', 'two', '2', 'three', '3', 'four','4', 'five', '5',
|
||||
'six', '6', 'seven', '7', 'eight', '8', 'nine', '9', 'ten', '10',
|
||||
'x','one or more']
|
||||
|
||||
|
||||
# Constants for common tag groupings
|
||||
TAG_GROUPS: Dict[str, List[str]] = {
|
||||
"Cantrips": ["Cantrips", "Card Draw", "Spellslinger", "Spells Matter"],
|
||||
"Tokens": ["Token Creation", "Tokens Matter"],
|
||||
"Counters": ["Counters Matter"],
|
||||
"Combat": ["Combat Matters", "Combat Tricks"],
|
||||
"Artifacts": ["Artifacts Matter", "Artifact Tokens"],
|
||||
"Enchantments": ["Enchantments Matter", "Enchantment Tokens"],
|
||||
"Lands": ["Lands Matter"],
|
||||
"Spells": ["Spellslinger", "Spells Matter"]
|
||||
}
|
||||
|
||||
# Common regex patterns
|
||||
PATTERN_GROUPS: Dict[str, Optional[str]] = {
|
||||
"draw": r"draw[s]? a card|draw[s]? one card",
|
||||
"combat": r"attack[s]?|block[s]?|combat damage",
|
||||
"tokens": r"create[s]? .* token|put[s]? .* token",
|
||||
"counters": r"\+1/\+1 counter|\-1/\-1 counter|loyalty counter",
|
||||
"sacrifice": r"sacrifice[s]? .*|sacrificed",
|
||||
"exile": r"exile[s]? .*|exiled",
|
||||
"cost_reduction": r"cost[s]? \{[\d\w]\} less|affinity for|cost[s]? less to cast|chosen type cost|copy cost|from exile cost|from exile this turn cost|from your graveyard cost|has undaunted|have affinity for artifacts|other than your hand cost|spells cost|spells you cast cost|that target .* cost|those spells cost|you cast cost|you pay cost"
|
||||
}
|
||||
|
||||
# Creature/Counter types
|
||||
COUNTER_TYPES: List[str] = [r'\+0/\+1', r'\+0/\+2', r'\+1/\+0', r'\+1/\+2', r'\+2/\+0', r'\+2/\+2',
|
||||
'-0/-1', '-0/-2', '-1/-0', '-1/-2', '-2/-0', '-2/-2',
|
||||
'Acorn', 'Aegis', 'Age', 'Aim', 'Arrow', 'Arrowhead','Awakening',
|
||||
'Bait', 'Blaze', 'Blessing', 'Blight',' Blood', 'Bloddline',
|
||||
'Bloodstain', 'Book', 'Bounty', 'Brain', 'Bribery', 'Brick',
|
||||
'Burden', 'Cage', 'Carrion', 'Charge', 'Coin', 'Collection',
|
||||
'Component', 'Contested', 'Corruption', 'CRANK!', 'Credit',
|
||||
'Croak', 'Corpse', 'Crystal', 'Cube', 'Currency', 'Death',
|
||||
'Defense', 'Delay', 'Depletion', 'Descent', 'Despair', 'Devotion',
|
||||
'Divinity', 'Doom', 'Dream', 'Duty', 'Echo', 'Egg', 'Elixir',
|
||||
'Ember', 'Energy', 'Enlightened', 'Eon', 'Eruption', 'Everything',
|
||||
'Experience', 'Eyeball', 'Eyestalk', 'Fade', 'Fate', 'Feather',
|
||||
'Feeding', 'Fellowship', 'Fetch', 'Filibuster', 'Finality', 'Flame',
|
||||
'Flood', 'Foreshadow', 'Fungus', 'Fury', 'Fuse', 'Gem', 'Ghostform',
|
||||
'Glpyh', 'Gold', 'Growth', 'Hack', 'Harmony', 'Hatching', 'Hatchling',
|
||||
'Healing', 'Hit', 'Hope',' Hone', 'Hoofprint', 'Hour', 'Hourglass',
|
||||
'Hunger', 'Ice', 'Imposter', 'Incarnation', 'Incubation', 'Infection',
|
||||
'Influence', 'Ingenuity', 'Intel', 'Intervention', 'Invitation',
|
||||
'Isolation', 'Javelin', 'Judgment', 'Keyword', 'Ki', 'Kick',
|
||||
'Knickknack', 'Knowledge', 'Landmark', 'Level', 'Loot', 'Lore',
|
||||
'Loyalty', 'Luck', 'Magnet', 'Manabond', 'Manifestation', 'Mannequin',
|
||||
'Mask', 'Matrix', 'Memory', 'Midway', 'Mine', 'Mining', 'Mire',
|
||||
'Music', 'Muster', 'Necrodermis', 'Nest', 'Net', 'Night', 'Oil',
|
||||
'Omen', 'Ore', 'Page', 'Pain', 'Palliation', 'Paralyzing', 'Pause',
|
||||
'Petal', 'Petrification', 'Phyresis', 'Phylatery', 'Pin', 'Plague',
|
||||
'Plot', 'Point', 'Poison', 'Polyp', 'Possession', 'Pressure', 'Prey',
|
||||
'Pupa', 'Quest', 'Rad', 'Rejection', 'Reprieve', 'Rev', 'Revival',
|
||||
'Ribbon', 'Ritual', 'Rope', 'Rust', 'Scream', 'Scroll', 'Shell',
|
||||
'Shield', 'Silver', 'Shred', 'Sleep', 'Sleight', 'Slime', 'Slumber',
|
||||
'Soot', 'Soul', 'Spark', 'Spite', 'Spore', 'Stash', 'Storage',
|
||||
'Story', 'Strife', 'Study', 'Stun', 'Supply', 'Suspect', 'Takeover',
|
||||
'Task', 'Ticket', 'Tide', 'Time', 'Tower', 'Training', 'Trap',
|
||||
'Treasure', 'Unity', 'Unlock', 'Valor', 'Velocity', 'Verse',
|
||||
'Vitality', 'Void', 'Volatile', 'Vortex', 'Vow', 'Voyage', 'Wage',
|
||||
'Winch', 'Wind', 'Wish']
|
||||
|
||||
CREATURE_TYPES: List[str] = ['Advisor', 'Aetherborn', 'Alien', 'Ally', 'Angel', 'Antelope', 'Ape', 'Archer', 'Archon', 'Armadillo',
|
||||
'Army', 'Artificer', 'Assassin', 'Assembly-Worker', 'Astartes', 'Atog', 'Aurochs', 'Automaton',
|
||||
'Avatar', 'Azra', 'Badger', 'Balloon', 'Barbarian', 'Bard', 'Basilisk', 'Bat', 'Bear', 'Beast', 'Beaver',
|
||||
'Beeble', 'Beholder', 'Berserker', 'Bird', 'Blinkmoth', 'Boar', 'Brainiac', 'Bringer', 'Brushwagg',
|
||||
'C\'tan', 'Camarid', 'Camel', 'Capybara', 'Caribou', 'Carrier', 'Cat', 'Centaur', 'Chicken', 'Child',
|
||||
'Chimera', 'Citizen', 'Cleric', 'Clown', 'Cockatrice', 'Construct', 'Coward', 'Coyote', 'Crab', 'Crocodile',
|
||||
'Custodes', 'Cyberman', 'Cyclops', 'Dalek', 'Dauthi', 'Demigod', 'Demon', 'Deserter', 'Detective', 'Devil',
|
||||
'Dinosaur', 'Djinn', 'Doctor', 'Dog', 'Dragon', 'Drake', 'Dreadnought', 'Drone', 'Druid', 'Dryad', 'Dwarf',
|
||||
'Efreet', 'Egg', 'Elder', 'Eldrazi', 'Elemental', 'Elephant', 'Elf', 'Elk', 'Employee', 'Eye', 'Faerie',
|
||||
'Ferret', 'Fish', 'Flagbearer', 'Fox', 'Fractal', 'Frog', 'Fungus', 'Gamer', 'Gargoyle', 'Germ', 'Giant',
|
||||
'Gith', 'Glimmer', 'Gnoll', 'Gnome', 'Goat', 'Goblin', 'God', 'Golem', 'Gorgon', 'Graveborn', 'Gremlin',
|
||||
'Griffin', 'Guest', 'Hag', 'Halfling', 'Hamster', 'Harpy', 'Head', 'Hellion', 'Hero', 'Hippo', 'Hippogriff',
|
||||
'Homarid', 'Homunculus', 'Hornet', 'Horror', 'Horse', 'Human', 'Hydra', 'Hyena', 'Illusion', 'Imp',
|
||||
'Incarnation', 'Inkling', 'Inquisitor', 'Insect', 'Jackal', 'Jellyfish', 'Juggernaut', 'Kavu', 'Kirin',
|
||||
'Kithkin', 'Knight', 'Kobold', 'Kor', 'Kraken', 'Lamia', 'Lammasu', 'Leech', 'Leviathan', 'Lhurgoyf',
|
||||
'Licid', 'Lizard', 'Manticore', 'Masticore', 'Mercenary', 'Merfolk', 'Metathran', 'Minion', 'Minotaur',
|
||||
'Mite', 'Mole', 'Monger', 'Mongoose', 'Monk', 'Monkey', 'Moonfolk', 'Mount', 'Mouse', 'Mutant', 'Myr',
|
||||
'Mystic', 'Naga', 'Nautilus', 'Necron', 'Nephilim', 'Nightmare', 'Nightstalker', 'Ninja', 'Noble', 'Noggle',
|
||||
'Nomad', 'Nymph', 'Octopus', 'Ogre', 'Ooze', 'Orb', 'Orc', 'Orgg', 'Otter', 'Ouphe', 'Ox', 'Oyster', 'Pangolin',
|
||||
'Peasant', 'Pegasus', 'Pentavite', 'Performer', 'Pest', 'Phelddagrif', 'Phoenix', 'Phyrexian', 'Pilot',
|
||||
'Pincher', 'Pirate', 'Plant', 'Porcupine', 'Possum', 'Praetor', 'Primarch', 'Prism', 'Processor', 'Rabbit',
|
||||
'Raccoon', 'Ranger', 'Rat', 'Rebel', 'Reflection', 'Reveler', 'Rhino', 'Rigger', 'Robot', 'Rogue', 'Rukh',
|
||||
'Sable', 'Salamander', 'Samurai', 'Sand', 'Saproling', 'Satyr', 'Scarecrow', 'Scientist', 'Scion', 'Scorpion',
|
||||
'Scout', 'Sculpture', 'Serf', 'Serpent', 'Servo', 'Shade', 'Shaman', 'Shapeshifter', 'Shark', 'Sheep', 'Siren',
|
||||
'Skeleton', 'Skunk', 'Slith', 'Sliver', 'Sloth', 'Slug', 'Snail', 'Snake', 'Soldier', 'Soltari', 'Spawn',
|
||||
'Specter', 'Spellshaper', 'Sphinx', 'Spider', 'Spike', 'Spirit', 'Splinter', 'Sponge', 'Spy', 'Squid',
|
||||
'Squirrel', 'Starfish', 'Surrakar', 'Survivor', 'Synth', 'Teddy', 'Tentacle', 'Tetravite', 'Thalakos',
|
||||
'Thopter', 'Thrull', 'Tiefling', 'Time Lord', 'Toy', 'Treefolk', 'Trilobite', 'Triskelavite', 'Troll',
|
||||
'Turtle', 'Tyranid', 'Unicorn', 'Urzan', 'Vampire', 'Varmint', 'Vedalken', 'Volver', 'Wall', 'Walrus',
|
||||
'Warlock', 'Warrior', 'Wasp', 'Weasel', 'Weird', 'Werewolf', 'Whale', 'Wizard', 'Wolf', 'Wolverine', 'Wombat',
|
||||
'Worm', 'Wraith', 'Wurm', 'Yeti', 'Zombie', 'Zubera']
|
||||
|
||||
NON_CREATURE_TYPES: List[str] = ['Legendary', 'Creature', 'Enchantment', 'Artifact',
|
||||
'Battle', 'Sorcery', 'Instant', 'Land', '-', '—',
|
||||
'Blood', 'Clue', 'Food', 'Gold', 'Incubator',
|
||||
'Junk', 'Map', 'Powerstone', 'Treasure',
|
||||
'Equipment', 'Fortification', 'vehicle',
|
||||
'Bobblehead', 'Attraction', 'Contraption',
|
||||
'Siege',
|
||||
'Aura', 'Background', 'Saga', 'Role', 'Shard',
|
||||
'Cartouche', 'Case', 'Class', 'Curse', 'Rune',
|
||||
'Shrine',
|
||||
'Plains', 'Island', 'Swamp', 'Forest', 'Mountain',
|
||||
'Cave', 'Desert', 'Gate', 'Lair', 'Locus', 'Mine',
|
||||
'Power-Plant', 'Sphere', 'Tower', 'Urza\'s']
|
||||
|
||||
OUTLAW_TYPES: List[str] = ['Assassin', 'Mercenary', 'Pirate', 'Rogue', 'Warlock']
|
||||
|
||||
ENCHANTMENT_TOKENS: List[str] = ['Cursed Role', 'Monster Role', 'Royal Role', 'Sorcerer Role',
|
||||
'Virtuous Role', 'Wicked Role', 'Young Hero Role', 'Shard']
|
||||
ARTIFACT_TOKENS: List[str] = ['Blood', 'Clue', 'Food', 'Gold', 'Incubator',
|
||||
'Junk','Map','Powerstone', 'Treasure']
|
||||
|
||||
# Constants for DataFrame validation and processing
|
||||
REQUIRED_COLUMNS: List[str] = [
|
||||
'name', 'faceName', 'edhrecRank', 'colorIdentity', 'colors',
|
||||
'manaCost', 'manaValue', 'type', 'creatureTypes', 'text',
|
||||
'power', 'toughness', 'keywords', 'themeTags', 'layout', 'side'
|
||||
]
|
||||
|
||||
# Mapping of card types to their corresponding theme tags
|
||||
TYPE_TAG_MAPPING: List[str] = {
|
||||
'Artifact': ['Artifacts Matter'],
|
||||
'Battle': ['Battles Matter'],
|
||||
#'Creature': [],
|
||||
'Enchantment': ['Enchantments Matter'],
|
||||
'Equipment': ['Equipment', 'Voltron'],
|
||||
'Aura': ['Auras', 'Voltron'],
|
||||
'Instant': ['Spells Matter', 'Spellslinger'],
|
||||
'Land': ['Lands Matter'],
|
||||
'Planeswalker': ['Superfriends'],
|
||||
'Sorcery': ['Spells Matter', 'Spellslinger']
|
||||
}
|
||||
|
||||
# Constants for draw-related functionality
|
||||
DRAW_RELATED_TAGS: List[str] = [
|
||||
'Card Draw', # General card draw effects
|
||||
'Conditional Draw', # Draw effects with conditions/triggers
|
||||
'Cycling', # Cycling and similar discard-to-draw effects
|
||||
'Life to Draw', # Draw effects that require paying life
|
||||
'Loot', # Draw + discard effects
|
||||
'Replacement Draw', # Effects that modify or replace draws
|
||||
'Sacrifice to Draw', # Draw effects requiring sacrificing permanents
|
||||
'Unconditional Draw' # Pure card draw without conditions
|
||||
]
|
||||
|
||||
# Text patterns that exclude cards from being tagged as unconditional draw
|
||||
DRAW_EXCLUSION_PATTERNS: List[str] = [
|
||||
'annihilator', # Eldrazi mechanic that can match 'draw' patterns
|
||||
'ravenous', # Keyword that can match 'draw' patterns
|
||||
]
|
||||
|
||||
# Equipment-related constants
|
||||
EQUIPMENT_EXCLUSIONS: List[str] = [
|
||||
'Bruenor Battlehammer', # Equipment cost reduction
|
||||
'Nazahn, Revered Bladesmith', # Equipment tutor
|
||||
'Stonehewer Giant', # Equipment tutor
|
||||
]
|
||||
|
||||
EQUIPMENT_SPECIFIC_CARDS: List[str] = [
|
||||
'Ardenn, Intrepid Archaeologist', # Equipment movement
|
||||
'Armory Automaton', # Mass equip ability
|
||||
'Brass Squire', # Free equip ability
|
||||
'Danitha Capashen, Paragon', # Equipment cost reduction
|
||||
'Halvar, God of Battle', # Equipment movement
|
||||
'Kemba, Kha Regent', # Equipment payoff
|
||||
'Kosei, Penitent Warlord', # Wants to be eequipped
|
||||
'Puresteel Paladin', # Equipment draw engine
|
||||
'Reyav, Master Smith', # Equipment combat boost
|
||||
'Sram, Senior Edificer', # Equipment card draw
|
||||
'Valduk, Keeper of the Flame' # Equipment token creation
|
||||
]
|
||||
|
||||
EQUIPMENT_RELATED_TAGS: List[str] = [
|
||||
'Equipment', # Base equipment tag
|
||||
'Equipment Matters', # Cards that care about equipment
|
||||
'Voltron', # Commander-focused equipment strategy
|
||||
'Artifacts Matter', # Equipment are artifacts
|
||||
'Warriors Matter', # Common equipment tribal synergy
|
||||
'Knights Matter' # Common equipment tribal synergy
|
||||
]
|
||||
|
||||
EQUIPMENT_TEXT_PATTERNS: List[str] = [
|
||||
'attach', # Equipment attachment
|
||||
'equip', # Equipment keyword
|
||||
'equipped', # Equipment state
|
||||
'equipment', # Equipment type
|
||||
'unattach', # Equipment removal
|
||||
'unequip', # Equipment removal
|
||||
]
|
||||
|
||||
# Aura-related constants
|
||||
AURA_SPECIFIC_CARDS: List[str] = [
|
||||
'Ardenn, Intrepid Archaeologist', # Aura movement
|
||||
'Calix, Guided By Fate', # Create duplicate Auras
|
||||
'Gilwain, Casting Director', # Creates role tokens
|
||||
'Ivy, Gleeful Spellthief', # Copies spells that have single target
|
||||
'Killian, Ink Duelist', # Targetted spell cost reduction
|
||||
]
|
||||
|
||||
# Constants for Voltron strategy
|
||||
VOLTRON_COMMANDER_CARDS: List[str] = [
|
||||
'Akiri, Line-Slinger',
|
||||
'Ardenn, Intrepid Archaeologist',
|
||||
'Bruna, Light of Alabaster',
|
||||
'Danitha Capashen, Paragon',
|
||||
'Greven, Predator Captain',
|
||||
'Halvar, God of Battle',
|
||||
'Kaldra Compleat',
|
||||
'Kemba, Kha Regent',
|
||||
'Light-Paws, Emperor\'s Voice',
|
||||
'Nahiri, the Lithomancer',
|
||||
'Rafiq of the Many',
|
||||
'Reyav, Master Smith',
|
||||
'Rograkh, Son of Rohgahh',
|
||||
'Sram, Senior Edificer',
|
||||
'Syr Gwyn, Hero of Ashvale',
|
||||
'Tiana, Ship\'s Caretaker',
|
||||
'Uril, the Miststalker',
|
||||
'Valduk, Keeper of the Flame',
|
||||
'Wyleth, Soul of Steel'
|
||||
]
|
||||
|
||||
VOLTRON_PATTERNS: List[str] = [
|
||||
'attach',
|
||||
'aura you control',
|
||||
'enchant creature',
|
||||
'enchanted creature',
|
||||
'equipped creature',
|
||||
'equipment you control',
|
||||
'fortify',
|
||||
'living weapon',
|
||||
'reconfigure'
|
||||
]
|
||||
|
||||
# Constants for lands matter functionality
|
||||
LANDS_MATTER_PATTERNS: Dict[str, List[str]] = {
|
||||
'land_play': [
|
||||
'play a land',
|
||||
'play an additional land',
|
||||
'play two additional lands',
|
||||
'play lands from',
|
||||
'put a land card',
|
||||
'put a basic land card'
|
||||
],
|
||||
'land_search': [
|
||||
'search your library for a basic land card',
|
||||
'search your library for a land card',
|
||||
'search your library for up to two basic land',
|
||||
'search their library for a basic land card'
|
||||
],
|
||||
'land_state': [
|
||||
'land enters',
|
||||
'land card is put into your graveyard',
|
||||
'number of lands you control',
|
||||
'one or more land cards',
|
||||
'sacrifice a land',
|
||||
'target land'
|
||||
]
|
||||
}
|
||||
|
||||
DOMAIN_PATTERNS: List[str] = {
|
||||
'keyword': ['domain'],
|
||||
'text': ['basic land types among lands you control']
|
||||
}
|
||||
|
||||
LANDFALL_PATTERNS: List[str] = {
|
||||
'keyword': ['landfall'],
|
||||
'triggers': [
|
||||
'whenever a land enters the battlefield under your control',
|
||||
'when a land enters the battlefield under your control'
|
||||
]
|
||||
}
|
||||
|
||||
LANDWALK_PATTERNS: List[str] = {
|
||||
'basic': [
|
||||
'plainswalker',
|
||||
'islandwalk',
|
||||
'swampwalk',
|
||||
'mountainwalk',
|
||||
'forestwalk'
|
||||
],
|
||||
'nonbasic': [
|
||||
'nonbasic landwalk',
|
||||
'landwalk'
|
||||
]
|
||||
}
|
||||
|
||||
LAND_TYPES: List[str] = [
|
||||
# Basic lands
|
||||
'Plains', 'Island', 'Swamp', 'Mountain', 'Forest',
|
||||
# Special lands
|
||||
'Cave', 'Desert', 'Gate', 'Lair', 'Locus', 'Mine',
|
||||
'Power-Plant', 'Sphere', 'Tower', 'Urza\'s'
|
||||
]
|
||||
|
||||
LANDS_MATTER_SPECIFIC_CARDS: List[str] = [
|
||||
'Abundance',
|
||||
'Archdruid\'s Charm',
|
||||
'Archelos, Lagoon Mystic',
|
||||
'Catacylsmic Prospecting',
|
||||
'Coiling Oracle',
|
||||
'Disorienting Choice',
|
||||
'Eerie Ultimatum',
|
||||
'Gitrog Monster',
|
||||
'Mana Reflection',
|
||||
'Nahiri\'s Lithoforming',
|
||||
'Nine-fingers Keene',
|
||||
'Open the Way',
|
||||
'Realms Uncharted',
|
||||
'Reshape the Earth',
|
||||
'Scapeshift',
|
||||
'Yarok, the Desecrated',
|
||||
'Wonderscape Sage'
|
||||
]
|
||||
|
||||
# Constants for aristocrats functionality
|
||||
ARISTOCRAT_TEXT_PATTERNS: List[str] = [
|
||||
'another creature dies',
|
||||
'creature dies',
|
||||
'creature dying',
|
||||
'creature you control dies',
|
||||
'creature you own dies',
|
||||
'dies this turn',
|
||||
'dies, create',
|
||||
'dies, draw',
|
||||
'dies, each opponent',
|
||||
'dies, exile',
|
||||
'dies, put',
|
||||
'dies, return',
|
||||
'dies, sacrifice',
|
||||
'dies, you',
|
||||
'has blitz',
|
||||
'have blitz',
|
||||
'permanents were sacrificed',
|
||||
'sacrifice a creature',
|
||||
'sacrifice another',
|
||||
'sacrifice another creature',
|
||||
'sacrifice a nontoken',
|
||||
'sacrifice a permanent',
|
||||
'sacrifice another nontoken',
|
||||
'sacrifice another permanent',
|
||||
'sacrifice another token',
|
||||
'sacrifices a creature',
|
||||
'sacrifices another',
|
||||
'sacrifices another creature',
|
||||
'sacrifices another nontoken',
|
||||
'sacrifices another permanent',
|
||||
'sacrifices another token',
|
||||
'sacrifices a nontoken',
|
||||
'sacrifices a permanent',
|
||||
'sacrifices a token',
|
||||
'when this creature dies',
|
||||
'whenever a food',
|
||||
'whenever you sacrifice'
|
||||
]
|
||||
|
||||
ARISTOCRAT_SPECIFIC_CARDS: List[str] = [
|
||||
'Ashnod, Flesh Mechanist',
|
||||
'Blood Artist',
|
||||
'Butcher of Malakir',
|
||||
'Chatterfang, Squirrel General',
|
||||
'Cruel Celebrant',
|
||||
'Dictate of Erebos',
|
||||
'Endrek Sahr, Master Breeder',
|
||||
'Gisa, Glorious Resurrector',
|
||||
'Grave Pact',
|
||||
'Grim Haruspex',
|
||||
'Judith, the Scourge Diva',
|
||||
'Korvold, Fae-Cursed King',
|
||||
'Mayhem Devil',
|
||||
'Midnight Reaper',
|
||||
'Mikaeus, the Unhallowed',
|
||||
'Pitiless Plunderer',
|
||||
'Poison-Tip Archer',
|
||||
'Savra, Queen of the Golgari',
|
||||
'Sheoldred, the Apocalypse',
|
||||
'Syr Konrad, the Grim',
|
||||
'Teysa Karlov',
|
||||
'Viscera Seer',
|
||||
'Yawgmoth, Thran Physician',
|
||||
'Zulaport Cutthroat'
|
||||
]
|
||||
|
||||
ARISTOCRAT_EXCLUSION_PATTERNS: List[str] = [
|
||||
'blocking enchanted',
|
||||
'blocking it',
|
||||
'blocked by',
|
||||
'end the turn',
|
||||
'from your graveyard',
|
||||
'from your hand',
|
||||
'from your library',
|
||||
'into your hand'
|
||||
]
|
||||
|
||||
# Constants for stax functionality
|
||||
STAX_TEXT_PATTERNS: List[str] = [
|
||||
'an opponent controls'
|
||||
'can\'t attack',
|
||||
'can\'t be cast',
|
||||
'can\'t be activated',
|
||||
'can\'t cast spells',
|
||||
'can\'t enter',
|
||||
'can\'t search',
|
||||
'can\'t untap',
|
||||
'don\'t untap',
|
||||
'don\'t cause abilities',
|
||||
'each other player\'s',
|
||||
'each player\'s upkeep',
|
||||
'opponent would search',
|
||||
'opponents cast cost',
|
||||
'opponents can\'t',
|
||||
'opponents control',
|
||||
'opponents control can\'t',
|
||||
'opponents control enter tapped',
|
||||
'spells cost {1} more',
|
||||
'spells cost {2} more',
|
||||
'spells cost {3} more',
|
||||
'spells cost {4} more',
|
||||
'spells cost {5} more',
|
||||
'that player doesn\'t',
|
||||
'unless that player pays',
|
||||
'you control your opponent',
|
||||
'you gain protection'
|
||||
]
|
||||
|
||||
STAX_SPECIFIC_CARDS: List[str] = [
|
||||
'Archon of Emeria',
|
||||
'Drannith Magistrate',
|
||||
'Ethersworn Canonist',
|
||||
'Grand Arbiter Augustin IV',
|
||||
'Hokori, Dust Drinker',
|
||||
'Kataki, War\'s Wage',
|
||||
'Lavinia, Azorius Renegade',
|
||||
'Leovold, Emissary of Trest',
|
||||
'Magus of the Moon',
|
||||
'Narset, Parter of Veils',
|
||||
'Opposition Agent',
|
||||
'Rule of Law',
|
||||
'Sanctum Prelate',
|
||||
'Thalia, Guardian of Thraben',
|
||||
'Winter Orb'
|
||||
]
|
||||
|
||||
STAX_EXCLUSION_PATTERNS: List[str] = [
|
||||
'blocking enchanted',
|
||||
'blocking it',
|
||||
'blocked by',
|
||||
'end the turn',
|
||||
'from your graveyard',
|
||||
'from your hand',
|
||||
'from your library',
|
||||
'into your hand'
|
||||
]
|
||||
|
||||
# Constants for removal functionality
|
||||
REMOVAL_TEXT_PATTERNS: List[str] = [
|
||||
'destroy target',
|
||||
'destroys target',
|
||||
'exile target',
|
||||
'exiles target',
|
||||
'sacrifices target',
|
||||
'return target.*to.*hand',
|
||||
'returns target.*to.*hand'
|
||||
]
|
||||
|
||||
REMOVAL_SPECIFIC_CARDS: List[str] = ['from.*graveyard.*hand']
|
||||
|
||||
REMOVAL_EXCLUSION_PATTERNS: List[str] = []
|
||||
|
||||
REMOVAL_KEYWORDS: List[str] = []
|
||||
|
||||
# Constants for counterspell functionality
|
||||
COUNTERSPELL_TEXT_PATTERNS: List[str] = [
|
||||
'control counters a',
|
||||
'counter target',
|
||||
'counter that spell',
|
||||
'counter all',
|
||||
'counter each',
|
||||
'counter the next',
|
||||
'counters a spell',
|
||||
'counters target',
|
||||
'return target spell',
|
||||
'exile target spell',
|
||||
'counter unless',
|
||||
'unless its controller pays'
|
||||
]
|
||||
|
||||
COUNTERSPELL_SPECIFIC_CARDS: List[str] = [
|
||||
'Arcane Denial',
|
||||
'Counterspell',
|
||||
"Dovin's Veto",
|
||||
'Force of Will',
|
||||
'Mana Drain',
|
||||
'Mental Misstep',
|
||||
'Mindbreak Trap',
|
||||
'Mystic Confluence',
|
||||
'Pact of Negation',
|
||||
'Swan Song'
|
||||
]
|
||||
|
||||
COUNTERSPELL_EXCLUSION_PATTERNS: List[str] = [
|
||||
'counter on',
|
||||
'counter from',
|
||||
'remove a counter',
|
||||
'move a counter',
|
||||
'distribute counter',
|
||||
'proliferate'
|
||||
]
|
||||
|
||||
# Constants for theft functionality
|
||||
THEFT_TEXT_PATTERNS: List[str] = [
|
||||
'cast a spell you don\'t own',
|
||||
'cast but don\'t own',
|
||||
'cost to cast this spell, sacrifice',
|
||||
'control but don\'t own',
|
||||
'exile top of target player\'s library',
|
||||
'exile top of each player\'s library',
|
||||
'gain control of',
|
||||
'target opponent\'s library',
|
||||
'that player\'s library',
|
||||
'you control enchanted creature'
|
||||
]
|
||||
|
||||
THEFT_SPECIFIC_CARDS: List[str] = [
|
||||
'Adarkar Valkyrie',
|
||||
'Captain N\'gathrod',
|
||||
'Hostage Taker',
|
||||
'Siphon Insight',
|
||||
'Thief of Sanity',
|
||||
'Xanathar, Guild Kingpin',
|
||||
'Zara, Renegade Recruiter'
|
||||
]
|
||||
|
||||
# Constants for big mana functionality
|
||||
BIG_MANA_TEXT_PATTERNS: List[str] = [
|
||||
'add {w}{u}{b}{r}{g}',
|
||||
'card onto the battlefield',
|
||||
'control with power [3-5] or greater',
|
||||
'creature with power [3-5] or greater',
|
||||
'double the power',
|
||||
'from among them onto the battlefield',
|
||||
'from among them without paying',
|
||||
'hand onto the battlefield',
|
||||
'mana, add one mana',
|
||||
'mana, it produces twice',
|
||||
'mana, it produces three',
|
||||
'mana, its controller adds',
|
||||
'pay {w}{u}{b}{r}{g}',
|
||||
'spell with power 5 or greater',
|
||||
'value [5-7] or greater',
|
||||
'you may cast it without paying'
|
||||
]
|
||||
|
||||
BIG_MANA_SPECIFIC_CARDS: List[str] = [
|
||||
'Akroma\'s Memorial',
|
||||
'Apex Devastator',
|
||||
'Apex of Power',
|
||||
'Brass\'s Bounty',
|
||||
'Cabal Coffers',
|
||||
'Caged Sun',
|
||||
'Doubling Cube',
|
||||
'Forsaken Monument',
|
||||
'Guardian Project',
|
||||
'Mana Reflection',
|
||||
'Nyxbloom Ancient',
|
||||
'Omniscience',
|
||||
'One with the Multiverse',
|
||||
'Portal to Phyrexia',
|
||||
'Vorinclex, Voice of Hunger'
|
||||
]
|
||||
|
||||
BIG_MANA_KEYWORDS: List[str] = [
|
||||
'Cascade',
|
||||
'Convoke',
|
||||
'Discover',
|
||||
'Emerge',
|
||||
'Improvise',
|
||||
'Surge'
|
||||
]
|
||||
|
||||
# Constants for board wipe effects
|
||||
BOARD_WIPE_TEXT_PATTERNS: Dict[str, List[str]] = {
|
||||
'mass_destruction': [
|
||||
'destroy all',
|
||||
'destroy each',
|
||||
'destroy the rest',
|
||||
'destroys all',
|
||||
'destroys each',
|
||||
'destroys the rest'
|
||||
],
|
||||
'mass_exile': [
|
||||
'exile all',
|
||||
'exile each',
|
||||
'exile the rest',
|
||||
'exiles all',
|
||||
'exiles each',
|
||||
'exiles the rest'
|
||||
],
|
||||
'mass_bounce': [
|
||||
'return all',
|
||||
'return each',
|
||||
'put all creatures',
|
||||
'returns all',
|
||||
'returns each',
|
||||
'puts all creatures'
|
||||
],
|
||||
'mass_sacrifice': [
|
||||
'sacrifice all',
|
||||
'sacrifice each',
|
||||
'sacrifice the rest',
|
||||
'sacrifices all',
|
||||
'sacrifices each',
|
||||
'sacrifices the rest'
|
||||
],
|
||||
'mass_damage': [
|
||||
'deals damage to each',
|
||||
'deals damage to all',
|
||||
'deals X damage to each',
|
||||
'deals X damage to all',
|
||||
'deals that much damage to each',
|
||||
'deals that much damage to all'
|
||||
]
|
||||
}
|
||||
|
||||
BOARD_WIPE_SPECIFIC_CARDS: List[str] = [
|
||||
'Akroma\'s Vengeance',
|
||||
'All Is Dust',
|
||||
'Austere Command',
|
||||
'Blasphemous Act',
|
||||
'Cleansing Nova',
|
||||
'Cyclonic Rift',
|
||||
'Damnation',
|
||||
'Day of Judgment',
|
||||
'Decree of Pain',
|
||||
'Devastation Tide',
|
||||
'Evacuation',
|
||||
'Extinction Event',
|
||||
'Farewell',
|
||||
'Hour of Devastation',
|
||||
'In Garruk\'s Wake',
|
||||
'Living Death',
|
||||
'Living End',
|
||||
'Merciless Eviction',
|
||||
'Nevinyrral\'s Disk',
|
||||
'Oblivion Stone',
|
||||
'Planar Cleansing',
|
||||
'Ravnica at War',
|
||||
'Shatter the Sky',
|
||||
'Supreme Verdict',
|
||||
'Terminus',
|
||||
'Time Wipe',
|
||||
'Toxic Deluge',
|
||||
'Vanquish the Horde',
|
||||
'Wrath of God'
|
||||
]
|
||||
|
||||
BOARD_WIPE_EXCLUSION_PATTERNS: List[str] = [
|
||||
'blocking enchanted',
|
||||
'blocking it',
|
||||
'blocked by',
|
||||
'end the turn',
|
||||
'from your graveyard',
|
||||
'from your hand',
|
||||
'from your library',
|
||||
'into your hand',
|
||||
'target player\'s library',
|
||||
'that player\'s library'
|
||||
]
|
||||
|
||||
# Constants for topdeck manipulation
|
||||
TOPDECK_TEXT_PATTERNS: List[str] = [
|
||||
'from the top',
|
||||
'look at the top',
|
||||
'reveal the top',
|
||||
'scries',
|
||||
'surveils',
|
||||
'top of your library',
|
||||
'you scry',
|
||||
'you surveil'
|
||||
]
|
||||
|
||||
TOPDECK_KEYWORDS: List[str] = [
|
||||
'Miracle',
|
||||
'Scry',
|
||||
'Surveil'
|
||||
]
|
||||
|
||||
TOPDECK_SPECIFIC_CARDS: List[str] = [
|
||||
'Aminatou, the Fateshifter',
|
||||
'Brainstorm',
|
||||
'Counterbalance',
|
||||
'Delver of Secrets',
|
||||
'Jace, the Mind Sculptor',
|
||||
'Lantern of Insight',
|
||||
'Melek, Izzet Paragon',
|
||||
'Mystic Forge',
|
||||
'Sensei\'s Divining Top',
|
||||
'Soothsaying',
|
||||
'Temporal Mastery',
|
||||
'Vampiric Tutor'
|
||||
]
|
||||
|
||||
TOPDECK_EXCLUSION_PATTERNS: List[str] = [
|
||||
'from the top of target player\'s library',
|
||||
'from the top of their library',
|
||||
'look at the top card of target player\'s library',
|
||||
'reveal the top card of target player\'s library'
|
||||
]
|
368
code/tagging/tag_utils.py
Normal file
368
code/tagging/tag_utils.py
Normal file
|
@ -0,0 +1,368 @@
|
|||
"""Utility module for tag manipulation and pattern matching in card data processing.
|
||||
|
||||
This module provides a collection of functions for working with card tags, types, and text patterns
|
||||
in a card game context. It includes utilities for:
|
||||
|
||||
- Creating boolean masks for filtering cards based on various criteria
|
||||
- Manipulating and extracting card types
|
||||
- Managing theme tags and card attributes
|
||||
- Pattern matching in card text and types
|
||||
- Mass effect detection (damage, removal, etc.)
|
||||
|
||||
The module is designed to work with pandas DataFrames containing card data and provides
|
||||
vectorized operations for efficient processing of large card collections.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Standard library imports
|
||||
import re
|
||||
from typing import List, Set, Union, Any
|
||||
|
||||
# Third-party imports
|
||||
import pandas as pd
|
||||
|
||||
# Local application imports
|
||||
from . import tag_constants
|
||||
|
||||
def pluralize(word: str) -> str:
|
||||
"""Convert a word to its plural form using basic English pluralization rules.
|
||||
|
||||
Args:
|
||||
word: The singular word to pluralize
|
||||
|
||||
Returns:
|
||||
The pluralized word
|
||||
"""
|
||||
if word.endswith('y'):
|
||||
return word[:-1] + 'ies'
|
||||
elif word.endswith(('s', 'sh', 'ch', 'x', 'z')):
|
||||
return word + 'es'
|
||||
elif word.endswith(('f')):
|
||||
return word[:-1] + 'ves'
|
||||
else:
|
||||
return word + 's'
|
||||
|
||||
def sort_list(items: Union[List[Any], pd.Series]) -> Union[List[Any], pd.Series]:
|
||||
"""Sort a list or pandas Series in ascending order.
|
||||
|
||||
Args:
|
||||
items: List or Series to sort
|
||||
|
||||
Returns:
|
||||
Sorted list or Series
|
||||
"""
|
||||
if isinstance(items, (list, pd.Series)):
|
||||
return sorted(items) if isinstance(items, list) else items.sort_values()
|
||||
return items
|
||||
|
||||
def create_type_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True) -> pd.Series[bool]:
|
||||
"""Create a boolean mask for rows where type matches one or more patterns.
|
||||
|
||||
Args:
|
||||
df: DataFrame to search
|
||||
type_text: Type text pattern(s) to match. Can be a single string or list of strings.
|
||||
regex: Whether to treat patterns as regex expressions (default: True)
|
||||
|
||||
Returns:
|
||||
Boolean Series indicating matching rows
|
||||
|
||||
Raises:
|
||||
ValueError: If type_text is empty or None
|
||||
TypeError: If type_text is not a string or list of strings
|
||||
"""
|
||||
if not type_text:
|
||||
raise ValueError("type_text cannot be empty or None")
|
||||
|
||||
if isinstance(type_text, str):
|
||||
type_text = [type_text]
|
||||
elif not isinstance(type_text, list):
|
||||
raise TypeError("type_text must be a string or list of strings")
|
||||
|
||||
if regex:
|
||||
pattern = '|'.join(f'{p}' for p in type_text)
|
||||
return df['type'].str.contains(pattern, case=False, na=False, regex=True)
|
||||
else:
|
||||
masks = [df['type'].str.contains(p, case=False, na=False, regex=False) for p in type_text]
|
||||
return pd.concat(masks, axis=1).any(axis=1)
|
||||
|
||||
def create_text_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True, combine_with_or: bool = True) -> pd.Series[bool]:
|
||||
"""Create a boolean mask for rows where text matches one or more patterns.
|
||||
|
||||
Args:
|
||||
df: DataFrame to search
|
||||
type_text: Type text pattern(s) to match. Can be a single string or list of strings.
|
||||
regex: Whether to treat patterns as regex expressions (default: True)
|
||||
combine_with_or: Whether to combine multiple patterns with OR (True) or AND (False)
|
||||
|
||||
Returns:
|
||||
Boolean Series indicating matching rows
|
||||
|
||||
Raises:
|
||||
ValueError: If type_text is empty or None
|
||||
TypeError: If type_text is not a string or list of strings
|
||||
"""
|
||||
if not type_text:
|
||||
raise ValueError("type_text cannot be empty or None")
|
||||
|
||||
if isinstance(type_text, str):
|
||||
type_text = [type_text]
|
||||
elif not isinstance(type_text, list):
|
||||
raise TypeError("type_text must be a string or list of strings")
|
||||
|
||||
if regex:
|
||||
pattern = '|'.join(f'{p}' for p in type_text)
|
||||
return df['text'].str.contains(pattern, case=False, na=False, regex=True)
|
||||
else:
|
||||
masks = [df['text'].str.contains(p, case=False, na=False, regex=False) for p in type_text]
|
||||
if combine_with_or:
|
||||
return pd.concat(masks, axis=1).any(axis=1)
|
||||
else:
|
||||
return pd.concat(masks, axis=1).all(axis=1)
|
||||
|
||||
def create_keyword_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True) -> pd.Series[bool]:
|
||||
"""Create a boolean mask for rows where keyword text matches one or more patterns.
|
||||
|
||||
Args:
|
||||
df: DataFrame to search
|
||||
type_text: Type text pattern(s) to match. Can be a single string or list of strings.
|
||||
regex: Whether to treat patterns as regex expressions (default: True)
|
||||
|
||||
Returns:
|
||||
Boolean Series indicating matching rows. For rows with empty/null keywords,
|
||||
returns False.
|
||||
|
||||
Raises:
|
||||
ValueError: If type_text is empty or None
|
||||
TypeError: If type_text is not a string or list of strings
|
||||
ValueError: If required 'keywords' column is missing from DataFrame
|
||||
"""
|
||||
# Validate required columns
|
||||
validate_dataframe_columns(df, {'keywords'})
|
||||
|
||||
# Handle empty DataFrame case
|
||||
if len(df) == 0:
|
||||
return pd.Series([], dtype=bool)
|
||||
|
||||
if not type_text:
|
||||
raise ValueError("type_text cannot be empty or None")
|
||||
|
||||
if isinstance(type_text, str):
|
||||
type_text = [type_text]
|
||||
elif not isinstance(type_text, list):
|
||||
raise TypeError("type_text must be a string or list of strings")
|
||||
|
||||
# Create default mask for null values
|
||||
# Handle null values and convert to string
|
||||
keywords = df['keywords'].fillna('')
|
||||
# Convert non-string values to strings
|
||||
keywords = keywords.astype(str)
|
||||
|
||||
if regex:
|
||||
pattern = '|'.join(f'{p}' for p in type_text)
|
||||
return keywords.str.contains(pattern, case=False, na=False, regex=True)
|
||||
else:
|
||||
masks = [keywords.str.contains(p, case=False, na=False, regex=False) for p in type_text]
|
||||
return pd.concat(masks, axis=1).any(axis=1)
|
||||
|
||||
def create_name_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True) -> pd.Series[bool]:
|
||||
"""Create a boolean mask for rows where name matches one or more patterns.
|
||||
|
||||
Args:
|
||||
df: DataFrame to search
|
||||
type_text: Type text pattern(s) to match. Can be a single string or list of strings.
|
||||
regex: Whether to treat patterns as regex expressions (default: True)
|
||||
|
||||
Returns:
|
||||
Boolean Series indicating matching rows
|
||||
|
||||
Raises:
|
||||
ValueError: If type_text is empty or None
|
||||
TypeError: If type_text is not a string or list of strings
|
||||
"""
|
||||
if not type_text:
|
||||
raise ValueError("type_text cannot be empty or None")
|
||||
|
||||
if isinstance(type_text, str):
|
||||
type_text = [type_text]
|
||||
elif not isinstance(type_text, list):
|
||||
raise TypeError("type_text must be a string or list of strings")
|
||||
|
||||
if regex:
|
||||
pattern = '|'.join(f'{p}' for p in type_text)
|
||||
return df['name'].str.contains(pattern, case=False, na=False, regex=True)
|
||||
else:
|
||||
masks = [df['name'].str.contains(p, case=False, na=False, regex=False) for p in type_text]
|
||||
return pd.concat(masks, axis=1).any(axis=1)
|
||||
|
||||
def extract_creature_types(type_text: str, creature_types: List[str], non_creature_types: List[str]) -> List[str]:
|
||||
"""Extract creature types from a type text string.
|
||||
|
||||
Args:
|
||||
type_text: The type line text to parse
|
||||
creature_types: List of valid creature types
|
||||
non_creature_types: List of non-creature types to exclude
|
||||
|
||||
Returns:
|
||||
List of extracted creature types
|
||||
"""
|
||||
types = [t.strip() for t in type_text.split()]
|
||||
return [t for t in types if t in creature_types and t not in non_creature_types]
|
||||
|
||||
def find_types_in_text(text: str, name: str, creature_types: List[str]) -> List[str]:
|
||||
"""Find creature types mentioned in card text.
|
||||
|
||||
Args:
|
||||
text: Card text to search
|
||||
name: Card name to exclude from search
|
||||
creature_types: List of valid creature types
|
||||
|
||||
Returns:
|
||||
List of found creature types
|
||||
"""
|
||||
if pd.isna(text):
|
||||
return []
|
||||
|
||||
found_types = []
|
||||
words = text.split()
|
||||
|
||||
for word in words:
|
||||
clean_word = re.sub(r'[^a-zA-Z-]', '', word)
|
||||
if clean_word in creature_types:
|
||||
if clean_word not in name:
|
||||
found_types.append(clean_word)
|
||||
|
||||
return list(set(found_types))
|
||||
|
||||
def add_outlaw_type(types: List[str], outlaw_types: List[str]) -> List[str]:
|
||||
"""Add Outlaw type if card has an outlaw-related type.
|
||||
|
||||
Args:
|
||||
types: List of current types
|
||||
outlaw_types: List of types that qualify for Outlaw
|
||||
|
||||
Returns:
|
||||
Updated list of types
|
||||
"""
|
||||
if any(t in outlaw_types for t in types) and 'Outlaw' not in types:
|
||||
return types + ['Outlaw']
|
||||
return types
|
||||
|
||||
def create_tag_mask(df: pd.DataFrame, tag_patterns: Union[str, List[str]], column: str = 'themeTags') -> pd.Series[bool]:
|
||||
"""Create a boolean mask for rows where tags match specified patterns.
|
||||
|
||||
Args:
|
||||
df: DataFrame to search
|
||||
tag_patterns: String or list of strings to match against tags
|
||||
column: Column containing tags to search (default: 'themeTags')
|
||||
|
||||
Returns:
|
||||
Boolean Series indicating matching rows
|
||||
|
||||
Examples:
|
||||
# Match cards with draw-related tags
|
||||
>>> mask = create_tag_mask(df, ['Card Draw', 'Conditional Draw'])
|
||||
>>> mask = create_tag_mask(df, 'Unconditional Draw')
|
||||
"""
|
||||
if isinstance(tag_patterns, str):
|
||||
tag_patterns = [tag_patterns]
|
||||
|
||||
# Handle empty DataFrame case
|
||||
if len(df) == 0:
|
||||
return pd.Series([], dtype=bool)
|
||||
|
||||
# Create mask for each pattern
|
||||
masks = [df[column].apply(lambda x: any(pattern in tag for tag in x)) for pattern in tag_patterns]
|
||||
|
||||
# Combine masks with OR
|
||||
return pd.concat(masks, axis=1).any(axis=1)
|
||||
|
||||
def validate_dataframe_columns(df: pd.DataFrame, required_columns: Set[str]) -> None:
|
||||
"""Validate that DataFrame contains all required columns.
|
||||
|
||||
Args:
|
||||
df: DataFrame to validate
|
||||
required_columns: Set of column names that must be present
|
||||
|
||||
Raises:
|
||||
ValueError: If any required columns are missing
|
||||
"""
|
||||
missing = required_columns - set(df.columns)
|
||||
if missing:
|
||||
raise ValueError(f"Missing required columns: {missing}")
|
||||
|
||||
def apply_tag_vectorized(df: pd.DataFrame, mask: pd.Series[bool], tags: Union[str, List[str]]) -> None:
|
||||
"""Apply tags to rows in a dataframe based on a boolean mask.
|
||||
|
||||
Args:
|
||||
df: The dataframe to modify
|
||||
mask: Boolean series indicating which rows to tag
|
||||
tags: List of tags to apply
|
||||
"""
|
||||
if not isinstance(tags, list):
|
||||
tags = [tags]
|
||||
|
||||
# Get current tags for masked rows
|
||||
current_tags = df.loc[mask, 'themeTags']
|
||||
|
||||
# Add new tags
|
||||
df.loc[mask, 'themeTags'] = current_tags.apply(lambda x: sorted(list(set(x + tags))))
|
||||
|
||||
def create_mass_effect_mask(df: pd.DataFrame, effect_type: str) -> pd.Series[bool]:
|
||||
"""Create a boolean mask for cards with mass removal effects of a specific type.
|
||||
|
||||
Args:
|
||||
df: DataFrame to search
|
||||
effect_type: Type of mass effect to match ('destruction', 'exile', 'bounce', 'sacrifice', 'damage')
|
||||
|
||||
Returns:
|
||||
Boolean Series indicating which cards have mass effects of the specified type
|
||||
|
||||
Raises:
|
||||
ValueError: If effect_type is not recognized
|
||||
"""
|
||||
if effect_type not in tag_constants.BOARD_WIPE_TEXT_PATTERNS:
|
||||
raise ValueError(f"Unknown effect type: {effect_type}")
|
||||
|
||||
patterns = tag_constants.BOARD_WIPE_TEXT_PATTERNS[effect_type]
|
||||
return create_text_mask(df, patterns)
|
||||
|
||||
def create_damage_pattern(number: Union[int, str]) -> str:
|
||||
"""Create a pattern for matching X damage effects.
|
||||
|
||||
Args:
|
||||
number: Number or variable (X) for damage amount
|
||||
|
||||
Returns:
|
||||
Pattern string for matching damage effects
|
||||
"""
|
||||
return f'deals {number} damage'
|
||||
|
||||
def create_mass_damage_mask(df: pd.DataFrame) -> pd.Series[bool]:
|
||||
"""Create a boolean mask for cards with mass damage effects.
|
||||
|
||||
Args:
|
||||
df: DataFrame to search
|
||||
|
||||
Returns:
|
||||
Boolean Series indicating which cards have mass damage effects
|
||||
"""
|
||||
# Create patterns for numeric damage
|
||||
number_patterns = [create_damage_pattern(i) for i in range(1, 21)]
|
||||
|
||||
# Add X damage pattern
|
||||
number_patterns.append(create_damage_pattern('X'))
|
||||
|
||||
# Add patterns for damage targets
|
||||
target_patterns = [
|
||||
'to each creature',
|
||||
'to all creatures',
|
||||
'to each player',
|
||||
'to each opponent',
|
||||
'to everything'
|
||||
]
|
||||
|
||||
# Create masks
|
||||
damage_mask = create_text_mask(df, number_patterns)
|
||||
target_mask = create_text_mask(df, target_patterns)
|
||||
|
||||
return damage_mask & target_mask
|
6437
code/tagging/tagger.py
Normal file
6437
code/tagging/tagger.py
Normal file
File diff suppressed because it is too large
Load diff
50
code/type_definitions.py
Normal file
50
code/type_definitions.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, TypedDict, Union
|
||||
import pandas as pd
|
||||
|
||||
class CardDict(TypedDict):
|
||||
"""Type definition for card dictionary structure used in deck_builder.py.
|
||||
|
||||
Contains all the necessary fields to represent a Magic: The Gathering card
|
||||
in the deck building process.
|
||||
"""
|
||||
name: str
|
||||
type: str
|
||||
manaCost: Union[str, None]
|
||||
manaValue: int
|
||||
|
||||
class CommanderDict(TypedDict):
|
||||
"""Type definition for commander dictionary structure used in deck_builder.py.
|
||||
|
||||
Contains all the necessary fields to represent a commander card and its
|
||||
associated metadata.
|
||||
"""
|
||||
Commander_Name: str
|
||||
Mana_Cost: str
|
||||
Mana_Value: int
|
||||
Color_Identity: str
|
||||
Colors: List[str]
|
||||
Type: str
|
||||
Creature_Types: str
|
||||
Text: str
|
||||
Power: int
|
||||
Toughness: int
|
||||
Themes: List[str]
|
||||
CMC: float
|
||||
|
||||
# Type alias for price cache dictionary used in price_checker.py
|
||||
PriceCache = Dict[str, float]
|
||||
|
||||
# DataFrame type aliases for different card categories
|
||||
CardLibraryDF = pd.DataFrame
|
||||
CommanderDF = pd.DataFrame
|
||||
LandDF = pd.DataFrame
|
||||
ArtifactDF = pd.DataFrame
|
||||
CreatureDF = pd.DataFrame
|
||||
NonCreatureDF = pd.DataFrame
|
||||
EnchantmentDF = pd.DataFrame
|
||||
InstantDF = pd.DataFrame
|
||||
PlaneswalkerDF = pd.DataFrame
|
||||
NonPlaneswalkerDF = pd.DataFrame
|
||||
SorceryDF = pd.DataFrame
|
Loading…
Add table
Add a link
Reference in a new issue