mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
commit
9950838520
14 changed files with 867 additions and 4609 deletions
24
.dockerignore
Normal file
24
.dockerignore
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.log
|
||||
.git
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.hypothesis
|
||||
.venv/
|
||||
.env
|
||||
logs/*.log
|
||||
*.egg-info/
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -14,4 +14,5 @@ logs/
|
|||
non_interactive_test.py
|
||||
test_determinism.py
|
||||
test.py
|
||||
deterministic_test.py
|
||||
deterministic_test.py
|
||||
build.ps1
|
||||
221
DOCKER.md
Normal file
221
DOCKER.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# Docker Guide for MTG Python Deckbuilder
|
||||
|
||||
A comprehensive guide for running the MTG Python Deckbuilder in Docker containers with full file persistence and cross-platform support.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Linux/macOS/Remote Host
|
||||
```bash
|
||||
# Make scripts executable (one time only)
|
||||
chmod +x quick-start.sh run-docker-linux.sh
|
||||
|
||||
# Simplest method - just run this:
|
||||
./quick-start.sh
|
||||
|
||||
# Or use the full script with options:
|
||||
./run-docker-linux.sh compose
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
```powershell
|
||||
# Run with Docker Compose (recommended)
|
||||
.\run-docker.ps1 compose
|
||||
|
||||
# Or manual Docker run
|
||||
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
|
||||
```
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
- **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" \
|
||||
-v "$(pwd)/csv_files:/app/csv_files" \
|
||||
mtg-deckbuilder
|
||||
```
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
## 📁 Docker Compose Files
|
||||
|
||||
The project includes two Docker Compose configurations:
|
||||
|
||||
### `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
|
||||
|
||||
**Complete cleanup:**
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
## 🔍 Verifying Everything Works
|
||||
|
||||
After running the application:
|
||||
|
||||
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
|
||||
```
|
||||
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
|
||||
```
|
||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Use Python 3.11 slim image for smaller size
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install system dependencies if needed
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY code/ ./code/
|
||||
COPY csv_files/ ./csv_files/
|
||||
COPY mypy.ini .
|
||||
|
||||
# Create necessary directories as mount points
|
||||
RUN mkdir -p deck_files logs csv_files
|
||||
|
||||
# Create volumes for persistent data
|
||||
VOLUME ["/app/deck_files", "/app/logs", "/app/csv_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"]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
from .builder import DeckBuilder
|
||||
from .builder_utils import *
|
||||
from .builder_constants import *
|
||||
|
||||
__all__ = [
|
||||
'DeckBuilder',
|
||||
]
|
||||
|
|
@ -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'
|
||||
]
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -16,7 +16,10 @@ from __future__ import annotations
|
|||
|
||||
# Standard library imports
|
||||
import re
|
||||
from typing import List, Set, Union, Any
|
||||
from typing import List, Set, Union, Any, Tuple
|
||||
from functools import lru_cache
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Third-party imports
|
||||
import pandas as pd
|
||||
|
|
@ -24,6 +27,43 @@ import pandas as pd
|
|||
# Local application imports
|
||||
from . import tag_constants
|
||||
|
||||
|
||||
# --- Internal helpers for performance -----------------------------------------------------------
|
||||
@lru_cache(maxsize=2048)
|
||||
def _build_joined_pattern(parts: Tuple[str, ...]) -> str:
|
||||
"""Join multiple regex parts with '|'. Cached for reuse across calls."""
|
||||
return '|'.join(parts)
|
||||
|
||||
|
||||
@lru_cache(maxsize=2048)
|
||||
def _compile_pattern(pattern: str, ignore_case: bool = True):
|
||||
"""Compile a regex pattern with optional IGNORECASE. Cached for reuse."""
|
||||
flags = re.IGNORECASE if ignore_case else 0
|
||||
return re.compile(pattern, flags)
|
||||
|
||||
def _ensure_norm_series(df: pd.DataFrame, source_col: str, norm_col: str) -> pd.Series:
|
||||
"""Ensure a cached normalized string series exists on df for source_col.
|
||||
|
||||
Normalization here means: fillna('') and cast to str once. This avoids
|
||||
repeating fill/astype work on every mask creation. Extra columns are
|
||||
later dropped by final reindex in output.
|
||||
|
||||
Args:
|
||||
df: DataFrame containing the column
|
||||
source_col: Name of the source column (e.g., 'text')
|
||||
norm_col: Name of the cache column to create/use (e.g., '__text_s')
|
||||
|
||||
Returns:
|
||||
The normalized pandas Series.
|
||||
"""
|
||||
if norm_col in df.columns:
|
||||
return df[norm_col]
|
||||
# Create normalized string series
|
||||
series = df[source_col].fillna('') if source_col in df.columns else pd.Series([''] * len(df), index=df.index)
|
||||
series = series.astype(str)
|
||||
df[norm_col] = series
|
||||
return df[norm_col]
|
||||
|
||||
def pluralize(word: str) -> str:
|
||||
"""Convert a word to its plural form using basic English pluralization rules.
|
||||
|
||||
|
|
@ -78,12 +118,21 @@ def create_type_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex:
|
|||
elif not isinstance(type_text, list):
|
||||
raise TypeError("type_text must be a string or list of strings")
|
||||
|
||||
if len(df) == 0:
|
||||
return pd.Series([], dtype=bool)
|
||||
|
||||
# Use normalized cached series
|
||||
type_series = _ensure_norm_series(df, 'type', '__type_s')
|
||||
|
||||
if regex:
|
||||
pattern = '|'.join(f'{p}' for p in type_text)
|
||||
return df['type'].str.contains(pattern, case=False, na=False, regex=True)
|
||||
pattern = _build_joined_pattern(tuple(type_text)) if len(type_text) > 1 else type_text[0]
|
||||
compiled = _compile_pattern(pattern, ignore_case=True)
|
||||
return type_series.str.contains(compiled, na=False, regex=True)
|
||||
else:
|
||||
masks = [df['type'].str.contains(p, case=False, na=False, regex=False) for p in type_text]
|
||||
return pd.concat(masks, axis=1).any(axis=1)
|
||||
masks = [type_series.str.contains(p, case=False, na=False, regex=False) for p in type_text]
|
||||
if not masks:
|
||||
return pd.Series(False, index=df.index)
|
||||
return pd.Series(np.logical_or.reduce(masks), index=df.index)
|
||||
|
||||
def create_text_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True, combine_with_or: bool = True) -> pd.Series[bool]:
|
||||
"""Create a boolean mask for rows where text matches one or more patterns.
|
||||
|
|
@ -109,15 +158,22 @@ def create_text_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex:
|
|||
elif not isinstance(type_text, list):
|
||||
raise TypeError("type_text must be a string or list of strings")
|
||||
|
||||
if len(df) == 0:
|
||||
return pd.Series([], dtype=bool)
|
||||
|
||||
# Use normalized cached series
|
||||
text_series = _ensure_norm_series(df, 'text', '__text_s')
|
||||
|
||||
if regex:
|
||||
pattern = '|'.join(f'{p}' for p in type_text)
|
||||
return df['text'].str.contains(pattern, case=False, na=False, regex=True)
|
||||
pattern = _build_joined_pattern(tuple(type_text)) if len(type_text) > 1 else type_text[0]
|
||||
compiled = _compile_pattern(pattern, ignore_case=True)
|
||||
return text_series.str.contains(compiled, na=False, regex=True)
|
||||
else:
|
||||
masks = [df['text'].str.contains(p, case=False, na=False, regex=False) for p in type_text]
|
||||
if combine_with_or:
|
||||
return pd.concat(masks, axis=1).any(axis=1)
|
||||
else:
|
||||
return pd.concat(masks, axis=1).all(axis=1)
|
||||
masks = [text_series.str.contains(p, case=False, na=False, regex=False) for p in type_text]
|
||||
if not masks:
|
||||
return pd.Series(False, index=df.index)
|
||||
reduced = np.logical_or.reduce(masks) if combine_with_or else np.logical_and.reduce(masks)
|
||||
return pd.Series(reduced, index=df.index)
|
||||
|
||||
def create_keyword_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True) -> pd.Series[bool]:
|
||||
"""Create a boolean mask for rows where keyword text matches one or more patterns.
|
||||
|
|
@ -151,18 +207,18 @@ def create_keyword_mask(df: pd.DataFrame, type_text: Union[str, List[str]], rege
|
|||
elif not isinstance(type_text, list):
|
||||
raise TypeError("type_text must be a string or list of strings")
|
||||
|
||||
# Create default mask for null values
|
||||
# Handle null values and convert to string
|
||||
keywords = df['keywords'].fillna('')
|
||||
# Convert non-string values to strings
|
||||
keywords = keywords.astype(str)
|
||||
# Use normalized cached series for keywords
|
||||
keywords = _ensure_norm_series(df, 'keywords', '__keywords_s')
|
||||
|
||||
if regex:
|
||||
pattern = '|'.join(f'{p}' for p in type_text)
|
||||
return keywords.str.contains(pattern, case=False, na=False, regex=True)
|
||||
pattern = _build_joined_pattern(tuple(type_text)) if len(type_text) > 1 else type_text[0]
|
||||
compiled = _compile_pattern(pattern, ignore_case=True)
|
||||
return keywords.str.contains(compiled, na=False, regex=True)
|
||||
else:
|
||||
masks = [keywords.str.contains(p, case=False, na=False, regex=False) for p in type_text]
|
||||
return pd.concat(masks, axis=1).any(axis=1)
|
||||
if not masks:
|
||||
return pd.Series(False, index=df.index)
|
||||
return pd.Series(np.logical_or.reduce(masks), index=df.index)
|
||||
|
||||
def create_name_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex: bool = True) -> pd.Series[bool]:
|
||||
"""Create a boolean mask for rows where name matches one or more patterns.
|
||||
|
|
@ -187,12 +243,21 @@ def create_name_mask(df: pd.DataFrame, type_text: Union[str, List[str]], regex:
|
|||
elif not isinstance(type_text, list):
|
||||
raise TypeError("type_text must be a string or list of strings")
|
||||
|
||||
if len(df) == 0:
|
||||
return pd.Series([], dtype=bool)
|
||||
|
||||
# Use normalized cached series
|
||||
name_series = _ensure_norm_series(df, 'name', '__name_s')
|
||||
|
||||
if regex:
|
||||
pattern = '|'.join(f'{p}' for p in type_text)
|
||||
return df['name'].str.contains(pattern, case=False, na=False, regex=True)
|
||||
pattern = _build_joined_pattern(tuple(type_text)) if len(type_text) > 1 else type_text[0]
|
||||
compiled = _compile_pattern(pattern, ignore_case=True)
|
||||
return name_series.str.contains(compiled, na=False, regex=True)
|
||||
else:
|
||||
masks = [df['name'].str.contains(p, case=False, na=False, regex=False) for p in type_text]
|
||||
return pd.concat(masks, axis=1).any(axis=1)
|
||||
masks = [name_series.str.contains(p, case=False, na=False, regex=False) for p in type_text]
|
||||
if not masks:
|
||||
return pd.Series(False, index=df.index)
|
||||
return pd.Series(np.logical_or.reduce(masks), index=df.index)
|
||||
|
||||
def extract_creature_types(type_text: str, creature_types: List[str], non_creature_types: List[str]) -> List[str]:
|
||||
"""Extract creature types from a type text string.
|
||||
|
|
@ -307,6 +372,31 @@ def apply_tag_vectorized(df: pd.DataFrame, mask: pd.Series[bool], tags: Union[st
|
|||
# Add new tags
|
||||
df.loc[mask, 'themeTags'] = current_tags.apply(lambda x: sorted(list(set(x + tags))))
|
||||
|
||||
def apply_rules(df: pd.DataFrame, rules: List[dict]) -> None:
|
||||
"""Apply a list of rules to a DataFrame.
|
||||
|
||||
Each rule dict supports:
|
||||
- mask: pd.Series of booleans or a callable df->mask
|
||||
- tags: str|List[str]
|
||||
|
||||
Example:
|
||||
rules = [
|
||||
{ 'mask': lambda d: create_text_mask(d, 'lifelink'), 'tags': ['Lifelink'] },
|
||||
]
|
||||
|
||||
Args:
|
||||
df: DataFrame to update
|
||||
rules: list of rule dicts
|
||||
"""
|
||||
for rule in rules:
|
||||
mask = rule.get('mask')
|
||||
if callable(mask):
|
||||
mask = mask(df)
|
||||
if mask is None:
|
||||
continue
|
||||
tags = rule.get('tags', [])
|
||||
apply_tag_vectorized(df, mask, tags)
|
||||
|
||||
def create_mass_effect_mask(df: pd.DataFrame, effect_type: str) -> pd.Series[bool]:
|
||||
"""Create a boolean mask for cards with mass removal effects of a specific type.
|
||||
|
||||
|
|
@ -326,6 +416,60 @@ def create_mass_effect_mask(df: pd.DataFrame, effect_type: str) -> pd.Series[boo
|
|||
patterns = tag_constants.BOARD_WIPE_TEXT_PATTERNS[effect_type]
|
||||
return create_text_mask(df, patterns)
|
||||
|
||||
def create_trigger_mask(
|
||||
df: pd.DataFrame,
|
||||
subjects: Union[str, List[str]],
|
||||
include_attacks: bool = False,
|
||||
) -> pd.Series:
|
||||
"""Create a mask for text that contains trigger phrases followed by subjects.
|
||||
|
||||
Example: with subjects=['a creature','you'] builds patterns:
|
||||
'when a creature', 'whenever you', 'at you', etc.
|
||||
|
||||
Args:
|
||||
df: DataFrame
|
||||
subjects: A subject string or list (will be normalized to list)
|
||||
include_attacks: If True, also include '{trigger} .* attacks'
|
||||
|
||||
Returns:
|
||||
Boolean Series mask
|
||||
"""
|
||||
subs = [subjects] if isinstance(subjects, str) else subjects
|
||||
patterns: List[str] = []
|
||||
for trig in tag_constants.TRIGGERS:
|
||||
patterns.extend([f"{trig} {s}" for s in subs])
|
||||
if include_attacks:
|
||||
patterns.append(f"{trig} .* attacks")
|
||||
return create_text_mask(df, patterns)
|
||||
|
||||
def create_numbered_phrase_mask(
|
||||
df: pd.DataFrame,
|
||||
verb: Union[str, List[str]],
|
||||
noun: str = '',
|
||||
numbers: List[str] | None = None,
|
||||
) -> pd.Series:
|
||||
"""Create a boolean mask for phrases like 'draw {num} card'.
|
||||
|
||||
Args:
|
||||
df: DataFrame to search
|
||||
verb: Action verb or list of verbs (e.g., 'draw' or ['gain', 'gains'])
|
||||
noun: Optional object noun in singular form (e.g., 'card'); if empty, omitted
|
||||
numbers: Optional list of number words/digits (defaults to tag_constants.NUM_TO_SEARCH)
|
||||
|
||||
Returns:
|
||||
Boolean Series mask
|
||||
"""
|
||||
if numbers is None:
|
||||
numbers = tag_constants.NUM_TO_SEARCH
|
||||
# Normalize verbs to list
|
||||
verbs = [verb] if isinstance(verb, str) else verb
|
||||
# Build patterns
|
||||
if noun:
|
||||
patterns = [fr"{v}\s+{num}\s+{noun}" for v in verbs for num in numbers]
|
||||
else:
|
||||
patterns = [fr"{v}\s+{num}" for v in verbs for num in numbers]
|
||||
return create_text_mask(df, patterns)
|
||||
|
||||
def create_damage_pattern(number: Union[int, str]) -> str:
|
||||
"""Create a pattern for matching X damage effects.
|
||||
|
||||
|
|
|
|||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
services:
|
||||
mtg-deckbuilder:
|
||||
build: .
|
||||
container_name: mtg-deckbuilder-main
|
||||
stdin_open: true # Equivalent to docker run -i
|
||||
tty: true # Equivalent to docker run -t
|
||||
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
|
||||
# Ensure proper cleanup
|
||||
restart: "no"
|
||||
58
pyproject.toml
Normal file
58
pyproject.toml
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "mtg-deckbuilder"
|
||||
version = "1.0.0"
|
||||
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
authors = [
|
||||
{name = "Your Name", email = "your.email@example.com"}
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
requires-python = ">=3.13" # This is what it was built with anyway
|
||||
dependencies = [
|
||||
"pandas>=1.5.0",
|
||||
"inquirer>=3.1.3",
|
||||
"typing_extensions>=4.5.0",
|
||||
"fuzzywuzzy>=0.18.0",
|
||||
"python-Levenshtein>=0.12.0",
|
||||
"tqdm>=4.66.0",
|
||||
"scrython>=1.10.0",
|
||||
"numpy>=1.24.0",
|
||||
"requests>=2.31.0",
|
||||
"prettytable>=3.9.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"mypy>=1.3.0",
|
||||
"pandas-stubs>=2.0.0",
|
||||
"pytest>=8.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mtg-deckbuilder = "code.main:run_menu"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/mwisnowski/mtg_python_deckbuilder"
|
||||
Repository = "https://github.com/mwisnowski/mtg_python_deckbuilder"
|
||||
Issues = "https://github.com/mwisnowski/mtg_python_deckbuilder/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["code*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["*.csv"]
|
||||
79
run-docker.ps1
Normal file
79
run-docker.ps1
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# MTG Deckbuilder Docker Runner Script
|
||||
# This script provides easy commands to run the MTG Deckbuilder in Docker with proper volume mounting
|
||||
|
||||
Write-Host "MTG Deckbuilder Docker Helper" -ForegroundColor Green
|
||||
Write-Host "==============================" -ForegroundColor Green
|
||||
|
||||
function Show-Help {
|
||||
Write-Host ""
|
||||
Write-Host "Available commands:" -ForegroundColor Yellow
|
||||
Write-Host " .\run-docker.ps1 build - Build the Docker image"
|
||||
Write-Host " .\run-docker.ps1 run - Run the application with volume mounting"
|
||||
Write-Host " .\run-docker.ps1 compose - Use docker-compose (recommended)"
|
||||
Write-Host " .\run-docker.ps1 clean - Remove containers and images"
|
||||
Write-Host " .\run-docker.ps1 help - Show this help"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# Get command line argument
|
||||
$command = $args[0]
|
||||
|
||||
switch ($command) {
|
||||
"build" {
|
||||
Write-Host "Building MTG Deckbuilder Docker image..." -ForegroundColor Yellow
|
||||
docker build -t mtg-deckbuilder .
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Build successful!" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Build failed!" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
"run" {
|
||||
Write-Host "Running MTG Deckbuilder with volume mounting..." -ForegroundColor Yellow
|
||||
|
||||
# Ensure local directories exist
|
||||
if (!(Test-Path "deck_files")) { New-Item -ItemType Directory -Path "deck_files" }
|
||||
if (!(Test-Path "logs")) { New-Item -ItemType Directory -Path "logs" }
|
||||
if (!(Test-Path "csv_files")) { New-Item -ItemType Directory -Path "csv_files" }
|
||||
|
||||
# Run with proper volume mounting
|
||||
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
|
||||
}
|
||||
|
||||
"compose" {
|
||||
Write-Host "Running MTG Deckbuilder with Docker Compose..." -ForegroundColor Yellow
|
||||
|
||||
# Ensure local directories exist
|
||||
if (!(Test-Path "deck_files")) { New-Item -ItemType Directory -Path "deck_files" }
|
||||
if (!(Test-Path "logs")) { New-Item -ItemType Directory -Path "logs" }
|
||||
if (!(Test-Path "csv_files")) { New-Item -ItemType Directory -Path "csv_files" }
|
||||
|
||||
docker-compose up --build
|
||||
}
|
||||
|
||||
"clean" {
|
||||
Write-Host "Cleaning up Docker containers and images..." -ForegroundColor Yellow
|
||||
docker-compose down 2>$null
|
||||
docker rmi mtg-deckbuilder 2>$null
|
||||
docker system prune -f
|
||||
Write-Host "Cleanup complete!" -ForegroundColor Green
|
||||
}
|
||||
|
||||
"help" {
|
||||
Show-Help
|
||||
}
|
||||
|
||||
default {
|
||||
Write-Host "Invalid command: $command" -ForegroundColor Red
|
||||
Show-Help
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Note: Your deck files, logs, and CSV files will be saved in the local directories" -ForegroundColor Cyan
|
||||
Write-Host "and will persist between Docker runs." -ForegroundColor Cyan
|
||||
252
run-docker.sh
Normal file
252
run-docker.sh
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
#!/bin/bash
|
||||
# MTG Deckbuilder Docker Runner Script for Linux/macOS
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
echo "MTG Deckbuilder Docker Helper (Linux)"
|
||||
echo "====================================="
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
print_debug() {
|
||||
echo -e "${BLUE}[DEBUG]${NC} $1"
|
||||
}
|
||||
|
||||
show_help() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}Available commands:${NC}"
|
||||
echo " ./run-docker-linux.sh setup - Initial setup (create directories, check Docker)"
|
||||
echo " ./run-docker-linux.sh build - Build the Docker image"
|
||||
echo " ./run-docker-linux.sh run - Run with manual volume mounting"
|
||||
echo " ./run-docker-linux.sh compose - Use docker-compose run (recommended for interactive)"
|
||||
echo " ./run-docker-linux.sh compose-build - Build and run with docker-compose"
|
||||
echo " ./run-docker-linux.sh compose-up - Use docker-compose up (not recommended for interactive)"
|
||||
echo " ./run-docker-linux.sh debug - Run with debug info and volume verification"
|
||||
echo " ./run-docker-linux.sh clean - Remove containers and images"
|
||||
echo " ./run-docker-linux.sh help - Show this help"
|
||||
echo ""
|
||||
echo -e "${BLUE}For interactive applications like MTG Deckbuilder:${NC}"
|
||||
echo -e "${BLUE} - Use 'compose' or 'run' commands${NC}"
|
||||
echo -e "${BLUE} - Avoid 'compose-up' as it doesn't handle input properly${NC}"
|
||||
}
|
||||
|
||||
setup_directories() {
|
||||
print_status "Setting up directories..."
|
||||
|
||||
# Create directories with proper permissions
|
||||
mkdir -p deck_files logs csv_files
|
||||
|
||||
# Set permissions to ensure Docker can write
|
||||
chmod 755 deck_files logs csv_files
|
||||
|
||||
print_status "Current directory: $(pwd)"
|
||||
print_status "Directory structure:"
|
||||
ls -la | grep -E "(deck_files|logs|csv_files|^d)"
|
||||
|
||||
echo ""
|
||||
print_status "Directory setup complete!"
|
||||
}
|
||||
|
||||
check_docker() {
|
||||
print_status "Checking Docker installation..."
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
print_error "Docker is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
print_warning "docker-compose not found, trying docker compose..."
|
||||
if ! docker compose version &> /dev/null; then
|
||||
print_error "Neither docker-compose nor 'docker compose' is available"
|
||||
exit 1
|
||||
else
|
||||
COMPOSE_CMD="docker compose"
|
||||
fi
|
||||
else
|
||||
COMPOSE_CMD="docker-compose"
|
||||
fi
|
||||
|
||||
print_status "Docker is available"
|
||||
print_status "Compose command: $COMPOSE_CMD"
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
"setup")
|
||||
check_docker
|
||||
setup_directories
|
||||
print_status "Setup complete! You can now run: ./run-docker-linux.sh compose"
|
||||
;;
|
||||
|
||||
"build")
|
||||
print_status "Building MTG Deckbuilder Docker image..."
|
||||
docker build -t mtg-deckbuilder .
|
||||
if [ $? -eq 0 ]; then
|
||||
print_status "Build successful!"
|
||||
else
|
||||
print_error "Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
|
||||
"run")
|
||||
print_status "Running MTG Deckbuilder with manual volume mounting..."
|
||||
|
||||
# Ensure directories exist
|
||||
setup_directories
|
||||
|
||||
print_debug "Volume mounts:"
|
||||
print_debug " $(pwd)/deck_files -> /app/deck_files"
|
||||
print_debug " $(pwd)/logs -> /app/logs"
|
||||
print_debug " $(pwd)/csv_files -> /app/csv_files"
|
||||
|
||||
# Run with proper volume mounting
|
||||
docker run -it --rm \
|
||||
-v "$(pwd)/deck_files:/app/deck_files" \
|
||||
-v "$(pwd)/logs:/app/logs" \
|
||||
-v "$(pwd)/csv_files:/app/csv_files" \
|
||||
-e PYTHONUNBUFFERED=1 \
|
||||
-e TERM=xterm-256color \
|
||||
mtg-deckbuilder
|
||||
;;
|
||||
|
||||
"compose")
|
||||
print_status "Running MTG Deckbuilder with Docker Compose..."
|
||||
|
||||
# Ensure directories exist
|
||||
setup_directories
|
||||
|
||||
# Check for compose command
|
||||
check_docker
|
||||
|
||||
print_debug "Using compose command: $COMPOSE_CMD"
|
||||
print_debug "Working directory: $(pwd)"
|
||||
|
||||
print_status "Starting interactive session..."
|
||||
print_warning "Use Ctrl+C to exit when done"
|
||||
|
||||
# Run with compose in interactive mode
|
||||
$COMPOSE_CMD run --rm mtg-deckbuilder
|
||||
;;
|
||||
|
||||
"compose-build")
|
||||
print_status "Building and running MTG Deckbuilder with Docker Compose..."
|
||||
|
||||
# Ensure directories exist
|
||||
setup_directories
|
||||
|
||||
# Check for compose command
|
||||
check_docker
|
||||
|
||||
print_debug "Using compose command: $COMPOSE_CMD"
|
||||
print_debug "Working directory: $(pwd)"
|
||||
|
||||
print_status "Building image and starting interactive session..."
|
||||
print_warning "Use Ctrl+C to exit when done"
|
||||
|
||||
# Build and run with compose in interactive mode
|
||||
$COMPOSE_CMD build
|
||||
$COMPOSE_CMD run --rm mtg-deckbuilder
|
||||
;;
|
||||
|
||||
"compose-up")
|
||||
print_status "Running MTG Deckbuilder with Docker Compose UP (not recommended for interactive apps)..."
|
||||
|
||||
# Ensure directories exist
|
||||
setup_directories
|
||||
|
||||
# Check for compose command
|
||||
check_docker
|
||||
|
||||
print_debug "Using compose command: $COMPOSE_CMD"
|
||||
print_debug "Working directory: $(pwd)"
|
||||
|
||||
print_warning "This may not work properly for interactive applications!"
|
||||
print_warning "Use 'compose' command instead for better interactivity"
|
||||
|
||||
# Run with compose
|
||||
$COMPOSE_CMD up --build
|
||||
;;
|
||||
|
||||
"debug")
|
||||
print_status "Running in debug mode..."
|
||||
|
||||
setup_directories
|
||||
|
||||
print_debug "=== DEBUG INFO ==="
|
||||
print_debug "Current user: $(whoami)"
|
||||
print_debug "Current directory: $(pwd)"
|
||||
print_debug "Directory permissions:"
|
||||
ls -la deck_files logs csv_files 2>/dev/null || print_warning "Some directories don't exist yet"
|
||||
|
||||
print_debug "=== DOCKER INFO ==="
|
||||
docker --version
|
||||
docker info | grep -E "(Operating System|Architecture)"
|
||||
|
||||
print_debug "=== RUNNING CONTAINER ==="
|
||||
docker run -it --rm \
|
||||
-v "$(pwd)/deck_files:/app/deck_files" \
|
||||
-v "$(pwd)/logs:/app/logs" \
|
||||
-v "$(pwd)/csv_files:/app/csv_files" \
|
||||
-e PYTHONUNBUFFERED=1 \
|
||||
-e TERM=xterm-256color \
|
||||
mtg-deckbuilder /bin/bash -c "
|
||||
echo 'Container info:'
|
||||
echo 'Working dir: \$(pwd)'
|
||||
echo 'Mount points:'
|
||||
ls -la /app/
|
||||
echo 'Testing file creation:'
|
||||
touch /app/deck_files/test_file.txt
|
||||
echo 'File created: \$(ls -la /app/deck_files/test_file.txt)'
|
||||
echo 'Starting application...'
|
||||
python main.py
|
||||
"
|
||||
;;
|
||||
|
||||
"clean")
|
||||
print_status "Cleaning up Docker containers and images..."
|
||||
|
||||
check_docker
|
||||
|
||||
# Stop and remove containers
|
||||
$COMPOSE_CMD down 2>/dev/null || true
|
||||
docker stop mtg-deckbuilder 2>/dev/null || true
|
||||
docker rm mtg-deckbuilder 2>/dev/null || true
|
||||
|
||||
# Remove image
|
||||
docker rmi mtg-deckbuilder 2>/dev/null || true
|
||||
|
||||
# Clean up unused resources
|
||||
docker system prune -f
|
||||
|
||||
print_status "Cleanup complete!"
|
||||
;;
|
||||
|
||||
"help"|*)
|
||||
show_help
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}Note: Your deck files, logs, and CSV files will be saved in:${NC}"
|
||||
echo -e "${BLUE} $(pwd)/deck_files${NC}"
|
||||
echo -e "${BLUE} $(pwd)/logs${NC}"
|
||||
echo -e "${BLUE} $(pwd)/csv_files${NC}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue