diff --git a/DOCKER.md b/DOCKER.md index 83602e3..3984737 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -1,8 +1,10 @@ -# Docker Usage Guide for MTG Deckbuilder +# Docker Guide for MTG Python Deckbuilder -## Quick Start (Recommended) +A comprehensive guide for running the MTG Python Deckbuilder in Docker containers with full file persistence and cross-platform support. -### Linux/Remote Host (Interactive Applications) +## 🚀 Quick Start + +### Linux/macOS/Remote Host ```bash # Make scripts executable (one time only) chmod +x quick-start.sh run-docker-linux.sh @@ -10,42 +12,16 @@ chmod +x quick-start.sh run-docker-linux.sh # Simplest method - just run this: ./quick-start.sh -# Or use the full script with more options: +# Or use the full script with options: ./run-docker-linux.sh compose ``` ### Windows (PowerShell) ```powershell -# Run with Docker Compose +# Run with Docker Compose (recommended) .\run-docker.ps1 compose -``` -## Important: Interactive Applications & Docker Compose - -**Your MTG Deckbuilder is an interactive application** that uses menus and requires keyboard input. This creates special requirements: - -### ✅ What Works for Interactive Apps: -- `docker run -it` (manual) -- `docker-compose run` (recommended) -- `./quick-start.sh` (easiest) - -### ❌ What Doesn't Work: -- `docker-compose up` (runs in background, no interaction) -- Running without `-it` flags - -### Why the Difference? - -- **`docker-compose up`**: Starts services in the background, doesn't attach to your terminal -- **`docker-compose run`**: Creates a new container and attaches to your terminal for interaction - -## Manual Docker Commands - -### Windows PowerShell -```powershell -# Build the image -docker build -t mtg-deckbuilder . - -# Run with volume mounting for file persistence +# Or manual Docker run docker run -it --rm ` -v "${PWD}/deck_files:/app/deck_files" ` -v "${PWD}/logs:/app/logs" ` @@ -53,12 +29,83 @@ docker run -it --rm ` mtg-deckbuilder ``` -### Linux/macOS/Git Bash -```bash -# Build the image -docker build -t mtg-deckbuilder . +## 📋 Prerequisites -# Run with volume mounting for file persistence +- **Docker** installed and running +- **Docker Compose** (usually included with Docker) +- Basic terminal/command line knowledge + +## 🔧 Available Commands + +### Quick Start Scripts + +| Script | Platform | Description | +|--------|----------|-------------| +| `./quick-start.sh` | Linux/macOS | Simplest way to run the application | +| `.\run-docker.ps1 compose` | Windows | PowerShell equivalent | + +### Full Featured Scripts + +| Command | Description | +|---------|-------------| +| `./run-docker-linux.sh setup` | Create directories and check Docker installation | +| `./run-docker-linux.sh build` | Build the Docker image | +| `./run-docker-linux.sh compose` | Run with Docker Compose (recommended) | +| `./run-docker-linux.sh run` | Run with manual volume mounting | +| `./run-docker-linux.sh clean` | Remove containers and images | + +## 🗂️ File Persistence + +Your files are automatically saved to local directories that persist between runs: + +``` +mtg_python_deckbuilder/ +├── deck_files/ # Your saved decks (CSV and TXT files) +├── logs/ # Application logs and debug info +├── csv_files/ # Card database and color-sorted files +└── ... +``` + +### How It Works + +The Docker container uses **volume mounting** to map container directories to your local filesystem: + +- Container path `/app/deck_files` ↔ Host path `./deck_files` +- Container path `/app/logs` ↔ Host path `./logs` +- Container path `/app/csv_files` ↔ Host path `./csv_files` + +When the application saves files, they appear in your local directories and remain there after the container stops. + +## 🎮 Interactive Application Requirements + +The MTG Deckbuilder is an **interactive application** that uses menus and requires keyboard input. + +### ✅ Commands That Work +- `docker compose run --rm mtg-deckbuilder` +- `docker run -it --rm mtg-deckbuilder` +- `./quick-start.sh` +- Helper scripts with `compose` command + +### ❌ Commands That Don't Work +- `docker compose up` (runs in background, no interaction) +- `docker run` without `-it` flags +- Any command without proper TTY allocation + +### Why the Difference? +- **`docker compose run`**: Creates new container with terminal attachment +- **`docker compose up`**: Starts service in background without terminal + +## 🔨 Manual Docker Commands + +### Build the Image +```bash +docker build -t mtg-deckbuilder . +``` + +### Run with Full Volume Mounting + +**Linux/macOS:** +```bash docker run -it --rm \ -v "$(pwd)/deck_files:/app/deck_files" \ -v "$(pwd)/logs:/app/logs" \ @@ -66,97 +113,109 @@ docker run -it --rm \ mtg-deckbuilder ``` -## File Persistence Explained - -The key to saving your files is **volume mounting**. Here's what happens: - -### Without Volume Mounting (Bad) -- Files are saved inside the container -- When container stops, files are lost forever -- Example: `docker run -it mtg-deckbuilder` ❌ - -### With Volume Mounting (Good) -- Files are saved to your local directories -- Files persist between container runs -- Local directories are "mounted" into the container -- Example: `docker run -it -v "./deck_files:/app/deck_files" mtg-deckbuilder` ✅ - -## Directory Structure After Running - -After running the Docker container, you'll have these local directories: - -``` -mtg_python_deckbuilder/ -├── deck_files/ # Your saved decks (CSV and TXT files) -├── logs/ # Application logs -├── csv_files/ # Card database files -└── ... +**Windows PowerShell:** +```powershell +docker run -it --rm ` + -v "${PWD}/deck_files:/app/deck_files" ` + -v "${PWD}/logs:/app/logs" ` + -v "${PWD}/csv_files:/app/csv_files" ` + mtg-deckbuilder ``` -## Troubleshooting +## 📁 Docker Compose Files -### Files Still Not Saving? +The project includes two Docker Compose configurations: -1. **Check directory creation**: The helper scripts automatically create the needed directories -2. **Verify volume mounts**: Look for `-v` flags in your docker run command -3. **Check permissions**: Make sure you have write access to the local directories +### `docker-compose.yml` (Main) +- Standard configuration +- Container name: `mtg-deckbuilder-main` +- Use with: `docker compose run --rm mtg-deckbuilder` + +### `docker-compose.interactive.yml` (Alternative) +- Identical functionality +- Container name: `mtg-deckbuilder-interactive` +- Use with: `docker compose -f docker compose.interactive.yml run --rm mtg-deckbuilder-interactive` + +Both files provide the same functionality and file persistence. + +## 🐛 Troubleshooting + +### Files Not Saving? + +1. **Check volume mounts**: Ensure you see `-v` flags in your docker command +2. **Verify directories exist**: Scripts automatically create needed directories +3. **Check permissions**: Ensure you have write access to the project directory +4. **Use correct command**: Use `docker compose run`, not `docker compose up` + +### Application Won't Start Interactively? + +1. **Use the right command**: `docker compose run --rm mtg-deckbuilder` +2. **Check TTY allocation**: Ensure `-it` flags are present in manual commands +3. **Avoid background mode**: Don't use `docker compose up` for interactive apps + +### Permission Issues? + +Files created by Docker may be owned by `root`. This is normal on Linux systems. + +### Container Build Fails? + +1. **Update Docker**: Ensure you have a recent version +2. **Clear cache**: Run `docker system prune -f` +3. **Check network**: Ensure Docker can download dependencies ### Starting Fresh -```powershell -# Windows - Clean up everything -.\run-docker.ps1 clean - -# Or manually -docker-compose down -docker rmi mtg-deckbuilder -``` - -### Container Won't Start - -1. Make sure Docker Desktop is running -2. Try rebuilding: `.\run-docker.ps1 build` -3. Check for port conflicts -4. Review Docker logs: `docker logs mtg-deckbuilder` - -## Helper Script Commands - -### Windows PowerShell -```powershell -.\run-docker.ps1 build # Build the Docker image -.\run-docker.ps1 run # Run with manual volume mounting -.\run-docker.ps1 compose # Run with Docker Compose (recommended) -.\run-docker.ps1 clean # Clean up containers and images -.\run-docker.ps1 help # Show help -``` - -### Linux/macOS +**Complete cleanup:** ```bash -./run-docker.sh build # Build the Docker image -./run-docker.sh run # Run with manual volume mounting -./run-docker.sh compose # Run with Docker Compose (recommended) -./run-docker.sh clean # Clean up containers and images -./run-docker.sh help # Show help +# Stop all containers +docker compose down +docker compose -f docker-compose.interactive.yml down + +# Remove image +docker rmi mtg-deckbuilder + +# Clean up system +docker system prune -f + +# Rebuild +docker compose build ``` -## Why Docker Compose is Recommended +## 🔍 Verifying Everything Works -Docker Compose offers several advantages: +After running the application: -1. **Simpler commands**: Just `docker-compose up` -2. **Configuration in file**: All settings stored in `docker-compose.yml` -3. **Automatic cleanup**: Containers are removed when stopped -4. **Consistent behavior**: Same setup every time - -## Verifying File Persistence - -After running the application and creating/saving files: - -1. Exit the Docker container -2. Check your local directories: - ```powershell - ls deck_files # Should show your saved deck files - ls logs # Should show log files - ls csv_files # Should show card database files +1. **Create or modify some data** (run setup, build a deck, etc.) +2. **Exit the container** (Ctrl+C or select Quit) +3. **Check your local directories**: + ```bash + ls -la deck_files/ # Should show any decks you created + ls -la logs/ # Should show log files + ls -la csv_files/ # Should show card database files ``` -3. Run the container again - your files should still be there! +4. **Run again** - your data should still be there! + +## 🎯 Best Practices + +1. **Use the quick-start script** for simplest experience +2. **Always use `docker compose run`** for interactive applications +3. **Keep your project directory organized** - files persist locally +4. **Regularly backup your `deck_files/`** if you create valuable decks +5. **Use `clean` commands** to free up disk space when needed + +## 🌟 Benefits of Docker Approach + +- ✅ **Consistent environment** across different machines +- ✅ **No Python installation required** on host system +- ✅ **Isolated dependencies** - won't conflict with other projects +- ✅ **Easy sharing** - others can run your setup instantly +- ✅ **Cross-platform** - works on Windows, macOS, and Linux +- ✅ **File persistence** - your work is saved locally +- ✅ **Easy cleanup** - remove everything with one command + +--- + +**Need help?** Check the troubleshooting section above or refer to the helper script help: +```bash +./run-docker-linux.sh help +``` diff --git a/Dockerfile b/Dockerfile index e87160f..b91bf1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,18 +25,22 @@ COPY csv_files/ ./csv_files/ COPY mypy.ini . # Create necessary directories as mount points -RUN mkdir -p deck_files logs +RUN mkdir -p deck_files logs csv_files # Create volumes for persistent data VOLUME ["/app/deck_files", "/app/logs", "/app/csv_files"] -# Set the working directory to code for proper imports -WORKDIR /app/code - -# Create symbolic links so the app can find the data directories -RUN ln -sf /app/deck_files ./deck_files && \ +# Create symbolic links BEFORE changing working directory +RUN cd /app/code && \ + ln -sf /app/deck_files ./deck_files && \ ln -sf /app/logs ./logs && \ ln -sf /app/csv_files ./csv_files +# Verify symbolic links were created +RUN cd /app/code && ls -la deck_files logs csv_files + +# Set the working directory to code for proper imports +WORKDIR /app/code + # Run the application CMD ["python", "main.py"] diff --git a/code/deck_builder/builder_constants.py b/code/deck_builder/builder_constants.py index 1f13c8b..9a3b0fe 100644 --- a/code/deck_builder/builder_constants.py +++ b/code/deck_builder/builder_constants.py @@ -364,7 +364,7 @@ LAND_REMOVAL_MAX_ATTEMPTS: Final[int] = 3 PROTECTED_LANDS: Final[List[str]] = BASIC_LANDS + [land['name'] for land in KINDRED_STAPLE_LANDS] # Other defaults -DEFAULT_CREATURE_COUNT: Final[int] = 35 # Default number of creatures +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 diff --git a/code/deck_builder_old/__init__.py b/code/deck_builder_old/__init__.py deleted file mode 100644 index 3f168f8..0000000 --- a/code/deck_builder_old/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .builder import DeckBuilder -from .builder_utils import * -from .builder_constants import * - -__all__ = [ - 'DeckBuilder', -] \ No newline at end of file diff --git a/code/deck_builder_old/builder_constants.py b/code/deck_builder_old/builder_constants.py deleted file mode 100644 index 9761812..0000000 --- a/code/deck_builder_old/builder_constants.py +++ /dev/null @@ -1,437 +0,0 @@ -from typing import Dict, List, Final, Tuple, Union, Callable -from settings import CARD_DATA_COLUMNS as CSV_REQUIRED_COLUMNS # unified - -__all__ = [ - 'CSV_REQUIRED_COLUMNS' -] -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*+-]+$'} -} - -# (CSV_REQUIRED_COLUMNS imported from settings to avoid duplication) - -# 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' -] \ No newline at end of file diff --git a/code/deck_builder_old/builder_old.py b/code/deck_builder_old/builder_old.py deleted file mode 100644 index e0eb0d8..0000000 --- a/code/deck_builder_old/builder_old.py +++ /dev/null @@ -1,2497 +0,0 @@ -from __future__ import annotations - -import math -import numpy as np -import os -import random -import time -from functools import lru_cache -from typing import Dict, List, Optional, Union - -import inquirer.prompt -import keyboard -import pandas as pd -import pprint -from fuzzywuzzy import process -from tqdm import tqdm - -from settings import CSV_DIRECTORY, MULTIPLE_COPY_CARDS -from .builder_constants import ( - BASIC_LANDS, CARD_TYPES, DEFAULT_NON_BASIC_LAND_SLOTS, - COMMANDER_CSV_PATH, FUZZY_MATCH_THRESHOLD, MAX_FUZZY_CHOICES, FETCH_LAND_DEFAULT_COUNT, - COMMANDER_POWER_DEFAULT, COMMANDER_TOUGHNESS_DEFAULT, COMMANDER_MANA_COST_DEFAULT, - COMMANDER_MANA_VALUE_DEFAULT, COMMANDER_TYPE_DEFAULT, COMMANDER_TEXT_DEFAULT, - THEME_PRIORITY_BONUS, THEME_POOL_SIZE_MULTIPLIER, DECK_DIRECTORY, - COMMANDER_COLOR_IDENTITY_DEFAULT, COMMANDER_COLORS_DEFAULT, COMMANDER_TAGS_DEFAULT, - COMMANDER_THEMES_DEFAULT, COMMANDER_CREATURE_TYPES_DEFAULT, DUAL_LAND_TYPE_MAP, - CSV_READ_TIMEOUT, CSV_PROCESSING_BATCH_SIZE, CSV_VALIDATION_RULES, CSV_REQUIRED_COLUMNS, - STAPLE_LAND_CONDITIONS, TRIPLE_LAND_TYPE_MAP, MISC_LAND_MAX_COUNT, MISC_LAND_MIN_COUNT, - MISC_LAND_POOL_SIZE, LAND_REMOVAL_MAX_ATTEMPTS, PROTECTED_LANDS, - MANA_COLORS, MANA_PIP_PATTERNS, THEME_WEIGHT_MULTIPLIER -) -from . import builder_utils -from file_setup import setup_utils -from input_handler import InputHandler -from exceptions import ( - BasicLandCountError, - BasicLandError, - CommanderMoveError, - CardTypeCountError, - CommanderColorError, - CommanderSelectionError, - CommanderValidationError, - CSVError, - CSVReadError, - CSVTimeoutError, - CSVValidationError, - DataFrameValidationError, - DuplicateCardError, - DeckBuilderError, - EmptyDataFrameError, - FetchLandSelectionError, - FetchLandValidationError, - IdealDeterminationError, - LandRemovalError, - LibraryOrganizationError, - LibrarySortError, - PriceAPIError, - PriceConfigurationError, - PriceLimitError, - PriceTimeoutError, - PriceValidationError, - ThemeSelectionError, - ThemeWeightError, - StapleLandError, - ManaPipError, - ThemeTagError, - ThemeWeightingError, - ThemePoolError -) -from type_definitions import ( - CommanderDict, - CardLibraryDF, - CommanderDF, - LandDF, - ArtifactDF, - CreatureDF, - NonCreatureDF, - PlaneswalkerDF, - NonPlaneswalkerDF) - -import logging_util - -# Create logger for this module -logger = logging_util.logging.getLogger(__name__) -logger.setLevel(logging_util.LOG_LEVEL) -logger.addHandler(logging_util.file_handler) -logger.addHandler(logging_util.stream_handler) - -# Try to import scrython and price_checker -try: - import scrython - from price_check import PriceChecker - use_scrython = True -except ImportError: - scrython = None - PriceChecker = None - use_scrython = False - logger.warning("Scrython is not installed. Price checking features will be unavailable." - ) - -pd.set_option('display.max_columns', None) -pd.set_option('display.max_rows', None) -pd.set_option('display.max_colwidth', 50) - -def new_line(num_lines: int = 1) -> None: - """Print specified number of newlines for formatting output. - - Args: - num_lines (int): Number of newlines to print. Defaults to 1. - - Returns: - None - """ - if num_lines < 0: - raise ValueError("Number of lines cannot be negative") - print('\n' * num_lines) - -class DeckBuilder: - - def __init__(self) -> None: - """Initialize DeckBuilder with empty dataframes and default attributes.""" - # Initialize dataframes with type hints - self.card_library: CardLibraryDF = pd.DataFrame({ - 'Card Name': pd.Series(dtype='str'), - 'Card Type': pd.Series(dtype='str'), - 'Mana Cost': pd.Series(dtype='str'), - 'Mana Value': pd.Series(dtype='int'), - 'Creature Types': pd.Series(dtype='object'), - 'Themes': pd.Series(dtype='object'), - 'Commander': pd.Series(dtype='bool'), - }) - - # Initialize component dataframes - self.commander_df: CommanderDF = pd.DataFrame() - self.land_df: LandDF = pd.DataFrame() - self.artifact_df: ArtifactDF = pd.DataFrame() - self.creature_df: CreatureDF = pd.DataFrame() - self.noncreature_df: NonCreatureDF = pd.DataFrame() - self.nonplaneswalker_df: NonPlaneswalkerDF = pd.DataFrame() - # Initialize other attributes with type hints - self.commander_info: Dict = {} - self.max_card_price: Optional[float] = None - self.commander_dict: CommanderDict = {} - self.commander: str = '' - self.commander_type: str = '' - self.commander_text: str = '' - self.commander_power: int = 0 - self.commander_toughness: int = 0 - self.commander_mana_cost: str = '' - self.commander_mana_value: int = 0 - self.color_identity: Union[str, List[str]] = '' - self.color_identity_full: str = '' - self.colors: List[str] = [] - self.creature_types: str = '' - self.commander_tags: List[str] = [] - self.themes: List[str] = [] - - # Initialize handlers - self.price_checker = PriceChecker() if PriceChecker else None - self.input_handler = InputHandler() - - def pause_with_message(self, message: str = "Press Enter to continue...") -> None: - """Display a message and wait for user input. - - Args: - message: Message to display before pausing - """ - """Helper function to pause execution with a message.""" - print(f"\n{message}") - input() - - # Determine and Validate commander - def determine_commander(self) -> None: - """Main orchestrator method for commander selection and initialization process. - - This method coordinates the commander selection workflow by: - 1. Loading commander data - 2. Facilitating commander selection - 3. Confirming the selection - 4. Initializing commander attributes - - Raises: - CommanderLoadError: If commander data cannot be loaded - CommanderSelectionError: If commander selection fails - CommanderValidationError: If commander data is invalid - """ - logger.info("Starting commander selection process") - - try: - # Load commander data using builder_utils - df = builder_utils.load_commander_data() - logger.debug("Commander data loaded successfully") - - # Select commander - commander_name = self._select_commander(df) - logger.info(f"Commander selected: {commander_name}") - - # Confirm selection - commander_data = self._confirm_commander(df, commander_name) - logger.info("Commander selection confirmed") - - # Initialize commander - self._initialize_commander(commander_data) - logger.info("Commander initialization complete") - - except DeckBuilderError as e: - logger.error(f"Commander selection failed: {e}") - raise - except Exception as e: - logger.error(f"Unexpected error in commander selection: {e}") - raise DeckBuilderError(f"Commander selection failed: {str(e)}") - - def _select_commander(self, df: pd.DataFrame) -> str: - """Handle the commander selection process including fuzzy matching. - - Args: - df: DataFrame containing commander data - - Returns: - Selected commander name - - Raises: - CommanderSelectionError: If commander selection fails - """ - while True: - try: - card_choice = self.input_handler.questionnaire( - 'Text', - 'Enter a card name to be your commander' - ) - - # Use builder_utils for fuzzy matching - match, choices, exact_match = builder_utils.process_fuzzy_matches(card_choice, df) - - if exact_match: - return match - - # Handle multiple matches - choices.append(('Neither', 0)) - logger.info("Multiple commander matches found") - - choice = self.input_handler.questionnaire( - 'Choice', - 'Multiple matches found. Please select:', - choices_list=[name for name, _ in choices] - ) - - if choice != 'Neither': - return choice - - except DeckBuilderError as e: - logger.warning(f"Commander selection attempt failed: {e}") - continue - - def _confirm_commander(self, df: pd.DataFrame, commander_name: str) -> Dict: - """Confirm commander selection and validate data. - - Args: - df: DataFrame containing commander data - commander_name: Name of selected commander - - Returns: - Dictionary containing commander data - - Raises: - CommanderValidationError: If commander data is invalid - """ - try: - # Validate commander data - commander_data = builder_utils.validate_commander_selection(df, commander_name) - - # Store commander DataFrame - self.commander_df = pd.DataFrame(commander_data) - - # Display commander info - print('\nSelected Commander:') - pprint.pprint(commander_data, sort_dicts=False) - - # Confirm selection - if not self.input_handler.questionnaire('Confirm', 'Is this the commander you want?', True): - raise CommanderSelectionError("Commander selection cancelled by user") - - # Check price if enabled - if self.price_checker: - self.price_checker.get_card_price(commander_name) - - return commander_data - - except DeckBuilderError as e: - logger.error(f"Commander confirmation failed: {e}") - raise - - def _initialize_commander(self, commander_data: Dict) -> None: - """Initialize commander attributes from validated data. - - Args: - commander_data: Dictionary containing commander information - - Raises: - CommanderValidationError: If required attributes are missing - """ - try: - # Store commander info - self.commander_info = commander_data - self.commander = commander_data['name'][0] - - # Initialize commander attributes - self.commander_setup() - logger.debug("Commander attributes initialized successfully") - - except Exception as e: - logger.error(f"Commander initialization failed: {e}") - raise CommanderValidationError(f"Failed to initialize commander: {str(e)}") - - # Setup Commander - def commander_setup(self) -> None: - """Set up commander attributes and initialize deck building. - - This method orchestrates the commander setup process by calling specialized - helper methods to handle different aspects of initialization. - - Raises: - CommanderValidationError: If commander validation fails - DeckBuilderError: If deck building initialization fails - """ - try: - # Initialize commander attributes - self._initialize_commander_attributes() - - # Set up commander components - self._setup_commander_type_and_text() - self._setup_commander_stats() - self._setup_color_identity() - self._setup_creature_types() - self._setup_commander_tags() - - # Initialize commander dictionary and deck - self._initialize_commander_dict() - self._initialize_deck_building() - - logger.info("Commander setup completed successfully") - - except CommanderValidationError as e: - logger.error(f"Commander validation failed: {e}") - raise - except DeckBuilderError as e: - logger.error(f"Deck building initialization failed: {e}") - raise - - def _initialize_commander_attributes(self) -> None: - """Initialize basic commander attributes with defaults. - - Uses settings.py constants for default values. - """ - self.commander_power = COMMANDER_POWER_DEFAULT - self.commander_toughness = COMMANDER_TOUGHNESS_DEFAULT - self.commander_mana_value = COMMANDER_MANA_VALUE_DEFAULT - self.commander_type = COMMANDER_TYPE_DEFAULT - self.commander_text = COMMANDER_TEXT_DEFAULT - self.commander_mana_cost = COMMANDER_MANA_COST_DEFAULT - self.color_identity = COMMANDER_COLOR_IDENTITY_DEFAULT - self.colors = COMMANDER_COLORS_DEFAULT.copy() - self.creature_types = COMMANDER_CREATURE_TYPES_DEFAULT - self.commander_tags = COMMANDER_TAGS_DEFAULT.copy() - self.themes = COMMANDER_THEMES_DEFAULT.copy() - - def _setup_commander_type_and_text(self) -> None: - """Set up and validate commander type line and text. - - Raises: - CommanderTypeError: If type line validation fails - """ - df = self.commander_df - type_line = str(df.at[0, 'type']) - self.commander_type = self.input_handler.validate_commander_type(type_line) - self.commander_text = str(df.at[0, 'text']) - - def _setup_commander_stats(self) -> None: - """Set up and validate commander power, toughness, and mana values. - - Raises: - CommanderStatsError: If stats validation fails - """ - df = self.commander_df - - # Validate power and toughness - self.commander_power = self.input_handler.validate_commander_stats( - 'power', str(df.at[0, 'power'])) - self.commander_toughness = self.input_handler.validate_commander_stats( - 'toughness', str(df.at[0, 'toughness'])) - - # Set mana cost and value - self.commander_mana_cost = str(df.at[0, 'manaCost']) - self.commander_mana_value = self.input_handler.validate_commander_stats( - 'mana value', int(df.at[0, 'manaValue'])) - - def _setup_color_identity(self) -> None: - """Set up and validate commander color identity. - - Raises: - CommanderColorError: If color identity validation fails - """ - df = self.commander_df - try: - color_id = df.at[0, 'colorIdentity'] - if pd.isna(color_id): - color_id = 'COLORLESS' - - self.color_identity = self.input_handler.validate_commander_colors(color_id) - self.color_identity_full = '' - self.determine_color_identity() - print(self.color_identity_full) - - # Set colors list - if pd.notna(df.at[0, 'colors']) and df.at[0, 'colors'].strip(): - self.colors = [color.strip() for color in df.at[0, 'colors'].split(',') if color.strip()] - if not self.colors: - self.colors = ['COLORLESS'] - else: - self.colors = ['COLORLESS'] - - except Exception as e: - raise CommanderColorError(f"Failed to set color identity: {str(e)}") - - def _setup_creature_types(self) -> None: - """Set up commander creature types.""" - df = self.commander_df - self.creature_types = str(df.at[0, 'creatureTypes']) - - def _setup_commander_tags(self) -> None: - """Set up and validate commander theme tags. - - Raises: - CommanderTagError: If tag validation fails - """ - df = self.commander_df - tags = list(df.at[0, 'themeTags']) - self.commander_tags = self.input_handler.validate_commander_tags(tags) - self.determine_themes() - - def _initialize_commander_dict(self) -> None: - """Initialize the commander dictionary with validated data.""" - self.commander_dict: CommanderDict = { - 'Commander Name': self.commander, - 'Mana Cost': self.commander_mana_cost, - 'Mana Value': self.commander_mana_value, - 'Color Identity': self.color_identity_full, - 'Colors': self.colors, - 'Type': self.commander_type, - 'Creature Types': self.creature_types, - 'Text': self.commander_text, - 'Power': self.commander_power, - 'Toughness': self.commander_toughness, - 'Themes': self.themes, - 'CMC': 0.0 - } - self.add_card(self.commander, self.commander_type, - self.commander_mana_cost, self.commander_mana_value, - self.creature_types, self.commander_tags, True) - - def _initialize_deck_building(self) -> None: - """Initialize deck building process. - - Raises: - DeckBuilderError: If deck building initialization fails - """ - try: - # Set up initial deck structure - self.setup_dataframes() - self.determine_ideals() - - # Add cards by category - self.add_lands() - self.add_creatures() - self.add_ramp() - self.add_board_wipes() - self.add_interaction() - self.add_card_advantage() - - # Fill remaining slots if needed - if len(self.card_library) < 100: - self.fill_out_deck() - - # Process and organize deck - self.organize_library() - - # Log deck composition - self._log_deck_composition() - - # Finalize deck - self.get_cmc() - self.count_pips() - self.concatenate_duplicates() - self.organize_library() - self.sort_library() - self.commander_to_top() - - # Save final deck - FILE_TIME = time.strftime("%Y%m%d-%H%M%S") - DECK_FILE = f'{self.commander}_{FILE_TIME}.csv' - self.card_library.to_csv(f'{DECK_DIRECTORY}/{DECK_FILE}', index=False) - - except Exception as e: - raise DeckBuilderError(f"Failed to initialize deck building: {str(e)}") - - def _log_deck_composition(self) -> None: - """Log the deck composition statistics.""" - logger.info(f'Creature cards (including commander): {self.creature_cards}') - logger.info(f'Planeswalker cards: {self.planeswalker_cards}') - logger.info(f'Battle cards: {self.battle_cards}') - logger.info(f'Instant cards: {self.instant_cards}') - logger.info(f'Sorcery cards: {self.sorcery_cards}') - logger.info(f'Artifact cards: {self.artifact_cards}') - logger.info(f'Enchantment cards: {self.enchantment_cards}') - logger.info(f'Land cards cards: {self.land_cards}') - logger.info(f'Number of cards in Library: {len(self.card_library)}') - - # Determine and validate color identity - def determine_color_identity(self) -> None: - """Determine the deck's color identity and set related attributes. - - This method orchestrates the color identity determination process by: - 1. Validating the color identity input - 2. Determining the appropriate color combination type - 3. Setting color identity attributes based on the combination - - Raises: - CommanderColorError: If color identity validation fails - """ - try: - # Validate color identity using input handler - validated_identity = self.input_handler.validate_commander_colors(self.color_identity) - - # Determine color combination type and set attributes - if self._determine_mono_color(validated_identity): - return - - if self._determine_dual_color(validated_identity): - return - - if self._determine_tri_color(validated_identity): - return - - if self._determine_other_color(validated_identity): - return - - # Handle unknown color identity - logger.warning(f"Unknown color identity: {validated_identity}") - self.color_identity_full = 'Unknown' - self.files_to_load = ['colorless'] - - except CommanderColorError as e: - logger.error(f"Color identity validation failed: {e}") - raise - except Exception as e: - logger.error(f"Error in determine_color_identity: {e}") - raise CommanderColorError(f"Failed to determine color identity: {str(e)}") - - def _determine_mono_color(self, color_identity: str) -> bool: - """Handle single color identities. - - Args: - color_identity: Validated color identity string - - Returns: - True if color identity was handled, False otherwise - """ - from settings import MONO_COLOR_MAP - - if color_identity in MONO_COLOR_MAP: - self.color_identity_full, self.files_to_load = MONO_COLOR_MAP[color_identity] - return True - return False - - def _determine_dual_color(self, color_identity: str) -> bool: - """Handle two-color combinations. - - Args: - color_identity: Validated color identity string - - Returns: - True if color identity was handled, False otherwise - """ - from settings import DUAL_COLOR_MAP - - if color_identity in DUAL_COLOR_MAP: - identity_info = DUAL_COLOR_MAP[color_identity] - self.color_identity_full = identity_info[0] - self.color_identity_options = identity_info[1] - self.files_to_load = identity_info[2] - return True - return False - - def _determine_tri_color(self, color_identity: str) -> bool: - """Handle three-color combinations. - - Args: - color_identity: Validated color identity string - - Returns: - True if color identity was handled, False otherwise - """ - from settings import TRI_COLOR_MAP - - if color_identity in TRI_COLOR_MAP: - identity_info = TRI_COLOR_MAP[color_identity] - self.color_identity_full = identity_info[0] - self.color_identity_options = identity_info[1] - self.files_to_load = identity_info[2] - return True - return False - - def _determine_other_color(self, color_identity: str) -> bool: - """Handle four and five color combinations. - - Args: - color_identity: Validated color identity string - - Returns: - True if color identity was handled, False otherwise - """ - from builder_constants import OTHER_COLOR_MAP - - if color_identity in OTHER_COLOR_MAP: - identity_info = OTHER_COLOR_MAP[color_identity] - self.color_identity_full = identity_info[0] - self.color_identity_options = identity_info[1] - self.files_to_load = identity_info[2] - return True - return False - - # CSV and dataframe functionality - def read_csv(self, filename: str, converters: dict | None = None) -> pd.DataFrame: - """Read and validate CSV file with comprehensive error handling. - - Args: - filename: Name of the CSV file without extension - converters: Dictionary of converters for specific columns - - Returns: - pd.DataFrame: Validated and processed DataFrame - - Raises: - CSVReadError: If file cannot be read - CSVValidationError: If data fails validation - CSVTimeoutError: If read operation times out - EmptyDataFrameError: If DataFrame is empty - """ - filepath = f'{CSV_DIRECTORY}/{filename}_cards.csv' - - try: - # Read with timeout - df = pd.read_csv( - filepath, - converters=converters or {'themeTags': pd.eval, 'creatureTypes': pd.eval}, - ) - - # Check for empty DataFrame - if df.empty: - raise EmptyDataFrameError(f"Empty DataFrame from {filename}_cards.csv") - - # Validate required columns - missing_cols = set(CSV_REQUIRED_COLUMNS) - set(df.columns) - if missing_cols: - raise CSVValidationError(f"Missing required columns: {missing_cols}") - - # Validate data rules - for col, rules in CSV_VALIDATION_RULES.items(): - if rules.get('required', False) and df[col].isnull().any(): - raise CSVValidationError(f"Missing required values in column: {col}") - if 'type' in rules: - expected_type = rules['type'] - actual_type = df[col].dtype.name - if expected_type == 'str' and actual_type not in ['object', 'string']: - raise CSVValidationError(f"Invalid type for column {col}: expected {expected_type}, got {actual_type}") - elif expected_type != 'str' and not actual_type.startswith(expected_type): - raise CSVValidationError(f"Invalid type for column {col}: expected {expected_type}, got {actual_type}") - logger.debug(f"Successfully read and validated {filename}_cards.csv") - #print(df.columns) - return df - - except pd.errors.EmptyDataError: - raise EmptyDataFrameError(f"Empty CSV file: {filename}_cards.csv") - - except FileNotFoundError as e: - logger.error(f"File {filename}_cards.csv not found: {e}") - setup_utils.regenerate_csvs_all() - return self.read_csv(filename, converters) - - except TimeoutError: - raise CSVTimeoutError(f"Timeout reading {filename}_cards.csv", CSV_READ_TIMEOUT) - - except Exception as e: - logger.error(f"Error reading {filename}_cards.csv: {e}") - raise CSVReadError(f"Failed to read {filename}_cards.csv: {str(e)}") - - def write_csv(self, df: pd.DataFrame, filename: str) -> None: - """Write DataFrame to CSV with error handling and logger. - - Args: - df: DataFrame to write - filename: Name of the CSV file without extension - """ - try: - filepath = f'{CSV_DIRECTORY}/{filename}.csv' - df.to_csv(filepath, index=False) - logger.debug(f"Successfully wrote {filename}.csv") - except Exception as e: - logger.error(f"Error writing {filename}.csv: {e}") - - def _load_and_combine_data(self) -> pd.DataFrame: - """Load and combine data from multiple CSV files. - - Returns: - Combined DataFrame from all source files - - Raises: - CSVError: If data loading or combining fails - EmptyDataFrameError: If no valid data is loaded - """ - logger.info("Loading and combining data from CSV files...") - all_df = [] - - try: - # Wrap files_to_load with tqdm for progress bar - for file in tqdm(self.files_to_load, desc="Loading card data files", leave=False): - df = self.read_csv(file) - if df.empty: - raise EmptyDataFrameError(f"Empty DataFrame from {file}") - all_df.append(df) - #print(df.columns) - return builder_utils.combine_dataframes(all_df) - - except (CSVError, EmptyDataFrameError) as e: - logger.error(f"Error loading and combining data: {e}") - raise - - def _split_into_specialized_frames(self, df: pd.DataFrame) -> None: - """Split combined DataFrame into specialized component frames. - - Args: - df: Source DataFrame to split - - Raises: - DataFrameValidationError: If data splitting fails - """ - try: - # Extract lands - self.land_df = df[df['type'].str.contains('Land')].copy() - self.land_df.sort_values(by='edhrecRank', inplace=True) - - # Remove lands from main DataFrame - df = df[~df['type'].str.contains('Land')] - df.to_csv(f'{CSV_DIRECTORY}/test_cards.csv', index=False) - - # Create specialized frames - self.artifact_df = df[df['type'].str.contains('Artifact')].copy() - self.battle_df = df[df['type'].str.contains('Battle')].copy() - self.creature_df = df[df['type'].str.contains('Creature')].copy() - self.noncreature_df = df[~df['type'].str.contains('Creature')].copy() - self.enchantment_df = df[df['type'].str.contains('Enchantment')].copy() - self.instant_df = df[df['type'].str.contains('Instant')].copy() - self.planeswalker_df = df[df['type'].str.contains('Planeswalker')].copy() - self.nonplaneswalker_df = df[~df['type'].str.contains('Planeswalker')].copy() - self.sorcery_df = df[df['type'].str.contains('Sorcery')].copy() - - self.battle_df.to_csv(f'{CSV_DIRECTORY}/test_battle_cards.csv', index=False) - - # Sort all frames - for frame in [self.artifact_df, self.battle_df, self.creature_df, - self.noncreature_df, self.enchantment_df, self.instant_df, - self.planeswalker_df, self.sorcery_df]: - frame.sort_values(by='edhrecRank', inplace=True) - - except Exception as e: - logger.error(f"Error splitting DataFrames: {e}") - raise DataFrameValidationError("DataFrame splitting failed", {}, {"error": str(e)}) - - def _validate_dataframes(self) -> None: - """Validate all component DataFrames. - - Raises: - DataFrameValidationError: If validation fails - """ - try: - frames_to_validate = { - 'land': self.land_df, - 'artifact': self.artifact_df, - 'battle': self.battle_df, - 'creature': self.creature_df, - 'noncreature': self.noncreature_df, - 'enchantment': self.enchantment_df, - 'instant': self.instant_df, - 'planeswalker': self.planeswalker_df, - 'sorcery': self.sorcery_df - } - - for name, frame in frames_to_validate.items(): - rules = builder_utils.get_validation_rules(name) - if not builder_utils.validate_dataframe(frame, rules): - raise DataFrameValidationError(f"{name} validation failed", rules) - - except Exception as e: - logger.error(f"DataFrame validation failed: {e}") - raise - - def _save_intermediate_results(self) -> None: - """Save intermediate DataFrames for debugging and analysis. - - Raises: - CSVError: If saving fails - """ - try: - frames_to_save = { - 'lands': self.land_df, - 'artifacts': self.artifact_df, - 'battles': self.battle_df, - 'creatures': self.creature_df, - 'noncreatures': self.noncreature_df, - 'enchantments': self.enchantment_df, - 'instants': self.instant_df, - 'planeswalkers': self.planeswalker_df, - 'sorcerys': self.sorcery_df - } - - for name, frame in frames_to_save.items(): - self.write_csv(frame, f'test_{name}') - - except Exception as e: - logger.error(f"Error saving intermediate results: {e}") - raise CSVError(f"Failed to save intermediate results: {str(e)}") - - def setup_dataframes(self) -> None: - """Initialize and validate all required DataFrames. - - This method orchestrates the DataFrame setup process by: - 1. Loading and combining data from CSV files - 2. Splitting into specialized component frames - 3. Validating all DataFrames - 4. Saving intermediate results - - Raises: - CSVError: If any CSV operations fail - EmptyDataFrameError: If any required DataFrame is empty - DataFrameValidationError: If validation fails - """ - try: - # Load and combine data - self.full_df = self._load_and_combine_data() - self.full_df = self.full_df[~self.full_df['name'].str.contains(self.commander)] - self.full_df.sort_values(by='edhrecRank', inplace=True) - self.full_df.to_csv(f'{CSV_DIRECTORY}/test_full_cards.csv', index=False) - - # Split into specialized frames - self._split_into_specialized_frames(self.full_df) - # Validate all frames - self._validate_dataframes() - - # Save intermediate results - self._save_intermediate_results() - - logger.info("DataFrame setup completed successfully") - - except (CSVError, EmptyDataFrameError, DataFrameValidationError) as e: - logger.error(f"Error in DataFrame setup: {e}") - raise - - # Theme selection - def determine_themes(self) -> None: - """Determine and set up themes for the deck building process. - - This method handles: - 1. Theme selection (primary, secondary, tertiary) - 2. Theme weight calculations - 3. Hidden theme detection and setup - - Raises: - ThemeSelectionError: If theme selection fails - ThemeWeightError: If weight calculation fails - """ - try: - # Get available themes from commander tags - themes = self.commander_tags.copy() - - # Get available themes from commander tags - themes = self.commander_tags.copy() - - # Initialize theme flags - self.hidden_theme = False - self.secondary_theme = False - self.tertiary_theme = False - - # Select primary theme (required) - self.primary_theme = builder_utils.select_theme( - themes, - 'Choose a primary theme for your commander deck.\n' - 'This will be the "focus" of the deck, in a kindred deck this will typically be a creature type for example.' - ) - themes.remove(self.primary_theme) - - # Initialize self.weights from settings - from settings import THEME_WEIGHTS_DEFAULT - self.weights = THEME_WEIGHTS_DEFAULT.copy() - # Set initial weights for primary-only case - self.weights['primary'] = 1.0 - self.weights['secondary'] = 0.0 - self.weights['tertiary'] = 0.0 - self.primary_weight = 1.0 - - # Select secondary theme if desired - if themes: - self.secondary_theme = builder_utils.select_theme( - themes, - 'Choose a secondary theme for your commander deck.\n' - 'This will typically be a secondary focus, like card draw for Spellslinger, or +1/+1 counters for Aggro.', - optional=True - ) - - # Check for Stop Here before modifying themes list - if self.secondary_theme == 'Stop Here': - self.secondary_theme = False - elif self.secondary_theme: - themes.remove(self.secondary_theme) - self.weights['secondary'] = 0.6 - self.weights = builder_utils.adjust_theme_weights( - self.primary_theme, - self.secondary_theme, - None, # No tertiary theme yet - self.weights - ) - self.primary_weight = self.weights['primary'] - self.secondary_weight = self.weights['secondary'] - - # Select tertiary theme if desired - if themes and self.secondary_theme and self.secondary_theme != 'Stop Here': - self.tertiary_theme = builder_utils.select_theme( - themes, - 'Choose a tertiary theme for your commander deck.\n' - 'This will typically be a tertiary focus, or just something else to do that your commander is good at.', - optional=True - ) - - # Check for Stop Here before modifying themes list - if self.tertiary_theme == 'Stop Here': - self.tertiary_theme = False - elif self.tertiary_theme: - self.weights['tertiary'] = 0.3 - self.weights = builder_utils.adjust_theme_weights( - self.primary_theme, - self.secondary_theme, - self.tertiary_theme, - self.weights - ) - self.primary_weight = self.weights['primary'] - self.secondary_weight = self.weights['secondary'] - self.tertiary_weight = self.weights['tertiary'] - - # Build final themes list - self.themes = [self.primary_theme] - if self.secondary_theme: - self.themes.append(self.secondary_theme) - if self.tertiary_theme: - self.themes.append - self.determine_hidden_themes() - - except (ThemeSelectionError, ThemeWeightError) as e: - logger.error(f"Error in theme determination: {e}") - raise - - def determine_hidden_themes(self) -> None: - """ - Setting 'Hidden' themes for multiple-copy cards, such as 'Hare Apparent' or 'Shadowborn Apostle'. - These are themes that will be prompted for under specific conditions, such as a matching Kindred theme or a matching color combination and Spellslinger theme for example. - Typically a hidden theme won't come up, but if it does, it will take priority with theme self.weights to ensure a decent number of the specialty cards are added. - """ - # Setting hidden theme for Kindred-specific themes - hidden_themes = ['Advisor Kindred', 'Demon Kindred', 'Dwarf Kindred', 'Rabbit Kindred', 'Rat Kindred', 'Wraith Kindred'] - theme_cards = ['Persistent Petitioners', 'Shadowborn Apostle', 'Seven Dwarves', 'Hare Apparent', ['Rat Colony', 'Relentless Rats'], 'Nazgûl'] - color = ['B', 'B', 'R', 'W', 'B', 'B'] - for i in range(min(len(hidden_themes), len(theme_cards), len(color))): - if (hidden_themes[i] in self.themes - and hidden_themes[i] != 'Rat Kindred' - and color[i] in self.colors): - logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') - choice = self.input_handler.questionnaire('Confirm', message='', default_value=False) - if choice: - self.hidden_theme = theme_cards[i] - self.themes.append(self.hidden_theme) - self.weights['primary'] = round(self.weights['primary'] / 3, 2) - self.weights['secondary'] = round(self.weights['secondary'] / 2, 2) - self.weights['tertiary'] = self.weights['tertiary'] - self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2) - self.primary_weight = self.weights['primary'] - self.secondary_weight = self.weights['secondary'] - self.tertiary_weight = self.weights['tertiary'] - self.hidden_weight = self.weights['hidden'] - else: - continue - - elif (hidden_themes[i] in self.themes - and hidden_themes[i] == 'Rat Kindred' - and color[i] in self.colors): - logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i][0]} or {theme_cards[i][1]} deck?') - choice = self.input_handler.questionnaire('Confirm', message='', default_value=False) - if choice: - print('Which one?') - choice = self.input_handler.questionnaire('Choice', choices_list=theme_cards[i], message='') - if choice: - self.hidden_theme = choice - self.themes.append(self.hidden_theme) - self.weights['primary'] = round(self.weights['primary'] / 3, 2) - self.weights['secondary'] = round(self.weights['secondary'] / 2, 2) - self.weights['tertiary'] = self.weights['tertiary'] - self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2) - self.primary_weight = self.weights['primary'] - self.secondary_weight = self.weights['secondary'] - self.tertiary_weight = self.weights['tertiary'] - self.hidden_weight = self.weights['hidden'] - else: - continue - - # Setting the hidden theme for non-Kindred themes - hidden_themes = ['Little Fellas', 'Mill', 'Spellslinger', 'Spells Matter', 'Spellslinger', 'Spells Matter',] - theme_cards = ['Hare Apparent', 'Persistent Petitions', 'Dragon\'s Approach', 'Dragon\'s Approach', 'Slime Against Humanity', 'Slime Against Humanity'] - color = ['W', 'B', 'R', 'R', 'G', 'G'] - for i in range(min(len(hidden_themes), len(theme_cards), len(color))): - if (hidden_themes[i] in self.themes - and color[i] in self.colors): - logger.info(f'Looks like you\'re making a {hidden_themes[i]} deck, would you like it to be a {theme_cards[i]} deck?') - choice = self.input_handler.questionnaire('Confirm', message='', default_value=False) - if choice: - self.hidden_theme = theme_cards[i] - self.themes.append(self.hidden_theme) - self.weights['primary'] = round(self.weights['primary'] / 3, 2) - self.weights['secondary'] = round(self.weights['secondary'] / 2, 2) - self.weights['tertiary'] = self.weights['tertiary'] - self.weights['hidden'] = round(1.0 - self.weights['primary'] - self.weights['secondary'] - self.weights['tertiary'], 2) - self.primary_weight = self.weights['primary'] - self.secondary_weight = self.weights['secondary'] - self.tertiary_weight = self.weights['tertiary'] - self.hidden_weight = self.weights['hidden'] - else: - continue - - # Setting ideals - def determine_ideals(self): - """Determine ideal card counts and price settings for the deck. - - This method handles: - 1. Price configuration (if price checking is enabled) - 2. Setting ideal counts for different card types - 3. Calculating remaining free slots - - Raises: - PriceConfigurationError: If there are issues configuring price settings - IdealDeterminationError: If there are issues determining ideal counts - """ - try: - # Initialize free slots - self.free_slots = 99 - - # Configure price settings if enabled - if use_scrython: - try: - builder_utils.configure_price_settings(self.price_checker, self.input_handler) - except ValueError as e: - raise PriceConfigurationError(f"Failed to configure price settings: {str(e)}") - - # Get deck composition values - try: - composition = builder_utils.get_deck_composition_values(self.input_handler) - except ValueError as e: - raise IdealDeterminationError(f"Failed to determine deck composition: {str(e)}") - - # Update class attributes with composition values - self.ideal_ramp = composition['ramp'] - self.ideal_land_count = composition['lands'] - self.min_basics = composition['basic_lands'] - self.ideal_creature_count = composition['creatures'] - self.ideal_removal = composition['removal'] - self.ideal_wipes = composition['wipes'] - self.ideal_card_advantage = composition['card_advantage'] - self.ideal_protection = composition['protection'] - - # Update free slots - for value in [self.ideal_ramp, self.ideal_land_count, self.ideal_creature_count, - self.ideal_removal, self.ideal_wipes, self.ideal_card_advantage, - self.ideal_protection]: - self.free_slots -= value - - print(f'\nFree slots that aren\'t part of the ideals: {self.free_slots}') - print('Keep in mind that many of the ideals can also cover multiple roles, but this will give a baseline POV.') - - except (PriceConfigurationError, IdealDeterminationError) as e: - logger.error(f"Error in determine_ideals: {e}") - raise - - # Adding card to library - def add_card(self, card: str, card_type: str, mana_cost: str, mana_value: int, creature_types: list = None, tags: list = None, is_commander: bool = False) -> None: - """Add a card to the deck library with price checking if enabled. - Args: - card (str): Name of the card to add - card_type (str): Type of the card (e.g., 'Creature', 'Instant') - mana_cost (str): Mana cost string representation - mana_value (int): Converted mana cost/mana value - creature_types (list): List of creature types in the card (if any) - themes (list): List of themes the card has - is_commander (bool, optional): Whether this card is the commander. Defaults to False. - - Returns: - None - - Raises: - PriceLimitError: If card price exceeds maximum allowed price - PriceAPIError: If there is an error fetching the price - PriceTimeoutError: If the price check times out - PriceValidationError: If the price data is invalid - """ - multiple_copies = BASIC_LANDS + MULTIPLE_COPY_CARDS - - # Skip if card already exists and isn't allowed multiple copies - if card in pd.Series(self.card_library['Card Name']).values and card not in multiple_copies: - return - - # Handle price checking - card_price = 0.0 - try: - # Get price and validate - card_price = self.price_checker.get_card_price(card) - self.price_checker.validate_card_price(card, card_price) - self.price_checker.update_deck_price(card_price) - except (PriceAPIError, PriceTimeoutError, PriceValidationError, PriceLimitError) as e: - logger.warning(str(e)) - return - - # Create card entry - card_entry = [card, card_type, mana_cost, mana_value, creature_types, tags, is_commander] - - # Add to library - self.card_library.loc[len(self.card_library)] = card_entry - - logger.debug(f"Added {card} to deck library") - - # Get card counts, sort library, set commander at index 1, and combine duplicates into 1 entry - def organize_library(self): - """Organize and count cards in the library by their types. - - This method counts the number of cards for each card type in the library - and updates the corresponding instance variables. It uses the count_cards_by_type - helper function from builder_utils for efficient counting. - - The method handles the following card types: - - Artifacts - - Battles - - Creatures - - Enchantments - - Instants - - Kindred (if applicable) - - Lands - - Planeswalkers - - Sorceries - - Raises: - CardTypeCountError: If there are issues counting cards by type - LibraryOrganizationError: If library organization fails - """ - try: - # Get all card types to count, including Kindred if not already present - all_types = CARD_TYPES + ['Kindred'] if 'Kindred' not in CARD_TYPES else CARD_TYPES - - # Use helper function to count cards by type - card_counters = builder_utils.count_cards_by_type(self.card_library, all_types) - - # Update instance variables with counts - self.artifact_cards = card_counters['Artifact'] - self.battle_cards = card_counters['Battle'] - self.creature_cards = card_counters['Creature'] - self.enchantment_cards = card_counters['Enchantment'] - self.instant_cards = card_counters['Instant'] - self.kindred_cards = card_counters.get('Kindred', 0) - self.land_cards = card_counters['Land'] - self.planeswalker_cards = card_counters['Planeswalker'] - self.sorcery_cards = card_counters['Sorcery'] - - logger.debug(f"Library organized successfully with {len(self.card_library)} total cards") - - except (CardTypeCountError, Exception) as e: - logger.error(f"Error organizing library: {e}") - raise LibraryOrganizationError(f"Failed to organize library: {str(e)}") - - def sort_library(self) -> None: - """Sort the card library by card type and name. - - This method sorts the card library first by card type according to the - CARD_TYPE_SORT_ORDER constant, and then alphabetically by card name. - It uses the assign_sort_order() helper function to ensure consistent - type-based sorting across the application. - - The sorting order is: - 1. Card type (Planeswalker -> Battle -> Creature -> Instant -> Sorcery -> - Artifact -> Enchantment -> Land) - 2. Card name (alphabetically) - - Raises: - LibrarySortError: If there are issues during the sorting process - """ - try: - # Use the assign_sort_order helper function to add sort order - sorted_library = builder_utils.assign_sort_order(self.card_library) - - # Sort by Sort Order and Card Name - sorted_library = sorted_library.sort_values( - by=['Sort Order', 'Card Name'], - ascending=[True, True] - ) - - # Clean up and reset index - self.card_library = ( - sorted_library - .drop(columns=['Sort Order']) - .reset_index(drop=True) - ) - - logger.debug("Card library sorted successfully") - - except Exception as e: - logger.error(f"Error sorting library: {e}") - raise LibrarySortError( - "Failed to sort card library", - {"error": str(e)} - ) - - def commander_to_top(self) -> None: - """Move commander card to the top of the library while preserving commander status. - - This method identifies the commander card in the library using a boolean mask, - removes it from its current position, and prepends it to the top of the library. - The commander's status and attributes are preserved during the move. - - Raises: - CommanderMoveError: If the commander cannot be found in the library or - if there are issues with the move operation. - """ - try: - # Create boolean mask to identify commander - commander_mask = self.card_library['Commander'] - - # Check if commander exists in library - if not commander_mask.any(): - error_msg = "Commander not found in library" - logger.warning(error_msg) - raise CommanderMoveError(error_msg) - - # Get commander row and name for logging - commander_row = self.card_library[commander_mask].copy() - commander_name = commander_row['Card Name'].iloc[0] - - # Remove commander from current position - self.card_library = self.card_library[~commander_mask] - - # Prepend commander to top of library - self.card_library = pd.concat([commander_row, self.card_library], ignore_index=True) - - logger.info(f"Successfully moved commander '{commander_name}' to top of library") - - except CommanderMoveError: - raise - except Exception as e: - error_msg = f"Error moving commander to top: {str(e)}" - logger.error(error_msg) - raise CommanderMoveError(error_msg) - - def concatenate_duplicates(self): - """Process duplicate cards in the library using the helper function. - - This method consolidates duplicate cards (like basic lands and special cards - that can have multiple copies) into single entries with updated counts. - It uses the process_duplicate_cards helper function from builder_utils. - - Raises: - DuplicateCardError: If there are issues processing duplicate cards - """ - try: - # Get list of cards that can have duplicates - duplicate_lists = BASIC_LANDS + MULTIPLE_COPY_CARDS - - # Process duplicates using helper function - self.card_library = builder_utils.process_duplicate_cards( - self.card_library, - duplicate_lists - ) - - logger.info("Successfully processed duplicate cards") - - except DuplicateCardError as e: - logger.error(f"Error processing duplicate cards: {e}") - raise - - # Land Management - def add_lands(self): - """ - Add lands to the deck based on ideal count and deck requirements. - - The process follows these steps: - 1. Add basic lands distributed by color identity - 2. Add utility/staple lands - 3. Add fetch lands if requested - 4. Add theme-specific lands (e.g., Kindred) - 5. Add multi-color lands based on color count - 6. Add miscellaneous utility lands - 7. Adjust total land count to match ideal count - """ - MAX_ADJUSTMENT_ATTEMPTS = (self.ideal_land_count - self.min_basics) * 1.5 - self.total_basics = 0 - - try: - # Add lands in sequence - self.add_basics() - self.check_basics() - self.add_standard_non_basics() - self.add_fetches() - - # Add theme and color-specific lands - if any('Kindred' in theme for theme in self.themes): - self.add_kindred_lands() - if len(self.colors) >= 2: - self.add_dual_lands() - if len(self.colors) >= 3: - self.add_triple_lands() - - self.add_misc_lands() - - # Clean up land database - mask = self.land_df['name'].isin(self.card_library['Card Name']) - self.land_df = self.land_df[~mask] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - # Adjust to ideal land count - self.check_basics() - print() - logger.info('Adjusting total land count to match ideal count...') - self.organize_library() - - attempts = 0 - while self.land_cards > int(self.ideal_land_count) and attempts < MAX_ADJUSTMENT_ATTEMPTS: - logger.info(f'Current lands: {self.land_cards}, Target: {self.ideal_land_count}') - self.remove_basic() - self.organize_library() - attempts += 1 - - if attempts >= MAX_ADJUSTMENT_ATTEMPTS: - logger.warning(f"Could not reach ideal land count after {MAX_ADJUSTMENT_ATTEMPTS} attempts") - - logger.info(f'Final land count: {self.land_cards}') - - except Exception as e: - logger.error(f"Error during land addition: {e}") - raise - - def add_basics(self): - """Add basic lands to the deck based on color identity and commander tags. - - This method: - 1. Calculates total basics needed based on ideal land count - 2. Gets appropriate basic land mapping (normal or snow-covered) - 3. Distributes basics across colors - 4. Updates the land database - - Raises: - BasicLandError: If there are issues with basic land addition - LandDistributionError: If land distribution fails - """ - try: - # Calculate total basics needed - total_basics = self.ideal_land_count - DEFAULT_NON_BASIC_LAND_SLOTS - if total_basics <= 0: - raise BasicLandError("Invalid basic land count calculation") - - # Get appropriate basic land mapping - use_snow = 'Snow' in self.commander_tags - color_to_basic = builder_utils.get_basic_land_mapping(use_snow) - - # Calculate distribution - basics_per_color, remaining = builder_utils.calculate_basics_per_color( - total_basics, - len(self.colors) - ) - - print() - logger.info( - f'Adding {total_basics} basic lands distributed across ' - f'{len(self.colors)} colors' - ) - - # Initialize distribution dictionary - distribution = {color: basics_per_color for color in self.colors} - - # Distribute remaining basics - if remaining > 0: - distribution = builder_utils.distribute_remaining_basics( - distribution, - remaining, - self.colors - ) - - # Add basics according to distribution - lands_to_remove = [] - for color, count in distribution.items(): - basic = color_to_basic.get(color) - if basic: - for _ in range(count): - self.add_card(basic, 'Basic Land', None, 0, is_commander=False) - lands_to_remove.append(basic) - - # Update land database - self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - except Exception as e: - logger.error(f"Error adding basic lands: {e}") - raise BasicLandError(f"Failed to add basic lands: {str(e)}") - - def add_standard_non_basics(self): - """Add staple utility lands to the deck based on predefined conditions and requirements. - - This method processes the STAPLE_LAND_CONDITIONS from settings to add appropriate - utility lands to the deck. For each potential staple land, it: - - 1. Validates the land against deck requirements using: - - Commander tags - - Color identity - - Commander power level - - Other predefined conditions - - 2. Adds validated lands to the deck and tracks them in self.staples - - 3. Updates the land database to remove added lands - - The method ensures no duplicate lands are added and maintains proper logging - of all additions. - - Raises: - StapleLandError: If there are issues adding staple lands, such as - validation failures or database update errors. - """ - print() - logger.info('Adding staple non-basic lands') - self.staples = [] - - try: - for land in STAPLE_LAND_CONDITIONS: - if builder_utils.validate_staple_land_conditions( - land, - STAPLE_LAND_CONDITIONS, - self.commander_tags, - self.colors, - self.commander_power - ): - if land not in self.card_library['Card Name'].values: - self.add_card(land, 'Land', None, 0) - self.staples.append(land) - logger.debug(f"Added staple land: {land}") - - self.land_df = builder_utils.process_staple_lands( - self.staples, self.card_library, self.land_df - ) - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - logger.info(f'Added {len(self.staples)} staple lands:') - print(*self.staples, sep='\n') - except Exception as e: - logger.error(f"Error adding staple lands: {e}") - raise StapleLandError(f"Failed to add staple lands: {str(e)}") - - def add_fetches(self): - """Add fetch lands to the deck based on user input and deck colors. - - This method handles: - 1. Getting user input for desired number of fetch lands - 2. Validating the input - 3. Getting available fetch lands based on deck colors - 4. Selecting and adding appropriate fetch lands - 5. Updating the land database - - Raises: - FetchLandValidationError: If fetch land count is invalid - FetchLandSelectionError: If unable to select required fetch lands - PriceLimitError: If fetch lands exceed price limits - """ - try: - # Get user input for fetch lands - print() - logger.info('Adding fetch lands') - print('How many fetch lands would you like to include?\n' - 'For most decks you\'ll likely be good with 3 or 4, just enough to thin the deck and help ensure the color availability.\n' - 'If you\'re doing Landfall, more fetches would be recommended just to get as many Landfall triggers per turn.') - - # Get and validate fetch count - fetch_count = self.input_handler.questionnaire('Number', default_value=FETCH_LAND_DEFAULT_COUNT, message='Default') - validated_count = builder_utils.validate_fetch_land_count(fetch_count) - - # Get available fetch lands based on colors and budget - max_price = self.max_card_price if hasattr(self, 'max_card_price') else None - available_fetches = builder_utils.get_available_fetch_lands( - self.colors, - self.price_checker if use_scrython else None, - max_price - ) - - # Select fetch lands - selected_fetches = builder_utils.select_fetch_lands( - available_fetches, - validated_count - ) - - # Add selected fetch lands to deck - lands_to_remove = set() - for fetch in selected_fetches: - self.add_card(fetch, 'Land', None, 0) - lands_to_remove.add(fetch) - - # Update land database - self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(selected_fetches)} fetch lands:') - print(*selected_fetches, sep='\n') - - except (FetchLandValidationError, FetchLandSelectionError, PriceLimitError) as e: - logger.error(f"Error adding fetch lands: {e}") - raise - - def add_kindred_lands(self): - """Add Kindred-themed lands to the deck based on commander themes. - - This method handles: - 1. Getting available Kindred lands based on deck themes - 2. Selecting and adding appropriate Kindred lands - 3. Updating the land database - - Raises: - KindredLandSelectionError: If unable to select required Kindred lands - PriceLimitError: If Kindred lands exceed price limits - """ - try: - print() - logger.info('Adding Kindred-themed lands') - - # Get available Kindred lands based on themes and budget - max_price = self.max_card_price if hasattr(self, 'max_card_price') else None - available_lands = builder_utils.get_available_kindred_lands( - self.land_df, - self.colors, - self.commander_tags, - self.price_checker if use_scrython else None, - max_price - ) - - # Select Kindred lands - selected_lands = builder_utils.select_kindred_lands( - available_lands, - len(available_lands) - ) - - # Add selected Kindred lands to deck - lands_to_remove = set() - for land in selected_lands: - self.add_card(land, 'Land', None, 0) - lands_to_remove.add(land) - - # Update land database - self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(selected_lands)} Kindred-themed lands:') - print(*selected_lands, sep='\n') - - except Exception as e: - logger.error(f"Error adding Kindred lands: {e}") - raise - - def add_dual_lands(self): - """Add dual lands to the deck based on color identity and user preference. - - This method handles the addition of dual lands by: - 1. Validating if dual lands should be added - 2. Getting available dual lands based on deck colors - 3. Selecting appropriate dual lands - 4. Adding selected lands to the deck - 5. Updating the land database - - The process uses helper functions from builder_utils for modular operation. - """ - try: - # Check if we should add dual lands - print() - print('Would you like to include Dual-type lands (i.e. lands that count as both a Plains and a Swamp for example)?') - use_duals = self.input_handler.questionnaire('Confirm', message='', default_value=True) - - if not use_duals: - logger.info('Skipping adding Dual-type land cards.') - return - - logger.info('Adding Dual-type lands') - # Get color pairs by checking DUAL_LAND_TYPE_MAP keys against files_to_load - color_pairs = [] - for key in DUAL_LAND_TYPE_MAP: - if key in self.files_to_load: - color_pairs.extend([f'Land — {DUAL_LAND_TYPE_MAP[key]}', f'Snow Land — {DUAL_LAND_TYPE_MAP[key]}']) - - # Validate dual lands for these color pairs - if not builder_utils.validate_dual_lands(color_pairs, 'Snow' in self.commander_tags): - logger.info('No valid dual lands available for this color combination.') - return - - # Get available dual lands - dual_df = builder_utils.get_available_dual_lands( - self.land_df, - color_pairs, - 'Snow' in self.commander_tags - ) - - # Select appropriate dual lands - selected_lands = builder_utils.select_dual_lands( - dual_df, - self.price_checker if use_scrython else None, - self.max_card_price if hasattr(self, 'max_card_price') else None - ) - - # Add selected lands to deck - for land in selected_lands: - self.add_card(land['name'], land['type'], - land['manaCost'], land['manaValue']) - - # Update land database - self.land_df = builder_utils.process_dual_lands( - selected_lands, - self.card_library, - self.land_df - ) - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(selected_lands)} Dual-type land cards:') - for card in selected_lands: - print(card['name']) - - except Exception as e: - logger.error(f"Error adding dual lands: {e}") - raise - - def add_triple_lands(self): - """Add triple lands to the deck based on color identity and user preference. - - This method handles the addition of triple lands by: - 1. Validating if triple lands should be added - 2. Getting available triple lands based on deck colors - 3. Selecting appropriate triple lands - 4. Adding selected lands to the deck - 5. Updating the land database - - The process uses helper functions from builder_utils for modular operation. - """ - try: - # Check if we should add triple lands - print() - print('Would you like to include triple lands (i.e. lands that count as a Mountain, Forest, and Plains for example)?') - use_triples = self.input_handler.questionnaire('Confirm', message='', default_value=True) - - if not use_triples: - logger.info('Skipping adding triple lands.') - return - - logger.info('Adding triple lands') - # Get color triplets by checking TRIPLE_LAND_TYPE_MAP keys against files_to_load - color_triplets = [] - for key in TRIPLE_LAND_TYPE_MAP: - if key in self.files_to_load: - color_triplets.extend([f'Land — {TRIPLE_LAND_TYPE_MAP[key]}']) - - # Validate triple lands for these color triplets - if not builder_utils.validate_triple_lands(color_triplets, 'Snow' in self.commander_tags): - logger.info('No valid triple lands available for this color combination.') - return - - # Get available triple lands - triple_df = builder_utils.get_available_triple_lands( - self.land_df, - color_triplets, - 'Snow' in self.commander_tags - ) - - # Select appropriate triple lands - selected_lands = builder_utils.select_triple_lands( - triple_df, - self.price_checker if use_scrython else None, - self.max_card_price if hasattr(self, 'max_card_price') else None - ) - - # Add selected lands to deck - for land in selected_lands: - self.add_card(land['name'], land['type'], - land['manaCost'], land['manaValue']) - - # Update land database - self.land_df = builder_utils.process_triple_lands( - selected_lands, - self.card_library, - self.land_df - ) - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(selected_lands)} triple lands:') - for card in selected_lands: - print(card['name']) - - except Exception as e: - logger.error(f"Error adding triple lands: {e}") - - def add_misc_lands(self): - """Add additional utility lands that fit the deck's color identity. - - This method randomly selects a number of miscellaneous utility lands to add to the deck. - The number of lands is randomly determined between MISC_LAND_MIN_COUNT and MISC_LAND_MAX_COUNT. - Lands are selected from a filtered pool of the top MISC_LAND_POOL_SIZE lands by EDHREC rank. - - The method handles price constraints if price checking is enabled and updates the land - database after adding lands to prevent duplicates. - - Raises: - MiscLandSelectionError: If there are issues selecting appropriate misc lands - """ - print() - logger.info('Adding miscellaneous utility lands') - - try: - # Get available misc lands - available_lands = builder_utils.get_available_misc_lands( - self.land_df, - MISC_LAND_POOL_SIZE - ) - - if not available_lands: - logger.warning("No eligible miscellaneous lands found") - return - - # Select random number of lands - selected_lands = builder_utils.select_misc_lands( - available_lands, - MISC_LAND_MIN_COUNT, - MISC_LAND_MAX_COUNT, - self.price_checker if use_scrython else None, - self.max_card_price if hasattr(self, 'max_card_price') else None - ) - - # Add selected lands - lands_to_remove = set() - for card in selected_lands: - self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue']) - lands_to_remove.add(card['name']) - - # Update land database - self.land_df = self.land_df[~self.land_df['name'].isin(lands_to_remove)] - self.land_df.to_csv(f'{CSV_DIRECTORY}/test_lands.csv', index=False) - - logger.info(f'Added {len(selected_lands)} miscellaneous lands:') - for card in selected_lands: - print(card['name']) - - except Exception as e: - logger.error(f"Error adding misc lands: {e}") - raise - - def check_basics(self): - """Check and display counts of each basic land type in the deck. - - This method analyzes the deck's basic land composition by: - 1. Counting each type of basic land (including snow-covered) - 2. Displaying the counts for each basic land type - 3. Calculating and storing the total number of basic lands - - The method uses helper functions from builder_utils for consistent - counting and display formatting. - - Raises: - BasicLandCountError: If there are issues counting basic lands - - Note: - Updates self.total_basics with the sum of all basic lands - """ - basic_lands = { - 'Plains': 0, - 'Island': 0, - 'Swamp': 0, - 'Mountain': 0, - 'Forest': 0, - 'Snow-Covered Plains': 0, - 'Snow-Covered Island': 0, - 'Snow-Covered Swamp': 0, - 'Snow-Covered Mountain': 0, - 'Snow-Covered Forest': 0 - } - - self.total_basics = 0 - - try: - for land in basic_lands: - count = len(self.card_library[self.card_library['Card Name'] == land]) - basic_lands[land] = count - self.total_basics += count - print() - logger.info("Basic Land Counts:") - for land, count in basic_lands.items(): - if count > 0: - print(f"{land}: {count}") - logger.info(f"Total basic lands: {self.total_basics}") - except BasicLandCountError as e: - logger.error(f"Error counting basic lands: {e}") - self.total_basics = 0 - raise - - def remove_basic(self, max_attempts: int = 3): - """ - Remove a basic land while maintaining color balance. - Attempts to remove from colors with more basics first. - - Args: - max_attempts: Maximum number of removal attempts before falling back to non-basics - """ - print() - logger.info('Land count over ideal count, removing a basic land.') - - color_to_basic = { - 'W': 'Plains', 'U': 'Island', 'B': 'Swamp', - 'R': 'Mountain', 'G': 'Forest' - } - - # Get current basic land counts using vectorized operations - basic_counts = { - basic: len(self.card_library[self.card_library['Card Name'] == basic]) - for color, basic in color_to_basic.items() - if color in self.colors - } - - sum_basics = sum(basic_counts.values()) - attempts = 0 - - while attempts < max_attempts and sum_basics > self.min_basics: - if not basic_counts: - logger.warning("No basic lands found to remove") - break - - basic_land = max(basic_counts.items(), key=lambda x: x[1])[0] - try: - # Use boolean indexing for efficiency - mask = self.card_library['Card Name'] == basic_land - if not mask.any(): - basic_counts.pop(basic_land) - continue - - index_to_drop = self.card_library[mask].index[0] - self.card_library = self.card_library.drop(index_to_drop).reset_index(drop=True) - logger.info(f'{basic_land} removed successfully') - return - - except (IndexError, KeyError) as e: - logger.error(f"Error removing {basic_land}: {e}") - basic_counts.pop(basic_land) - - attempts += 1 - - # If we couldn't remove a basic land, try removing a non-basic - logger.warning("Could not remove basic land, attempting to remove non-basic") - self.remove_land() - - def remove_land(self): - """Remove a random non-basic, non-staple land from the deck. - - This method attempts to remove a non-protected land from the deck up to - LAND_REMOVAL_MAX_ATTEMPTS times. It uses helper functions to filter removable - lands and select a land for removal. - - Raises: - LandRemovalError: If no removable lands are found or removal fails - """ - print() - logger.info('Attempting to remove a non-protected land') - attempts = 0 - - while attempts < LAND_REMOVAL_MAX_ATTEMPTS: - try: - # Get removable lands - removable_lands = builder_utils.filter_removable_lands(self.card_library, PROTECTED_LANDS + self.staples) - - # Select a land for removal - card_index, card_name = builder_utils.select_land_for_removal(removable_lands) - - # Remove the selected land - logger.info(f"Removing {card_name}") - self.card_library.drop(card_index, inplace=True) - self.card_library.reset_index(drop=True, inplace=True) - logger.info("Land removed successfully") - return - - except LandRemovalError as e: - logger.warning(f"Attempt {attempts + 1} failed: {e}") - attempts += 1 - continue - except Exception as e: - logger.error(f"Unexpected error removing land: {e}") - raise LandRemovalError(f"Failed to remove land: {str(e)}") - - # If we reach here, we've exceeded max attempts - raise LandRemovalError(f"Could not find a removable land after {LAND_REMOVAL_MAX_ATTEMPTS} attempts") - # Count pips and get average CMC - def count_pips(self): - """Analyze and display the distribution of colored mana symbols (pips) in card casting costs. - - This method processes the mana costs of all cards in the deck to: - 1. Count the number of colored mana symbols for each color - 2. Calculate the percentage distribution of colors - 3. Log detailed pip distribution information - - The analysis uses helper functions from builder_utils for consistent counting - and percentage calculations. Results are logged with detailed breakdowns - of pip counts and distributions. - - Dependencies: - - MANA_COLORS from settings.py for color iteration - - builder_utils.count_color_pips() for counting pips - - builder_utils.calculate_pip_percentages() for distribution calculation - - Returns: - None - - Raises: - ManaPipError: If there are issues with: - - Counting pips for specific colors - - Calculating pip percentages - - Unexpected errors during analysis - - Logs: - - Warning if no colored mana symbols are found - - Info with detailed pip distribution and percentages - - Error details if analysis fails - """ - print() - logger.info('Analyzing color pip distribution...') - - try: - # Get mana costs from card library - mana_costs = self.card_library['Mana Cost'].dropna() - - # Count pips for each color using helper function - pip_counts = {} - for color in MANA_COLORS: - try: - pip_counts[color] = builder_utils.count_color_pips(mana_costs, color) - except (TypeError, ValueError) as e: - raise ManaPipError( - f"Error counting {color} pips", - {"color": color, "error": str(e)} - ) - - # Calculate percentages using helper function - try: - percentages = builder_utils.calculate_pip_percentages(pip_counts) - except (TypeError, ValueError) as e: - raise ManaPipError( - "Error calculating pip percentages", - {"error": str(e)} - ) - - # Log detailed pip distribution - total_pips = sum(pip_counts.values()) - if total_pips == 0: - logger.warning("No colored mana symbols found in casting costs") - return - - logger.info("Color Pip Distribution:") - for color in MANA_COLORS: - count = pip_counts[color] - if count > 0: - percentage = percentages[color] - print(f"{color}: {count} pips ({percentage:.1f}%)") - print() - logger.info(f"Total colored pips: {total_pips}") - # Filter out zero percentages - non_zero_percentages = {color: pct for color, pct in percentages.items() if pct > 0} - logger.info(f"Distribution ratios: {non_zero_percentages}\n") - - except ManaPipError as e: - logger.error(f"Mana pip analysis failed: {e}") - raise - except Exception as e: - logger.error(f"Unexpected error in pip analysis: {e}") - raise ManaPipError("Failed to analyze mana pips", {"error": str(e)}) - - def get_cmc(self): - """Calculate average converted mana cost of non-land cards.""" - logger.info('Calculating average mana value of non-land cards.') - - try: - # Filter non-land cards - non_land = self.card_library[ - ~self.card_library['Card Type'].str.contains('Land') - ].copy() - - if non_land.empty: - logger.warning("No non-land cards found") - self.cmc = 0.0 - else: - total_cmc = non_land['Mana Value'].sum() - self.cmc = round(total_cmc / len(non_land), 2) - - self.commander_dict.update({'CMC': float(self.cmc)}) - logger.info(f"Average CMC: {self.cmc}") - - except Exception as e: - logger.error(f"Error calculating CMC: {e}") - self.cmc = 0.0 - - def weight_by_theme(self, tag: str, ideal: int = 1, weight: float = 1.0, df: Optional[pd.DataFrame] = None) -> None: - """Add cards with specific tag up to weighted ideal count. - - Args: - tag: Theme tag to filter cards by - ideal: Target number of cards to add - weight: Theme weight factor (0.0-1.0) - df: Source DataFrame to filter cards from - - Raises: - ThemeWeightingError: If weight calculation fails - ThemePoolError: If card pool is empty or insufficient - """ - try: - # Calculate target card count using weight and safety multiplier - target_count = math.ceil(ideal * weight * THEME_WEIGHT_MULTIPLIER) - logger.info(f'Finding {target_count} cards with the "{tag}" tag...') - - # Handle Kindred theme special case - tags = [tag, 'Kindred Support'] if 'Kindred' in tag else [tag] - - # Calculate initial pool size - pool_size = builder_utils.calculate_weighted_pool_size(target_count, weight) - - # Filter cards by theme - if df is None: - raise ThemePoolError(f"No source DataFrame provided for theme {tag}") - - tag_df = builder_utils.filter_theme_cards(df, tags, pool_size) - if tag_df.empty: - raise ThemePoolError(f"No cards found for theme {tag}") - - # Select cards considering price and duplicates - selected_cards = builder_utils.select_weighted_cards( - tag_df, - target_count, - self.price_checker if use_scrython else None, - self.max_card_price if hasattr(self, 'max_card_price') else None - ) - - # Process selected cards - cards_added = [] - for card in selected_cards: - # Handle multiple copy cards - if card['name'] in MULTIPLE_COPY_CARDS: - copies = { - 'Nazgûl': 9, - 'Seven Dwarves': 7 - }.get(card['name'], target_count - len(cards_added)) - - for _ in range(copies): - cards_added.append(card) - - # Handle regular cards - elif card['name'] not in self.card_library['Card Name'].values: - cards_added.append(card) - else: - logger.warning(f"{card['name']} already in Library, skipping it.") - - # Add selected cards to library - for card in cards_added: - self.add_card( - card['name'], - card['type'], - card['manaCost'], - card['manaValue'], - card.get('creatureTypes'), - card['themeTags'] - ) - - # Update DataFrames - used_cards = {card['name'] for card in selected_cards} - self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(used_cards)] - - logger.info(f'Added {len(cards_added)} {tag} cards') - for card in cards_added: - print(card['name']) - - except (ThemeWeightingError, ThemePoolError) as e: - logger.error(f"Error in weight_by_theme: {e}") - raise - except Exception as e: - logger.error(f"Unexpected error in weight_by_theme: {e}") - raise ThemeWeightingError(f"Failed to process theme {tag}: {str(e)}") - - def add_by_tags(self, tag, ideal_value=1, df=None, ignore_existing=False): - """Add cards with specific tag up to ideal_value count. - Args: - tag: The theme tag to filter cards by - ideal_value: Target number of cards to add - df: DataFrame containing candidate cards - - Raises: - ThemeTagError: If there are issues with tag processing or card selection - """ - try: - # Count existing cards with target tag - print() - if not ignore_existing: - existing_count = len(self.card_library[self.card_library['Themes'].apply(lambda x: x is not None and tag in x)]) - remaining_slots = max(0, ideal_value - existing_count + 1) - else: - existing_count = 0 - remaining_slots = max(0, ideal_value - existing_count + 1) - - if remaining_slots == 0: - if not ignore_existing: - logger.info(f'Already have {existing_count} cards with tag "{tag}" - no additional cards needed') - return - else: - logger.info(f'Already have {ideal_value} cards with tag "{tag}" - no additional cards needed') - return - - logger.info(f'Finding {remaining_slots} additional cards with the "{tag}" tag...') - - # Filter cards with the given tag - skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1 - tag_df = df.copy() - tag_df.sort_values(by='edhrecRank', inplace=True) - tag_df = tag_df[tag_df['themeTags'].apply(lambda x: x is not None and tag in x)] - - # Calculate initial pool size using THEME_POOL_SIZE_MULTIPLIER - pool_size = int(remaining_slots * THEME_POOL_SIZE_MULTIPLIER) - tag_df = tag_df.head(pool_size) - - # Convert to list of card dictionaries with priority scores - card_pool = [] - for _, row in tag_df.iterrows(): - theme_tags = row['themeTags'] if row['themeTags'] is not None else [] - priority = builder_utils.calculate_theme_priority(theme_tags, self.themes, THEME_PRIORITY_BONUS) - card_pool.append({ - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'], - 'creatureTypes': row['creatureTypes'], - 'themeTags': theme_tags, - 'priority': priority - }) - - # Sort card pool by priority score - card_pool.sort(key=lambda x: x['priority'], reverse=True) - - # Select cards up to remaining slots - cards_to_add = [] - for card in card_pool: - if len(cards_to_add) >= remaining_slots: - break - - # Check price constraints if enabled - if use_scrython and hasattr(self, 'max_card_price') and self.max_card_price: - price = self.price_checker.get_card_price(card['name']) - if price > self.max_card_price * 1.1: - continue - - # Handle multiple-copy cards - if card['name'] in MULTIPLE_COPY_CARDS: - existing_copies = len(self.card_library[self.card_library['Card Name'] == card['name']]) - if existing_copies < ideal_value: - cards_to_add.append(card) - continue - - # Add new cards if not already in library - if card['name'] not in self.card_library['Card Name'].values: - if 'Creature' in card['type'] and skip_creatures: - continue - else: - if 'Creature' in card['type']: - self.creature_cards += 1 - skip_creatures = self.creature_cards > self.ideal_creature_count * 1.1 - cards_to_add.append(card) - - # Add selected cards to library - for card in cards_to_add: - if len(self.card_library) < 100: - self.add_card(card['name'], card['type'], - card['manaCost'], card['manaValue'], - card['creatureTypes'], card['themeTags']) - else: - break - - # Update DataFrames - card_pool_names = [item['name'] for item in card_pool] - self.noncreature_df = self.noncreature_df[~self.noncreature_df['name'].isin(card_pool_names)] - - logger.info(f'Added {len(cards_to_add)} {tag} cards (total with tag: {existing_count + len(cards_to_add)})') - for card in cards_to_add: - print(card['name']) - - except Exception as e: - raise ThemeTagError(f"Error processing tag '{tag}'", {"error": str(e)}) - - def add_creatures(self): - """ - Add creatures to the deck based on themes and weights. - - This method processes the primary, secondary, and tertiary themes to add - creatures proportionally according to their weights. The total number of - creatures added will approximate the ideal_creature_count. - - The method follows this process: - 1. Process hidden theme if present - 2. Process primary theme - 3. Process secondary theme if present - 4. Process tertiary theme if present - - Each theme is weighted according to its importance: - - Hidden theme: Highest priority if present - - Primary theme: Main focus - - Secondary theme: Supporting focus - - Tertiary theme: Minor focus - - Args: - None - - Returns: - None - - Raises: - ThemeWeightingError: If there are issues with theme weight calculations - ThemePoolError: If the card pool for a theme is insufficient - Exception: For any other unexpected errors during creature addition - - Note: - The method uses error handling to ensure the deck building process - continues even if a particular theme encounters issues. - """ - print() - logger.info(f'Adding creatures to deck based on the ideal creature count of {self.ideal_creature_count}...') - - try: - if self.hidden_theme: - print() - logger.info(f'Processing Hidden theme: {self.hidden_theme}') - self.weight_by_theme(self.hidden_theme, self.ideal_creature_count, self.hidden_weight, self.creature_df) - - logger.info(f'Processing primary theme: {self.primary_theme}') - self.weight_by_theme(self.primary_theme, self.ideal_creature_count, self.primary_weight, self.creature_df) - - if self.secondary_theme: - print() - logger.info(f'Processing secondary theme: {self.secondary_theme}') - self.weight_by_theme(self.secondary_theme, self.ideal_creature_count, self.secondary_weight, self.creature_df) - - if self.tertiary_theme: - print() - logger.info(f'Processing tertiary theme: {self.tertiary_theme}') - self.weight_by_theme(self.tertiary_theme, self.ideal_creature_count, self.tertiary_weight, self.creature_df) - - except Exception as e: - logger.error(f"Error while adding creatures: {e}") - finally: - self.organize_library() - - def add_ramp(self): - """Add ramp cards to the deck based on ideal ramp count. - - This method adds three categories of ramp cards: - 1. Mana rocks (artifacts that produce mana) - ~1/3 of ideal ramp count - 2. Mana dorks (creatures that produce mana) - ~1/4 of ideal ramp count - 3. General ramp spells - remaining portion of ideal ramp count - - The method uses the add_by_tags() helper to add cards from each category - while respecting the deck's themes and color identity. - - Args: - None - - Returns: - None - - Raises: - ThemeTagError: If there are issues adding cards with ramp-related tags - """ - try: - self.add_by_tags('Mana Rock', math.ceil(self.ideal_ramp / 3), self.noncreature_df) - self.add_by_tags('Mana Dork', math.ceil(self.ideal_ramp / 4), self.creature_df) - self.add_by_tags('Ramp', self.ideal_ramp, self.noncreature_df) - except Exception as e: - logger.error(f"Error while adding Ramp: {e}") - - def add_interaction(self): - """Add interaction cards to the deck for removal and protection. - - This method adds two categories of interaction cards: - 1. Removal spells based on ideal_removal count - 2. Protection spells based on ideal_protection count - - Cards are selected from non-planeswalker cards to ensure appropriate - interaction types are added. - - Args: - None - - Returns: - None - - Raises: - ThemeTagError: If there are issues adding cards with interaction-related tags - """ - try: - self.add_by_tags('Removal', self.ideal_removal, self.nonplaneswalker_df) - self.add_by_tags('Protection', self.ideal_protection, self.nonplaneswalker_df) - except Exception as e: - logger.error(f"Error while adding Interaction: {e}") - - def add_board_wipes(self): - """Add board wipe cards to the deck. - - This method adds board wipe cards based on the ideal_wipes count. - Board wipes are selected from the full card pool to include all possible - options across different card types. - - Args: - None - - Returns: - None - - Raises: - ThemeTagError: If there are issues adding cards with the 'Board Wipes' tag - """ - try: - self.add_by_tags('Board Wipes', self.ideal_wipes, self.full_df) - except Exception as e: - logger.error(f"Error while adding Board Wipes: {e}") - - def add_card_advantage(self): - """Add card advantage effects to the deck. - - This method adds two categories of card draw effects: - 1. Conditional draw effects (20% of ideal_card_advantage) - - Cards that draw based on specific conditions or triggers - 2. Unconditional draw effects (80% of ideal_card_advantage) - - Cards that provide straightforward card draw - - Cards are selected from appropriate pools while avoiding planeswalkers - for unconditional draw effects. - - Args: - None - - Returns: - None - - Raises: - ThemeTagError: If there are issues adding cards with draw-related tags - """ - try: - self.add_by_tags('Conditional Draw', math.ceil(self.ideal_card_advantage * 0.2), self.full_df) - self.add_by_tags('Unconditional Draw', math.ceil(self.ideal_card_advantage * 0.8), self.nonplaneswalker_df) - except Exception as e: - logger.error(f"Error while adding Card Draw: {e}") - - def fill_out_deck(self): - """Fill out the deck to 100 cards with theme-appropriate cards. - - This method completes the deck by adding remaining cards up to the 100-card - requirement, prioritizing cards that match the deck's themes. The process - follows these steps: - - 1. Calculate how many cards are needed to reach 100 - 2. Add cards from each theme with weighted distribution: - - Hidden theme (if present) - - Tertiary theme (20% weight if present) - - Secondary theme (30% weight if present) - - Primary theme (50% weight) - - The method includes safeguards: - - Maximum attempts limit to prevent infinite loops - - Timeout to prevent excessive runtime - - Progress tracking to break early if insufficient progress - - Args: - None - - Returns: - None - - Raises: - ThemeTagError: If there are issues adding cards with specific theme tags - TimeoutError: If the process exceeds the maximum allowed time - - Note: - If the deck cannot be filled to 100 cards, a warning message is logged - indicating manual additions may be needed. - """ - print() - logger.info('Filling out the Library to 100 with cards fitting the themes.') - cards_needed = 100 - len(self.card_library) - if cards_needed <= 0: - return - - logger.info(f"Need to add {cards_needed} more cards") - - # Define maximum attempts and timeout - MAX_ATTEMPTS = max(20, cards_needed * 2) - MAX_TIME = 60 # Maximum time in seconds - start_time = time.time() - attempts = 0 - - while len(self.card_library) < 100 and attempts < MAX_ATTEMPTS: - # Check timeout - if time.time() - start_time > MAX_TIME: - logger.error("Timeout reached while filling deck") - break - - initial_count = len(self.card_library) - remaining = 100 - len(self.card_library) - - # Adjust self.weights based on remaining cards needed - weight_multiplier = remaining / cards_needed - - try: - # Add cards from each theme with adjusted self.weights - if self.hidden_theme and remaining > 0: - self.add_by_tags(self.hidden_theme, - math.ceil(weight_multiplier), - self.full_df, - True) - - # Adjust self.weights based on remaining cards needed - remaining = 100 - len(self.card_library) - weight_multiplier = remaining / cards_needed - if self.tertiary_theme and remaining > 0: - self.add_by_tags(self.tertiary_theme, - math.ceil(weight_multiplier * 0.2), - self.noncreature_df, - True) - - if self.secondary_theme and remaining > 0: - self.add_by_tags(self.secondary_theme, - math.ceil(weight_multiplier * 0.3), - self.noncreature_df, - True) - if remaining > 0: - self.add_by_tags(self.primary_theme, - math.ceil(weight_multiplier * 0.5), - self.noncreature_df, - True) - - # Check if we made progress - if len(self.card_library) == initial_count: - attempts += 1 - if attempts % 5 == 0: - print() - logger.warning(f"Made {attempts} attempts, still need {100 - len(self.card_library)} cards") - - # Break early if we're stuck - if attempts >= MAX_ATTEMPTS / 2 and len(self.card_library) < initial_count + (cards_needed / 4): - print() - logger.warning("Insufficient progress being made, breaking early") - break - - except Exception as e: - print() - logger.error(f"Error while adding cards: {e}") - attempts += 1 - - final_count = len(self.card_library) - if final_count < 100: - message = f"\nWARNING: Deck is incomplete with {final_count} cards. Manual additions may be needed." - print() - logger.warning(message) - else: - print() - logger.info(f"Successfully filled deck to {final_count} cards in {attempts} attempts") - -def main(): - """Main entry point for deck builder application.""" - build_deck = DeckBuilder() - build_deck.determine_commander() - pprint.pprint(build_deck.commander_dict, sort_dicts=False) - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/code/deck_builder_old/builder_utils.py b/code/deck_builder_old/builder_utils.py deleted file mode 100644 index 87345bc..0000000 --- a/code/deck_builder_old/builder_utils.py +++ /dev/null @@ -1,1642 +0,0 @@ -"""Utility module for MTG deck building operations. - -This module provides utility functions for various deck building operations including: -- DataFrame validation and processing -- Card type counting and validation -- Land selection and management -- Theme processing and weighting -- Price checking integration -- Mana pip analysis - -The module serves as a central collection of helper functions used throughout the -deck building process, handling data validation, card selection, and various -deck composition calculations. - -Key Features: -- DataFrame validation with timeout handling -- Card type counting and categorization -- Land type validation and selection (basic, fetch, dual, etc.) -- Theme tag processing and weighting calculations -- Mana pip counting and color distribution analysis - -Typical usage example: - >>> df = load_commander_data() - >>> validate_dataframe(df, DATAFRAME_VALIDATION_RULES) - >>> process_dataframe_batch(df) - >>> count_cards_by_type(df, ['Creature', 'Instant', 'Sorcery']) -""" - -# Standard library imports -import functools -import time -from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union, cast - -# Third-party imports -import pandas as pd -from fuzzywuzzy import process - -# Local application imports -from exceptions import ( - CSVValidationError, - DataFrameTimeoutError, - DataFrameValidationError, - DeckBuilderError, - DuplicateCardError, - EmptyDataFrameError, - FetchLandSelectionError, - FetchLandValidationError, - KindredLandSelectionError, - KindredLandValidationError, - LandRemovalError, - ThemeSelectionError, - ThemeWeightError, - CardTypeCountError -) -from input_handler import InputHandler -from price_check import PriceChecker -from .builder_constants import ( - CARD_TYPE_SORT_ORDER, COLOR_TO_BASIC_LAND, COMMANDER_CONVERTERS, - COMMANDER_CSV_PATH, DATAFRAME_BATCH_SIZE, - DATAFRAME_REQUIRED_COLUMNS, DATAFRAME_TRANSFORM_TIMEOUT, - DATAFRAME_VALIDATION_RULES, DATAFRAME_VALIDATION_TIMEOUT, - DECK_COMPOSITION_PROMPTS, DEFAULT_BASIC_LAND_COUNT, - DEFAULT_CARD_ADVANTAGE_COUNT, DEFAULT_CREATURE_COUNT, - DEFAULT_LAND_COUNT, DEFAULT_MAX_CARD_PRICE, DEFAULT_MAX_DECK_PRICE, - DEFAULT_PROTECTION_COUNT, DEFAULT_RAMP_COUNT, - DEFAULT_REMOVAL_COUNT, DEFAULT_WIPES_COUNT, DUAL_LAND_TYPE_MAP, - DUPLICATE_CARD_FORMAT, FUZZY_MATCH_THRESHOLD, KINDRED_STAPLE_LANDS, - MANA_COLORS, MANA_PIP_PATTERNS, MAX_FUZZY_CHOICES, - SNOW_BASIC_LAND_MAPPING, THEME_POOL_SIZE_MULTIPLIER, - WEIGHT_ADJUSTMENT_FACTORS -) -from type_definitions import CardLibraryDF, CommanderDF, LandDF -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 variables for generic functions -T = TypeVar('T') -DataFrame = TypeVar('DataFrame', bound=pd.DataFrame) - -def timeout_wrapper(timeout: float) -> Callable: - """Decorator to add timeout to functions. - - Args: - timeout: Maximum execution time in seconds - - Returns: - Decorated function with timeout - - Raises: - DataFrameTimeoutError: If operation exceeds timeout - """ - def decorator(func: Callable[..., T]) -> Callable[..., T]: - @functools.wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> T: - start_time = time.time() - result = func(*args, **kwargs) - elapsed = time.time() - start_time - - if elapsed > timeout: - raise DataFrameTimeoutError( - func.__name__, - timeout, - elapsed, - {'args': args, 'kwargs': kwargs} - ) - return result - return wrapper - return decorator - -def get_validation_rules(data_type: str) -> Dict[str, Dict[str, Any]]: - """Get validation rules for specific data type. - - Args: - data_type: Type of data to get rules for - - Returns: - Dictionary of validation rules - """ - from .builder_constants import ( - CREATURE_VALIDATION_RULES, - SPELL_VALIDATION_RULES, - LAND_VALIDATION_RULES - ) - - rules_map = { - 'creature': CREATURE_VALIDATION_RULES, - 'spell': SPELL_VALIDATION_RULES, - 'land': LAND_VALIDATION_RULES - } - - return rules_map.get(data_type, DATAFRAME_VALIDATION_RULES) - -@timeout_wrapper(DATAFRAME_VALIDATION_TIMEOUT) -def validate_dataframe(df: pd.DataFrame, rules: Dict[str, Dict[str, Any]]) -> bool: - """Validate DataFrame against provided rules. - - Args: - df: DataFrame to validate - rules: Validation rules to apply - - Returns: - True if validation passes - - Raises: - DataFrameValidationError: If validation fails - """ - #print(df.columns) - if df.empty: - raise EmptyDataFrameError("validate_dataframe") - - try: - validate_required_columns(df) - validate_column_types(df, rules) - return True - except Exception as e: - raise DataFrameValidationError( - "DataFrame validation failed", - {'rules': rules, 'error': str(e)} - ) - -def validate_column_types(df: pd.DataFrame, rules: Dict[str, Dict[str, Any]]) -> bool: - """Validate column types against rules. - - Args: - df: DataFrame to validate - rules: Type validation rules - - Returns: - True if validation passes - - Raises: - DataFrameValidationError: If type validation fails - """ - for col, rule in rules.items(): - if col not in df.columns: - continue - - expected_type = rule.get('type') - if not expected_type: - continue - - if isinstance(expected_type, tuple): - valid = any(df[col].dtype.name.startswith(t) for t in expected_type) - else: - valid = df[col].dtype.name.startswith(expected_type) - - if not valid: - raise DataFrameValidationError( - col, - rule, - {'actual_type': df[col].dtype.name} - ) - - return True - -def validate_required_columns(df: pd.DataFrame) -> bool: - """Validate presence of required columns. - - Args: - df: DataFrame to validate - - Returns: - True if validation passes - - Raises: - DataFrameValidationError: If required columns are missing - """ - #print(df.columns) - missing = set(DATAFRAME_REQUIRED_COLUMNS) - set(df.columns) - if missing: - raise DataFrameValidationError( - "missing_columns", - {'required': DATAFRAME_REQUIRED_COLUMNS}, - {'missing': list(missing)} - ) - return True - -@timeout_wrapper(DATAFRAME_TRANSFORM_TIMEOUT) -def process_dataframe_batch(df: pd.DataFrame, batch_size: int = DATAFRAME_BATCH_SIZE) -> pd.DataFrame: - """Process DataFrame in batches. - - Args: - df: DataFrame to process - batch_size: Size of each batch - - Returns: - Processed DataFrame - - Raises: - DataFrameTimeoutError: If processing exceeds timeout - """ - processed_dfs = [] - - for i in range(0, len(df), batch_size): - batch = df.iloc[i:i + batch_size].copy() - processed = transform_dataframe(batch) - processed_dfs.append(processed) - - return pd.concat(processed_dfs, ignore_index=True) - -def transform_dataframe(df: pd.DataFrame) -> pd.DataFrame: - """Apply transformations to DataFrame. - - Args: - df: DataFrame to transform - - Returns: - Transformed DataFrame - """ - df = df.copy() - - # Fill missing values - df['colorIdentity'] = df['colorIdentity'].fillna('COLORLESS') - df['colors'] = df['colors'].fillna('COLORLESS') - - # Convert types - numeric_cols = ['manaValue', 'edhrecRank'] - for col in numeric_cols: - if col in df.columns: - df[col] = pd.to_numeric(df[col], errors='coerce') - - return df - -def combine_dataframes(dfs: List[pd.DataFrame]) -> pd.DataFrame: - """Combine multiple DataFrames with validation. - - Args: - dfs: List of DataFrames to combine - - Returns: - Combined DataFrame - - Raises: - EmptyDataFrameError: If no valid DataFrames to combine - """ - if not dfs: - raise EmptyDataFrameError("No DataFrames to combine") - - valid_dfs = [] - for df in dfs: - try: - if validate_dataframe(df, DATAFRAME_VALIDATION_RULES): - valid_dfs.append(df) - except DataFrameValidationError as e: - logger.warning(f"Skipping invalid DataFrame: {e}") - - if not valid_dfs: - raise EmptyDataFrameError("No valid DataFrames to combine") - - return pd.concat(valid_dfs, ignore_index=True) - -def load_commander_data(csv_path: str = COMMANDER_CSV_PATH, - converters: Dict = COMMANDER_CONVERTERS) -> pd.DataFrame: - """Load and prepare commander data from CSV file. - - Args: - csv_path (str): Path to commander CSV file. Defaults to COMMANDER_CSV_PATH. - converters (Dict): Column converters for CSV loading. Defaults to COMMANDER_CONVERTERS. - - Returns: - pd.DataFrame: Processed commander dataframe - - Raises: - DeckBuilderError: If CSV file cannot be loaded or processed - """ - try: - df = pd.read_csv(csv_path, converters=converters) - df['colorIdentity'] = df['colorIdentity'].fillna('COLORLESS') - df['colors'] = df['colors'].fillna('COLORLESS') - return df - except FileNotFoundError: - logger.error(f"Commander CSV file not found at {csv_path}") - raise DeckBuilderError(f"Commander data file not found: {csv_path}") - except Exception as e: - logger.error(f"Error loading commander data: {e}") - raise DeckBuilderError(f"Failed to load commander data: {str(e)}") - -def process_fuzzy_matches(card_name: str, - df: pd.DataFrame, - threshold: int = FUZZY_MATCH_THRESHOLD, - max_choices: int = MAX_FUZZY_CHOICES) -> Tuple[str, List[Tuple[str, int]], bool]: - """Process fuzzy matching for commander name selection. - - Args: - card_name (str): Input card name to match - df (pd.DataFrame): Commander dataframe to search - threshold (int): Minimum score for direct match. Defaults to FUZZY_MATCH_THRESHOLD. - max_choices (int): Maximum number of choices to return. Defaults to MAX_FUZZY_CHOICES. - - Returns: - Tuple[str, List[Tuple[str, int]], bool]: Selected card name, list of matches with scores, and match status - """ - try: - match, score, _ = process.extractOne(card_name, df['name']) - if score >= threshold: - return match, [], True - - fuzzy_choices = process.extract(card_name, df['name'], limit=max_choices) - fuzzy_choices = [(name, score) for name, score in fuzzy_choices] - return "", fuzzy_choices, False - except Exception as e: - logger.error(f"Error in fuzzy matching: {e}") - raise DeckBuilderError(f"Failed to process fuzzy matches: {str(e)}") - -def validate_commander_selection(df: pd.DataFrame, commander_name: str) -> Dict: - """Validate and format commander data from selection. - - Args: - df (pd.DataFrame): Commander dataframe - commander_name (str): Selected commander name - - Returns: - Dict: Formatted commander data dictionary - - Raises: - DeckBuilderError: If commander data is invalid or missing - """ - try: - filtered_df = df[df['name'] == commander_name] - if filtered_df.empty: - raise DeckBuilderError(f"No commander found with name: {commander_name}") - - commander_dict = filtered_df.to_dict('list') - - # Validate required fields - required_fields = ['name', 'type', 'colorIdentity', 'colors', 'manaCost', 'manaValue'] - for field in required_fields: - if field not in commander_dict or not commander_dict[field]: - raise DeckBuilderError(f"Missing required commander data: {field}") - - return commander_dict - except Exception as e: - logger.error(f"Error validating commander selection: {e}") - raise DeckBuilderError(f"Failed to validate commander selection: {str(e)}") - -def select_theme(themes_list: List[str], prompt: str, optional=False) -> str: - """Handle the selection of a theme from a list with user interaction. - - Args: - themes_list: List of available themes to choose from - prompt: Message to display when prompting for theme selection - - Returns: - str: Selected theme name - - Raises: - ThemeSelectionError: If user chooses to stop without selecting a theme - """ - try: - if not themes_list: - raise ThemeSelectionError("No themes available for selection") - - print(prompt) - for idx, theme in enumerate(themes_list, 1): - print(f"{idx}. {theme}") - print("0. Stop selection") - - while True: - try: - choice = int(input("Enter the number of your choice: ")) - if choice == 0: - return 'Stop Here' - if 1 <= choice <= len(themes_list): - return themes_list[choice - 1] - print("Invalid choice. Please try again.") - except ValueError: - print("Please enter a valid number.") - - except Exception as e: - logger.error(f"Error in theme selection: {e}") - raise ThemeSelectionError(f"Theme selection failed: {str(e)}") - -def adjust_theme_weights(primary_theme: str, - secondary_theme: str, - tertiary_theme: str, - weights: Dict[str, float]) -> Dict[str, float]: - """Calculate adjusted theme weights based on theme combinations. - - Args: - primary_theme: The main theme selected - secondary_theme: The second theme selected - tertiary_theme: The third theme selected - weights: Initial theme weights dictionary - - Returns: - Dict[str, float]: Adjusted theme weights - - Raises: - ThemeWeightError: If weight calculations fail - """ - try: - adjusted_weights = weights.copy() - - for theme, factors in WEIGHT_ADJUSTMENT_FACTORS.items(): - if theme in [primary_theme, secondary_theme, tertiary_theme]: - for target_theme, factor in factors.items(): - if target_theme in adjusted_weights: - adjusted_weights[target_theme] = round(adjusted_weights[target_theme] * factor, 2) - - # Normalize weights to ensure they sum to 1.0 - total_weight = sum(adjusted_weights.values()) - if total_weight > 0: - adjusted_weights = {k: round(v/total_weight, 2) for k, v in adjusted_weights.items()} - - return adjusted_weights - - except Exception as e: - logger.error(f"Error adjusting theme weights: {e}") - raise ThemeWeightError(f"Failed to adjust theme weights: {str(e)}") -def configure_price_settings(price_checker: Optional[PriceChecker], input_handler: InputHandler) -> None: - """Handle configuration of price settings if price checking is enabled. - - Args: - price_checker: Optional PriceChecker instance for price validation - input_handler: InputHandler instance for user input - - Returns: - None - - Raises: - ValueError: If invalid price values are provided - """ - if not price_checker: - return - - try: - # Configure max deck price - print('Would you like to set an intended max price of the deck?\n' - 'There will be some leeway of ~10%, with a couple alternative options provided.') - if input_handler.questionnaire('Confirm', message='', default_value=False): - print('What would you like the max price to be?') - max_deck_price = float(input_handler.questionnaire('Number', default_value=DEFAULT_MAX_DECK_PRICE)) - price_checker.max_deck_price = max_deck_price - print() - - # Configure max card price - print('Would you like to set a max price per card?\n' - 'There will be some leeway of ~10% when choosing cards and you can choose to keep it or not.') - if input_handler.questionnaire('Confirm', message='', default_value=False): - print('What would you like the max price to be?') - max_card_price = float(input_handler.questionnaire('Number', default_value=DEFAULT_MAX_CARD_PRICE)) - price_checker.max_card_price = max_card_price - print() - - except ValueError as e: - logger.error(f"Error configuring price settings: {e}") - raise - -def get_deck_composition_values(input_handler: InputHandler) -> Dict[str, int]: - """Collect deck composition values from the user. - - Args: - input_handler: InputHandler instance for user input - - Returns: - Dict[str, int]: Mapping of component names to their values - - Raises: - ValueError: If invalid numeric values are provided - """ - try: - composition = {} - for component, prompt in DECK_COMPOSITION_PROMPTS.items(): - if component not in ['max_deck_price', 'max_card_price']: - default_map = { - 'ramp': DEFAULT_RAMP_COUNT, - 'lands': DEFAULT_LAND_COUNT, - 'basic_lands': DEFAULT_BASIC_LAND_COUNT, - 'creatures': DEFAULT_CREATURE_COUNT, - 'removal': DEFAULT_REMOVAL_COUNT, - 'wipes': DEFAULT_WIPES_COUNT, - 'card_advantage': DEFAULT_CARD_ADVANTAGE_COUNT, - 'protection': DEFAULT_PROTECTION_COUNT - } - default_value = default_map.get(component, 0) - - print(prompt) - composition[component] = int(input_handler.questionnaire('Number', message='Default', default_value=default_value)) - print() - - return composition - - except ValueError as e: - logger.error(f"Error getting deck composition values: {e}") - raise - -def assign_sort_order(df: pd.DataFrame) -> pd.DataFrame: - """Assign sort order to cards based on their types. - - This function adds a 'Sort Order' column to the DataFrame based on the - CARD_TYPE_SORT_ORDER constant from settings. Cards are sorted according to - their primary type, with the order specified in CARD_TYPE_SORT_ORDER. - - Args: - df: DataFrame containing card information with a 'Card Type' column - - Returns: - DataFrame with an additional 'Sort Order' column - - Example: - >>> df = pd.DataFrame({ - ... 'Card Type': ['Creature', 'Instant', 'Land'] - ... }) - >>> sorted_df = assign_sort_order(df) - >>> sorted_df['Sort Order'].tolist() - ['Creature', 'Instant', 'Land'] - """ - # Create a copy of the input DataFrame - df = df.copy() - - # Initialize Sort Order column with default value - df['Sort Order'] = 'Other' - - # Assign sort order based on card types - for card_type in CARD_TYPE_SORT_ORDER: - mask = df['Card Type'].str.contains(card_type, case=False, na=False) - df.loc[mask, 'Sort Order'] = card_type - - # Convert Sort Order to categorical for proper sorting - df['Sort Order'] = pd.Categorical( - df['Sort Order'], - categories=CARD_TYPE_SORT_ORDER + ['Other'], - ordered=True - ) - return df - -def process_duplicate_cards(card_library: pd.DataFrame, duplicate_lists: List[str]) -> pd.DataFrame: - """Process duplicate cards in the library and consolidate them with updated counts. - - This function identifies duplicate cards that are allowed to have multiple copies - (like basic lands and certain special cards), consolidates them into single entries, - and updates their counts. Card names are formatted using DUPLICATE_CARD_FORMAT. - - Args: - card_library: DataFrame containing the deck's card library - duplicate_lists: List of card names allowed to have multiple copies - - Returns: - DataFrame with processed duplicate cards and updated counts - - Raises: - DuplicateCardError: If there are issues processing duplicate cards - - Example: - >>> card_library = pd.DataFrame({ - ... 'name': ['Forest', 'Forest', 'Mountain', 'Mountain', 'Sol Ring'], - ... 'type': ['Basic Land', 'Basic Land', 'Basic Land', 'Basic Land', 'Artifact'] - ... }) - >>> duplicate_lists = ['Forest', 'Mountain'] - >>> result = process_duplicate_cards(card_library, duplicate_lists) - >>> print(result['name'].tolist()) - ['Forest x 2', 'Mountain x 2', 'Sol Ring'] - """ - try: - # Create a copy of the input DataFrame - processed_library = card_library.copy() - - # Process each allowed duplicate card - for card_name in duplicate_lists: - # Find all instances of the card - card_mask = processed_library['Card Name'] == card_name - card_count = card_mask.sum() - - if card_count > 1: - # Keep only the first instance and update its name with count - first_instance = processed_library[card_mask].iloc[0] - processed_library = processed_library[~card_mask] - - first_instance['Card Name'] = DUPLICATE_CARD_FORMAT.format( - card_name=card_name, - count=card_count - ) - processed_library = pd.concat([processed_library, pd.DataFrame([first_instance])]) - - return processed_library.reset_index(drop=True) - - except Exception as e: - raise DuplicateCardError( - f"Failed to process duplicate cards: {str(e)}", - details={'error': str(e)} - ) - -def count_cards_by_type(card_library: pd.DataFrame, card_types: List[str]) -> Dict[str, int]: - """Count the number of cards for each specified card type in the library. - - Args: - card_library: DataFrame containing the card library - card_types: List of card types to count - - Returns: - Dictionary mapping card types to their counts - - Raises: - CardTypeCountError: If counting fails for any card type - """ - try: - type_counts = {} - for card_type in card_types: - # Use pandas str.contains() for efficient type matching - # Case-insensitive matching with na=False to handle missing values - type_mask = card_library['Card Type'].str.contains( - card_type, - case=False, - na=False - ) - type_counts[card_type] = int(type_mask.sum()) - - return type_counts - except Exception as e: - print(card_type) - logger.error(f"Error counting cards by type: {e}") - raise CardTypeCountError(f"Failed to count cards by type: {str(e)}") - -def calculate_basics_per_color(total_basics: int, num_colors: int) -> Tuple[int, int]: - """Calculate the number of basic lands per color and remaining basics. - - Args: - total_basics: Total number of basic lands to distribute - num_colors: Number of colors in the deck - - Returns: - Tuple containing (basics per color, remaining basics) - - Example: - >>> calculate_basics_per_color(20, 3) - (6, 2) # 6 basics per color with 2 remaining - """ - if num_colors == 0: - return 0, total_basics - - basics_per_color = total_basics // num_colors - remaining_basics = total_basics % num_colors - - return basics_per_color, remaining_basics - -def get_basic_land_mapping(use_snow_covered: bool = False) -> Dict[str, str]: - """Get the appropriate basic land mapping based on snow-covered preference. - - Args: - use_snow_covered: Whether to use snow-covered basic lands - - Returns: - Dictionary mapping colors to their corresponding basic land names - - Example: - >>> get_basic_land_mapping(False) - {'W': 'Plains', 'U': 'Island', ...} - >>> get_basic_land_mapping(True) - {'W': 'Snow-Covered Plains', 'U': 'Snow-Covered Island', ...} - """ - return SNOW_BASIC_LAND_MAPPING if use_snow_covered else COLOR_TO_BASIC_LAND - -def distribute_remaining_basics( - basics_per_color: Dict[str, int], - remaining_basics: int, - colors: List[str] -) -> Dict[str, int]: - """Distribute remaining basic lands across colors. - - This function takes the initial distribution of basic lands and distributes - any remaining basics across the colors. The distribution prioritizes colors - based on their position in the color list (typically WUBRG order). - - Args: - basics_per_color: Initial distribution of basics per color - remaining_basics: Number of remaining basics to distribute - colors: List of colors to distribute basics across - - Returns: - Updated dictionary with final basic land counts per color - - Example: - >>> distribute_remaining_basics( - ... {'W': 6, 'U': 6, 'B': 6}, - ... 2, - ... ['W', 'U', 'B'] - ... ) - {'W': 7, 'U': 7, 'B': 6} - """ - if not colors: - return basics_per_color - - # Create a copy to avoid modifying the input dictionary - final_distribution = basics_per_color.copy() - - # Distribute remaining basics - color_index = 0 - while remaining_basics > 0 and color_index < len(colors): - color = colors[color_index] - if color in final_distribution: - final_distribution[color] += 1 - remaining_basics -= 1 - color_index = (color_index + 1) % len(colors) - - return final_distribution - -def validate_staple_land_conditions( - land_name: str, - conditions: dict, - commander_tags: List[str], - colors: List[str], - commander_power: int -) -> bool: - """Validate if a staple land meets its inclusion conditions. - - Args: - land_name: Name of the staple land to validate - conditions: Dictionary mapping land names to their condition functions - commander_tags: List of tags associated with the commander - colors: List of colors in the deck - commander_power: Power level of the commander - - Returns: - bool: True if the land meets its conditions, False otherwise - - Example: - >>> conditions = {'Command Tower': lambda tags, colors, power: len(colors) > 1} - >>> validate_staple_land_conditions('Command Tower', conditions, [], ['W', 'U'], 7) - True - """ - condition = conditions.get(land_name) - if not condition: - return False - return condition(commander_tags, colors, commander_power) - -def process_staple_lands( - lands_to_add: List[str], - card_library: pd.DataFrame, - land_df: pd.DataFrame -) -> pd.DataFrame: - """Update the land DataFrame by removing added staple lands. - - Args: - lands_to_add: List of staple land names to be added - card_library: DataFrame containing all available cards - land_df: DataFrame containing available lands - - Returns: - Updated land DataFrame with staple lands removed - - Example: - >>> process_staple_lands(['Command Tower'], card_library, land_df) - DataFrame without 'Command Tower' in the available lands - """ - updated_land_df = land_df[~land_df['name'].isin(lands_to_add)] - return updated_land_df - -def validate_fetch_land_count(count: int, min_count: int = 0, max_count: int = 9) -> int: - """Validate the requested number of fetch lands. - - Args: - count: Number of fetch lands requested - min_count: Minimum allowed fetch lands (default: 0) - max_count: Maximum allowed fetch lands (default: 9) - - Returns: - Validated fetch land count - - Raises: - FetchLandValidationError: If count is invalid - - Example: - >>> validate_fetch_land_count(5) - 5 - >>> validate_fetch_land_count(-1) # raises FetchLandValidationError - """ - try: - fetch_count = int(count) - if fetch_count < min_count or fetch_count > max_count: - raise FetchLandValidationError( - f"Fetch land count must be between {min_count} and {max_count}", - {"requested": fetch_count, "min": min_count, "max": max_count} - ) - return fetch_count - except ValueError: - raise FetchLandValidationError( - f"Invalid fetch land count: {count}", - {"value": count} - ) - -def get_available_fetch_lands(colors: List[str], price_checker: Optional[Any] = None, - max_price: Optional[float] = None) -> List[str]: - """Get list of fetch lands available for the deck's colors and budget. - - Args: - colors: List of deck colors - price_checker: Optional price checker instance - max_price: Maximum allowed price per card - - Returns: - List of available fetch land names - - Example: - >>> get_available_fetch_lands(['U', 'R']) - ['Scalding Tarn', 'Flooded Strand', ...] - """ - from settings import GENERIC_FETCH_LANDS, COLOR_TO_FETCH_LANDS - - # Start with generic fetches that work in any deck - available_fetches = GENERIC_FETCH_LANDS.copy() - - # Add color-specific fetches - for color in colors: - if color in COLOR_TO_FETCH_LANDS: - available_fetches.extend(COLOR_TO_FETCH_LANDS[color]) - - # Remove duplicates while preserving order - available_fetches = list(dict.fromkeys(available_fetches)) - - # Filter by price if price checking is enabled - if price_checker and max_price: - available_fetches = [ - fetch for fetch in available_fetches - if price_checker.get_card_price(fetch) <= max_price * 1.1 - ] - return available_fetches - -def select_fetch_lands(available_fetches: List[str], count: int, - allow_duplicates: bool = False) -> List[str]: - """Randomly select fetch lands from the available pool. - - Args: - available_fetches: List of available fetch lands - count: Number of fetch lands to select - allow_duplicates: Whether to allow duplicate selections - - Returns: - List of selected fetch land names - - Raises: - FetchLandSelectionError: If unable to select required number of fetches - - Example: - >>> select_fetch_lands(['Flooded Strand', 'Polluted Delta'], 2) - ['Polluted Delta', 'Flooded Strand'] - """ - import random - - if not available_fetches: - raise FetchLandSelectionError( - "No fetch lands available to select from", - {"requested": count} - ) - - if not allow_duplicates and count > len(available_fetches): - raise FetchLandSelectionError( - f"Not enough unique fetch lands available (requested {count}, have {len(available_fetches)})", - {"requested": count, "available": len(available_fetches)} - ) - - if allow_duplicates: - return random.choices(available_fetches, k=count) - else: - return random.sample(available_fetches, k=count) - -def validate_kindred_lands(land_name: str, commander_tags: List[str], colors: List[str]) -> bool: - """Validate if a Kindred land meets inclusion criteria. - - Args: - land_name: Name of the Kindred land to validate - commander_tags: List of tags associated with the commander - colors: List of colors in the deck - - Returns: - bool: True if the land meets criteria, False otherwise - - Raises: - KindredLandValidationError: If validation fails - - Example: - >>> validate_kindred_lands('Cavern of Souls', ['Elf Kindred'], ['G']) - True - """ - try: - # Check if any commander tags are Kindred-related - has_kindred_theme = any('Kindred' in tag for tag in commander_tags) - if not has_kindred_theme: - return False - - # Validate color requirements - if land_name in KINDRED_STAPLE_LANDS: - return True - - # Additional validation logic can be added here - return True - - except Exception as e: - raise KindredLandValidationError( - f"Failed to validate Kindred land {land_name}", - {"error": str(e), "tags": commander_tags, "colors": colors} - ) -def get_available_kindred_lands(land_df: pd.DataFrame, colors: List[str], commander_tags: List[str], - price_checker: Optional[Any] = None, - max_price: Optional[float] = None) -> List[str]: - """Get list of Kindred lands available for the deck's colors and themes. - - Args: - colors: List of deck colors - commander_tags: List of commander theme tags - price_checker: Optional price checker instance - max_price: Maximum allowed price per card - - Returns: - List of available Kindred land names - - Example: - >>> get_available_kindred_lands(['G'], ['Elf Kindred']) - ['Cavern of Souls', 'Path of Ancestry', ...] - """ - # Only proceed if deck has tribal themes - if not any('Kindred' in tag for tag in commander_tags): - return [] - - available_lands = [] - - # Add staple Kindred lands first - available_lands.extend([land['name'] for land in KINDRED_STAPLE_LANDS - if validate_kindred_lands(land['name'], commander_tags, colors)]) - - # Extract creature types from Kindred themes - creature_types = [tag.replace(' Kindred', '') - for tag in commander_tags - if 'Kindred' in tag] - - # Find lands specific to each creature type - for creature_type in creature_types: - logger.info(f'Searching for {creature_type}-specific lands') - - # Filter lands by creature type mentions in text or type - type_specific = land_df[ - land_df['text'].notna() & - (land_df['text'].str.contains(creature_type, case=False) | - land_df['type'].str.contains(creature_type, case=False)) - ] - - # Add any found type-specific lands - if not type_specific.empty: - available_lands.extend(type_specific['name'].tolist()) - - # Filter by price if price checking is enabled - if price_checker and max_price: - available_lands = [ - land for land in available_lands - if price_checker.get_card_price(land) <= max_price * 1.1 - ] - - return available_lands - -def select_kindred_lands(available_lands: List[str], count: int = None, - allow_duplicates: bool = False) -> List[str]: - """Select Kindred lands from the available pool. - - Args: - available_lands: List of available Kindred lands - - Returns: - List of selected Kindred land names - - Raises: - KindredLandSelectionError: If unable to select required number of lands - - Example: - >>> select_kindred_lands(['Cavern of Souls', 'Path of Ancestry']) - ['Cavern of Souls', 'Path of Ancestry'] - """ - import random - if not available_lands: - raise KindredLandSelectionError( - "No Kindred lands available to select from", - {"requested": count} - ) - - if not allow_duplicates and count > len(available_lands): - raise KindredLandSelectionError( - f"Not enough unique Kindred lands available (requested {count}, have {len(available_lands)})", - {"requested": count, "available": len(available_lands)} - ) - - if allow_duplicates: - return random.choices(available_lands, k=count) - else: - return random.sample(available_lands, k=count) - -def process_kindred_lands(lands_to_add: List[str], card_library: pd.DataFrame, - land_df: pd.DataFrame) -> pd.DataFrame: - """Update the land DataFrame by removing added Kindred lands. - - Args: - lands_to_add: List of Kindred land names to be added - card_library: DataFrame containing all available cards - land_df: DataFrame containing available lands - - Returns: - Updated land DataFrame with Kindred lands removed - - Example: - >>> process_kindred_lands(['Cavern of Souls'], card_library, land_df) - DataFrame without 'Cavern of Souls' in the available lands - """ - updated_land_df = land_df[~land_df['name'].isin(lands_to_add)] - return updated_land_df - -def validate_dual_lands(color_pairs: List[str], use_snow: bool = False) -> bool: - """Validate if dual lands should be added based on deck configuration. - - Args: - color_pairs: List of color pair combinations (e.g., ['azorius', 'orzhov']) - use_snow: Whether to use snow-covered lands - - Returns: - bool: True if dual lands should be added, False otherwise - - Example: - >>> validate_dual_lands(['azorius', 'orzhov'], False) - True - """ - if not color_pairs: - return False - - # Validate color pairs against DUAL_LAND_TYPE_MAP - return len(color_pairs) > 0 - -def get_available_dual_lands(land_df: pd.DataFrame, color_pairs: List[str], - use_snow: bool = False) -> pd.DataFrame: - """Get available dual lands based on color pairs and snow preference. - - Args: - land_df: DataFrame containing available lands - color_pairs: List of color pair combinations - use_snow: Whether to use snow-covered lands - - Returns: - DataFrame containing available dual lands - - Example: - >>> get_available_dual_lands(land_df, ['azorius'], False) - DataFrame with azorius dual lands - """ - # Create type filters based on color pairs - type_filters = color_pairs - - # Filter lands - if type_filters: - return land_df[land_df['type'].isin(type_filters)].copy() - return pd.DataFrame() - -def select_dual_lands(dual_df: pd.DataFrame, price_checker: Optional[Any] = None, - max_price: Optional[float] = None) -> List[Dict[str, Any]]: - """Select appropriate dual lands from available pool. - - Args: - dual_df: DataFrame of available dual lands - price_checker: Optional price checker instance - max_price: Maximum allowed price per card - - Returns: - List of selected dual land dictionaries - - Example: - >>> select_dual_lands(dual_df, price_checker, 20.0) - [{'name': 'Hallowed Fountain', 'type': 'Land — Plains Island', ...}] - """ - if dual_df.empty: - return [] - - # Sort by EDHREC rank - dual_df.sort_values(by='edhrecRank', inplace=True) - - # Convert to list of card dictionaries - selected_lands = [] - for _, row in dual_df.iterrows(): - card = { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - } - - # Check price if enabled - if price_checker and max_price: - try: - price = price_checker.get_card_price(card['name']) - if price > max_price * 1.1: - continue - except Exception as e: - logger.warning(f"Price check failed for {card['name']}: {e}") - continue - - selected_lands.append(card) - - return selected_lands - -def process_dual_lands(lands_to_add: List[Dict[str, Any]], card_library: pd.DataFrame, - land_df: pd.DataFrame) -> pd.DataFrame: - """Update land DataFrame after adding dual lands. - - Args: - lands_to_add: List of dual lands to be added - card_library: Current deck library - land_df: DataFrame of available lands - - Returns: - Updated land DataFrame - - Example: - >>> process_dual_lands(dual_lands, card_library, land_df) - Updated DataFrame without added dual lands - """ - lands_to_remove = set(land['name'] for land in lands_to_add) - return land_df[~land_df['name'].isin(lands_to_remove)] - -def validate_triple_lands(color_triplets: List[str], use_snow: bool = False) -> bool: - """Validate if triple lands should be added based on deck configuration. - - Args: - color_triplets: List of color triplet combinations (e.g., ['esper', 'bant']) - use_snow: Whether to use snow-covered lands - - Returns: - bool: True if triple lands should be added, False otherwise - - Example: - >>> validate_triple_lands(['esper', 'bant'], False) - True - """ - if not color_triplets: - return False - - # Validate color triplets - return len(color_triplets) > 0 - -def get_available_triple_lands(land_df: pd.DataFrame, color_triplets: List[str], - use_snow: bool = False) -> pd.DataFrame: - """Get available triple lands based on color triplets and snow preference. - - Args: - land_df: DataFrame containing available lands - color_triplets: List of color triplet combinations - use_snow: Whether to use snow-covered lands - - Returns: - DataFrame containing available triple lands - - Example: - >>> get_available_triple_lands(land_df, ['esper'], False) - DataFrame with esper triple lands - """ - # Create type filters based on color triplets - type_filters = color_triplets - - # Filter lands - if type_filters: - return land_df[land_df['type'].isin(type_filters)].copy() - return pd.DataFrame() - -def select_triple_lands(triple_df: pd.DataFrame, price_checker: Optional[Any] = None, - max_price: Optional[float] = None) -> List[Dict[str, Any]]: - """Select appropriate triple lands from available pool. - - Args: - triple_df: DataFrame of available triple lands - price_checker: Optional price checker instance - max_price: Maximum allowed price per card - - Returns: - List of selected triple land dictionaries - - Example: - >>> select_triple_lands(triple_df, price_checker, 20.0) - [{'name': 'Raffine's Tower', 'type': 'Land — Plains Island Swamp', ...}] - """ - if triple_df.empty: - return [] - - # Sort by EDHREC rank - triple_df.sort_values(by='edhrecRank', inplace=True) - - # Convert to list of card dictionaries - selected_lands = [] - for _, row in triple_df.iterrows(): - card = { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - } - - # Check price if enabled - if price_checker and max_price: - try: - price = price_checker.get_card_price(card['name']) - if price > max_price * 1.1: - continue - except Exception as e: - logger.warning(f"Price check failed for {card['name']}: {e}") - continue - - selected_lands.append(card) - - return selected_lands - -def process_triple_lands(lands_to_add: List[Dict[str, Any]], card_library: pd.DataFrame, - land_df: pd.DataFrame) -> pd.DataFrame: - """Update land DataFrame after adding triple lands. - - Args: - lands_to_add: List of triple lands to be added - card_library: Current deck library - land_df: DataFrame of available lands - - Returns: - Updated land DataFrame - - Example: - >>> process_triple_lands(triple_lands, card_library, land_df) - Updated DataFrame without added triple lands - """ - lands_to_remove = set(land['name'] for land in lands_to_add) - return land_df[~land_df['name'].isin(lands_to_remove)] - -def get_available_misc_lands(land_df: pd.DataFrame, max_pool_size: int) -> List[Dict[str, Any]]: - """Retrieve the top N lands from land_df for miscellaneous land selection. - - Args: - land_df: DataFrame containing available lands - max_pool_size: Maximum number of lands to include in the pool - - Returns: - List of dictionaries containing land information - - Example: - >>> get_available_misc_lands(land_df, 100) - [{'name': 'Command Tower', 'type': 'Land', ...}, ...] - """ - try: - # Take top N lands by EDHREC rank - top_lands = land_df.head(max_pool_size).copy() - - # Convert to list of dictionaries - available_lands = [ - { - 'name': row['name'], - 'type': row['type'], - 'manaCost': row['manaCost'], - 'manaValue': row['manaValue'] - } - for _, row in top_lands.iterrows() - ] - - return available_lands - - except Exception as e: - logger.error(f"Error getting available misc lands: {e}") - return [] - -def select_misc_lands(available_lands: List[Dict[str, Any]], min_count: int, max_count: int, - price_checker: Optional[PriceChecker] = None, - max_price: Optional[float] = None) -> List[Dict[str, Any]]: - """Randomly select a number of lands between min_count and max_count. - - Args: - available_lands: List of available lands to select from - min_count: Minimum number of lands to select - max_count: Maximum number of lands to select - price_checker: Optional price checker instance - max_price: Maximum allowed price per card - - Returns: - List of selected land dictionaries - - Example: - >>> select_misc_lands(available_lands, 5, 10) - [{'name': 'Command Tower', 'type': 'Land', ...}, ...] - """ - import random - - if not available_lands: - return [] - - # Randomly determine number of lands to select - target_count = random.randint(min_count, max_count) - selected_lands = [] - - # Create a copy of available lands to avoid modifying the original - land_pool = available_lands.copy() - - while land_pool and len(selected_lands) < target_count: - # Randomly select a land - land = random.choice(land_pool) - land_pool.remove(land) - - # Check price if enabled - if price_checker and max_price: - try: - price = price_checker.get_card_price(land['name']) - if price > max_price * 1.1: - continue - except Exception as e: - logger.warning(f"Price check failed for {land['name']}: {e}") - continue - - selected_lands.append(land) - - return selected_lands - - -def filter_removable_lands(card_library: pd.DataFrame, protected_lands: List[str]) -> pd.DataFrame: - """Filter the card library to get lands that can be removed. - - Args: - card_library: DataFrame containing all cards in the deck - protected_lands: List of land names that cannot be removed - - Returns: - DataFrame containing only removable lands - - Raises: - LandRemovalError: If no removable lands are found - DataFrameValidationError: If card_library validation fails - """ - try: - # Validate input DataFrame - if card_library.empty: - raise EmptyDataFrameError("filter_removable_lands") - - # Filter for lands only - lands_df = card_library[card_library['Card Type'].str.contains('Land', case=False, na=False)].copy() - - # Remove protected lands - removable_lands = lands_df[~lands_df['Card Name'].isin(protected_lands)] - - if removable_lands.empty: - raise LandRemovalError( - "No removable lands found in deck", - {"protected_lands": protected_lands} - ) - - logger.debug(f"Found {len(removable_lands)} removable lands") - return removable_lands - - except Exception as e: - logger.error(f"Error filtering removable lands: {e}") - raise - -def select_land_for_removal(filtered_lands: pd.DataFrame) -> Tuple[int, str]: - """Randomly select a land for removal from filtered lands. - - Args: - filtered_lands: DataFrame containing only removable lands - - Returns: - Tuple containing (index in original DataFrame, name of selected land) - - Raises: - LandRemovalError: If filtered_lands is empty - DataFrameValidationError: If filtered_lands validation fails - """ - try: - if filtered_lands.empty: - raise LandRemovalError( - "No lands available for removal", - {"filtered_lands_size": len(filtered_lands)} - ) - - # Randomly select a land - selected_land = filtered_lands.sample(n=1).iloc[0] - index = selected_land.name - land_name = selected_land['Card Name'] - - logger.info(f"Selected land for removal: {land_name}") - return index, land_name - - except Exception as e: - logger.error(f"Error selecting land for removal: {e}") - raise - -def get_card_theme_overlap(card_tags: List[str], deck_themes: List[str]) -> int: - """Count how many deck themes a given card matches. - - Args: - card_tags: List of tags associated with the card - deck_themes: List of themes in the deck - - Returns: - Number of deck themes that match the card's tags - - Example: - >>> card_tags = ['Artifacts Matter', 'Token Creation', 'Sacrifice'] - >>> deck_themes = ['Artifacts Matter', 'Sacrifice Matters'] - >>> get_card_theme_overlap(card_tags, deck_themes) - 2 - """ - if not card_tags or not deck_themes: - return 0 - - # Convert to sets for efficient intersection - card_tag_set = set(card_tags) - deck_theme_set = set(deck_themes) - - # Count overlapping themes - return len(card_tag_set.intersection(deck_theme_set)) - -def calculate_theme_priority(card_tags: List[str], deck_themes: List[str], THEME_PRIORITY_BONUS: float) -> float: - """Calculate priority score for a card based on theme overlap. - - Args: - card_tags: List of tags associated with the card - deck_themes: List of themes in the deck - THEME_PRIORITY_BONUS: Bonus multiplier for each additional theme match - - Returns: - Priority score for the card (higher means more theme overlap) - - Example: - >>> card_tags = ['Artifacts Matter', 'Token Creation', 'Sacrifice'] - >>> deck_themes = ['Artifacts Matter', 'Sacrifice Matters'] - >>> calculate_theme_priority(card_tags, deck_themes, 1.2) - 1.44 # Base score of 1.0 * (1.2 ^ 2) for two theme matches - """ - overlap_count = get_card_theme_overlap(card_tags, deck_themes) - if overlap_count == 0: - return 0.0 - - # Calculate priority score with exponential bonus for multiple matches - return pow(THEME_PRIORITY_BONUS, overlap_count) - -def calculate_weighted_pool_size(ideal_count: int, weight: float, multiplier: float = THEME_POOL_SIZE_MULTIPLIER) -> int: - """Calculate the size of the initial card pool based on ideal count and weight. - - Args: - ideal_count: Target number of cards to select - weight: Theme weight factor (0.0-1.0) - multiplier: Pool size multiplier (default from settings) - - Returns: - Calculated pool size - - Example: - >>> calculate_weighted_pool_size(10, 0.8, 2.0) - 16 - """ - return int(ideal_count * weight * multiplier) - -def filter_theme_cards(df: pd.DataFrame, themes: List[str], pool_size: int) -> pd.DataFrame: - """Filter cards by theme and return top cards by EDHREC rank. - - Args: - df: Source DataFrame to filter - themes: List of theme tags to filter by - pool_size: Number of cards to return - - Returns: - Filtered DataFrame with top cards - - Raises: - ValueError: If themes is None or contains invalid values - TypeError: If themes is not a list - - Example: - >>> filtered_df = filter_theme_cards(cards_df, ['Artifacts Matter', 'Token Creation'], 20) - """ - # Input validation - if themes is None: - raise ValueError("themes parameter cannot be None") - - if not isinstance(themes, list): - raise TypeError("themes must be a list of strings") - - if not all(isinstance(theme, str) for theme in themes): - raise ValueError("all themes must be strings") - - if not themes: - return pd.DataFrame() # Return empty DataFrame for empty themes list - - # Create copy to avoid modifying original - filtered_df = df.copy() - - # Filter by theme - filtered_df = filtered_df[filtered_df['themeTags'].apply( - lambda x: any(theme in x for theme in themes) if isinstance(x, list) else False - )] - - # Sort by EDHREC rank and take top cards - filtered_df.sort_values('edhrecRank', inplace=True) - return filtered_df.head(pool_size) - -def select_weighted_cards( - card_pool: pd.DataFrame, - target_count: int, - price_checker: Optional[Any] = None, - max_price: Optional[float] = None -) -> List[Dict[str, Any]]: - """Select cards from pool considering price constraints. - - Args: - card_pool: DataFrame of candidate cards - target_count: Number of cards to select - price_checker: Optional price checker instance - max_price: Maximum allowed price per card - - Returns: - List of selected card dictionaries - - Example: - >>> selected = select_weighted_cards(pool_df, 5, price_checker, 10.0) - """ - selected_cards = [] - - for _, card in card_pool.iterrows(): - if len(selected_cards) >= target_count: - break - - # Check price if enabled - if price_checker and max_price: - try: - price = price_checker.get_card_price(card['name']) - if price > max_price * 1.1: - continue - except Exception as e: - logger.warning(f"Price check failed for {card['name']}: {e}") - continue - - selected_cards.append({ - 'name': card['name'], - 'type': card['type'], - 'manaCost': card['manaCost'], - 'manaValue': card['manaValue'], - 'themeTags': card['themeTags'] - }) - - return selected_cards - -def count_color_pips(mana_costs: pd.Series, color: str) -> int: - """Count the number of colored mana pips of a specific color in mana costs. - - Args: - mana_costs: Series of mana cost strings to analyze - color: Color to count pips for (W, U, B, R, or G) - - Returns: - Total number of pips of the specified color - - Example: - >>> mana_costs = pd.Series(['{2}{W}{W}', '{W}{U}', '{B}{R}']) - >>> count_color_pips(mana_costs, 'W') - 3 - """ - if not isinstance(mana_costs, pd.Series): - raise TypeError("mana_costs must be a pandas Series") - - if color not in MANA_COLORS: - raise ValueError(f"Invalid color: {color}. Must be one of {MANA_COLORS}") - - pattern = MANA_PIP_PATTERNS[color] - - # Count occurrences of the pattern in non-null mana costs - pip_counts = mana_costs.fillna('').str.count(pattern) - - return int(pip_counts.sum()) - -def calculate_pip_percentages(pip_counts: Dict[str, int]) -> Dict[str, float]: - """Calculate the percentage distribution of mana pips for each color. - - Args: - pip_counts: Dictionary mapping colors to their pip counts - - Returns: - Dictionary mapping colors to their percentage of total pips (0-100) - - Example: - >>> pip_counts = {'W': 10, 'U': 5, 'B': 5, 'R': 0, 'G': 0} - >>> calculate_pip_percentages(pip_counts) - {'W': 50.0, 'U': 25.0, 'B': 25.0, 'R': 0.0, 'G': 0.0} - - Note: - If total pip count is 0, returns 0% for all colors to avoid division by zero. - """ - if not isinstance(pip_counts, dict): - raise TypeError("pip_counts must be a dictionary") - - # Validate colors - invalid_colors = set(pip_counts.keys()) - set(MANA_COLORS) - if invalid_colors: - raise ValueError(f"Invalid colors in pip_counts: {invalid_colors}") - - total_pips = sum(pip_counts.values()) - - if total_pips == 0: - return {color: 0.0 for color in MANA_COLORS} - - percentages = {} - for color in MANA_COLORS: - count = pip_counts.get(color, 0) - percentage = (count / total_pips) * 100 - percentages[color] = round(percentage, 1) - - return percentages diff --git a/diagnose-volumes.sh b/diagnose-volumes.sh deleted file mode 100644 index 0674537..0000000 --- a/diagnose-volumes.sh +++ /dev/null @@ -1,143 +0,0 @@ -#!/bin/bash -# Diagnostic script to debug Docker volume mounting issues - -echo "=== MTG Deckbuilder Volume Mount Diagnostics ===" -echo "Date: $(date)" -echo "User: $(whoami)" -echo "Working Directory: $(pwd)" -echo "" - -# Check if Docker is working -echo "=== Docker Info ===" -docker --version -echo "" - -# Check host directories -echo "=== Host Directory Check ===" -echo "Current directory contents:" -ls -la - -echo "" -echo "Checking for data directories:" -for dir in deck_files logs csv_files; do - if [ -d "$dir" ]; then - echo "✓ $dir exists" - echo " Permissions: $(ls -ld $dir | awk '{print $1, $3, $4}')" - echo " Contents: $(ls -la $dir | wc -l) items" - if [ "$(ls -A $dir)" ]; then - echo " Files: $(ls -A $dir | head -3)" - else - echo " (empty)" - fi - else - echo "✗ $dir missing" - echo " Creating..." - mkdir -p "$dir" - chmod 755 "$dir" - fi - echo "" -done - -# Test basic Docker volume mounting -echo "=== Docker Volume Mount Test ===" -echo "Testing if Docker can write to host directories..." - -docker run --rm \ - -v "$(pwd)/deck_files:/test/deck_files" \ - -v "$(pwd)/logs:/test/logs" \ - -v "$(pwd)/csv_files:/test/csv_files" \ - alpine:latest /bin/sh -c " - echo 'Container test started' - echo 'Working directory: \$(pwd)' - echo 'Mount points:' - ls -la /test/ - echo '' - echo 'Testing file creation:' - echo 'test-$(date +%s)' > /test/deck_files/docker-test.txt - echo 'test-$(date +%s)' > /test/logs/docker-test.log - echo 'test-$(date +%s)' > /test/csv_files/docker-test.csv - echo 'Files created in container' - ls -la /test/*/docker-test.* - " - -echo "" -echo "=== Host File Check After Docker Test ===" -echo "Checking if files were created on host:" -for dir in deck_files logs csv_files; do - echo "$dir:" - if [ -f "$dir/docker-test.txt" ] || [ -f "$dir/docker-test.log" ] || [ -f "$dir/docker-test.csv" ]; then - ls -la "$dir"/docker-test.* - else - echo " No test files found" - fi -done - -# Test with the actual MTG image -echo "" -echo "=== MTG Deckbuilder Container Test ===" -echo "Testing with actual MTG deckbuilder image..." - -# First check if image exists -if docker images | grep -q mtg-deckbuilder; then - echo "MTG deckbuilder image found" - - docker run --rm \ - -v "$(pwd)/deck_files:/app/deck_files" \ - -v "$(pwd)/logs:/app/logs" \ - -v "$(pwd)/csv_files:/app/csv_files" \ - mtg-deckbuilder /bin/bash -c " - echo 'MTG Container test' - echo 'Working directory: \$(pwd)' - echo 'Python path: \$(which python)' - echo 'App directory contents:' - ls -la /app/ - echo '' - echo 'Mount point permissions:' - ls -la /app/deck_files /app/logs /app/csv_files - echo '' - echo 'Testing file creation:' - echo 'mtg-test-$(date +%s)' > /app/deck_files/mtg-test.txt - echo 'mtg-test-$(date +%s)' > /app/logs/mtg-test.log - echo 'mtg-test-$(date +%s)' > /app/csv_files/mtg-test.csv - echo 'MTG test files created' - - # Try to run a quick Python test - cd /app/code - python -c 'import os; print(\"Python can access:\", os.listdir(\"/app\"))' - python -c ' -import os -from pathlib import Path -print(\"Testing Path operations:\") -deck_path = Path(\"/app/deck_files\") -print(f\"Deck path exists: {deck_path.exists()}\") -print(f\"Deck path writable: {os.access(deck_path, os.W_OK)}\") -test_file = deck_path / \"python-test.txt\" -try: - test_file.write_text(\"Python test\") - print(f\"Python write successful: {test_file.read_text()}\") -except Exception as e: - print(f\"Python write failed: {e}\") -' - " -else - echo "MTG deckbuilder image not found. Building..." - docker build -t mtg-deckbuilder . -fi - -echo "" -echo "=== Final Host Check ===" -echo "Files in host directories after all tests:" -for dir in deck_files logs csv_files; do - echo "$dir:" - ls -la "$dir"/ 2>/dev/null || echo " Directory empty or inaccessible" - echo "" -done - -# Cleanup test files -echo "=== Cleanup ===" -echo "Removing test files..." -rm -f deck_files/docker-test.* logs/docker-test.* csv_files/docker-test.* -rm -f deck_files/mtg-test.* logs/mtg-test.* csv_files/mtg-test.* -rm -f deck_files/python-test.* logs/python-test.* csv_files/python-test.* - -echo "Diagnostics complete!" diff --git a/docker-compose.interactive.yml b/docker-compose.interactive.yml deleted file mode 100644 index 4a276ec..0000000 --- a/docker-compose.interactive.yml +++ /dev/null @@ -1,16 +0,0 @@ -services: - mtg-deckbuilder-interactive: - build: . - container_name: mtg-deckbuilder-interactive - stdin_open: true - tty: true - volumes: - - ${PWD}/deck_files:/app/deck_files - - ${PWD}/logs:/app/logs - - ${PWD}/csv_files:/app/csv_files - environment: - - PYTHONUNBUFFERED=1 - - TERM=xterm-256color - - DEBIAN_FRONTEND=noninteractive - # Don't restart automatically - restart: "no" diff --git a/pyproject.toml b/pyproject.toml index 4b0ca0a..b1e5a45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -requires-python = ">=3.10" +requires-python = ">=3.13" # This is what it was built with anyway dependencies = [ "pandas>=1.5.0", "inquirer>=3.1.3", diff --git a/quick-start.sh b/quick-start.sh deleted file mode 100644 index 60722c8..0000000 --- a/quick-start.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -# Simple MTG Deckbuilder runner with proper interactivity - -echo "MTG Deckbuilder - Quick Start" -echo "==============================" - -# Colors -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -# Create directories if they don't exist -echo -e "${GREEN}Setting up directories...${NC}" -mkdir -p deck_files logs csv_files - -# Check which compose command is available -if command -v docker-compose &> /dev/null; then - COMPOSE_CMD="docker-compose" -elif docker compose version &> /dev/null; then - COMPOSE_CMD="docker compose" -else - echo "Error: Neither docker-compose nor 'docker compose' is available" - exit 1 -fi - -echo -e "${GREEN}Using: $COMPOSE_CMD${NC}" -echo -e "${YELLOW}Starting MTG Deckbuilder...${NC}" -echo "Press Ctrl+C to exit when done" -echo "" - -# Run with the interactive compose file -$COMPOSE_CMD -f docker-compose.interactive.yml run --rm mtg-deckbuilder-interactive