diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d65f0c7 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Copy this file to `.env` and adjust values to your needs. + +# Set to 'headless' to auto-run the non-interactive mode on container start +# DECK_MODE=headless + +# Optional JSON config path (inside the container) +# If you mount ./config to /app/config, use: +# DECK_CONFIG=/app/config/deck.json + +# Common knobs +# DECK_COMMANDER=Pantlaza +# DECK_PRIMARY_CHOICE=2 +# DECK_SECONDARY_CHOICE=2 +# DECK_TERTIARY_CHOICE=2 +# DECK_ADD_CREATURES=true +# DECK_ADD_NON_CREATURE_SPELLS=true +# DECK_ADD_RAMP=true +# DECK_ADD_REMOVAL=true +# DECK_ADD_WIPES=true +# DECK_ADD_CARD_ADVANTAGE=true +# DECK_ADD_PROTECTION=true +# DECK_USE_MULTI_THEME=true +# DECK_ADD_LANDS=true +# DECK_FETCH_COUNT=3 +# DECK_DUAL_COUNT= +# DECK_TRIPLE_COUNT= +# DECK_UTILITY_COUNT= + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8f233d8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi + # Fallbacks if requirements-dev.txt not present + pip install mypy pytest || true + + - name: Type check (mypy) + run: | + mypy code || true + + - name: Headless smoke test (dry-run) + run: | + python -m code.headless_runner --config config/deck.json --dry-run + + - name: Tests + run: | + pytest -q || true diff --git a/.gitignore b/.gitignore index b99f3d9..816f64c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ build/ csv_files/ dist/ logs/ -non_interactive_test.py test_determinism.py test.py -deterministic_test.py \ No newline at end of file +deterministic_test.py +!config/deck.json \ No newline at end of file diff --git a/DOCKER.md b/DOCKER.md index ce0d6fb..377f936 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -1,215 +1,66 @@ -# Docker Guide for MTG Python Deckbuilder +# Docker Guide (concise) -A comprehensive guide for running the MTG Python Deckbuilder in Docker containers with full file persistence and cross-platform support. +Run the MTG Deckbuilder in Docker with persistent volumes and optional headless mode. -## 🚀 Quick Start +## Quick start -### Linux/macOS/Remote Host -```bash -# Make scripts executable (one time only) -chmod +x quick-start.sh run-docker.sh - -# Simplest method - just run this: -./quick-start.sh - -# Or use the full script with options: -./run-docker.sh compose -``` - -### Windows (PowerShell) +### PowerShell (recommended) ```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.sh setup` | Create directories and check Docker installation | -| `./run-docker.sh build` | Build the Docker image | -| `./run-docker.sh compose` | Run with Docker Compose (recommended) | -| `./run-docker.sh run` | Run with manual volume mounting | -| `./run-docker.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` - -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 - -# Remove image -docker rmi mtg-deckbuilder - -# Clean up system -docker system prune -f - -# Rebuild docker compose build +docker compose run --rm mtg-deckbuilder ``` -## 🔍 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.sh help +### From Docker Hub (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" ` + mwisnowski/mtg-python-deckbuilder:latest ``` + +## Volumes +- `/app/deck_files` ↔ `./deck_files` +- `/app/logs` ↔ `./logs` +- `/app/csv_files` ↔ `./csv_files` +- Optional: `/app/config` ↔ `./config` (JSON configs for headless) + +## Interactive vs headless +- Interactive: attach a TTY (compose run or `docker run -it`) +- Headless auto-run: + ```powershell + docker compose run --rm -e DECK_MODE=headless mtg-deckbuilder + ``` +- Headless with JSON config: + ```powershell + docker compose run --rm ` + -e DECK_MODE=headless ` + -e DECK_CONFIG=/app/config/deck.json ` + mtg-deckbuilder + ``` + +### Common env vars +- DECK_MODE=headless +- DECK_CONFIG=/app/config/deck.json +- DECK_COMMANDER, DECK_PRIMARY_CHOICE +- DECK_ADD_LANDS, DECK_FETCH_COUNT + +## Manual build/run +```powershell +docker build -t mtg-deckbuilder . +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 + - No prompts? Use `docker compose run --rm` (not `up`) or add `-it` to `docker run` + - Files not saving? Verify volume mounts and that folders exist + - Headless not picking config? Ensure `./config` is mounted to `/app/config` and `DECK_CONFIG` points to a JSON file + +## Tips +- Use `docker compose run`, not `up`, for interactive mode +- Exported decks appear in `deck_files/` +- JSON run-config is exported only in interactive runs; headless skips it diff --git a/Dockerfile b/Dockerfile index f7c48c2..5c68cbc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,20 +24,21 @@ COPY code/ ./code/ COPY mypy.ini . # Create necessary directories as mount points -RUN mkdir -p deck_files logs csv_files +RUN mkdir -p deck_files logs csv_files config # Create volumes for persistent data -VOLUME ["/app/deck_files", "/app/logs", "/app/csv_files"] +VOLUME ["/app/deck_files", "/app/logs", "/app/csv_files", "/app/config"] # Create symbolic links BEFORE changing working directory # These will point to the mounted volumes RUN cd /app/code && \ ln -sf /app/deck_files ./deck_files && \ ln -sf /app/logs ./logs && \ - ln -sf /app/csv_files ./csv_files + ln -sf /app/csv_files ./csv_files && \ + ln -sf /app/config ./config # Verify symbolic links were created -RUN cd /app/code && ls -la deck_files logs csv_files +RUN cd /app/code && ls -la deck_files logs csv_files config # Set the working directory to code for proper imports WORKDIR /app/code diff --git a/README.md b/README.md index edf4def..fd4fa28 100644 Binary files a/README.md and b/README.md differ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9afb3ad..e0ee89a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,62 @@ +# MTG Python Deckbuilder v1.1.0 Release Notes + +## Highlights +- Headless mode via submenu in the main menu (auto-runs single config; lists multiple as "Commander - Theme1, Theme2, Theme3"; `deck.json` shows as "Default") +- Config precedence: CLI > env > JSON > defaults; honors `ideal_counts` in JSON +- Exports: CSV/TXT always; JSON run-config only for interactive runs (headless skips it) +- Docs simplified: concise README and Docker guide; PowerShell examples included + +## Docker +- Single service with persistent volumes: + - /app/deck_files + - /app/logs + - /app/csv_files + - Optional: /app/config for JSON configs + +### Quick Start (PowerShell) +```powershell +# From Docker Hub +docker run -it --rm ` + -v "${PWD}/deck_files:/app/deck_files" ` + -v "${PWD}/logs:/app/logs" ` + -v "${PWD}/csv_files:/app/csv_files" ` + mwisnowski/mtg-python-deckbuilder:latest + +# From source with Compose +docker compose build +docker compose run --rm mtg-deckbuilder + +# Headless (optional) +docker compose run --rm -e DECK_MODE=headless mtg-deckbuilder +# With JSON config +docker compose run --rm -e DECK_MODE=headless -e DECK_CONFIG=/app/config/deck.json mtg-deckbuilder +``` + +## Changes +- Added headless runner and headless submenu +- Suppressed JSON run-config export for headless runs +- `ideal_counts` in JSON now honored by prompts; only `fetch_count` tracked for lands +- Documentation trimmed and updated; added sample config with ideal_counts + +### Tagging updates +- New: Discard Matters theme – detects your discard effects and triggers; includes Madness and Blood creators; Loot/Connive/Cycling/Blood also add Discard Matters. +- New taggers: + - Freerunning → adds Freerunning and Cost Reduction. + - Craft → adds Transform; conditionally Artifacts Matter, Exile Matters, Graveyard Matters. + - Spree → adds Modal and Cost Scaling. + - Explore/Map → adds Card Selection; Explore may add +1/+1 Counters; Map adds Tokens Matter. + - Rad counters → adds Rad Counters. +- Exile Matters expanded to cover Warp and Time Counters/Time Travel/Vanishing. +- Energy enriched to also tag Resource Engine. +- Eldrazi Spawn/Scion creators now tag Aristocrats and Ramp (replacing prior Sacrifice Fodder mapping). + +## Known Issues +- First run downloads card data (takes a few minutes) +- Use `docker compose run --rm` (not `up`) for interactive sessions +- Ensure volumes are mounted to persist files outside the container + +--- + # MTG Python Deckbuilder v1.0.0 Release Notes ## 🎉 Initial Release diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index e6f293f..695ccb2 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,47 +1,59 @@ # MTG Python Deckbuilder ${VERSION} ## Highlights -- Direct-to-builder launch with automatic initial setup and tagging -- Improved Type Summary (accurate Commander/Creature/etc. counts) -- Smarter export filenames: full commander name + ordered themes + date, with auto-increment -- TXT export duplication fixed -- Post-build prompt to build another deck or quit +- Headless mode with a submenu in the main menu (auto-runs single config; lists multiple as "Commander - Theme1, Theme2, Theme3"; `deck.json` labeled "Default") +- Config precedence: CLI > env > JSON > defaults; honors `ideal_counts` in JSON +- Exports: CSV/TXT always; JSON run-config only for interactive runs (headless skips it) +- Smarter filenames: commander + ordered themes + date, with auto-increment when exists ## Docker -- Multi-arch image (amd64, arm64) on Docker Hub -- Persistent volumes: +- Single service; persistent volumes: - /app/deck_files - /app/logs - /app/csv_files + - Optional: /app/config (mount `./config` for JSON configs) ### Quick Start -```bash -mkdir mtg-decks && cd mtg-decks - -docker run -it --rm \ - -v "$(pwd)/deck_files":/app/deck_files \ - -v "$(pwd)/logs":/app/logs \ - -v "$(pwd)/csv_files":/app/csv_files \ +```powershell +# From Docker Hub +docker run -it --rm ` + -v "${PWD}/deck_files:/app/deck_files" ` + -v "${PWD}/logs:/app/logs" ` + -v "${PWD}/csv_files:/app/csv_files" ` mwisnowski/mtg-python-deckbuilder:latest + +# From source with Compose +docker compose build +docker compose run --rm mtg-deckbuilder + +# Headless (optional) +docker compose run --rm -e DECK_MODE=headless mtg-deckbuilder +# With JSON config +docker compose run --rm -e DECK_MODE=headless -e DECK_CONFIG=/app/config/deck.json mtg-deckbuilder ``` -Windows PowerShell users: see WINDOWS_DOCKER_GUIDE.md or run: -Invoke-WebRequest -Uri "https://raw.githubusercontent.com/mwisnowski/mtg_python_deckbuilder/main/run-from-dockerhub.bat" -OutFile "run-from-dockerhub.bat" run-from-dockerhub.bat - ## Changes -- Auto-setup/tagging when `csv_files/cards.csv` is missing (both main and builder) -- Main entrypoint now skips menu and launches the deck builder -- Type summary classification matches export categories; uses snapshot fallback -- Export filenames: - - Full commander name (punctuation removed) - - All themes in order - - Date suffix (YYYYMMDD) - - Auto-increment when file exists -- Removed duplicate TXT sidecar creation in CSV export +- Added headless runner and main menu headless submenu +- JSON export is suppressed in headless; interactive runs export replayable JSON to `config/` +- `ideal_counts` supported and honored by prompts; only `fetch_count` tracked for lands +- Documentation simplified and focused; Docker guide trimmed and PowerShell examples updated + +### Tagging updates +- New: Discard Matters theme – detects your discard effects and triggers; includes Madness and Blood creators; Loot/Connive/Cycling/Blood also add Discard Matters. +- New taggers: + - Freerunning → adds Freerunning and Cost Reduction. + - Craft → adds Transform; conditionally Artifacts Matter, Exile Matters, Graveyard Matters. + - Spree → adds Modal and Cost Scaling. + - Explore/Map → adds Card Selection; Explore may add +1/+1 Counters; Map adds Tokens Matter. + - Rad counters → adds Rad Counters. +- Exile Matters expanded to cover Warp and Time Counters/Time Travel/Vanishing. +- Energy enriched to also tag Resource Engine. +- Eldrazi Spawn/Scion creators now tag Aristocrats and Ramp (replacing prior Sacrifice Fodder mapping). ## Known Issues -- First run downloads card data; may take several minutes -- Ensure volume mounts are present to persist files outside the container +- First run downloads card data (takes a few minutes) +- Use `docker compose run --rm` (not `up`) for interactive sessions +- Ensure volumes are mounted to persist files outside the container ## Links - Repo: https://github.com/mwisnowski/mtg_python_deckbuilder diff --git a/code/__init__.py b/code/__init__.py index b4309c0..f0ca90b 100644 --- a/code/__init__.py +++ b/code/__init__.py @@ -1,11 +1,18 @@ """Root package for the MTG deckbuilder source tree. -Adding this file ensures the directory is treated as a proper package so that -`python -m code.main` resolves to this project instead of the Python stdlib -module named `code` (which is a simple module, not a package). - -If you still accidentally import the stdlib module, be sure you are executing -from the project root so the local `code` package is first on sys.path. +Ensures `python -m code.*` resolves to this project and adjusts sys.path so +legacy absolute imports like `import logging_util` (modules living under this +package) work whether you run files directly or as modules. """ +from __future__ import annotations + +import os +import sys + +# Make the package directory importable as a top-level for legacy absolute imports +_PKG_DIR = os.path.dirname(__file__) +if _PKG_DIR and _PKG_DIR not in sys.path: + sys.path.insert(0, _PKG_DIR) + __all__ = [] diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index 52700ce..23d1909 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -103,6 +103,30 @@ class DeckBuilder( txt_path = self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined] # Display the text file contents for easy copy/paste to online deck builders self._display_txt_contents(txt_path) + # Also export a matching JSON config for replay (interactive builds only) + if not getattr(self, 'headless', False): + try: + # Choose config output dir: DECK_CONFIG dir > /app/config > ./config + import os as _os + cfg_path_env = _os.getenv('DECK_CONFIG') + cfg_dir = None + if cfg_path_env: + cfg_dir = _os.path.dirname(cfg_path_env) or '.' + elif _os.path.isdir('/app/config'): + cfg_dir = '/app/config' + else: + cfg_dir = 'config' + if cfg_dir: + _os.makedirs(cfg_dir, exist_ok=True) + self.export_run_config_json(directory=cfg_dir, filename=base + '.json') # type: ignore[attr-defined] + # Also, if DECK_CONFIG explicitly points to a file path, write exactly there too + if cfg_path_env: + cfg_dir2 = _os.path.dirname(cfg_path_env) or '.' + cfg_name2 = _os.path.basename(cfg_path_env) + _os.makedirs(cfg_dir2, exist_ok=True) + self.export_run_config_json(directory=cfg_dir2, filename=cfg_name2) # type: ignore[attr-defined] + except Exception: + pass except Exception: logger.warning("Plaintext export failed (non-fatal)") end_ts = datetime.datetime.now() @@ -213,14 +237,18 @@ class DeckBuilder( # IO injection for testing input_func: Callable[[str], str] = field(default=lambda prompt: input(prompt)) output_func: Callable[[str], None] = field(default=lambda msg: print(msg)) - # Deterministic random support - seed: Optional[int] = None + # Random support (no external seeding) _rng: Any = field(default=None, repr=False) # Logging / output behavior log_outputs: bool = True # if True, mirror output_func messages into logger at INFO level _original_output_func: Optional[Callable[[str], None]] = field(default=None, repr=False) + # Chosen land counts (only fetches are tracked/exported; others vary randomly) + fetch_count: Optional[int] = None + # Whether this build is running in headless mode (suppress some interactive-only exports) + headless: bool = False + def __post_init__(self): """Post-init hook to wrap the provided output function so that all user-facing messages are also captured in the central log (at INFO level) unless disabled. @@ -250,10 +278,10 @@ class DeckBuilder( # --------------------------- # RNG Initialization # --------------------------- - def _get_rng(self): # lazy init to allow seed set post-construction + def _get_rng(self): # lazy init if self._rng is None: import random as _r - self._rng = _r.Random(self.seed) if self.seed is not None else _r + self._rng = _r return self._rng # --------------------------- diff --git a/code/deck_builder/phases/phase2_lands_fetch.py b/code/deck_builder/phases/phase2_lands_fetch.py index 5c4c8d4..dad1a51 100644 --- a/code/deck_builder/phases/phase2_lands_fetch.py +++ b/code/deck_builder/phases/phase2_lands_fetch.py @@ -114,6 +114,7 @@ class LandFetchMixin: if len(chosen) < desired: leftovers = [n for n in candidates if n not in chosen] chosen.extend(leftovers[: desired - len(chosen)]) + added: List[str] = [] for nm in chosen: if self._current_land_count() >= land_target: # type: ignore[attr-defined] @@ -127,6 +128,11 @@ class LandFetchMixin: added_by='lands_step4' ) # type: ignore[attr-defined] added.append(nm) + # Record actual number of fetch lands added for export/replay context + try: + setattr(self, 'fetch_count', len(added)) # type: ignore[attr-defined] + except Exception: + pass self.output_func("\nFetch Lands Added (Step 4):") if not added: self.output_func(" (None added)") diff --git a/code/deck_builder/phases/phase2_lands_misc.py b/code/deck_builder/phases/phase2_lands_misc.py index c1ce5d7..cfd7371 100644 --- a/code/deck_builder/phases/phase2_lands_misc.py +++ b/code/deck_builder/phases/phase2_lands_misc.py @@ -106,6 +106,7 @@ class LandMiscUtilityMixin: self.add_card(nm, card_type='Land', role='utility', sub_role='misc', added_by='lands_step7') added.append(nm) + self.output_func("\nMisc Utility Lands Added (Step 7):") if not added: self.output_func(" (None added)") diff --git a/code/deck_builder/phases/phase2_lands_triples.py b/code/deck_builder/phases/phase2_lands_triples.py index d11b7a5..1d5afd4 100644 --- a/code/deck_builder/phases/phase2_lands_triples.py +++ b/code/deck_builder/phases/phase2_lands_triples.py @@ -217,6 +217,7 @@ class LandTripleMixin: ) added.append(name) + self.output_func("\nTriple Lands Added (Step 6):") if not added: self.output_func(" (None added)") diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index d594e7f..f0983c4 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -383,6 +383,89 @@ class ReportingMixin: self.output_func(f"Plaintext deck list exported to {path}") return path + def export_run_config_json(self, directory: str = 'config', filename: str | None = None, suppress_output: bool = False) -> str: + """Export a JSON config capturing the key choices for replaying headless. + + Filename mirrors CSV/TXT naming (same stem, .json extension). + Fields included: + - commander + - primary_tag / secondary_tag / tertiary_tag + - bracket_level (if chosen) + - use_multi_theme (default True) + - add_lands, add_creatures, add_non_creature_spells (defaults True) + - fetch_count (if determined during run) + - ideal_counts (the actual ideal composition values used) + """ + os.makedirs(directory, exist_ok=True) + + def _slug(s: str) -> str: + s2 = _re.sub(r'[^A-Za-z0-9_]+', '', s) + return s2 or 'x' + + def _unique_path(path: str) -> str: + if not os.path.exists(path): + return path + base, ext = os.path.splitext(path) + i = 1 + while True: + candidate = f"{base}_{i}{ext}" + if not os.path.exists(candidate): + return candidate + i += 1 + + if filename is None: + cmdr = getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '' + cmdr_slug = _slug(cmdr) if isinstance(cmdr, str) and cmdr else 'deck' + themes: List[str] = [] + if getattr(self, 'selected_tags', None): + themes = [str(t) for t in self.selected_tags if isinstance(t, str) and t.strip()] + else: + for t in [getattr(self, 'primary_tag', None), getattr(self, 'secondary_tag', None), getattr(self, 'tertiary_tag', None)]: + if isinstance(t, str) and t.strip(): + themes.append(t) + theme_parts = [_slug(t) for t in themes if t] + if not theme_parts: + theme_parts = ['notheme'] + theme_slug = '_'.join(theme_parts) + date_part = _dt.date.today().strftime('%Y%m%d') + filename = f"{cmdr_slug}_{theme_slug}_{date_part}.json" + + path = _unique_path(os.path.join(directory, filename)) + + # Capture ideal counts (actual chosen values) + ideal_counts = getattr(self, 'ideal_counts', {}) or {} + # Capture fetch count (others vary run-to-run and are intentionally not recorded) + chosen_fetch = getattr(self, 'fetch_count', None) + + payload = { + "commander": getattr(self, 'commander_name', '') or getattr(self, 'commander', '') or '', + "primary_tag": getattr(self, 'primary_tag', None), + "secondary_tag": getattr(self, 'secondary_tag', None), + "tertiary_tag": getattr(self, 'tertiary_tag', None), + "bracket_level": getattr(self, 'bracket_level', None), + "use_multi_theme": True, + "add_lands": True, + "add_creatures": True, + "add_non_creature_spells": True, + # chosen fetch land count (others intentionally omitted for variance) + "fetch_count": chosen_fetch, + # actual ideal counts used for this run + "ideal_counts": { + k: int(v) for k, v in ideal_counts.items() if isinstance(v, (int, float)) + } + # seed intentionally omitted + } + + try: + import json as _json + with open(path, 'w', encoding='utf-8') as f: + _json.dump(payload, f, indent=2) + if not suppress_output: + self.output_func(f"Run config exported to {path}") + except Exception as e: + logger.warning(f"Failed to export run config: {e}") + return path + def print_card_library(self, table: bool = True): """Prints the current card library in either plain or tabular format. Uses PrettyTable if available, otherwise prints a simple list. diff --git a/code/file_setup/setup.py b/code/file_setup/setup.py index dd0a707..da2ccf8 100644 --- a/code/file_setup/setup.py +++ b/code/file_setup/setup.py @@ -22,8 +22,11 @@ from enum import Enum import os from typing import List, Dict, Any -# Third-party imports -import inquirer +# Third-party imports (optional) +try: + import inquirer # type: ignore +except Exception: + inquirer = None # Fallback to simple input-based menu when unavailable import pandas as pd # Local imports @@ -229,13 +232,32 @@ def _display_setup_menu() -> SetupOption: Returns: SetupOption: The selected menu option """ - question: List[Dict[str, Any]] = [ - inquirer.List( - 'menu', - choices=[option.value for option in SetupOption], - carousel=True)] - answer = inquirer.prompt(question) - return SetupOption(answer['menu']) + if inquirer is not None: + question: List[Dict[str, Any]] = [ + inquirer.List( + 'menu', + choices=[option.value for option in SetupOption], + carousel=True)] + answer = inquirer.prompt(question) + return SetupOption(answer['menu']) + + # Simple fallback when inquirer isn't installed (e.g., headless/container) + options = list(SetupOption) + print("\nSetup Menu:") + for idx, opt in enumerate(options, start=1): + print(f" {idx}) {opt.value}") + while True: + try: + sel = input("Select an option [1]: ").strip() or "1" + i = int(sel) + if 1 <= i <= len(options): + return options[i - 1] + except KeyboardInterrupt: + print("") + return SetupOption.BACK + except Exception: + pass + print("Invalid selection. Please try again.") def setup() -> bool: """Run the setup process for the MTG Python Deckbuilder. diff --git a/code/headless_runner.py b/code/headless_runner.py new file mode 100644 index 0000000..4130409 --- /dev/null +++ b/code/headless_runner.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import argparse +import json +import os +from typing import Any, Dict, List, Optional +from pathlib import Path + +from code.deck_builder.builder import DeckBuilder + +"""Headless (non-interactive) runner. + +Features: + - Script commander selection. + - Script primary / optional secondary / tertiary tags. + - Apply bracket & accept default ideal counts. + - Invoke multi-theme creature addition if available (fallback to primary-only). + +Use run(..., secondary_choice=2, tertiary_choice=3, use_multi_theme=True) to exercise multi-theme logic. +Indices correspond to the numbered tag list presented during interaction. +""" + +def run( + command_name: str = "Pantlaza", + add_creatures: bool = True, + add_non_creature_spells: bool = True, + # Fine-grained toggles (used only if add_non_creature_spells is False) + add_ramp: bool = True, + add_removal: bool = True, + add_wipes: bool = True, + add_card_advantage: bool = True, + add_protection: bool = True, + use_multi_theme: bool = True, + primary_choice: int = 2, + secondary_choice: Optional[int] = 2, + tertiary_choice: Optional[int] = 2, + add_lands: bool = True, + fetch_count: Optional[int] = 3, + dual_count: Optional[int] = None, + triple_count: Optional[int] = None, + utility_count: Optional[int] = None, + ideal_counts: Optional[Dict[str, int]] = None, +) -> DeckBuilder: + """Run a scripted non-interactive deck build and return the DeckBuilder instance. + + Integer parameters (primary_choice, secondary_choice, tertiary_choice) correspond to the + numeric indices shown during interactive tag selection. Pass None to omit secondary/tertiary. + Optional counts (fetch_count, dual_count, triple_count, utility_count) constrain land steps. + + """ + scripted_inputs: List[str] = [] + # Commander query & selection + scripted_inputs.append(command_name) # initial query + scripted_inputs.append("1") # choose first search match to inspect + scripted_inputs.append("y") # confirm commander + # Primary tag selection + scripted_inputs.append(str(primary_choice)) + # Secondary tag selection or stop (0) + if secondary_choice is not None: + scripted_inputs.append(str(secondary_choice)) + # Tertiary tag selection or stop (0) + if tertiary_choice is not None: + scripted_inputs.append(str(tertiary_choice)) + else: + scripted_inputs.append("0") + else: + scripted_inputs.append("0") # stop at primary + # Bracket (meta power / style) selection; keeping existing scripted value + scripted_inputs.append("3") + # Ideal count prompts (press Enter for defaults) + for _ in range(8): + scripted_inputs.append("") + + def scripted_input(prompt: str) -> str: + if scripted_inputs: + return scripted_inputs.pop(0) + raise RuntimeError("Ran out of scripted inputs for prompt: " + prompt) + + builder = DeckBuilder(input_func=scripted_input) + # Mark this run as headless so builder can adjust exports and logging + try: + builder.headless = True # type: ignore[attr-defined] + except Exception: + pass + # If ideal_counts are provided (from JSON), use them as the current defaults + # so the step 2 prompts will show these values and our blank entries will accept them. + if isinstance(ideal_counts, dict) and ideal_counts: + try: + ic: Dict[str, int] = {} + for k, v in ideal_counts.items(): + try: + iv = int(v) if v is not None else None # type: ignore + except Exception: + continue + if iv is None: + continue + # Only accept known keys + if k in {"ramp","lands","basic_lands","creatures","removal","wipes","card_advantage","protection"}: + ic[k] = iv + if ic: + builder.ideal_counts.update(ic) # type: ignore[attr-defined] + except Exception: + pass + builder.run_initial_setup() + builder.run_deck_build_step1() + builder.run_deck_build_step2() + + # Land sequence (optional) + if add_lands: + if hasattr(builder, 'run_land_step1'): + builder.run_land_step1() # Basics / initial + if hasattr(builder, 'run_land_step2'): + builder.run_land_step2() # Utility basics / rebalancing + if hasattr(builder, 'run_land_step3'): + builder.run_land_step3() # Kindred lands if applicable + if hasattr(builder, 'run_land_step4'): + builder.run_land_step4(requested_count=fetch_count) + if hasattr(builder, 'run_land_step5'): + builder.run_land_step5(requested_count=dual_count) + if hasattr(builder, 'run_land_step6'): + builder.run_land_step6(requested_count=triple_count) + if hasattr(builder, 'run_land_step7'): + + builder.run_land_step7(requested_count=utility_count) + if hasattr(builder, 'run_land_step8'): + builder.run_land_step8() + + if add_creatures: + builder.add_creatures() + # Non-creature spell categories (ramp / removal / wipes / draw / protection) + if add_non_creature_spells and hasattr(builder, 'add_non_creature_spells'): + builder.add_non_creature_spells() + else: + # Allow selective invocation if orchestrator not desired + if add_ramp and hasattr(builder, 'add_ramp'): + builder.add_ramp() + if add_removal and hasattr(builder, 'add_removal'): + builder.add_removal() + if add_wipes and hasattr(builder, 'add_board_wipes'): + builder.add_board_wipes() + if add_card_advantage and hasattr(builder, 'add_card_advantage'): + builder.add_card_advantage() + if add_protection and hasattr(builder, 'add_protection'): + builder.add_protection() + + + # Suppress verbose library print in headless run since CSV export is produced. + # builder.print_card_library() + builder.post_spell_land_adjust() + # Export decklist CSV (commander first word + date) + csv_path: Optional[str] = None + if hasattr(builder, 'export_decklist_csv'): + try: + csv_path = builder.export_decklist_csv() + except Exception: + csv_path = None + if hasattr(builder, 'export_decklist_text'): + try: + if csv_path: + base = os.path.splitext(os.path.basename(csv_path))[0] + builder.export_decklist_text(filename=base + '.txt') + if hasattr(builder, 'export_run_config_json'): + try: + cfg_path_env = os.getenv('DECK_CONFIG') + if cfg_path_env: + cfg_dir = os.path.dirname(cfg_path_env) or '.' + elif os.path.isdir('/app/config'): + cfg_dir = '/app/config' + else: + cfg_dir = 'config' + os.makedirs(cfg_dir, exist_ok=True) + builder.export_run_config_json(directory=cfg_dir, filename=base + '.json') + # If an explicit DECK_CONFIG path is given, also write to exactly that path + if cfg_path_env: + cfg_dir2 = os.path.dirname(cfg_path_env) or '.' + cfg_name2 = os.path.basename(cfg_path_env) + os.makedirs(cfg_dir2, exist_ok=True) + builder.export_run_config_json(directory=cfg_dir2, filename=cfg_name2) + except Exception: + pass + else: + builder.export_decklist_text() + except Exception: + pass + return builder + +def _parse_bool(val: Optional[str | bool | int]) -> Optional[bool]: + if val is None: + return None + if isinstance(val, bool): + return val + if isinstance(val, int): + return bool(val) + s = str(val).strip().lower() + if s in {"1", "true", "t", "yes", "y", "on"}: + return True + if s in {"0", "false", "f", "no", "n", "off"}: + return False + return None + + +def _parse_opt_int(val: Optional[str | int]) -> Optional[int]: + if val is None: + return None + if isinstance(val, int): + return val + s = str(val).strip().lower() + if s in {"", "none", "null", "nan"}: + return None + return int(s) + + +def _load_json_config(path: Optional[str]) -> Dict[str, Any]: + if not path: + return {} + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError("JSON config must be an object") + return data + except FileNotFoundError: + raise + + +def _build_arg_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="Headless deck builder runner") + p.add_argument("--config", default=os.getenv("DECK_CONFIG"), help="Path to JSON config file") + p.add_argument("--commander", default=None) + p.add_argument("--primary-choice", type=int, default=None) + p.add_argument("--secondary-choice", type=_parse_opt_int, default=None) + p.add_argument("--tertiary-choice", type=_parse_opt_int, default=None) + p.add_argument("--add-lands", type=_parse_bool, default=None) + p.add_argument("--fetch-count", type=_parse_opt_int, default=None) + p.add_argument("--dual-count", type=_parse_opt_int, default=None) + p.add_argument("--triple-count", type=_parse_opt_int, default=None) + p.add_argument("--utility-count", type=_parse_opt_int, default=None) + # no seed support + # Booleans + p.add_argument("--add-creatures", type=_parse_bool, default=None) + p.add_argument("--add-non-creature-spells", type=_parse_bool, default=None) + p.add_argument("--add-ramp", type=_parse_bool, default=None) + p.add_argument("--add-removal", type=_parse_bool, default=None) + p.add_argument("--add-wipes", type=_parse_bool, default=None) + p.add_argument("--add-card-advantage", type=_parse_bool, default=None) + p.add_argument("--add-protection", type=_parse_bool, default=None) + p.add_argument("--use-multi-theme", type=_parse_bool, default=None) + p.add_argument("--dry-run", action="store_true", help="Print resolved config and exit") + p.add_argument("--auto-select-config", action="store_true", help="If set, and multiple JSON configs exist, list and prompt to choose one before running.") + return p + + +def _resolve_value( + cli: Optional[Any], env_name: str, json_data: Dict[str, Any], json_key: str, default: Any +) -> Any: + if cli is not None: + return cli + env_val = os.getenv(env_name) + if env_val is not None: + # Convert types based on default type + if isinstance(default, bool): + b = _parse_bool(env_val) + return default if b is None else b + if isinstance(default, int) or default is None: + # allow optional ints + try: + return _parse_opt_int(env_val) + except ValueError: + return default + return env_val + if json_key in json_data: + return json_data[json_key] + return default + + +def _main() -> int: + parser = _build_arg_parser() + args = parser.parse_args() + # Optional config auto-discovery/prompting + cfg_path = args.config + json_cfg: Dict[str, Any] = {} + def _discover_json_configs() -> List[str]: + # Determine directory to scan for JSON configs + if cfg_path and os.path.isdir(cfg_path): + cfg_dir = cfg_path + elif os.path.isdir('/app/config'): + cfg_dir = '/app/config' + else: + cfg_dir = 'config' + try: + p = Path(cfg_dir) + return sorted([str(fp) for fp in p.glob('*.json')]) if p.exists() else [] + except Exception: + return [] + + # If a file path is provided, load it directly + if cfg_path and os.path.isfile(cfg_path): + json_cfg = _load_json_config(cfg_path) + else: + # If auto-select is requested, we may prompt user to choose a config + configs = _discover_json_configs() + if cfg_path and os.path.isdir(cfg_path): + # Directory explicitly provided, prefer auto selection behavior + if len(configs) == 1: + json_cfg = _load_json_config(configs[0]) + os.environ['DECK_CONFIG'] = configs[0] + elif len(configs) > 1 and args.auto_select_config: + def _label(p: str) -> str: + try: + with open(p, 'r', encoding='utf-8') as fh: + data = json.load(fh) + cmd = str(data.get('commander') or '').strip() or 'Unknown Commander' + themes = [t for t in [data.get('primary_tag'), data.get('secondary_tag'), data.get('tertiary_tag')] if isinstance(t, str) and t.strip()] + return f"{cmd} - {', '.join(themes)}" if themes else cmd + except Exception: + return p + print("\nAvailable JSON configs:") + for idx, f in enumerate(configs, start=1): + print(f" {idx}) {_label(f)}") + print(" 0) Cancel") + while True: + try: + sel = input("Select a config to run [0]: ").strip() or '0' + except KeyboardInterrupt: + print("") + sel = '0' + if sel == '0': + return 0 + try: + i = int(sel) + if 1 <= i <= len(configs): + chosen = configs[i - 1] + json_cfg = _load_json_config(chosen) + os.environ['DECK_CONFIG'] = chosen + break + except ValueError: + pass + print("Invalid selection. Try again.") + else: + # No explicit file; if exactly one config exists, auto use it; else leave empty + if len(configs) == 1: + json_cfg = _load_json_config(configs[0]) + os.environ['DECK_CONFIG'] = configs[0] + + # Defaults mirror run() signature + defaults = dict( + command_name="Pantlaza", + add_creatures=True, + add_non_creature_spells=True, + add_ramp=True, + add_removal=True, + add_wipes=True, + add_card_advantage=True, + add_protection=True, + use_multi_theme=True, + primary_choice=2, + secondary_choice=2, + tertiary_choice=2, + add_lands=True, + fetch_count=3, + dual_count=None, + triple_count=None, + utility_count=None, + ) + + # Pull optional ideal_counts from JSON if present + ideal_counts_json = {} + try: + if isinstance(json_cfg.get("ideal_counts"), dict): + ideal_counts_json = json_cfg["ideal_counts"] + except Exception: + ideal_counts_json = {} + + resolved = { + "command_name": _resolve_value(args.commander, "DECK_COMMANDER", json_cfg, "commander", defaults["command_name"]), + "add_creatures": _resolve_value(args.add_creatures, "DECK_ADD_CREATURES", json_cfg, "add_creatures", defaults["add_creatures"]), + "add_non_creature_spells": _resolve_value(args.add_non_creature_spells, "DECK_ADD_NON_CREATURE_SPELLS", json_cfg, "add_non_creature_spells", defaults["add_non_creature_spells"]), + "add_ramp": _resolve_value(args.add_ramp, "DECK_ADD_RAMP", json_cfg, "add_ramp", defaults["add_ramp"]), + "add_removal": _resolve_value(args.add_removal, "DECK_ADD_REMOVAL", json_cfg, "add_removal", defaults["add_removal"]), + "add_wipes": _resolve_value(args.add_wipes, "DECK_ADD_WIPES", json_cfg, "add_wipes", defaults["add_wipes"]), + "add_card_advantage": _resolve_value(args.add_card_advantage, "DECK_ADD_CARD_ADVANTAGE", json_cfg, "add_card_advantage", defaults["add_card_advantage"]), + "add_protection": _resolve_value(args.add_protection, "DECK_ADD_PROTECTION", json_cfg, "add_protection", defaults["add_protection"]), + "use_multi_theme": _resolve_value(args.use_multi_theme, "DECK_USE_MULTI_THEME", json_cfg, "use_multi_theme", defaults["use_multi_theme"]), + "primary_choice": _resolve_value(args.primary_choice, "DECK_PRIMARY_CHOICE", json_cfg, "primary_choice", defaults["primary_choice"]), + "secondary_choice": _resolve_value(args.secondary_choice, "DECK_SECONDARY_CHOICE", json_cfg, "secondary_choice", defaults["secondary_choice"]), + "tertiary_choice": _resolve_value(args.tertiary_choice, "DECK_TERTIARY_CHOICE", json_cfg, "tertiary_choice", defaults["tertiary_choice"]), + "add_lands": _resolve_value(args.add_lands, "DECK_ADD_LANDS", json_cfg, "add_lands", defaults["add_lands"]), + "fetch_count": _resolve_value(args.fetch_count, "DECK_FETCH_COUNT", json_cfg, "fetch_count", defaults["fetch_count"]), + "dual_count": _resolve_value(args.dual_count, "DECK_DUAL_COUNT", json_cfg, "dual_count", defaults["dual_count"]), + "triple_count": _resolve_value(args.triple_count, "DECK_TRIPLE_COUNT", json_cfg, "triple_count", defaults["triple_count"]), + "utility_count": _resolve_value(args.utility_count, "DECK_UTILITY_COUNT", json_cfg, "utility_count", defaults["utility_count"]), + "ideal_counts": ideal_counts_json, + } + + if args.dry_run: + print(json.dumps(resolved, indent=2)) + return 0 + + run(**resolved) + return 0 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/code/main.py b/code/main.py index f1cd39f..8485b66 100644 --- a/code/main.py +++ b/code/main.py @@ -8,6 +8,7 @@ from __future__ import annotations # Standard library imports import sys from pathlib import Path +import json from typing import NoReturn # Local imports @@ -26,12 +27,7 @@ logger.addHandler(logging_util.stream_handler) builder = DeckBuilder() -def run_menu() -> NoReturn: - """Launch directly into the deck builder after ensuring data files exist. - - Creates required directories, ensures card CSVs are present (running setup - and tagging if needed), then starts the full deck build flow. Exits when done. - """ +def _ensure_data_ready() -> None: logger.info("Starting MTG Python Deckbuilder") Path('csv_files').mkdir(parents=True, exist_ok=True) Path('deck_files').mkdir(parents=True, exist_ok=True) @@ -47,6 +43,9 @@ def run_menu() -> NoReturn: logger.info("Initial setup and tagging completed.") except Exception as e: logger.error(f"Failed ensuring CSVs are ready: {e}") + + +def _interactive_loop() -> None: while True: try: # Fresh builder instance for each deck to avoid state carryover @@ -54,15 +53,157 @@ def run_menu() -> NoReturn: except Exception as e: logger.error(f"Unexpected error in deck builder: {e}") - # Prompt to build another deck or quit + # Prompt to build another deck or return to main menu try: resp = input("\nBuild another deck? (y/n): ").strip().lower() except KeyboardInterrupt: resp = 'n' print("") if resp not in ('y', 'yes'): + break + + +def run_menu() -> NoReturn: + """Launch directly into the deck builder after ensuring data files exist. + + Creates required directories, ensures card CSVs are present (running setup + and tagging if needed), then starts the full deck build flow. Exits when done. + """ + _ensure_data_ready() + + # Auto headless mode for container runs (no menu prompt) + auto_mode = os.getenv('DECK_MODE', '').strip().lower() + if auto_mode in ("headless", "noninteractive", "auto"): + try: + from headless_runner import _main as headless_main + headless_main() + except Exception as e: + logger.error(f"Headless run failed: {e}") + logger.info("Exiting application") + sys.exit(0) + + # Menu-driven selection + def _run_headless_with_config(selected_config: str | None) -> None: + """Run headless runner, optionally forcing a specific config path for this invocation.""" + try: + from headless_runner import _main as headless_main + # Temporarily override DECK_CONFIG for this run if provided + prev_cfg = os.environ.get('DECK_CONFIG') + try: + if selected_config: + os.environ['DECK_CONFIG'] = selected_config + headless_main() + finally: + if selected_config is not None: + if prev_cfg is not None: + os.environ['DECK_CONFIG'] = prev_cfg + else: + os.environ.pop('DECK_CONFIG', None) + except Exception as e: + logger.error(f"Headless run failed: {e}") + + def _headless_submenu() -> None: + """Submenu to choose a JSON config and run the headless builder. + + Behavior: + - If DECK_CONFIG points to a file, run it immediately. + - Else, search for *.json in (DECK_CONFIG as dir) or /app/config or ./config. + - If one file is found, run it immediately. + - If multiple files, list them for selection. + - If none, fall back to running headless using env/CLI/defaults. + """ + cfg_target = os.getenv('DECK_CONFIG') + # Case 1: DECK_CONFIG is an explicit file + if cfg_target and os.path.isfile(cfg_target): + print(f"\nRunning headless with config: {cfg_target}") + _run_headless_with_config(cfg_target) + return + + # Determine directory to scan for JSON configs + if cfg_target and os.path.isdir(cfg_target): + cfg_dir = cfg_target + elif os.path.isdir('/app/config'): + cfg_dir = '/app/config' + else: + cfg_dir = 'config' + + try: + p = Path(cfg_dir) + files = sorted([str(fp) for fp in p.glob('*.json')]) if p.exists() else [] + except Exception: + files = [] + + # No configs found: run headless with current env/CLI/defaults + if not files: + print("\nNo JSON configs found in '" + cfg_dir + "'. Running headless with env/CLI/defaults...") + _run_headless_with_config(None) + return + + # Single config: run automatically + if len(files) == 1: + print(f"\nFound one JSON config: {files[0]}\nRunning it now...") + _run_headless_with_config(files[0]) + return + + # Multiple configs: list and select + def _config_label(p: str) -> str: + try: + with open(p, 'r', encoding='utf-8') as fh: + data = json.load(fh) + cmd = str(data.get('commander') or '').strip() or 'Unknown Commander' + themes = [t for t in [data.get('primary_tag'), data.get('secondary_tag'), data.get('tertiary_tag')] if isinstance(t, str) and t.strip()] + name = os.path.basename(p).lower() + if name == 'deck.json': + return 'Default' + return f"{cmd} - {', '.join(themes)}" if themes else cmd + except Exception: + return p + + print("\nAvailable JSON configs:") + labels = [_config_label(f) for f in files] + for idx, label in enumerate(labels, start=1): + print(f" {idx}) {label}") + print(" 0) Back to main menu") + while True: + try: + sel = input("Select a config to run [0]: ").strip() or '0' + except KeyboardInterrupt: + print("") + sel = '0' + if sel == '0': + return + try: + i = int(sel) + if 1 <= i <= len(files): + _run_headless_with_config(files[i - 1]) + return + except ValueError: + pass + print("Invalid selection. Try again.") + + while True: + print("\n==== MTG Deckbuilder ====") + print("1) Interactive deck build") + print("2) Headless (env/JSON-configured) run") + print(" - Will auto-run a single config if found, or let you choose from many") + print("q) Quit") + try: + choice = input("Select an option [1]: ").strip().lower() or '1' + except KeyboardInterrupt: + print("") + choice = 'q' + + if choice in ('1', 'i', 'interactive'): + _interactive_loop() + # loop returns to main menu + elif choice in ('2', 'h', 'headless', 'noninteractive'): + _headless_submenu() + # after one headless run, return to menu + elif choice in ('q', 'quit', 'exit'): logger.info("Exiting application") sys.exit(0) + else: + print("Invalid selection. Please try again.") if __name__ == "__main__": run_menu() \ No newline at end of file diff --git a/code/non_interactive_test.py b/code/non_interactive_test.py deleted file mode 100644 index 031b227..0000000 --- a/code/non_interactive_test.py +++ /dev/null @@ -1,128 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional - -from deck_builder.builder import DeckBuilder - -"""Non-interactive harness. - -Features: - - Script commander selection. - - Script primary / optional secondary / tertiary tags. - - Apply bracket & accept default ideal counts. - - Invoke multi-theme creature addition if available (fallback to primary-only). - -Use run(..., secondary_choice=2, tertiary_choice=3, use_multi_theme=True) to exercise multi-theme logic. -Indices correspond to the numbered tag list presented during interaction. -""" - -def run( - command_name: str = "Pantlaza", - add_creatures: bool = True, - add_non_creature_spells: bool = True, - # Fine-grained toggles (used only if add_non_creature_spells is False) - add_ramp: bool = True, - add_removal: bool = True, - add_wipes: bool = True, - add_card_advantage: bool = True, - add_protection: bool = True, - use_multi_theme: bool = True, - primary_choice: int = 2, - secondary_choice: Optional[int] = 2, - tertiary_choice: Optional[int] = 2, - add_lands: bool = True, - fetch_count: Optional[int] = 3, - dual_count: Optional[int] = None, - triple_count: Optional[int] = None, - utility_count: Optional[int] = None, - seed: Optional[int] = None, -) -> DeckBuilder: - """Run a scripted non-interactive deck build and return the DeckBuilder instance. - - Integer parameters (primary_choice, secondary_choice, tertiary_choice) correspond to the - numeric indices shown during interactive tag selection. Pass None to omit secondary/tertiary. - Optional counts (fetch_count, dual_count, triple_count, utility_count) constrain land steps. - seed: optional deterministic RNG seed for reproducible builds. - """ - scripted_inputs: List[str] = [] - # Commander query & selection - scripted_inputs.append(command_name) # initial query - scripted_inputs.append("1") # choose first search match to inspect - scripted_inputs.append("y") # confirm commander - # Primary tag selection - scripted_inputs.append(str(primary_choice)) - # Secondary tag selection or stop (0) - if secondary_choice is not None: - scripted_inputs.append(str(secondary_choice)) - # Tertiary tag selection or stop (0) - if tertiary_choice is not None: - scripted_inputs.append(str(tertiary_choice)) - else: - scripted_inputs.append("0") - else: - scripted_inputs.append("0") # stop at primary - # Bracket (meta power / style) selection; keeping existing scripted value - scripted_inputs.append("3") - # Ideal count prompts (press Enter for defaults) - for _ in range(8): - scripted_inputs.append("") - - def scripted_input(prompt: str) -> str: - if scripted_inputs: - return scripted_inputs.pop(0) - raise RuntimeError("Ran out of scripted inputs for prompt: " + prompt) - - builder = DeckBuilder(input_func=scripted_input, seed=seed) - builder.run_initial_setup() - builder.run_deck_build_step1() - builder.run_deck_build_step2() - - # Land sequence (optional) - if add_lands: - if hasattr(builder, 'run_land_step1'): - builder.run_land_step1() # Basics / initial - if hasattr(builder, 'run_land_step2'): - builder.run_land_step2() # Utility basics / rebalancing - if hasattr(builder, 'run_land_step3'): - builder.run_land_step3() # Kindred lands if applicable - if hasattr(builder, 'run_land_step4'): - builder.run_land_step4(requested_count=fetch_count) - if hasattr(builder, 'run_land_step5'): - builder.run_land_step5(requested_count=dual_count) - if hasattr(builder, 'run_land_step6'): - builder.run_land_step6(requested_count=triple_count) - if hasattr(builder, 'run_land_step7'): - - builder.run_land_step7(requested_count=utility_count) - if hasattr(builder, 'run_land_step8'): - builder.run_land_step8() - - if add_creatures: - builder.add_creatures() - # Non-creature spell categories (ramp / removal / wipes / draw / protection) - if add_non_creature_spells and hasattr(builder, 'add_non_creature_spells'): - builder.add_non_creature_spells() - else: - # Allow selective invocation if orchestrator not desired - if add_ramp and hasattr(builder, 'add_ramp'): - builder.add_ramp() - if add_removal and hasattr(builder, 'add_removal'): - builder.add_removal() - if add_wipes and hasattr(builder, 'add_board_wipes'): - builder.add_board_wipes() - if add_card_advantage and hasattr(builder, 'add_card_advantage'): - builder.add_card_advantage() - if add_protection and hasattr(builder, 'add_protection'): - builder.add_protection() - - - # Suppress verbose library print in non-interactive run since CSV export is produced. - # builder.print_card_library() - builder.post_spell_land_adjust() - # Export decklist CSV (commander first word + date) - if hasattr(builder, 'export_decklist_csv'): - builder.export_decklist_csv() - return builder - -if __name__ == "__main__": - run() diff --git a/code/tagging/tagger.py b/code/tagging/tagger.py index 87a15f8..545b7eb 100644 --- a/code/tagging/tagger.py +++ b/code/tagging/tagger.py @@ -115,16 +115,34 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None: ## Tag for various effects tag_for_cost_reduction(df, color) print('\n====================\n') + # Freerunning is a keyworded cost-reduction mechanic + tag_for_freerunning(df, color) + print('\n====================\n') tag_for_card_draw(df, color) print('\n====================\n') + # Discard-centric effects and triggers + tag_for_discard_matters(df, color) + print('\n====================\n') + # Explore and Map tokens provide selection and incidental counters + tag_for_explore_and_map(df, color) + print('\n====================\n') tag_for_artifacts(df, color) print('\n====================\n') tag_for_enchantments(df, color) print('\n====================\n') + # Craft is a transform mechanic that often references artifacts, exile, and graveyards + tag_for_craft(df, color) + print('\n====================\n') tag_for_exile_matters(df, color) print('\n====================\n') + # Custom keywords/mechanics + tag_for_bending(df, color) + print('\n====================\n') tag_for_tokens(df, color) print('\n====================\n') + # Rad counters are tracked separately to surface the theme + tag_for_rad_counters(df, color) + print('\n====================\n') tag_for_life_matters(df, color) print('\n====================\n') tag_for_counters(df, color) @@ -135,6 +153,9 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None: print('\n====================\n') tag_for_spellslinger(df, color) print('\n====================\n') + # Spree spells are modal and cost-scale via additional payments + tag_for_spree(df, color) + print('\n====================\n') tag_for_ramp(df, color) print('\n====================\n') tag_for_themes(df, color) @@ -810,19 +831,19 @@ def tag_for_loot_effects(df: pd.DataFrame, color: str) -> None: # Apply tags based on masks if loot_mask.any(): - tag_utils.apply_tag_vectorized(df, loot_mask, ['Loot', 'Card Draw']) + tag_utils.apply_tag_vectorized(df, loot_mask, ['Loot', 'Card Draw', 'Discard Matters']) logger.info(f'Tagged {loot_mask.sum()} cards with standard loot effects') if connive_mask.any(): - tag_utils.apply_tag_vectorized(df, connive_mask, ['Connive', 'Loot', 'Card Draw']) + tag_utils.apply_tag_vectorized(df, connive_mask, ['Connive', 'Loot', 'Card Draw', 'Discard Matters']) logger.info(f'Tagged {connive_mask.sum()} cards with connive effects') if cycling_mask.any(): - tag_utils.apply_tag_vectorized(df, cycling_mask, ['Cycling', 'Loot', 'Card Draw']) + tag_utils.apply_tag_vectorized(df, cycling_mask, ['Cycling', 'Loot', 'Card Draw', 'Discard Matters']) logger.info(f'Tagged {cycling_mask.sum()} cards with cycling effects') if blood_mask.any(): - tag_utils.apply_tag_vectorized(df, blood_mask, ['Blood Tokens', 'Loot', 'Card Draw']) + tag_utils.apply_tag_vectorized(df, blood_mask, ['Blood Tokens', 'Loot', 'Card Draw', 'Discard Matters']) logger.info(f'Tagged {blood_mask.sum()} cards with blood token effects') logger.info('Completed tagging loot-like effects') @@ -2136,10 +2157,19 @@ def tag_for_exile_matters(df: pd.DataFrame, color: str) -> None: logger.info('Completed Suspend tagging') print('\n==========\n') + tag_for_warp(df, color) + logger.info('Completed Warp tagging') + print('\n==========\n') + + # New: Time counters and Time Travel support + tag_for_time_counters(df, color) + logger.info('Completed Time Counters tagging') + print('\n==========\n') + # Log completion and performance metrics duration = pd.Timestamp.now() - start_time logger.info(f'Completed all "Exile Matters" tagging in {duration.total_seconds():.2f}s') - + except Exception as e: logger.error(f'Error in tag_for_exile_matters: {str(e)}') raise @@ -2471,6 +2501,98 @@ def tag_for_suspend(df: pd.DataFrame, color: str) -> None: logger.info('Completed tagging Suspend cards') +## Cards that have or care about Warp +def tag_for_warp(df: pd.DataFrame, color: str) -> None: + """Tag cards with Warp using vectorized operations. + + Args: + df: DataFrame containing card data + color: Color identifier for logging purposes + """ + logger.info(f'Tagging Warp cards in {color}_cards.csv') + start_time = pd.Timestamp.now() + + try: + # Create masks for Warp + keyword_mask = tag_utils.create_keyword_mask(df, 'Warp') + text_mask = tag_utils.create_text_mask(df, 'Warp') + + final_mask = keyword_mask | text_mask + tag_utils.apply_rules(df, [{ 'mask': final_mask, 'tags': ['Warp', 'Exile Matters'] }]) + + duration = (pd.Timestamp.now() - start_time).total_seconds() + logger.info(f'Tagged {final_mask.sum()} Warp cards in {duration:.2f}s') + except Exception as e: + logger.error(f'Error tagging Warp cards: {str(e)}') + raise + + logger.info('Completed tagging Warp cards') + +def create_time_counters_mask(df: pd.DataFrame) -> pd.Series: + """Create a boolean mask for cards that mention time counters or Time Travel. + + This captures interactions commonly associated with Suspend without + requiring the Suspend keyword (e.g., Time Travel effects, adding/removing + time counters, or Vanishing). + + Args: + df: DataFrame to search + + Returns: + Boolean Series indicating which cards interact with time counters + """ + # Text patterns around time counters and time travel + text_patterns = [ + 'time counter', + 'time counters', + 'remove a time counter', + 'add a time counter', + 'time travel' + ] + text_mask = tag_utils.create_text_mask(df, text_patterns) + + # Keyword-based patterns that imply time counters + keyword_mask = tag_utils.create_keyword_mask(df, ['Vanishing']) + + return text_mask | keyword_mask + +def tag_for_time_counters(df: pd.DataFrame, color: str) -> None: + """Tag cards that interact with time counters or Time Travel. + + Applies a base 'Time Counters' tag. Adds 'Exile Matters' when the card also + mentions exile or Suspend, since those imply interaction with suspended + cards in exile. + + Args: + df: DataFrame containing card data + color: Color identifier for logging purposes + """ + logger.info(f'Tagging Time Counters interactions in {color}_cards.csv') + start_time = pd.Timestamp.now() + + try: + time_mask = create_time_counters_mask(df) + if not time_mask.any(): + logger.info('No Time Counters interactions found') + return + + # Always tag Time Counters + tag_utils.apply_rules(df, [{ 'mask': time_mask, 'tags': ['Time Counters'] }]) + + # Conditionally add Exile Matters if the card references exile or suspend + exile_mask = tag_utils.create_text_mask(df, tag_constants.PATTERN_GROUPS['exile']) + suspend_mask = tag_utils.create_keyword_mask(df, 'Suspend') | tag_utils.create_text_mask(df, 'Suspend') + time_exile_mask = time_mask & (exile_mask | suspend_mask) + if time_exile_mask.any(): + tag_utils.apply_rules(df, [{ 'mask': time_exile_mask, 'tags': ['Exile Matters'] }]) + + duration = (pd.Timestamp.now() - start_time).total_seconds() + logger.info('Completed Time Counters tagging in %.2fs', duration) + + except Exception as e: + logger.error(f'Error tagging Time Counters interactions: {str(e)}') + raise + ### Tokens def create_creature_token_mask(df: pd.DataFrame) -> pd.Series: """Create a boolean mask for cards that create creature tokens. @@ -2591,6 +2713,19 @@ def tag_for_tokens(df: pd.DataFrame, color: str) -> None: { 'mask': matters_mask, 'tags': ['Tokens Matter'] }, ]) + # Eldrazi Spawn/Scion special-casing: add Aristocrats and Ramp synergy tags + spawn_patterns = [ + 'eldrazi spawn creature token', + 'eldrazi scion creature token', + 'spawn creature token with "sacrifice', + 'scion creature token with "sacrifice' + ] + spawn_scion_mask = tag_utils.create_text_mask(df, spawn_patterns) + if spawn_scion_mask.any(): + tag_utils.apply_rules(df, [ + { 'mask': spawn_scion_mask, 'tags': ['Aristocrats', 'Ramp'] } + ]) + # Logging if creature_mask.any(): logger.info('Tagged %d cards that create creature tokens', creature_mask.sum()) @@ -2606,6 +2741,162 @@ def tag_for_tokens(df: pd.DataFrame, color: str) -> None: logger.error('Error tagging token cards: %s', str(e)) raise +### Freerunning (cost reduction variant) +def tag_for_freerunning(df: pd.DataFrame, color: str) -> None: + """Tag cards that reference the Freerunning mechanic. + + Adds Cost Reduction to ensure consistency, and a specific Freerunning tag for filtering. + """ + try: + required = {'text', 'themeTags'} + tag_utils.validate_dataframe_columns(df, required) + mask = tag_utils.create_keyword_mask(df, 'Freerunning') | tag_utils.create_text_mask(df, ['freerunning', 'free running']) + if mask.any(): + tag_utils.apply_rules(df, [ + { 'mask': mask, 'tags': ['Cost Reduction', 'Freerunning'] } + ]) + logger.info('Tagged %d Freerunning cards', mask.sum()) + except Exception as e: + logger.error('Error tagging Freerunning: %s', str(e)) + raise + +### Craft (transform mechanic with exile/graveyard/artifact hooks) +def tag_for_craft(df: pd.DataFrame, color: str) -> None: + """Tag cards with Craft. Adds Transform; conditionally adds Artifacts Matter, Exile Matters, and Graveyard Matters.""" + try: + required = {'text', 'themeTags'} + tag_utils.validate_dataframe_columns(df, required) + craft_mask = tag_utils.create_keyword_mask(df, 'Craft') | tag_utils.create_text_mask(df, ['craft with', 'craft —', ' craft ']) + if craft_mask.any(): + rules = [{ 'mask': craft_mask, 'tags': ['Transform'] }] + # Conditionals + artifact_cond = craft_mask & tag_utils.create_text_mask(df, ['artifact', 'artifacts']) + exile_cond = craft_mask & tag_utils.create_text_mask(df, ['exile']) + gy_cond = craft_mask & tag_utils.create_text_mask(df, ['graveyard']) + if artifact_cond.any(): + rules.append({ 'mask': artifact_cond, 'tags': ['Artifacts Matter'] }) + if exile_cond.any(): + rules.append({ 'mask': exile_cond, 'tags': ['Exile Matters'] }) + if gy_cond.any(): + rules.append({ 'mask': gy_cond, 'tags': ['Graveyard Matters'] }) + tag_utils.apply_rules(df, rules) + logger.info('Tagged %d Craft cards', craft_mask.sum()) + except Exception as e: + logger.error('Error tagging Craft: %s', str(e)) + raise + +### Spree (modal, cost-scaling spells) +def tag_for_spree(df: pd.DataFrame, color: str) -> None: + """Tag Spree spells with Modal and Cost Scaling.""" + try: + required = {'text', 'themeTags'} + tag_utils.validate_dataframe_columns(df, required) + mask = tag_utils.create_keyword_mask(df, 'Spree') | tag_utils.create_text_mask(df, ['spree']) + if mask.any(): + tag_utils.apply_rules(df, [ + { 'mask': mask, 'tags': ['Modal', 'Cost Scaling'] } + ]) + logger.info('Tagged %d Spree cards', mask.sum()) + except Exception as e: + logger.error('Error tagging Spree: %s', str(e)) + raise + +### Explore and Map tokens +def tag_for_explore_and_map(df: pd.DataFrame, color: str) -> None: + """Tag Explore and Map token interactions. + + - Explore: add Card Selection; if it places +1/+1 counters, add +1/+1 Counters + - Map Tokens: add Card Selection and Tokens Matter + """ + try: + required = {'text', 'themeTags'} + tag_utils.validate_dataframe_columns(df, required) + explore_mask = tag_utils.create_keyword_mask(df, 'Explore') | tag_utils.create_text_mask(df, ['explores', 'explore.']) + map_mask = tag_utils.create_text_mask(df, ['map token', 'map tokens']) + rules = [] + if explore_mask.any(): + rules.append({ 'mask': explore_mask, 'tags': ['Card Selection'] }) + # If the text also references +1/+1 counters, add that theme + explore_counters = explore_mask & tag_utils.create_text_mask(df, ['+1/+1 counter']) + if explore_counters.any(): + rules.append({ 'mask': explore_counters, 'tags': ['+1/+1 Counters'] }) + if map_mask.any(): + rules.append({ 'mask': map_mask, 'tags': ['Card Selection', 'Tokens Matter'] }) + if rules: + tag_utils.apply_rules(df, rules) + total = (explore_mask.astype(int) + map_mask.astype(int)).astype(bool).sum() + logger.info('Tagged %d Explore/Map cards', total) + except Exception as e: + logger.error('Error tagging Explore/Map: %s', str(e)) + raise + +### Rad counters +def tag_for_rad_counters(df: pd.DataFrame, color: str) -> None: + """Tag Rad counter interactions as a dedicated theme.""" + try: + required = {'text', 'themeTags'} + tag_utils.validate_dataframe_columns(df, required) + rad_mask = tag_utils.create_text_mask(df, ['rad counter', 'rad counters']) + if rad_mask.any(): + tag_utils.apply_rules(df, [ { 'mask': rad_mask, 'tags': ['Rad Counters'] } ]) + logger.info('Tagged %d Rad counter cards', rad_mask.sum()) + except Exception as e: + logger.error('Error tagging Rad counters: %s', str(e)) + raise + +### Discard Matters +def tag_for_discard_matters(df: pd.DataFrame, color: str) -> None: + """Tag cards that discard or care about discarding. + + Adds Discard Matters for: + - Text that makes you discard a card (costs or effects) + - Triggers on discarding + Also adds Loot where applicable is handled elsewhere; this focuses on the theme surface. + """ + try: + required = {'text', 'themeTags'} + tag_utils.validate_dataframe_columns(df, required) + + # Events where YOU discard (as part of a cost or effect). Keep generic 'discard a card' but filter out opponent/each-player cases. + discard_action_patterns = [ + r'you discard (?:a|one|two|three|x) card', + r'discard (?:a|one|two|three|x) card', + r'discard your hand', + r'as an additional cost to (?:cast this spell|activate this ability),? discard (?:a|one) card', + r'as an additional cost,? discard (?:a|one) card' + ] + action_mask = tag_utils.create_text_mask(df, discard_action_patterns) + exclude_opponent_patterns = [ + r'target player discards', + r'target opponent discards', + r'each player discards', + r'each opponent discards', + r'that player discards' + ] + exclude_mask = tag_utils.create_text_mask(df, exclude_opponent_patterns) + + # Triggers/conditions that care when you discard + discard_trigger_patterns = [ + r'whenever you discard', + r'if you discarded', + r'for each card you discarded', + r'when you discard' + ] + trigger_mask = tag_utils.create_text_mask(df, discard_trigger_patterns) + + # Blood tokens enable rummage (discard), and Madness explicitly cares about discarding + blood_patterns = [r'create (?:a|one|two|three|x|\d+) blood token'] + blood_mask = tag_utils.create_text_mask(df, blood_patterns) + madness_mask = tag_utils.create_text_mask(df, [r'\bmadness\b']) + + final_mask = ((action_mask & ~exclude_mask) | trigger_mask | blood_mask | madness_mask) + if final_mask.any(): + tag_utils.apply_rules(df, [ { 'mask': final_mask, 'tags': ['Discard Matters'] } ]) + logger.info('Tagged %d cards for Discard Matters', final_mask.sum()) + except Exception as e: + logger.error('Error tagging Discard Matters: %s', str(e)) + raise + ### Life Matters def tag_for_life_matters(df: pd.DataFrame, color: str) -> None: """Tag cards that care about life totals, life gain/loss, and related effects using vectorized operations. @@ -4195,6 +4486,47 @@ def tag_for_aristocrats(df: pd.DataFrame, color: str) -> None: logger.error(f'Error in tag_for_aristocrats: {str(e)}') raise +### Bending +def tag_for_bending(df: pd.DataFrame, color: str) -> None: + """Tag cards for bending-related keywords. + + Looks for 'airbend', 'waterbend', 'firebend', 'earthbend' in rules text and + applies tags accordingly. + """ + logger.info(f'Tagging Bending keywords in {color}_cards.csv') + start_time = pd.Timestamp.now() + + try: + rules = [] + air_mask = tag_utils.create_text_mask(df, 'airbend') + if air_mask.any(): + rules.append({ 'mask': air_mask, 'tags': ['Airbending', 'Exile Matters'] }) + + water_mask = tag_utils.create_text_mask(df, 'waterbend') + if water_mask.any(): + rules.append({ 'mask': water_mask, 'tags': ['Waterbending', 'Cost Reduction', 'Big Mana'] }) + + fire_mask = tag_utils.create_text_mask(df, 'firebend') + if fire_mask.any(): + rules.append({ 'mask': fire_mask, 'tags': ['Aggro', 'Combat Matters', 'Firebending', 'Mana Dork', 'Ramp', 'X Spells'] }) + + earth_mask = tag_utils.create_text_mask(df, 'earthbend') + if earth_mask.any(): + rules.append({ 'mask': earth_mask, 'tags': ['Earthbend', 'Lands Matter', 'Landfall'] }) + + if rules: + tag_utils.apply_rules(df, rules) + total = sum(int(r['mask'].sum()) for r in rules) + logger.info('Tagged %d cards with Bending keywords', total) + else: + logger.info('No Bending keywords found') + + duration = (pd.Timestamp.now() - start_time).total_seconds() + logger.info('Completed Bending tagging in %.2fs', duration) + + except Exception as e: + logger.error(f'Error tagging Bending keywords: {str(e)}') + raise ## Big Mana def create_big_mana_cost_mask(df: pd.DataFrame) -> pd.Series: @@ -4766,11 +5098,11 @@ def tag_for_energy(df: pd.DataFrame, color: str) -> None: energy_patterns = [r'\{e\}', 'energy counter', 'energy counters'] energy_mask = tag_utils.create_text_mask(df, energy_patterns) - # Apply tags via rules engine + # Apply tags via rules engine (also mark as a Resource Engine per request) tag_utils.apply_rules(df, rules=[ { 'mask': energy_mask, - 'tags': ['Energy'] + 'tags': ['Energy', 'Resource Engine'] } ]) diff --git a/config/deck.json b/config/deck.json new file mode 100644 index 0000000..57f247d --- /dev/null +++ b/config/deck.json @@ -0,0 +1,22 @@ +{ + "commander": "Aang, Airbending Master", + "primary_tag": "Experience Counters", + "secondary_tag": "Token Creation", + "tertiary_tag": null, + "bracket_level": 4, + "use_multi_theme": true, + "add_lands": true, + "add_creatures": true, + "add_non_creature_spells": true, + "fetch_count": 3, + "ideal_counts": { + "ramp": 8, + "lands": 35, + "basic_lands": 15, + "creatures": 25, + "removal": 10, + "wipes": 2, + "card_advantage": 10, + "protection": 8 + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 6cead17..6b96eca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,9 +8,16 @@ services: - ${PWD}/deck_files:/app/deck_files - ${PWD}/logs:/app/logs - ${PWD}/csv_files:/app/csv_files + # Optional: mount a config directory for headless JSON + - ${PWD}/config:/app/config environment: - PYTHONUNBUFFERED=1 - TERM=xterm-256color - DEBIAN_FRONTEND=noninteractive + # Set DECK_MODE=headless to auto-run non-interactive mode on start + # - DECK_MODE=headless + # Optional headless configuration (examples): + # - DECK_CONFIG=/app/config/deck.json + # - DECK_COMMANDER=Pantlaza # Ensure proper cleanup restart: "no" diff --git a/pyproject.toml b/pyproject.toml index fa8cd85..b95439d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mtg-deckbuilder" -version = "1.0.0" +version = "1.1.0" description = "A command-line tool for building and analyzing Magic: The Gathering decks" readme = "README.md" license = {file = "LICENSE"} @@ -25,15 +25,9 @@ classifiers = [ requires-python = ">=3.11" # 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", + "tqdm>=4.66.0", ] [project.optional-dependencies] @@ -42,6 +36,12 @@ dev = [ "pandas-stubs>=2.0.0", "pytest>=8.0.0", ] +reporting = [ + "prettytable>=3.9.0", +] +pricecheck = [ + "scrython>=1.10.0", +] [project.scripts] mtg-deckbuilder = "code.main:run_menu" diff --git a/requirements.txt b/requirements.txt index 7a568fe..ef22a28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,8 @@ 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 +tqdm>=4.66.0 +# Optional pretty output in reports; app falls back gracefully if missing prettytable>=3.9.0 -# Development dependencies -mypy>=1.3.0 -pandas-stubs>=2.0.0 -pytest>=8.0.0 \ No newline at end of file +# Development dependencies are in requirements-dev.txt \ No newline at end of file