mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
Release v1.1.0: headless runner + tagging updates (Discard Matters, Freerunning, Craft, Spree, Explore/Map, Rad, Energy/Resource Engine, Spawn/Scion)
This commit is contained in:
parent
36abbaa1dd
commit
99005c19f8
23 changed files with 1330 additions and 420 deletions
28
.env.example
Normal file
28
.env.example
Normal file
|
@ -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=
|
||||||
|
|
40
.github/workflows/ci.yml
vendored
Normal file
40
.github/workflows/ci.yml
vendored
Normal file
|
@ -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
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -12,7 +12,7 @@ build/
|
||||||
csv_files/
|
csv_files/
|
||||||
dist/
|
dist/
|
||||||
logs/
|
logs/
|
||||||
non_interactive_test.py
|
|
||||||
test_determinism.py
|
test_determinism.py
|
||||||
test.py
|
test.py
|
||||||
deterministic_test.py
|
deterministic_test.py
|
||||||
|
!config/deck.json
|
255
DOCKER.md
255
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
|
### PowerShell (recommended)
|
||||||
```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
|
```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 build
|
||||||
|
docker compose run --rm mtg-deckbuilder
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔍 Verifying Everything Works
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
After running the application:
|
## Volumes
|
||||||
|
- `/app/deck_files` ↔ `./deck_files`
|
||||||
|
- `/app/logs` ↔ `./logs`
|
||||||
|
- `/app/csv_files` ↔ `./csv_files`
|
||||||
|
- Optional: `/app/config` ↔ `./config` (JSON configs for headless)
|
||||||
|
|
||||||
1. **Create or modify some data** (run setup, build a deck, etc.)
|
## Interactive vs headless
|
||||||
2. **Exit the container** (Ctrl+C or select Quit)
|
- Interactive: attach a TTY (compose run or `docker run -it`)
|
||||||
3. **Check your local directories**:
|
- Headless auto-run:
|
||||||
```bash
|
```powershell
|
||||||
ls -la deck_files/ # Should show any decks you created
|
docker compose run --rm -e DECK_MODE=headless mtg-deckbuilder
|
||||||
ls -la logs/ # Should show log files
|
```
|
||||||
ls -la csv_files/ # Should show card database files
|
- Headless with JSON config:
|
||||||
|
```powershell
|
||||||
|
docker compose run --rm `
|
||||||
|
-e DECK_MODE=headless `
|
||||||
|
-e DECK_CONFIG=/app/config/deck.json `
|
||||||
|
mtg-deckbuilder
|
||||||
```
|
```
|
||||||
4. **Run again** - your data should still be there!
|
|
||||||
|
|
||||||
## 🎯 Best Practices
|
### Common env vars
|
||||||
|
- DECK_MODE=headless
|
||||||
|
- DECK_CONFIG=/app/config/deck.json
|
||||||
|
- DECK_COMMANDER, DECK_PRIMARY_CHOICE
|
||||||
|
- DECK_ADD_LANDS, DECK_FETCH_COUNT
|
||||||
|
|
||||||
1. **Use the quick-start script** for simplest experience
|
## Manual build/run
|
||||||
2. **Always use `docker compose run`** for interactive applications
|
```powershell
|
||||||
3. **Keep your project directory organized** - files persist locally
|
docker build -t mtg-deckbuilder .
|
||||||
4. **Regularly backup your `deck_files/`** if you create valuable decks
|
docker run -it --rm `
|
||||||
5. **Use `clean` commands** to free up disk space when needed
|
-v "${PWD}/deck_files:/app/deck_files" `
|
||||||
|
-v "${PWD}/logs:/app/logs" `
|
||||||
## 🌟 Benefits of Docker Approach
|
-v "${PWD}/csv_files:/app/csv_files" `
|
||||||
|
mtg-deckbuilder
|
||||||
- ✅ **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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
|
@ -24,20 +24,21 @@ COPY code/ ./code/
|
||||||
COPY mypy.ini .
|
COPY mypy.ini .
|
||||||
|
|
||||||
# Create necessary directories as mount points
|
# 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
|
# 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
|
# Create symbolic links BEFORE changing working directory
|
||||||
# These will point to the mounted volumes
|
# These will point to the mounted volumes
|
||||||
RUN cd /app/code && \
|
RUN cd /app/code && \
|
||||||
ln -sf /app/deck_files ./deck_files && \
|
ln -sf /app/deck_files ./deck_files && \
|
||||||
ln -sf /app/logs ./logs && \
|
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
|
# 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
|
# Set the working directory to code for proper imports
|
||||||
WORKDIR /app/code
|
WORKDIR /app/code
|
||||||
|
|
BIN
README.md
BIN
README.md
Binary file not shown.
|
@ -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
|
# MTG Python Deckbuilder v1.0.0 Release Notes
|
||||||
|
|
||||||
## 🎉 Initial Release
|
## 🎉 Initial Release
|
||||||
|
|
|
@ -1,47 +1,59 @@
|
||||||
# MTG Python Deckbuilder ${VERSION}
|
# MTG Python Deckbuilder ${VERSION}
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
- Direct-to-builder launch with automatic initial setup and tagging
|
- Headless mode with a submenu in the main menu (auto-runs single config; lists multiple as "Commander - Theme1, Theme2, Theme3"; `deck.json` labeled "Default")
|
||||||
- Improved Type Summary (accurate Commander/Creature/etc. counts)
|
- Config precedence: CLI > env > JSON > defaults; honors `ideal_counts` in JSON
|
||||||
- Smarter export filenames: full commander name + ordered themes + date, with auto-increment
|
- Exports: CSV/TXT always; JSON run-config only for interactive runs (headless skips it)
|
||||||
- TXT export duplication fixed
|
- Smarter filenames: commander + ordered themes + date, with auto-increment when exists
|
||||||
- Post-build prompt to build another deck or quit
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
- Multi-arch image (amd64, arm64) on Docker Hub
|
- Single service; persistent volumes:
|
||||||
- Persistent volumes:
|
|
||||||
- /app/deck_files
|
- /app/deck_files
|
||||||
- /app/logs
|
- /app/logs
|
||||||
- /app/csv_files
|
- /app/csv_files
|
||||||
|
- Optional: /app/config (mount `./config` for JSON configs)
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
```bash
|
```powershell
|
||||||
mkdir mtg-decks && cd mtg-decks
|
# From Docker Hub
|
||||||
|
docker run -it --rm `
|
||||||
docker run -it --rm \
|
-v "${PWD}/deck_files:/app/deck_files" `
|
||||||
-v "$(pwd)/deck_files":/app/deck_files \
|
-v "${PWD}/logs:/app/logs" `
|
||||||
-v "$(pwd)/logs":/app/logs \
|
-v "${PWD}/csv_files:/app/csv_files" `
|
||||||
-v "$(pwd)/csv_files":/app/csv_files \
|
|
||||||
mwisnowski/mtg-python-deckbuilder:latest
|
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
|
## Changes
|
||||||
- Auto-setup/tagging when `csv_files/cards.csv` is missing (both main and builder)
|
- Added headless runner and main menu headless submenu
|
||||||
- Main entrypoint now skips menu and launches the deck builder
|
- JSON export is suppressed in headless; interactive runs export replayable JSON to `config/`
|
||||||
- Type summary classification matches export categories; uses snapshot fallback
|
- `ideal_counts` supported and honored by prompts; only `fetch_count` tracked for lands
|
||||||
- Export filenames:
|
- Documentation simplified and focused; Docker guide trimmed and PowerShell examples updated
|
||||||
- Full commander name (punctuation removed)
|
|
||||||
- All themes in order
|
### Tagging updates
|
||||||
- Date suffix (YYYYMMDD)
|
- New: Discard Matters theme – detects your discard effects and triggers; includes Madness and Blood creators; Loot/Connive/Cycling/Blood also add Discard Matters.
|
||||||
- Auto-increment when file exists
|
- New taggers:
|
||||||
- Removed duplicate TXT sidecar creation in CSV export
|
- 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
|
## Known Issues
|
||||||
- First run downloads card data; may take several minutes
|
- First run downloads card data (takes a few minutes)
|
||||||
- Ensure volume mounts are present to persist files outside the container
|
- Use `docker compose run --rm` (not `up`) for interactive sessions
|
||||||
|
- Ensure volumes are mounted to persist files outside the container
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
- Repo: https://github.com/mwisnowski/mtg_python_deckbuilder
|
- Repo: https://github.com/mwisnowski/mtg_python_deckbuilder
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
"""Root package for the MTG deckbuilder source tree.
|
"""Root package for the MTG deckbuilder source tree.
|
||||||
|
|
||||||
Adding this file ensures the directory is treated as a proper package so that
|
Ensures `python -m code.*` resolves to this project and adjusts sys.path so
|
||||||
`python -m code.main` resolves to this project instead of the Python stdlib
|
legacy absolute imports like `import logging_util` (modules living under this
|
||||||
module named `code` (which is a simple module, not a package).
|
package) work whether you run files directly or as modules.
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
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__ = []
|
__all__ = []
|
||||||
|
|
|
@ -103,6 +103,30 @@ class DeckBuilder(
|
||||||
txt_path = self.export_decklist_text(filename=base + '.txt') # type: ignore[attr-defined]
|
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
|
# Display the text file contents for easy copy/paste to online deck builders
|
||||||
self._display_txt_contents(txt_path)
|
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:
|
except Exception:
|
||||||
logger.warning("Plaintext export failed (non-fatal)")
|
logger.warning("Plaintext export failed (non-fatal)")
|
||||||
end_ts = datetime.datetime.now()
|
end_ts = datetime.datetime.now()
|
||||||
|
@ -213,14 +237,18 @@ class DeckBuilder(
|
||||||
# IO injection for testing
|
# IO injection for testing
|
||||||
input_func: Callable[[str], str] = field(default=lambda prompt: input(prompt))
|
input_func: Callable[[str], str] = field(default=lambda prompt: input(prompt))
|
||||||
output_func: Callable[[str], None] = field(default=lambda msg: print(msg))
|
output_func: Callable[[str], None] = field(default=lambda msg: print(msg))
|
||||||
# Deterministic random support
|
# Random support (no external seeding)
|
||||||
seed: Optional[int] = None
|
|
||||||
_rng: Any = field(default=None, repr=False)
|
_rng: Any = field(default=None, repr=False)
|
||||||
|
|
||||||
# Logging / output behavior
|
# Logging / output behavior
|
||||||
log_outputs: bool = True # if True, mirror output_func messages into logger at INFO level
|
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)
|
_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):
|
def __post_init__(self):
|
||||||
"""Post-init hook to wrap the provided output function so that all user-facing
|
"""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.
|
messages are also captured in the central log (at INFO level) unless disabled.
|
||||||
|
@ -250,10 +278,10 @@ class DeckBuilder(
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# RNG Initialization
|
# 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:
|
if self._rng is None:
|
||||||
import random as _r
|
import random as _r
|
||||||
self._rng = _r.Random(self.seed) if self.seed is not None else _r
|
self._rng = _r
|
||||||
return self._rng
|
return self._rng
|
||||||
|
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
|
|
|
@ -114,6 +114,7 @@ class LandFetchMixin:
|
||||||
if len(chosen) < desired:
|
if len(chosen) < desired:
|
||||||
leftovers = [n for n in candidates if n not in chosen]
|
leftovers = [n for n in candidates if n not in chosen]
|
||||||
chosen.extend(leftovers[: desired - len(chosen)])
|
chosen.extend(leftovers[: desired - len(chosen)])
|
||||||
|
|
||||||
added: List[str] = []
|
added: List[str] = []
|
||||||
for nm in chosen:
|
for nm in chosen:
|
||||||
if self._current_land_count() >= land_target: # type: ignore[attr-defined]
|
if self._current_land_count() >= land_target: # type: ignore[attr-defined]
|
||||||
|
@ -127,6 +128,11 @@ class LandFetchMixin:
|
||||||
added_by='lands_step4'
|
added_by='lands_step4'
|
||||||
) # type: ignore[attr-defined]
|
) # type: ignore[attr-defined]
|
||||||
added.append(nm)
|
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):")
|
self.output_func("\nFetch Lands Added (Step 4):")
|
||||||
if not added:
|
if not added:
|
||||||
self.output_func(" (None added)")
|
self.output_func(" (None added)")
|
||||||
|
|
|
@ -106,6 +106,7 @@ class LandMiscUtilityMixin:
|
||||||
self.add_card(nm, card_type='Land', role='utility', sub_role='misc', added_by='lands_step7')
|
self.add_card(nm, card_type='Land', role='utility', sub_role='misc', added_by='lands_step7')
|
||||||
added.append(nm)
|
added.append(nm)
|
||||||
|
|
||||||
|
|
||||||
self.output_func("\nMisc Utility Lands Added (Step 7):")
|
self.output_func("\nMisc Utility Lands Added (Step 7):")
|
||||||
if not added:
|
if not added:
|
||||||
self.output_func(" (None added)")
|
self.output_func(" (None added)")
|
||||||
|
|
|
@ -217,6 +217,7 @@ class LandTripleMixin:
|
||||||
)
|
)
|
||||||
added.append(name)
|
added.append(name)
|
||||||
|
|
||||||
|
|
||||||
self.output_func("\nTriple Lands Added (Step 6):")
|
self.output_func("\nTriple Lands Added (Step 6):")
|
||||||
if not added:
|
if not added:
|
||||||
self.output_func(" (None added)")
|
self.output_func(" (None added)")
|
||||||
|
|
|
@ -383,6 +383,89 @@ class ReportingMixin:
|
||||||
self.output_func(f"Plaintext deck list exported to {path}")
|
self.output_func(f"Plaintext deck list exported to {path}")
|
||||||
return 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):
|
def print_card_library(self, table: bool = True):
|
||||||
"""Prints the current card library in either plain or tabular format.
|
"""Prints the current card library in either plain or tabular format.
|
||||||
Uses PrettyTable if available, otherwise prints a simple list.
|
Uses PrettyTable if available, otherwise prints a simple list.
|
||||||
|
|
|
@ -22,8 +22,11 @@ from enum import Enum
|
||||||
import os
|
import os
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
# Third-party imports
|
# Third-party imports (optional)
|
||||||
import inquirer
|
try:
|
||||||
|
import inquirer # type: ignore
|
||||||
|
except Exception:
|
||||||
|
inquirer = None # Fallback to simple input-based menu when unavailable
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
# Local imports
|
# Local imports
|
||||||
|
@ -229,6 +232,7 @@ def _display_setup_menu() -> SetupOption:
|
||||||
Returns:
|
Returns:
|
||||||
SetupOption: The selected menu option
|
SetupOption: The selected menu option
|
||||||
"""
|
"""
|
||||||
|
if inquirer is not None:
|
||||||
question: List[Dict[str, Any]] = [
|
question: List[Dict[str, Any]] = [
|
||||||
inquirer.List(
|
inquirer.List(
|
||||||
'menu',
|
'menu',
|
||||||
|
@ -237,6 +241,24 @@ def _display_setup_menu() -> SetupOption:
|
||||||
answer = inquirer.prompt(question)
|
answer = inquirer.prompt(question)
|
||||||
return SetupOption(answer['menu'])
|
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:
|
def setup() -> bool:
|
||||||
"""Run the setup process for the MTG Python Deckbuilder.
|
"""Run the setup process for the MTG Python Deckbuilder.
|
||||||
|
|
||||||
|
|
404
code/headless_runner.py
Normal file
404
code/headless_runner.py
Normal file
|
@ -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())
|
155
code/main.py
155
code/main.py
|
@ -8,6 +8,7 @@ from __future__ import annotations
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import json
|
||||||
from typing import NoReturn
|
from typing import NoReturn
|
||||||
|
|
||||||
# Local imports
|
# Local imports
|
||||||
|
@ -26,12 +27,7 @@ logger.addHandler(logging_util.stream_handler)
|
||||||
|
|
||||||
builder = DeckBuilder()
|
builder = DeckBuilder()
|
||||||
|
|
||||||
def run_menu() -> NoReturn:
|
def _ensure_data_ready() -> None:
|
||||||
"""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.
|
|
||||||
"""
|
|
||||||
logger.info("Starting MTG Python Deckbuilder")
|
logger.info("Starting MTG Python Deckbuilder")
|
||||||
Path('csv_files').mkdir(parents=True, exist_ok=True)
|
Path('csv_files').mkdir(parents=True, exist_ok=True)
|
||||||
Path('deck_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.")
|
logger.info("Initial setup and tagging completed.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed ensuring CSVs are ready: {e}")
|
logger.error(f"Failed ensuring CSVs are ready: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _interactive_loop() -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Fresh builder instance for each deck to avoid state carryover
|
# Fresh builder instance for each deck to avoid state carryover
|
||||||
|
@ -54,15 +53,157 @@ def run_menu() -> NoReturn:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in deck builder: {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:
|
try:
|
||||||
resp = input("\nBuild another deck? (y/n): ").strip().lower()
|
resp = input("\nBuild another deck? (y/n): ").strip().lower()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
resp = 'n'
|
resp = 'n'
|
||||||
print("")
|
print("")
|
||||||
if resp not in ('y', 'yes'):
|
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")
|
logger.info("Exiting application")
|
||||||
sys.exit(0)
|
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__":
|
if __name__ == "__main__":
|
||||||
run_menu()
|
run_menu()
|
|
@ -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()
|
|
|
@ -115,16 +115,34 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None:
|
||||||
## Tag for various effects
|
## Tag for various effects
|
||||||
tag_for_cost_reduction(df, color)
|
tag_for_cost_reduction(df, color)
|
||||||
print('\n====================\n')
|
print('\n====================\n')
|
||||||
|
# Freerunning is a keyworded cost-reduction mechanic
|
||||||
|
tag_for_freerunning(df, color)
|
||||||
|
print('\n====================\n')
|
||||||
tag_for_card_draw(df, color)
|
tag_for_card_draw(df, color)
|
||||||
print('\n====================\n')
|
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)
|
tag_for_artifacts(df, color)
|
||||||
print('\n====================\n')
|
print('\n====================\n')
|
||||||
tag_for_enchantments(df, color)
|
tag_for_enchantments(df, color)
|
||||||
print('\n====================\n')
|
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)
|
tag_for_exile_matters(df, color)
|
||||||
print('\n====================\n')
|
print('\n====================\n')
|
||||||
|
# Custom keywords/mechanics
|
||||||
|
tag_for_bending(df, color)
|
||||||
|
print('\n====================\n')
|
||||||
tag_for_tokens(df, color)
|
tag_for_tokens(df, color)
|
||||||
print('\n====================\n')
|
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)
|
tag_for_life_matters(df, color)
|
||||||
print('\n====================\n')
|
print('\n====================\n')
|
||||||
tag_for_counters(df, color)
|
tag_for_counters(df, color)
|
||||||
|
@ -135,6 +153,9 @@ def tag_by_color(df: pd.DataFrame, color: str) -> None:
|
||||||
print('\n====================\n')
|
print('\n====================\n')
|
||||||
tag_for_spellslinger(df, color)
|
tag_for_spellslinger(df, color)
|
||||||
print('\n====================\n')
|
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)
|
tag_for_ramp(df, color)
|
||||||
print('\n====================\n')
|
print('\n====================\n')
|
||||||
tag_for_themes(df, color)
|
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
|
# Apply tags based on masks
|
||||||
if loot_mask.any():
|
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')
|
logger.info(f'Tagged {loot_mask.sum()} cards with standard loot effects')
|
||||||
|
|
||||||
if connive_mask.any():
|
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')
|
logger.info(f'Tagged {connive_mask.sum()} cards with connive effects')
|
||||||
|
|
||||||
if cycling_mask.any():
|
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')
|
logger.info(f'Tagged {cycling_mask.sum()} cards with cycling effects')
|
||||||
|
|
||||||
if blood_mask.any():
|
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(f'Tagged {blood_mask.sum()} cards with blood token effects')
|
||||||
|
|
||||||
logger.info('Completed tagging loot-like effects')
|
logger.info('Completed tagging loot-like effects')
|
||||||
|
@ -2136,6 +2157,15 @@ def tag_for_exile_matters(df: pd.DataFrame, color: str) -> None:
|
||||||
logger.info('Completed Suspend tagging')
|
logger.info('Completed Suspend tagging')
|
||||||
print('\n==========\n')
|
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
|
# Log completion and performance metrics
|
||||||
duration = pd.Timestamp.now() - start_time
|
duration = pd.Timestamp.now() - start_time
|
||||||
logger.info(f'Completed all "Exile Matters" tagging in {duration.total_seconds():.2f}s')
|
logger.info(f'Completed all "Exile Matters" tagging in {duration.total_seconds():.2f}s')
|
||||||
|
@ -2471,6 +2501,98 @@ def tag_for_suspend(df: pd.DataFrame, color: str) -> None:
|
||||||
|
|
||||||
logger.info('Completed tagging Suspend cards')
|
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
|
### Tokens
|
||||||
def create_creature_token_mask(df: pd.DataFrame) -> pd.Series:
|
def create_creature_token_mask(df: pd.DataFrame) -> pd.Series:
|
||||||
"""Create a boolean mask for cards that create creature tokens.
|
"""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'] },
|
{ '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
|
# Logging
|
||||||
if creature_mask.any():
|
if creature_mask.any():
|
||||||
logger.info('Tagged %d cards that create creature tokens', creature_mask.sum())
|
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))
|
logger.error('Error tagging token cards: %s', str(e))
|
||||||
raise
|
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
|
### Life Matters
|
||||||
def tag_for_life_matters(df: pd.DataFrame, color: str) -> None:
|
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.
|
"""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)}')
|
logger.error(f'Error in tag_for_aristocrats: {str(e)}')
|
||||||
raise
|
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
|
## Big Mana
|
||||||
def create_big_mana_cost_mask(df: pd.DataFrame) -> pd.Series:
|
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_patterns = [r'\{e\}', 'energy counter', 'energy counters']
|
||||||
energy_mask = tag_utils.create_text_mask(df, energy_patterns)
|
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=[
|
tag_utils.apply_rules(df, rules=[
|
||||||
{
|
{
|
||||||
'mask': energy_mask,
|
'mask': energy_mask,
|
||||||
'tags': ['Energy']
|
'tags': ['Energy', 'Resource Engine']
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
22
config/deck.json
Normal file
22
config/deck.json
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,9 +8,16 @@ services:
|
||||||
- ${PWD}/deck_files:/app/deck_files
|
- ${PWD}/deck_files:/app/deck_files
|
||||||
- ${PWD}/logs:/app/logs
|
- ${PWD}/logs:/app/logs
|
||||||
- ${PWD}/csv_files:/app/csv_files
|
- ${PWD}/csv_files:/app/csv_files
|
||||||
|
# Optional: mount a config directory for headless JSON
|
||||||
|
- ${PWD}/config:/app/config
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- TERM=xterm-256color
|
- TERM=xterm-256color
|
||||||
- DEBIAN_FRONTEND=noninteractive
|
- 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
|
# Ensure proper cleanup
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mtg-deckbuilder"
|
name = "mtg-deckbuilder"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
|
description = "A command-line tool for building and analyzing Magic: The Gathering decks"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
|
@ -25,15 +25,9 @@ classifiers = [
|
||||||
requires-python = ">=3.11" # This is what it was built with anyway
|
requires-python = ">=3.11" # This is what it was built with anyway
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pandas>=1.5.0",
|
"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",
|
"numpy>=1.24.0",
|
||||||
"requests>=2.31.0",
|
"requests>=2.31.0",
|
||||||
"prettytable>=3.9.0",
|
"tqdm>=4.66.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
@ -42,6 +36,12 @@ dev = [
|
||||||
"pandas-stubs>=2.0.0",
|
"pandas-stubs>=2.0.0",
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
]
|
]
|
||||||
|
reporting = [
|
||||||
|
"prettytable>=3.9.0",
|
||||||
|
]
|
||||||
|
pricecheck = [
|
||||||
|
"scrython>=1.10.0",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
mtg-deckbuilder = "code.main:run_menu"
|
mtg-deckbuilder = "code.main:run_menu"
|
||||||
|
|
|
@ -1,15 +1,8 @@
|
||||||
pandas>=1.5.0
|
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
|
numpy>=1.24.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
tqdm>=4.66.0
|
||||||
|
# Optional pretty output in reports; app falls back gracefully if missing
|
||||||
prettytable>=3.9.0
|
prettytable>=3.9.0
|
||||||
|
|
||||||
# Development dependencies
|
# Development dependencies are in requirements-dev.txt
|
||||||
mypy>=1.3.0
|
|
||||||
pandas-stubs>=2.0.0
|
|
||||||
pytest>=8.0.0
|
|
Loading…
Add table
Add a link
Reference in a new issue