Compare commits
137
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2527b7cf98 | ||
|
|
47dde2c896 | ||
|
|
b2a25f7234 | ||
|
|
e0b538af6b | ||
|
|
f27fa61003 | ||
|
|
b07b59d206 | ||
|
|
52cbefaccf | ||
|
|
55fdc2ec4a | ||
|
|
ddfe8e601e | ||
|
|
9e3cd45936 | ||
|
|
c91f4c90bc | ||
|
|
80f67dd0a0 | ||
|
|
10220ec747 | ||
|
|
6fadeaa387 | ||
|
|
517c17047b | ||
|
|
c62e75af52 | ||
|
|
11940835fc | ||
|
|
d12b47f874 | ||
|
|
5b5bac42fb | ||
|
|
574bb79a0b | ||
|
|
5554ef4dd7 | ||
|
|
cf2d7ed806 | ||
|
|
aec1896094 | ||
|
|
75785ae918 | ||
|
|
252e46b288 | ||
|
|
e1bb2e7209 | ||
|
|
e77a945805 | ||
|
|
3f5d6d12e8 | ||
|
|
915c745911 | ||
|
|
00374c1f03 | ||
|
|
6e71ba88fd | ||
|
|
73d8e8d220 | ||
|
|
bff7b3a81e | ||
|
|
b1c17359f0 | ||
|
|
7440d0ce17 | ||
|
|
4d73f134c2 | ||
|
|
126684bb94 | ||
|
|
882e2370c1 | ||
|
|
bbca0b7985 | ||
|
|
da8de77704 | ||
|
|
11990c2fd3 | ||
|
|
aefd0cace6 | ||
|
|
ac536999eb | ||
|
|
2e43511d87 | ||
|
|
1bebe38e66 | ||
|
|
eca1653024 | ||
|
|
08f1cb7581 | ||
|
|
9d9e8675a9 | ||
|
|
64526b7bee | ||
|
|
e9732d292f | ||
|
|
fa5349ea7c | ||
|
|
1c491bc2b0 | ||
|
|
81fe5108d2 | ||
|
|
61fa37dc48 | ||
|
|
0118950939 | ||
|
|
22cf23b24d | ||
|
|
0010b0f394 | ||
|
|
d0cdafa66a | ||
|
|
bcf558418a | ||
|
|
61ac349445 | ||
|
|
06ce5cc91a | ||
|
|
fc2e9d88d5 | ||
|
|
4b0cd34eda | ||
|
|
83502ce726 | ||
|
|
8df5900ff9 | ||
|
|
864cefb8d1 | ||
|
|
6c03e56794 | ||
|
|
4b92a5f364 | ||
|
|
da0193eb11 | ||
|
|
71e0db4985 | ||
|
|
825946ae75 | ||
|
|
bd4df0c263 | ||
|
|
113b5c63f0 | ||
|
|
3f6d71ecbc | ||
|
|
4246555228 | ||
|
|
8dd9623417 | ||
|
|
817b71f69d | ||
|
|
e71cac9dd1 | ||
|
|
cb38081a8b | ||
|
|
cd9d2eebd8 | ||
|
|
e6f18d4a61 | ||
|
|
4e90337f54 | ||
|
|
a01a70f725 | ||
|
|
4da58681a4 | ||
|
|
224b4b5d5b | ||
|
|
1a34f00574 | ||
|
|
8ec7db1612 | ||
|
|
e0a75e2d04 | ||
|
|
15bd6f42bb | ||
|
|
0aa57ab7d2 | ||
|
|
9f5eb4a99f | ||
|
|
a9f18f53da | ||
|
|
c9e932ef43 | ||
|
|
cb99388e3c | ||
|
|
2e316548e7 | ||
|
|
b46e1e0877 | ||
|
|
e17b877dcc | ||
|
|
6ce21908d3 | ||
|
|
15495483f3 | ||
|
|
540fbbf1b3 | ||
|
|
a5cc7736b4 | ||
|
|
8c6248cf84 | ||
|
|
22d63fd15e | ||
|
|
2dc0ba2d25 | ||
|
|
8f180820f9 | ||
|
|
b283418879 | ||
|
|
61be42ea6d | ||
|
|
3906b93c6a | ||
|
|
af2a71d1a5 | ||
|
|
cf7c872e12 | ||
|
|
c9227dac70 | ||
|
|
34b1ea2e30 | ||
|
|
1c6454dce5 | ||
|
|
464dd732d5 | ||
|
|
f481243c4e | ||
|
|
f49b6cbea5 | ||
|
|
71f316a220 | ||
|
|
9556f9d7d0 | ||
|
|
9de0965c78 | ||
|
|
38bfa51d85 | ||
|
|
c58f561df4 | ||
|
|
6cf1e58018 | ||
|
|
1382d25e46 | ||
|
|
d8f7105471 | ||
|
|
071088715f | ||
|
|
95429d9f35 | ||
|
|
55379a98ef | ||
|
|
173dee6ccc | ||
|
|
34dee24399 | ||
|
|
88f1022d0e | ||
|
|
737863c55d | ||
|
|
e710287c35 | ||
|
|
2e3c1ec0f1 | ||
|
|
21f032d32e | ||
|
|
2881309d41 | ||
|
|
e162d50608 | ||
|
|
49bab7fb0a |
No files matched your search
+7
-7
@@ -6,13 +6,13 @@
|
||||
# Get yours at: https://app.ximilar.com/
|
||||
XIMILAR_API_KEY=your-ximilar-api-key-here
|
||||
|
||||
# Perplexity API Key (AI deck research & generation)
|
||||
# Get yours at: https://www.perplexity.ai/settings/api
|
||||
PPLX_API_KEY=your-perplexity-api-key-here
|
||||
# Perplexity model for deck research & generation (sonar / sonar-pro / sonar-deep-research)
|
||||
PPLX_MODEL=sonar-pro
|
||||
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
|
||||
COACH_PROVIDER=local
|
||||
# Anthropic API Key (AI deck generation & coaching with Claude Opus)
|
||||
# Get yours at: https://console.anthropic.com/settings/keys
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||
# Anthropic model for deck generation and coaching (Claude Opus recommended)
|
||||
ANTHROPIC_MODEL=claude-opus-4-20250514
|
||||
# Coach chat provider: "local" (Ollama) or "anthropic" (Claude API)
|
||||
COACH_PROVIDER=anthropic
|
||||
|
||||
# CORS allowed origins (comma-separated)
|
||||
# Default: http://localhost:5173,http://localhost:8080
|
||||
|
||||
+31
@@ -38,3 +38,34 @@ Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Precon decks — runtime data, downloaded locally by Forge/scripts
|
||||
# Do not commit .dck files; they are large and machine-generated
|
||||
precon-decks/*.dck
|
||||
precon-decks/precon-index.json
|
||||
|
||||
# Phase 0: Local Scryfall card image cache (~1.5-2GB)
|
||||
# Downloaded by scripts/download_card_images.py — never commit
|
||||
static/card-images/
|
||||
|
||||
# Phase 0: SQLite image index (generated by scripts/build_image_index.py)
|
||||
data/card_image_index.db
|
||||
|
||||
# Phase 6: AI decision logs and training sets (large runtime data)
|
||||
data/decision_logs/
|
||||
data/game_results.jsonl
|
||||
data/training_sets/
|
||||
|
||||
# Unity generated / local config files
|
||||
unity-client/Packages/packages-lock.json
|
||||
unity-client/ProjectSettings/EditorBuildSettings.asset
|
||||
unity-client/ProjectSettings/EditorSettings.asset
|
||||
unity-client/ProjectSettings/ShaderGraphSettings.asset
|
||||
unity-commander-ui/Packages/packages-lock.json
|
||||
unity-commander-ui/ProjectSettings/EditorBuildSettings.asset
|
||||
unity-commander-ui/ProjectSettings/EditorSettings.asset
|
||||
|
||||
# Third-party Unity assets — installed locally via Unity Package Manager / Asset Store
|
||||
# These are large binary asset packs; each dev installs from their own license
|
||||
unity-client/Assets/Eric VFX Studio/
|
||||
unity-client/Assets/TcgEngine/
|
||||
@@ -1,381 +1,180 @@
|
||||
# Commander AI Lab
|
||||
|
||||
Headless batch simulation environment for 3-AI MTG Commander pods.
|
||||
Run 100+ games, analyze win rates, and stress-test your decks — all from a web UI or CLI.
|
||||
A full-stack MTG Commander AI research platform. Simulate hundreds of games overnight, train ML models on the results, and let simulation data automatically shape the AI deck builder — all from a web UI or CLI.
|
||||
|
||||
## What It Does
|
||||
|
||||
| Layer | What it is |
|
||||
|---|---|
|
||||
| **Monte Carlo Engine** | Pure-Python headless sim — runs thousands of games in minutes, no JVM needed |
|
||||
| **Forge Engine** | Java/Forge rules-complete sim for ground-truth validation |
|
||||
| **Overnight Runner** | Unattended batch runner — runs sims, updates sim weights, trains neural net |
|
||||
| **ML Pipeline** | Outcome-weighted RL nudges card scoring weights; neural net trains on decision logs |
|
||||
| **Deck Builder** | 7-step LLM pipeline (EDHrec → Scryfall → Ollama) now injected with sim-learned insights |
|
||||
| **AI Coach** | LLM-powered coaching sessions (Anthropic/Ollama) with streaming support |
|
||||
| **Web UI** | React + FastAPI dashboard for decks, sims, training, and deck building |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Web Browser (ai-lab.js + ai-lab.css) │
|
||||
│ Select decks → Start batch → Poll progress → View stats │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
│ HTTP (REST)
|
||||
┌─────────────────────┴───────────────────────────────────────┐
|
||||
│ Python FastAPI Server (lab_api.py, port 8080) │
|
||||
│ /api/lab/start — spawn Java batch runner subprocess │
|
||||
│ /api/lab/status — poll progress (parses stdout) │
|
||||
│ /api/lab/result — return completed JSON results │
|
||||
│ /api/lab/decks — list decks from Forge user data │
|
||||
│ /api/lab/history— browse past batch runs │
|
||||
│ /api/lab/log — stream live console output │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
│ subprocess (java -jar)
|
||||
┌─────────────────────┴───────────────────────────────────────┐
|
||||
│ Java CLI (LabCli.java + BatchRunner + MultiThreadRunner) │
|
||||
│ Parses Forge sim output → GameResult objects → JSON │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
│ subprocess per game
|
||||
┌─────────────────────┴───────────────────────────────────────┐
|
||||
│ Card Forge Engine (forge-gui-desktop sim mode) │
|
||||
│ java -jar forge.jar sim -d "deck1" "deck2" "deck3" │
|
||||
│ -f commander -n 1 -q │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Web UI (React/TypeScript — frontend/) │
|
||||
│ Deck Builder · Sim Dashboard · Training · Coach │
|
||||
└────────────────────────┬────────────────────────────────────────┘
|
||||
│ HTTP/REST + WebSocket
|
||||
┌────────────────────────┴────────────────────────────────────────┐
|
||||
│ FastAPI Server (lab_api.py / routes/ — port 8080) │
|
||||
│ /api/lab/* /api/ml/* /api/coach/* /api/deck-builder/* │
|
||||
└────────┬──────────────────┬──────────────────┬──────────────────┘
|
||||
│ │ │
|
||||
Java subprocess Python sim engine Ollama (local LLM)
|
||||
(Forge rules) (Monte Carlo) qwen2.5:7b + others
|
||||
│ │
|
||||
┌────────┴───────┐ ┌───────┴────────────────────────────────────┐
|
||||
│ Forge Engine │ │ src/commander_ai_lab/ │
|
||||
│ (rules- │ │ sim/ — engine, models, rules │
|
||||
│ complete) │ │ deck_builder/ — 7-step LLM pipeline │
|
||||
└────────────────┘ │ coach/ — streaming LLM coach │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start (Windows)
|
||||
|
||||
### Prerequisites
|
||||
- **Java 17+** (`java -version`)
|
||||
- **Python 3.10+** (`python --version`)
|
||||
- **Maven 3.8+** (`mvn --version`)
|
||||
- **Forge repo** cloned and built at `D:\ForgeCommander\forge-repo`
|
||||
**Prerequisites:** Java 17+, Python 3.10+, Maven 3.8+, [Forge](https://github.com/Card-Forge/forge) built at `D:\ForgeCommander\forge-repo`
|
||||
|
||||
### 1. Build Forge (one-time)
|
||||
```powershell
|
||||
# 1. Build Forge (one-time)
|
||||
cd D:\ForgeCommander\forge-repo
|
||||
mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
||||
```
|
||||
|
||||
### 2. Build Commander AI Lab
|
||||
```powershell
|
||||
# 2. Build Commander AI Lab
|
||||
cd D:\ForgeCommander\commander-ai-lab
|
||||
mvn package "-DskipTests"
|
||||
```
|
||||
This produces `target/commander-ai-lab-1.0.0-SNAPSHOT.jar` (shaded fat JAR).
|
||||
|
||||
### 3. Install Python dependencies
|
||||
```powershell
|
||||
pip install fastapi uvicorn
|
||||
```
|
||||
# 3. Install Python deps
|
||||
pip install -e .
|
||||
|
||||
### 4. Launch the Web UI
|
||||
**Option A — One-click launcher:**
|
||||
```powershell
|
||||
# 4. Launch
|
||||
.\start-lab.bat
|
||||
# → open http://localhost:8080
|
||||
```
|
||||
Edit the paths at the top of `start-lab.bat` if needed.
|
||||
|
||||
**Option B — Manual launch:**
|
||||
## Overnight AFK Runner
|
||||
|
||||
Runs batch sims unattended, then updates card weights and trains the neural net:
|
||||
|
||||
```powershell
|
||||
python lab_api.py `
|
||||
--forge-jar "D:\ForgeCommander\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
|
||||
--forge-dir "D:\ForgeCommander\forge-repo\forge-gui" `
|
||||
--port 8080
|
||||
python overnight-run.py # default: 11.5h DeepSeek sims
|
||||
python overnight-run.py --batches 20 --games 50 # fixed batch count
|
||||
python overnight-run.py --no-update-weights # skip weight update
|
||||
python overnight-run.py --no-train # skip neural net training
|
||||
```
|
||||
|
||||
Open `http://localhost:8080` — select 3 decks, set game count, click **Run AI Sims**.
|
||||
**What happens after sims complete:**
|
||||
1. `update_weights.py` — nudges `learned_weights.json` from decision logs (feeds deck builder)
|
||||
2. `/api/ml/train` — trains the neural net on the full decision dataset
|
||||
|
||||
## Sim → Deck Builder Feedback Loop
|
||||
|
||||
Simulation outcomes automatically improve deck recommendations:
|
||||
|
||||
```
|
||||
Overnight sims → decisions_*.jsonl
|
||||
↓
|
||||
update_weights.py → learned_weights.json
|
||||
↓
|
||||
sim_insights.py → natural-language insight block
|
||||
↓
|
||||
Ollama prompts (suggest_cards + enforce_deck_ratios)
|
||||
↓
|
||||
Better card recommendations
|
||||
```
|
||||
|
||||
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. No-op until real sim data exists.
|
||||
|
||||
## Deck Builder Pipeline
|
||||
|
||||
The `POST /api/deck-builder/build` endpoint runs 7 steps:
|
||||
|
||||
1. Resolve commander via Scryfall
|
||||
2. Fetch EDHrec recommendations
|
||||
3. Fetch Scryfall candidates by category (ramp, removal, draw, lands)
|
||||
4. Ollama suggests synergy/protection/wincon cards ← **sim insights injected here**
|
||||
5. Filter by color identity, collection, ban list
|
||||
6. Ollama enforces ratios (trim/expand to 99) ← **sim insights injected here**
|
||||
7. Assemble final deck
|
||||
|
||||
## ML Pipeline
|
||||
|
||||
### 5. CLI-only usage (no web server)
|
||||
```powershell
|
||||
cd D:\ForgeCommander\forge-repo\forge-gui
|
||||
# Update sim card weights from decision logs (runs automatically in overnight-run.py)
|
||||
python -m ml.scripts.update_weights --min-games 10 --lr 0.05 --dry-run
|
||||
|
||||
java -jar "D:\ForgeCommander\commander-ai-lab\target\commander-ai-lab-1.0.0-SNAPSHOT.jar" `
|
||||
--forge-jar "..\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
|
||||
--forge-dir "." `
|
||||
--deck1 "Edgar Markov" `
|
||||
--deck2 "Grimgrin" `
|
||||
--deck3 "Xyris" `
|
||||
--games 100 `
|
||||
--threads 4 `
|
||||
--output results/batch-100.json
|
||||
# Reset weights to defaults
|
||||
python -m ml.scripts.update_weights --reset
|
||||
|
||||
# Generate synthetic training data
|
||||
python ml/scripts/generate_synthetic_data.py
|
||||
```
|
||||
|
||||
## Scaling Tests
|
||||
Learned weights are clamped to `[-20.0, 20.0]` and stored in `src/commander_ai_lab/sim/learned_weights.json`.
|
||||
|
||||
### Single-threaded baseline (10 games)
|
||||
```powershell
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
||||
--forge-jar "..." --forge-dir "..." `
|
||||
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
||||
--games 10 --threads 1 --output results/baseline-10.json
|
||||
## Python Simulator API
|
||||
|
||||
```python
|
||||
from commander_ai_lab.lab.experiments import build_deck, run_simulation
|
||||
|
||||
deck = build_deck(["Sol Ring", "Cultivate", "Lightning Bolt"] + ["Forest"] * 36)
|
||||
stats = run_simulation(deck, num_games=100, name_a="My Deck")
|
||||
print(f"Win rate: {stats['win_rate']:.1%}, Avg turns: {stats['avg_turns']:.1f}")
|
||||
```
|
||||
|
||||
### Multi-threaded (10 games, 2 threads)
|
||||
```powershell
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
||||
--forge-jar "..." --forge-dir "..." `
|
||||
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
||||
--games 10 --threads 2 --output results/mt-10-t2.json
|
||||
```
|
||||
|
||||
### Scale to 100 games (4 threads)
|
||||
```powershell
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
||||
--forge-jar "..." --forge-dir "..." `
|
||||
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
||||
--games 100 --threads 4 --output results/scale-100-t4.json
|
||||
```
|
||||
|
||||
### Maximum throughput test (100 games, 8 threads)
|
||||
```powershell
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
||||
--forge-jar "..." --forge-dir "..." `
|
||||
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
||||
--games 100 --threads 8 --seed 42 --output results/scale-100-t8-seed42.json
|
||||
```
|
||||
|
||||
### Performance comparison script
|
||||
Run different thread counts and compare sims/sec:
|
||||
```powershell
|
||||
# Baseline: 1 thread
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 1 --seed 42 --output results/perf-t1.json
|
||||
|
||||
# 2 threads
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 2 --seed 42 --output results/perf-t2.json
|
||||
|
||||
# 4 threads
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 4 --seed 42 --output results/perf-t4.json
|
||||
|
||||
# 8 threads
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 8 --seed 42 --output results/perf-t8.json
|
||||
```
|
||||
|
||||
Then compare the `simsPerSecond` values in each output JSON to see scaling behavior.
|
||||
The Monte Carlo engine models: combat keywords (flying, trample, deathtouch, lifelink, menace, reach, haste), summoning sickness, tapped/untap state, commander zone, multiplayer attack targeting, and board wipes. It does **not** model the stack, instants, or continuous type-changing effects.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/lab/decks` | GET | List available Commander decks |
|
||||
| `/api/lab/start` | POST | Start a batch run (returns batchId) |
|
||||
| `/api/lab/status?batchId=X` | GET | Poll progress for a running batch |
|
||||
| `/api/lab/result?batchId=X` | GET | Get completed batch result JSON |
|
||||
| `/api/lab/log?batchId=X` | GET | Get live log lines from running batch |
|
||||
| `/api/lab/history` | GET | List past batch result files |
|
||||
|
||||
### POST /api/lab/start
|
||||
```json
|
||||
{
|
||||
"decks": ["Edgar Markov", "Grimgrin", "Xyris"],
|
||||
"numGames": 100,
|
||||
"threads": 4,
|
||||
"seed": null,
|
||||
"clock": 120
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/lab/status response
|
||||
```json
|
||||
{
|
||||
"batchId": "abc123",
|
||||
"running": true,
|
||||
"completed": 42,
|
||||
"total": 100,
|
||||
"threads": 4,
|
||||
"elapsedMs": 156000,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
## Python Simulator (Monte Carlo Engine)
|
||||
|
||||
A pure-Python deck simulator for rapid Monte Carlo analysis — no Forge JVM required.
|
||||
Ported from the [mtg-commander-lan](https://github.com/) Node.js simulator.
|
||||
|
||||
### Location
|
||||
|
||||
```
|
||||
src/commander_ai_lab/
|
||||
├── __init__.py
|
||||
├── sim/ # Core simulation engine
|
||||
│ ├── __init__.py # Public exports
|
||||
│ ├── models.py # Card, Player, SimState, GameResult, PlayerStats
|
||||
│ ├── engine.py # GameEngine — turn loop, combat (flying/trample/deathtouch/lifelink/menace/reach)
|
||||
│ └── rules.py # AI_DEFAULT_WEIGHTS, enrich_card(), score_card(), parse_decklist()
|
||||
└── lab/ # High-level experiment helpers
|
||||
├── __init__.py
|
||||
└── experiments.py # build_deck(), run_single_game(), run_simulation(), _cli_main()
|
||||
```
|
||||
|
||||
### Install as editable package
|
||||
|
||||
```powershell
|
||||
cd D:\ForgeCommander\commander-ai-lab
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
This installs `commander_ai_lab` as an importable package and registers the `commander-sim` CLI command.
|
||||
|
||||
### Quick smoke test
|
||||
|
||||
```powershell
|
||||
python -m commander_ai_lab.lab.experiments
|
||||
```
|
||||
|
||||
Expected output — a 10-game Monte Carlo run of a sample "Korvold" deck:
|
||||
|
||||
```
|
||||
══════════════════════════════════════════
|
||||
Commander AI Lab — Experiment Runner
|
||||
══════════════════════════════════════════
|
||||
|
||||
--- Demo: Korvold Aristocrats (10 games) ---
|
||||
|
||||
Win rate: XX.X%
|
||||
Avg turns: XX.X
|
||||
Avg damage dealt: XX.X
|
||||
Avg spells cast: XX.X
|
||||
Avg creatures: XX.X
|
||||
Elapsed: 0.02s
|
||||
|
||||
Done.
|
||||
```
|
||||
|
||||
### Python API
|
||||
|
||||
```python
|
||||
from commander_ai_lab.sim.models import Card
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
from commander_ai_lab.lab.experiments import build_deck, run_simulation
|
||||
|
||||
# Build a deck from card names
|
||||
deck = build_deck(["Sol Ring", "Cultivate", "Lightning Bolt", "Forest"] + ["Forest"] * 36)
|
||||
|
||||
# Monte Carlo simulation (100 games)
|
||||
stats = run_simulation(deck, num_games=100, name_a="My Deck")
|
||||
print(f"Win rate: {stats['win_rate']:.1%}")
|
||||
print(f"Avg turns: {stats['avg_turns']:.1f}")
|
||||
```
|
||||
|
||||
Key functions:
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `build_deck(card_names)` | Create enriched `Card` list from names |
|
||||
| `build_deck_from_text(decklist)` | Parse `"1 Sol Ring\n37 Forest"` format |
|
||||
| `run_single_game(deck_a, deck_b)` | One game, returns winner/turns/stats |
|
||||
| `run_simulation(deck_a, deck_b, n)` | Monte Carlo over `n` games, aggregated stats |
|
||||
|
||||
### CLI entry point
|
||||
|
||||
After `pip install -e .`:
|
||||
|
||||
```powershell
|
||||
commander-sim
|
||||
```
|
||||
|
||||
Runs the same demo as `python -m commander_ai_lab.lab.experiments`.
|
||||
|
||||
### How the engine works
|
||||
|
||||
1. **Deck enrichment** — `enrich_card()` infers type, CMC, keywords, and power/toughness from card name heuristics when Scryfall data is unavailable.
|
||||
2. **AI scoring** — `score_card()` uses weighted categories (removal, ramp, draw, aggro, etc.) to pick the best play each turn.
|
||||
3. **Game loop** — Each turn: untap → draw → play lands → cast spells (AI scored) → combat (attack/block decisions) → end step. Full keyword support for flying, trample, deathtouch, lifelink, menace, and reach.
|
||||
4. **Monte Carlo** — `run_simulation()` runs N independent games and aggregates win rate, avg turns, damage dealt/received, spells cast, creatures played, removal used, ramp played, cards drawn, and max board size.
|
||||
| `/api/lab/start` | POST | Start Forge batch (returns batchId) |
|
||||
| `/api/lab/start-deepseek` | POST | Start Python/DeepSeek batch |
|
||||
| `/api/lab/status?batchId=X` | GET | Poll batch progress |
|
||||
| `/api/lab/result?batchId=X` | GET | Get completed batch JSON |
|
||||
| `/api/lab/history` | GET | List past batch runs |
|
||||
| `/api/ml/train` | POST | Start neural net training |
|
||||
| `/api/ml/train/status` | GET | Poll training progress |
|
||||
| `/api/coach/chat` | POST | LLM coaching session (streaming) |
|
||||
| `/api/deck-builder/build` | POST | Build a 99-card Commander deck |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
commander-ai-lab/
|
||||
├── lab_api.py # Python FastAPI server (web UI backend)
|
||||
├── overnight-run.py # AFK batch runner
|
||||
├── start-lab.bat # Windows one-click launcher
|
||||
├── pom.xml # Maven build (Java 17, shaded JAR)
|
||||
├── pyproject.toml # Python package config (src/ layout)
|
||||
├── ui/
|
||||
│ ├── index.html # Web UI entry point
|
||||
│ ├── ai-lab.js # UI controller (deck selection, progress, results)
|
||||
│ ├── ai-lab.css # Styles (dark theme, responsive)
|
||||
│ ├── collection.html # Card collection manager
|
||||
│ ├── collection.js # Collection controller
|
||||
│ ├── collection.css # Collection styles
|
||||
│ ├── deckbuilder.html # Deck builder + EDH advisor
|
||||
│ ├── deckbuilder.js # Deck builder controller
|
||||
│ └── deckbuilder.css # Deck builder styles
|
||||
├── src/commander_ai_lab/ # Python simulator package
|
||||
│ ├── __init__.py
|
||||
│ ├── sim/ # Core engine (models, engine, rules)
|
||||
│ └── lab/ # Experiment helpers
|
||||
├── src/main/java/commanderailab/ # Java batch runner (Forge integration)
|
||||
│ ├── cli/LabCli.java # CLI entry point (picocli)
|
||||
│ ├── batch/
|
||||
│ │ ├── BatchRunner.java # Single-thread runner (parses Forge output)
|
||||
│ │ └── MultiThreadBatchRunner.java # Multi-thread runner (ExecutorService)
|
||||
│ ├── bridge/WebApiBridge.java # Java-side web API bridge
|
||||
│ ├── schema/
|
||||
│ │ ├── BatchResult.java # Data model POJOs
|
||||
│ │ └── JsonExporter.java # JSON serialization + validation
|
||||
│ ├── stats/StatsAggregator.java # Win rates, turn counts, breakdowns
|
||||
│ └── ai/
|
||||
│ ├── AiPolicy.java # Policy interface
|
||||
│ └── ForgeBuiltinPolicy.java # v1 Forge AI wrapper
|
||||
├── scanner/ # Card scanner (OCR pipeline)
|
||||
│ ├── __init__.py
|
||||
│ ├── ocr.py # Tesseract OCR wrapper
|
||||
│ ├── pipeline.py # Image preprocessing pipeline
|
||||
│ └── preprocess.py # Image preprocessing helpers
|
||||
├── docs/
|
||||
│ ├── PROJECT_VISION.md
|
||||
│ ├── batch-result-schema.json
|
||||
│ └── game-log-schema.json
|
||||
├── sample-decks/ # 3 sample .dck files
|
||||
├── precon-decks/ # Preconstructed decks + index
|
||||
├── results/ # Batch output JSON files (gitignored)
|
||||
└── src/test/java/ # Unit tests
|
||||
├── lab_api.py # FastAPI server entry point
|
||||
├── frontend/ # React + TypeScript web UI
|
||||
├── ui/ # Legacy static HTML UI
|
||||
├── src/commander_ai_lab/
|
||||
│ ├── sim/ # Monte Carlo engine (engine, models, rules)
|
||||
│ ├── deck_builder/ # LLM deck builder pipeline
|
||||
│ │ ├── api/ # Scryfall, EDHrec, Ollama, sim_insights
|
||||
│ │ ├── core/ # Models, rules engine, assembler
|
||||
│ │ └── pipeline/ # build_deck.py (7-step orchestrator)
|
||||
│ └── coach/ # LLM coaching (Anthropic + Ollama)
|
||||
├── ml/
|
||||
│ ├── scripts/ # update_weights.py, ml_cli.py
|
||||
│ ├── training/ # Neural net trainer
|
||||
│ ├── encoder/ # Feature encoding
|
||||
│ └── models/ # Saved model checkpoints
|
||||
├── scanner/ # Card OCR pipeline
|
||||
├── logs/decisions/ # ML decision logs (gitignored)
|
||||
└── src/main/java/commanderailab/ # Java/Forge batch runner
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"schemaVersion": "1.0.0",
|
||||
"batchId": "uuid",
|
||||
"timestamp": "2026-03-06T...",
|
||||
"totalGames": 100,
|
||||
"completedGames": 100,
|
||||
"format": "commander",
|
||||
"podSize": 3,
|
||||
"engineVersion": "forge-2.0.12-SNAPSHOT",
|
||||
"masterSeed": 42,
|
||||
"threads": 4,
|
||||
"elapsedMs": 260000
|
||||
},
|
||||
"decks": [...],
|
||||
"games": [...],
|
||||
"summary": {
|
||||
"perDeck": [
|
||||
{ "deckName": "Edgar Markov", "winRate": 0.60, "wins": 60, ... }
|
||||
],
|
||||
"avgGameTurns": 12.3,
|
||||
"simsPerSecond": 0.38
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
The UI is a React + TypeScript SPA in the `frontend/` directory.
|
||||
|
||||
### Dev server (with hot reload)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Vite proxies `/api` and `/ws` to `localhost:8000`, so start the FastAPI backend first.
|
||||
|
||||
### Production build
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
FastAPI serves the built assets from `frontend/dist/`.
|
||||
|
||||
## Known Limitations
|
||||
- Winner's life total not captured (Forge quiet mode only reports losers)
|
||||
- Win condition classification is heuristic-based on Forge loss reason strings
|
||||
- Each game spawns a new JVM process (~2-3s overhead per game)
|
||||
- Max throughput ~0.04 sims/sec single-threaded (Forge sim is CPU-heavy)
|
||||
- Multithreading spawns parallel subprocesses — throughput scales linearly with threads up to CPU core count
|
||||
|
||||
- **Monte Carlo engine** — no stack, no instants, no continuous type changes (animated lands don't attack)
|
||||
- **Forge engine** — each game spawns a new JVM (~2-3s overhead), ~0.04 sims/sec single-threaded
|
||||
- **Forge winner life total** not captured (quiet mode only reports losers)
|
||||
- **Deck builder** — Ollama suggestions are only as good as the local model; `qwen2.5:7b` works well
|
||||
@@ -1 +1 @@
|
||||
# Coach clients — Perplexity, LM Studio
|
||||
# Coach clients — Anthropic Claude, LM Studio
|
||||
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Commander AI Lab — Anthropic Claude Structured Output Client
|
||||
════════════════════════════════════════════════════════════
|
||||
Uses the anthropic SDK to call Claude's messages endpoint
|
||||
with structured JSON output enforced via system prompt.
|
||||
Supports both sync and native async operation.
|
||||
|
||||
All substantive calls use .messages.stream() to avoid the SDK's
|
||||
10-minute non-streaming timeout restriction on large max_tokens.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
logger = logging.getLogger("coach.anthropic")
|
||||
|
||||
# ── Anthropic Models ───────────────────────────────────────────
|
||||
CLAUDE_OPUS = "claude-opus-4-0-20250514"
|
||||
CLAUDE_SONNET = "claude-sonnet-4-5"
|
||||
CLAUDE_HAIKU = "claude-haiku-4-5"
|
||||
|
||||
|
||||
class AnthropicResponse:
|
||||
"""Parsed response container from Anthropic API.
|
||||
|
||||
Drop-in replacement for PerplexityResponse — same attribute shape
|
||||
so all existing call-sites work without changes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
parsed_json: Optional[dict],
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
model: str,
|
||||
citations: list[str] = None,
|
||||
):
|
||||
self.content = content
|
||||
self.parsed_json = parsed_json
|
||||
self.prompt_tokens = prompt_tokens
|
||||
self.completion_tokens = completion_tokens
|
||||
self.model = model
|
||||
self.citations = citations or [] # Anthropic has no citations; kept for compat
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.parsed_json is not None
|
||||
|
||||
|
||||
class AnthropicClient:
|
||||
"""
|
||||
Anthropic Claude API client.
|
||||
|
||||
Drop-in replacement for PerplexityClient.
|
||||
All substantive calls use .messages.stream() so the SDK never
|
||||
enforces the 10-minute non-streaming timeout.
|
||||
|
||||
Usage:
|
||||
client = AnthropicClient(api_key="sk-ant-...")
|
||||
resp = client.chat_structured(
|
||||
system_prompt="You are a deck builder.",
|
||||
user_prompt="Build a deck for...",
|
||||
json_schema=deck_schema,
|
||||
schema_name="DeckList",
|
||||
)
|
||||
if resp.ok:
|
||||
deck = resp.parsed_json
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
model: str = CLAUDE_OPUS,
|
||||
timeout: int = 120,
|
||||
base_url: str = None,
|
||||
):
|
||||
if not api_key:
|
||||
raise ValueError("Anthropic API key is required")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self._client = anthropic.Anthropic(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
timeout=float(timeout),
|
||||
)
|
||||
self._async_client = anthropic.AsyncAnthropic(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
timeout=float(timeout),
|
||||
)
|
||||
|
||||
def chat_structured(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
json_schema: dict = None,
|
||||
schema_name: str = "response",
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 8192,
|
||||
) -> AnthropicResponse:
|
||||
"""
|
||||
Send a chat request expecting JSON output.
|
||||
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
|
||||
"""
|
||||
use_model = model or self.model
|
||||
effective_system = self._build_system(system_prompt, json_schema, schema_name)
|
||||
|
||||
try:
|
||||
with self._client.messages.stream(
|
||||
model=use_model,
|
||||
system=effective_system,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
) as stream:
|
||||
content = stream.get_final_text()
|
||||
final_msg = stream.get_final_message()
|
||||
return self._parse_streamed(content, final_msg, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] chat_structured failed: %s", e)
|
||||
raise
|
||||
|
||||
def chat_plain(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 4096,
|
||||
) -> AnthropicResponse:
|
||||
"""
|
||||
Send a plain chat request without strict structured output.
|
||||
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
|
||||
"""
|
||||
use_model = model or self.model
|
||||
|
||||
try:
|
||||
with self._client.messages.stream(
|
||||
model=use_model,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
) as stream:
|
||||
content = stream.get_final_text()
|
||||
final_msg = stream.get_final_message()
|
||||
return self._parse_streamed(content, final_msg, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] chat_plain failed: %s", e)
|
||||
raise
|
||||
|
||||
async def achat_structured(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
json_schema: dict = None,
|
||||
schema_name: str = "response",
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 8192,
|
||||
) -> AnthropicResponse:
|
||||
"""Native async structured chat using streaming."""
|
||||
use_model = model or self.model
|
||||
effective_system = self._build_system(system_prompt, json_schema, schema_name)
|
||||
|
||||
try:
|
||||
async with self._async_client.messages.stream(
|
||||
model=use_model,
|
||||
system=effective_system,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
) as stream:
|
||||
content = await stream.get_final_text()
|
||||
final_msg = await stream.get_final_message()
|
||||
return self._parse_streamed(content, final_msg, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] achat_structured failed: %s", e)
|
||||
raise
|
||||
|
||||
async def achat_plain(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 4096,
|
||||
) -> AnthropicResponse:
|
||||
"""Native async plain chat using streaming."""
|
||||
use_model = model or self.model
|
||||
|
||||
try:
|
||||
async with self._async_client.messages.stream(
|
||||
model=use_model,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
) as stream:
|
||||
content = await stream.get_final_text()
|
||||
final_msg = await stream.get_final_message()
|
||||
return self._parse_streamed(content, final_msg, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] achat_plain failed: %s", e)
|
||||
raise
|
||||
|
||||
def check_status(self) -> dict:
|
||||
"""
|
||||
Quick health check — tiny max_tokens=10 call, safe to use non-streaming
|
||||
since it will never approach the 10-minute threshold.
|
||||
"""
|
||||
try:
|
||||
response = self._client.messages.create(
|
||||
model=self.model,
|
||||
system="Reply with the word OK.",
|
||||
messages=[{"role": "user", "content": "Status check."}],
|
||||
max_tokens=10,
|
||||
)
|
||||
content = response.content[0].text if response.content else ""
|
||||
return {
|
||||
"connected": True,
|
||||
"model": self.model,
|
||||
"response": content,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"connected": False,
|
||||
"model": self.model,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
# ── Internal helpers ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _build_system(system_prompt: str, json_schema: dict, schema_name: str) -> str:
|
||||
"""Append JSON schema hint to system prompt when a schema is provided."""
|
||||
if not json_schema:
|
||||
return system_prompt
|
||||
schema_str = json.dumps(json_schema, indent=2)
|
||||
return (
|
||||
f"{system_prompt}\n\n"
|
||||
f"You MUST respond with valid JSON only — no markdown fences, no prose.\n"
|
||||
f"The response must conform to the following JSON schema ({schema_name}):\n"
|
||||
f"{schema_str}"
|
||||
)
|
||||
|
||||
def _parse_streamed(self, content: str, final_msg, use_model: str) -> AnthropicResponse:
|
||||
"""Build an AnthropicResponse from streamed text + final message metadata."""
|
||||
usage = getattr(final_msg, "usage", None)
|
||||
prompt_tokens = usage.input_tokens if usage else 0
|
||||
completion_tokens = usage.output_tokens if usage else 0
|
||||
|
||||
parsed = None
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed = self._extract_json(content)
|
||||
|
||||
logger.info(
|
||||
"[anthropic] model=%s tokens=(%d in, %d out) parsed=%s",
|
||||
use_model,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
"yes" if parsed else "no",
|
||||
)
|
||||
|
||||
return AnthropicResponse(
|
||||
content=content,
|
||||
parsed_json=parsed,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
model=use_model,
|
||||
citations=[],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_json(text: str) -> Optional[dict]:
|
||||
"""Best-effort JSON extraction from possibly noisy text."""
|
||||
try:
|
||||
return json.loads(text.strip())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Markdown fences
|
||||
fence_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text)
|
||||
if fence_match:
|
||||
try:
|
||||
return json.loads(fence_match.group(1).strip())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Brace counting
|
||||
start = text.find('{')
|
||||
if start == -1:
|
||||
return None
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
if ch == '\\' and in_string:
|
||||
escape_next = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
try:
|
||||
return json.loads(text[start:i + 1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
# Truncated JSON — attempt repair
|
||||
return AnthropicClient._repair_truncated_json(text[start:])
|
||||
|
||||
@staticmethod
|
||||
def _repair_truncated_json(text: str) -> Optional[dict]:
|
||||
"""
|
||||
Attempt to repair truncated JSON from max_tokens cutoff.
|
||||
Strategy: find the last complete nested object, then close
|
||||
all open brackets/braces.
|
||||
"""
|
||||
logger.info("Attempting truncated JSON repair (%d chars)...", len(text))
|
||||
last_good = -1
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
for i in range(len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
if ch == '\\' and in_string:
|
||||
escape_next = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth >= 1:
|
||||
last_good = i
|
||||
|
||||
if last_good == -1:
|
||||
return None
|
||||
|
||||
truncated = text[:last_good + 1]
|
||||
close_depth = 0
|
||||
in_arr = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
for ch in truncated:
|
||||
if esc:
|
||||
esc = False
|
||||
continue
|
||||
if ch == '\\' and in_str:
|
||||
esc = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = not in_str
|
||||
continue
|
||||
if in_str:
|
||||
continue
|
||||
if ch == '{':
|
||||
close_depth += 1
|
||||
elif ch == '}':
|
||||
close_depth -= 1
|
||||
elif ch == '[':
|
||||
in_arr += 1
|
||||
elif ch == ']':
|
||||
in_arr -= 1
|
||||
|
||||
suffix = ']' * in_arr + '}' * close_depth
|
||||
repaired = truncated + suffix
|
||||
try:
|
||||
result = json.loads(repaired)
|
||||
card_count = len(result.get('cards', []))
|
||||
logger.info("JSON repair succeeded: recovered %d cards", card_count)
|
||||
return result
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning("JSON repair failed: %s", e)
|
||||
return None
|
||||
+263
-70
@@ -5,7 +5,6 @@ Main orchestrator: loads deck reports, identifies underperformers,
|
||||
finds replacement candidates via embeddings, builds prompts,
|
||||
calls the LLM, parses responses, and persists coaching sessions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
@@ -18,12 +17,16 @@ from typing import Optional, List, Dict
|
||||
from .config import (
|
||||
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
|
||||
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
|
||||
COACH_PROVIDER, PPLX_MODEL,
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD, ensure_dirs,
|
||||
COACH_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD, DEFAULT_MAX_TOKENS, ensure_dirs,
|
||||
)
|
||||
from .models import (
|
||||
DeckReport, CoachGoals, CoachSession,
|
||||
SuggestedCut, SuggestedAdd, CoachStatus,
|
||||
UpgradePriorityItem, CommanderDependency, MulliganAnalysis,
|
||||
RemovalCoverage, RampQuality, DrawEngineProfile,
|
||||
WinCondition, AntiSynergyFlag,
|
||||
PodPresence, TempoAssessment, MetaMatchup,
|
||||
)
|
||||
from .llm_client import LLMClient
|
||||
from .embeddings import MTGEmbeddingIndex
|
||||
@@ -61,23 +64,18 @@ class CoachService:
|
||||
self.embeddings = embeddings
|
||||
ensure_dirs()
|
||||
|
||||
# ── Deck Report I/O ────────────────────────────────────────
|
||||
|
||||
# ── Deck Report I/O ──────────────────────
|
||||
def load_deck_report(self, deck_id: str) -> Optional[DeckReport]:
|
||||
"""Load a deck report from disk."""
|
||||
# Try exact filename first, then case-insensitive search
|
||||
report_path = DECK_REPORTS_DIR / f"{deck_id}.json"
|
||||
if not report_path.exists():
|
||||
# Try case-insensitive match
|
||||
for f in DECK_REPORTS_DIR.glob("*.json"):
|
||||
if re.sub(r'[^a-z0-9]', '', f.stem.lower()) == re.sub(r'[^a-z0-9]', '', deck_id.lower()):
|
||||
report_path = f
|
||||
break
|
||||
|
||||
if not report_path.exists():
|
||||
logger.warning("Deck report not found: %s", deck_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(report_path, "r") as f:
|
||||
data = json.load(f)
|
||||
@@ -92,16 +90,13 @@ class CoachService:
|
||||
return []
|
||||
return [f.stem for f in DECK_REPORTS_DIR.glob("*.json")]
|
||||
|
||||
# ── Coach Session I/O ──────────────────────────────────────
|
||||
|
||||
# ── Coach Session I/O ──────────────────
|
||||
def save_session(self, session: CoachSession) -> Path:
|
||||
"""Persist a coaching session to disk and SQLite."""
|
||||
# 1. Write JSON file (backward compat)
|
||||
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
|
||||
with open(path, "w") as f:
|
||||
f.write(session.model_dump_json(indent=2))
|
||||
# 2. Write to SQLite
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
_db_save_session({
|
||||
@@ -124,10 +119,8 @@ class CoachService:
|
||||
logger.warning("Failed to persist session to SQLite: %s", e)
|
||||
return path
|
||||
|
||||
|
||||
def load_session(self, session_id: str) -> Optional[CoachSession]:
|
||||
"""Load a coaching session from SQLite, falling back to disk."""
|
||||
# Try SQLite first
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
row = _db_load_session(session_id)
|
||||
@@ -150,7 +143,6 @@ class CoachService:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("SQLite load failed for %s: %s", session_id, e)
|
||||
# Fallback to disk
|
||||
path = COACH_SESSIONS_DIR / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -162,10 +154,8 @@ class CoachService:
|
||||
logger.error("Failed to load session %s: %s", session_id, e)
|
||||
return None
|
||||
|
||||
|
||||
def list_sessions(self, deck_id: str = None) -> List[dict]:
|
||||
"""List coaching sessions from SQLite, falling back to disk."""
|
||||
# Try SQLite first
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
rows = _db_list_sessions(deck_id)
|
||||
@@ -173,7 +163,6 @@ class CoachService:
|
||||
return rows
|
||||
except Exception as e:
|
||||
logger.warning("SQLite list_sessions failed: %s", e)
|
||||
# Fallback to disk
|
||||
if not COACH_SESSIONS_DIR.exists():
|
||||
return []
|
||||
sessions = []
|
||||
@@ -195,24 +184,23 @@ class CoachService:
|
||||
continue
|
||||
return sessions
|
||||
|
||||
|
||||
# ── Main Coaching Pipeline ─────────────────────────────────
|
||||
|
||||
async def run_coaching_session(self, deck_id: str,
|
||||
goals: Optional[CoachGoals] = None,
|
||||
fallback_report: Optional[DeckReport] = None,
|
||||
) -> CoachSession:
|
||||
# ── Main Coaching Pipeline ───────────────────────
|
||||
async def run_coaching_session(
|
||||
self,
|
||||
deck_id: str,
|
||||
goals: Optional[CoachGoals] = None,
|
||||
fallback_report: Optional[DeckReport] = None,
|
||||
) -> CoachSession:
|
||||
"""
|
||||
Full coaching pipeline:
|
||||
1. Load deck report (or use fallback from DB card list)
|
||||
2. Identify underperformers
|
||||
3. Find replacement candidates via embeddings
|
||||
4. Build prompt
|
||||
5. Call LLM
|
||||
5. Call LLM (streaming for Anthropic to avoid 10-min SDK timeout)
|
||||
6. Parse response
|
||||
7. Persist session
|
||||
"""
|
||||
# 0. Auto-load embeddings if not loaded
|
||||
if not self.embeddings.loaded:
|
||||
logger.info("Embeddings not loaded — attempting auto-load...")
|
||||
try:
|
||||
@@ -224,7 +212,6 @@ class CoachService:
|
||||
except Exception as e:
|
||||
logger.warning("Embeddings auto-load failed: %s", e)
|
||||
|
||||
# 1. Load deck report — try simulation report first, then fallback
|
||||
report = self.load_deck_report(deck_id)
|
||||
if report is None and fallback_report is not None:
|
||||
report = fallback_report
|
||||
@@ -232,25 +219,17 @@ class CoachService:
|
||||
if report is None:
|
||||
raise ValueError(f"Deck report not found for: {deck_id}")
|
||||
|
||||
# 2. Get deck card names (for exclusion from candidates)
|
||||
deck_card_names = [c.name for c in report.cards]
|
||||
|
||||
# 3. Find replacement candidates for underperformers
|
||||
candidates: Dict[str, List[dict]] = {}
|
||||
|
||||
underperformers = report.underperformers[:MAX_UNDERPERFORMERS]
|
||||
if not underperformers:
|
||||
# If no explicit underperformers, pick lowest impact cards
|
||||
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
|
||||
underperformers = [
|
||||
c.name for c in sorted_cards[:MAX_UNDERPERFORMERS]
|
||||
if c.impactScore < UNDERPERFORMER_IMPACT_THRESHOLD
|
||||
]
|
||||
|
||||
# If still no underperformers and we have cards, pick bottom N by impact
|
||||
if not underperformers and report.cards:
|
||||
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
|
||||
# Take the bottom 5 non-land cards as candidates for cuts
|
||||
underperformers = [
|
||||
c.name for c in sorted_cards[:5]
|
||||
if not any(t in (c.tags or []) for t in [])
|
||||
@@ -269,17 +248,81 @@ class CoachService:
|
||||
else:
|
||||
logger.warning("Embeddings not loaded — skipping candidate search")
|
||||
|
||||
# 4. Build prompts
|
||||
# Phase 4a: behavior-aware RAG — primary + replacement-effect secondary query
|
||||
rag_cards: List[dict] = []
|
||||
try:
|
||||
from services.rag_store import query_cards
|
||||
# Primary: commander synergy context
|
||||
base_query = f"{report.commander} {' '.join(report.colorIdentity)} commander deck"
|
||||
rag_cards = query_cards(base_query, n_results=10, color_identity=report.colorIdentity)
|
||||
# Secondary: replacement effects / graveyard hate / protection
|
||||
if len(rag_cards) < 15:
|
||||
repl_cards = query_cards(
|
||||
f"replacement effect graveyard {' '.join(report.colorIdentity)}",
|
||||
n_results=5,
|
||||
color_identity=report.colorIdentity,
|
||||
require_replacement=True,
|
||||
)
|
||||
# Deduplicate by name before merging
|
||||
existing_names = {c["name"] for c in rag_cards}
|
||||
rag_cards += [c for c in repl_cards if c["name"] not in existing_names]
|
||||
logger.info("RAG returned %d cards for '%s'", len(rag_cards), deck_id)
|
||||
except Exception as e:
|
||||
logger.warning("RAG query failed (non-fatal): %s", e)
|
||||
|
||||
# Phase 4b: rules context for grounding the coach prompt
|
||||
rules_context: List[dict] = []
|
||||
try:
|
||||
from services.rag_store import query_rules
|
||||
rules_context = query_rules(
|
||||
f"Commander rules {report.commander} {' '.join(report.colorIdentity)}",
|
||||
n_results=3,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
system_prompt = build_system_prompt(report, goals)
|
||||
user_prompt = build_user_prompt(report, candidates)
|
||||
user_prompt = build_user_prompt(report, candidates, rag_cards=rag_cards, rules_context=rules_context)
|
||||
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
import anthropic as _anthropic
|
||||
anthropic_key = ANTHROPIC_API_KEY
|
||||
if not anthropic_key:
|
||||
raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.")
|
||||
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
|
||||
async with aclient.messages.stream(
|
||||
model=ANTHROPIC_MODEL,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
temperature=0.7,
|
||||
) as stream:
|
||||
content = await stream.get_final_text()
|
||||
final_msg = await stream.get_final_message()
|
||||
usage = getattr(final_msg, "usage", None)
|
||||
from types import SimpleNamespace
|
||||
llm_response = SimpleNamespace(
|
||||
content=content,
|
||||
model=final_msg.model,
|
||||
prompt_tokens=usage.input_tokens if usage else 0,
|
||||
completion_tokens=usage.output_tokens if usage else 0,
|
||||
parsed_json=None,
|
||||
)
|
||||
try:
|
||||
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
|
||||
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
|
||||
llm_response.parsed_json = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
match = re.search(r'\{[\s\S]*\}', content)
|
||||
if match:
|
||||
try:
|
||||
llm_response.parsed_json = json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
llm_response = await self.llm.chat(system_prompt, user_prompt)
|
||||
|
||||
# 5. Call LLM
|
||||
logger.info("Calling LLM for deck: %s", deck_id)
|
||||
llm_response = await self.llm.chat(system_prompt, user_prompt)
|
||||
|
||||
# 6. Parse response into CoachSession
|
||||
session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
||||
|
||||
session = CoachSession(
|
||||
sessionId=session_id,
|
||||
deckId=deck_id,
|
||||
@@ -289,31 +332,109 @@ class CoachService:
|
||||
completionTokens=llm_response.completion_tokens,
|
||||
goals=goals,
|
||||
)
|
||||
|
||||
if llm_response.parsed_json:
|
||||
self._populate_session_from_json(session, llm_response.parsed_json)
|
||||
else:
|
||||
# Fallback: store raw text
|
||||
session.rawTextExplanation = llm_response.content
|
||||
session.summary = "LLM returned non-JSON response. See raw explanation."
|
||||
|
||||
# 7. Persist
|
||||
self.save_session(session)
|
||||
logger.info("Coaching session saved: %s", session_id)
|
||||
|
||||
return session
|
||||
|
||||
# ── Quick Digest ──────────────────────────────
|
||||
async def run_quick_digest(
|
||||
self,
|
||||
deck_id: str,
|
||||
goals: Optional[CoachGoals] = None,
|
||||
fallback_report: Optional[DeckReport] = None,
|
||||
) -> CoachSession:
|
||||
"""Fast ~400-600 token call: summary + top 3 upgradePriority + commanderDependency."""
|
||||
report = self.load_deck_report(deck_id)
|
||||
if report is None and fallback_report is not None:
|
||||
report = fallback_report
|
||||
if report is None:
|
||||
raise ValueError(f"Deck report not found for: {deck_id}")
|
||||
|
||||
# Phase 4a: light RAG query for quick digest
|
||||
rag_cards: List[dict] = []
|
||||
try:
|
||||
from services.rag_store import query_cards
|
||||
query_text = f"{report.commander} {' '.join(report.colorIdentity)}"
|
||||
rag_cards = query_cards(query_text, n_results=8, color_identity=report.colorIdentity)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from .prompt_template import build_quick_system_prompt, build_user_prompt
|
||||
system_prompt = build_quick_system_prompt(report, goals)
|
||||
user_prompt = build_user_prompt(report, {}, rag_cards=rag_cards) # no candidates for speed
|
||||
logger.info("Quick digest for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
|
||||
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
import anthropic as _anthropic
|
||||
anthropic_key = ANTHROPIC_API_KEY
|
||||
if not anthropic_key:
|
||||
raise ConnectionError("Anthropic API key not configured.")
|
||||
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
|
||||
async with aclient.messages.stream(
|
||||
model=ANTHROPIC_MODEL,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
max_tokens=1024,
|
||||
temperature=0.5,
|
||||
) as stream:
|
||||
content = await stream.get_final_text()
|
||||
final_msg = await stream.get_final_message()
|
||||
usage = getattr(final_msg, "usage", None)
|
||||
from types import SimpleNamespace
|
||||
llm_response = SimpleNamespace(
|
||||
content=content,
|
||||
model=final_msg.model,
|
||||
prompt_tokens=usage.input_tokens if usage else 0,
|
||||
completion_tokens=usage.output_tokens if usage else 0,
|
||||
parsed_json=None,
|
||||
)
|
||||
try:
|
||||
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
|
||||
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
|
||||
llm_response.parsed_json = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
match = re.search(r'\{[\s\S]*\}', content)
|
||||
if match:
|
||||
try:
|
||||
llm_response.parsed_json = json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
llm_response = await self.llm.chat(system_prompt, user_prompt)
|
||||
|
||||
session_id = f"quick-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
||||
session = CoachSession(
|
||||
sessionId=session_id,
|
||||
deckId=deck_id,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
modelUsed=llm_response.model,
|
||||
promptTokens=llm_response.prompt_tokens,
|
||||
completionTokens=llm_response.completion_tokens,
|
||||
goals=goals,
|
||||
isQuickDigest=True,
|
||||
)
|
||||
if llm_response.parsed_json:
|
||||
self._populate_session_from_json(session, llm_response.parsed_json)
|
||||
else:
|
||||
session.rawTextExplanation = llm_response.content
|
||||
session.summary = "Quick digest returned non-JSON. See raw explanation."
|
||||
self.save_session(session)
|
||||
return session
|
||||
|
||||
# ── JSON Parser ───────────────────────────────
|
||||
def _populate_session_from_json(self, session: CoachSession, data: dict):
|
||||
"""Parse LLM JSON response into CoachSession fields."""
|
||||
session.summary = data.get("summary", "")
|
||||
session.rawTextExplanation = data.get("rawTextExplanation", "")
|
||||
session.manaBaseAdvice = data.get("manaBaseAdvice")
|
||||
|
||||
# Heuristic hints
|
||||
hints = data.get("heuristicHints", [])
|
||||
session.heuristicHints = hints if isinstance(hints, list) else [str(hints)]
|
||||
|
||||
# Suggested cuts
|
||||
for cut_data in data.get("suggestedCuts", []):
|
||||
if isinstance(cut_data, dict):
|
||||
session.suggestedCuts.append(SuggestedCut(
|
||||
@@ -321,8 +442,6 @@ class CoachService:
|
||||
reason=cut_data.get("reason", ""),
|
||||
replacementOptions=cut_data.get("replacementOptions", []),
|
||||
))
|
||||
|
||||
# Suggested adds
|
||||
for add_data in data.get("suggestedAdds", []):
|
||||
if isinstance(add_data, dict):
|
||||
session.suggestedAdds.append(SuggestedAdd(
|
||||
@@ -331,25 +450,104 @@ class CoachService:
|
||||
reason=add_data.get("reason", ""),
|
||||
synergyWith=add_data.get("synergyWith", []),
|
||||
))
|
||||
for item in data.get("upgradePriority", []):
|
||||
if isinstance(item, dict):
|
||||
session.upgradePriority.append(UpgradePriorityItem(
|
||||
rank=item.get("rank", 0),
|
||||
cut=item.get("cut", ""),
|
||||
add=item.get("add", ""),
|
||||
reasoning=item.get("reasoning", ""),
|
||||
expectedImpact=item.get("expectedImpact", ""),
|
||||
))
|
||||
cd = data.get("commanderDependency")
|
||||
if isinstance(cd, dict):
|
||||
session.commanderDependency = CommanderDependency(
|
||||
score=cd.get("score", 5),
|
||||
dependentCards=cd.get("dependentCards", []),
|
||||
recoveryPlan=cd.get("recoveryPlan", ""),
|
||||
)
|
||||
ma = data.get("mulliganAnalysis")
|
||||
if isinstance(ma, dict):
|
||||
session.mulliganAnalysis = MulliganAnalysis(
|
||||
estimatedKeepRate=ma.get("estimatedKeepRate", ""),
|
||||
worstOffenders=ma.get("worstOffenders", []),
|
||||
recommendation=ma.get("recommendation", ""),
|
||||
)
|
||||
rc = data.get("removalCoverage")
|
||||
if isinstance(rc, dict):
|
||||
session.removalCoverage = RemovalCoverage(**{
|
||||
k: rc.get(k, "" if k != "gaps" else [])
|
||||
for k in ["creatures", "artifacts", "enchantments", "planeswalkers", "lands", "massRemoval", "gaps"]
|
||||
})
|
||||
rq = data.get("rampQuality")
|
||||
if isinstance(rq, dict):
|
||||
session.rampQuality = RampQuality(**{
|
||||
k: rq.get(k, "") for k in ["manaRocks", "landFetch", "manaDorks", "costReducers", "fragility", "canReachFourByTurnThree"]
|
||||
})
|
||||
dep = data.get("drawEngineProfile")
|
||||
if isinstance(dep, dict):
|
||||
session.drawEngineProfile = DrawEngineProfile(
|
||||
burstDraw=dep.get("burstDraw", []),
|
||||
repeatableEngines=dep.get("repeatableEngines", []),
|
||||
assessment=dep.get("assessment", ""),
|
||||
sustainability=dep.get("sustainability", ""),
|
||||
)
|
||||
for wc in data.get("winConditions", []):
|
||||
if isinstance(wc, dict):
|
||||
session.winConditions.append(WinCondition(
|
||||
name=wc.get("name", ""),
|
||||
cards=wc.get("cards", []),
|
||||
independence=wc.get("independence", ""),
|
||||
description=wc.get("description", ""),
|
||||
))
|
||||
for af in data.get("antiSynergyFlags", []):
|
||||
if isinstance(af, dict):
|
||||
session.antiSynergyFlags.append(AntiSynergyFlag(
|
||||
cards=af.get("cards", []),
|
||||
conflict=af.get("conflict", ""),
|
||||
severity=af.get("severity", ""),
|
||||
))
|
||||
pp = data.get("podPresence")
|
||||
if isinstance(pp, dict):
|
||||
session.podPresence = PodPresence(
|
||||
threatLevel=pp.get("threatLevel", ""),
|
||||
politicalTools=pp.get("politicalTools", []),
|
||||
adversarialRating=pp.get("adversarialRating", ""),
|
||||
recommendation=pp.get("recommendation", ""),
|
||||
)
|
||||
ta = data.get("tempoAssessment")
|
||||
if isinstance(ta, dict):
|
||||
session.tempoAssessment = TempoAssessment(
|
||||
peakTurn=ta.get("peakTurn", 0),
|
||||
profile=ta.get("profile", ""),
|
||||
survivalWindow=ta.get("survivalWindow", ""),
|
||||
assessment=ta.get("assessment", ""),
|
||||
)
|
||||
for mm in data.get("metaMatchups", []):
|
||||
if isinstance(mm, dict):
|
||||
session.metaMatchups.append(MetaMatchup(
|
||||
archetype=mm.get("archetype", ""),
|
||||
assessment=mm.get("assessment", ""),
|
||||
keyThreats=mm.get("keyThreats", []),
|
||||
keyAnswers=mm.get("keyAnswers", []),
|
||||
overallRating=mm.get("overallRating", ""),
|
||||
))
|
||||
|
||||
# ── Status Check ───────────────────────────────────────────
|
||||
|
||||
# ── Status Check ──────────────────────────────
|
||||
def get_status(self) -> CoachStatus:
|
||||
"""Check the health of all coach subsystems."""
|
||||
status = CoachStatus()
|
||||
|
||||
# Check LLM / Perplexity provider
|
||||
if COACH_PROVIDER == "perplexity":
|
||||
pplx_key = os.environ.get("PPLX_API_KEY", "")
|
||||
if pplx_key:
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
anthropic_key = ANTHROPIC_API_KEY
|
||||
if anthropic_key:
|
||||
status.llmConnected = True
|
||||
status.llmModel = f"Perplexity ({PPLX_MODEL})"
|
||||
status.llmModels = [PPLX_MODEL]
|
||||
status.llmModel = f"Claude Opus ({ANTHROPIC_MODEL})"
|
||||
status.llmModels = [ANTHROPIC_MODEL]
|
||||
else:
|
||||
status.llmConnected = False
|
||||
status.llmModel = None
|
||||
status.llmModels = []
|
||||
status.error = "Perplexity API key not configured. Set PPLX_API_KEY env var."
|
||||
status.error = "Anthropic API key not configured. Set ANTHROPIC_API_KEY env var."
|
||||
else:
|
||||
llm_status = self.llm.check_connection()
|
||||
status.llmConnected = llm_status.get("connected", False)
|
||||
@@ -357,12 +555,7 @@ class CoachService:
|
||||
status.llmModels = llm_status.get("models", [])
|
||||
if not status.llmConnected:
|
||||
status.error = llm_status.get("error", "Local LLM not reachable")
|
||||
|
||||
# Check embeddings
|
||||
status.embeddingsLoaded = self.embeddings.loaded
|
||||
status.embeddingCards = self.embeddings.card_count
|
||||
|
||||
# Check deck reports
|
||||
status.deckReportsAvailable = len(self.list_deck_reports())
|
||||
|
||||
return status
|
||||
+28
-29
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Commander AI Lab — Coach Configuration
|
||||
═══════════════════════════════════════
|
||||
────────────────────────────────────────
|
||||
Central config for Ollama, embeddings, and coach service paths.
|
||||
All paths are relative to the commander-ai-lab root directory.
|
||||
"""
|
||||
@@ -8,61 +8,60 @@ All paths are relative to the commander-ai-lab root directory.
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# ── Base Paths ──────────────────────────────────────────────
|
||||
# Resolve relative to wherever the lab is installed
|
||||
# ── Base Paths ───────────────────────────────────────────────────────
|
||||
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
|
||||
|
||||
# ── Ollama (OpenAI-compatible API) ──────────────────────
|
||||
# ── Ollama (OpenAI-compatible API) ────────────────────────────
|
||||
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
|
||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
|
||||
LLM_MAX_RETRIES = 3
|
||||
|
||||
# ── Embeddings ─────────────────────────────────────────────
|
||||
# ── Embeddings ───────────────────────────────────────────────────
|
||||
EMBEDDINGS_DIR = LAB_ROOT / "data"
|
||||
EMBEDDINGS_NPZ = EMBEDDINGS_DIR / "mtg-embeddings.npz"
|
||||
EMBEDDINGS_PARQUET = EMBEDDINGS_DIR / "mtg-embeddings.parquet"
|
||||
|
||||
# ── Deck Reports ───────────────────────────────────────────
|
||||
# ── Deck Reports ────────────────────────────────────────────────
|
||||
DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
|
||||
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
|
||||
|
||||
# ── Prompt Limits ──────────────────────────────────────────
|
||||
# ── Prompt Limits ───────────────────────────────────────────────
|
||||
MAX_PROMPT_TOKENS = 2048
|
||||
MAX_CANDIDATES_PER_UNDERPERFORMER = 5
|
||||
MAX_UNDERPERFORMERS = 4
|
||||
MAX_CANDIDATES_PER_UNDERPERFORMER = 8
|
||||
MAX_UNDERPERFORMERS = 8
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
|
||||
|
||||
# ── LLM Generation Settings (Coach — Ollama) ──────────────
|
||||
# ── LLM Generation Settings (Coach — Ollama fallback) ─────────────
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
|
||||
# so we need a generous limit to avoid truncation
|
||||
DEFAULT_MAX_TOKENS = 8192
|
||||
DEFAULT_MAX_TOKENS = 16384
|
||||
|
||||
# ── Perplexity Deck Generation Settings ───────────────────────
|
||||
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
|
||||
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
|
||||
# Perplexity cloud model (sonar / sonar-pro / sonar-deep-research)
|
||||
PPLX_MODEL = os.environ.get("PPLX_MODEL", "sonar-pro")
|
||||
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
|
||||
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "local")
|
||||
# ── Anthropic Claude Deck Generation Settings ──────────────────────
|
||||
# Provider for deck generation: "anthropic" (Claude) or "local" (Ollama)
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "anthropic")
|
||||
# Model used when DECK_GEN_PROVIDER="anthropic"
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "claude-opus-4-20250514")
|
||||
# Anthropic API key
|
||||
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
# Anthropic model for deck generation and coaching
|
||||
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-20250514")
|
||||
# Coach chat provider: "anthropic" (Claude API) or "local" (Ollama)
|
||||
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "anthropic")
|
||||
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://localhost:11434/v1")
|
||||
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
|
||||
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
|
||||
|
||||
# ── Smart Substitution Settings ───────────────────────────────
|
||||
# Minimum embedding similarity to accept a substitute without Perplexity fallback
|
||||
# ── Smart Substitution Settings ───────────────────────────────────
|
||||
# Minimum embedding similarity to accept a substitute without Anthropic fallback
|
||||
SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY", "0.75"))
|
||||
# Maximum number of alternatives to suggest per missing card
|
||||
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
|
||||
# Model for substitution fallback (sonar is fine — small focused queries)
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "gpt-oss:20b")
|
||||
# Enable Perplexity fallback for low-confidence embedding matches
|
||||
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
|
||||
# Model for substitution fallback (claude-haiku-4-5 is fast + cheap for focused queries)
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "claude-haiku-4-5")
|
||||
# Enable Anthropic fallback for low-confidence embedding matches
|
||||
SUBSTITUTION_USE_ANTHROPIC_FALLBACK = os.environ.get("SUBSTITUTION_USE_ANTHROPIC_FALLBACK", "true").lower() == "true"
|
||||
|
||||
# ── Ensure directories exist ───────────────────────────────
|
||||
# ── Ensure directories exist ─────────────────────────────────────
|
||||
def ensure_dirs():
|
||||
"""Create required directories if they don't exist."""
|
||||
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
+3
-11
@@ -21,7 +21,6 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.request import urlopen, Request
|
||||
@@ -29,6 +28,7 @@ from urllib.parse import quote
|
||||
|
||||
import numpy as np
|
||||
|
||||
from services.card_text import build_card_text
|
||||
from .config import EMBEDDINGS_DIR, EMBEDDINGS_NPZ, EMBEDDINGS_PARQUET
|
||||
|
||||
logger = logging.getLogger("coach.embeddings")
|
||||
@@ -115,15 +115,8 @@ def _fetch_scryfall_card(card_name: str) -> Optional[dict]:
|
||||
|
||||
|
||||
def _build_card_text(card: dict) -> str:
|
||||
"""Build the text string to embed, matching minimaxir/mtg-embeddings format."""
|
||||
parts = [card["name"]]
|
||||
if card.get("mana_cost"):
|
||||
parts.append(card["mana_cost"])
|
||||
if card.get("type_line"):
|
||||
parts.append(card["type_line"])
|
||||
if card.get("oracle_text"):
|
||||
parts.append(card["oracle_text"])
|
||||
return " | ".join(parts)
|
||||
"""Delegate to shared build_card_text() -- see services/card_text.py."""
|
||||
return build_card_text(card)
|
||||
|
||||
|
||||
class CardMatch:
|
||||
@@ -331,7 +324,6 @@ class MTGEmbeddingIndex:
|
||||
d = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
|
||||
for n in d["names"]:
|
||||
existing_names.add(n.lower())
|
||||
existing = dict(d)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
+127
-130
@@ -1,22 +1,25 @@
|
||||
"""
|
||||
Commander AI Lab — Ollama Client
|
||||
Commander AI Lab — Ollama LLM Client
|
||||
════════════════════════════════════
|
||||
Async HTTP client targeting Ollama's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Includes retry logic for
|
||||
non-JSON responses and configurable timeout.
|
||||
Fully async HTTP client targeting Ollama's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Every network call uses
|
||||
httpx.AsyncClient so it NEVER blocks the FastAPI event loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
LLM_URL, LLM_MODEL,
|
||||
LLM_TIMEOUT, LLM_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
|
||||
LLM_URL,
|
||||
LLM_MODEL,
|
||||
LLM_TIMEOUT,
|
||||
LLM_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("coach.llm")
|
||||
@@ -24,9 +27,15 @@ logger = logging.getLogger("coach.llm")
|
||||
|
||||
class LLMResponse:
|
||||
"""Parsed LLM response container."""
|
||||
def __init__(self, content: str, parsed_json: Optional[dict],
|
||||
prompt_tokens: int, completion_tokens: int,
|
||||
model: str):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
parsed_json: Optional[dict],
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
model: str,
|
||||
):
|
||||
self.content = content
|
||||
self.parsed_json = parsed_json
|
||||
self.prompt_tokens = prompt_tokens
|
||||
@@ -36,7 +45,7 @@ class LLMResponse:
|
||||
|
||||
class LLMClient:
|
||||
"""
|
||||
Client for Ollama's OpenAI-compatible API.
|
||||
Fully async client for Ollama's OpenAI-compatible API.
|
||||
|
||||
Usage:
|
||||
client = LLMClient()
|
||||
@@ -45,19 +54,25 @@ class LLMClient:
|
||||
suggestions = response.parsed_json
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = None, model: str = None,
|
||||
timeout: int = None):
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = None,
|
||||
model: str = None,
|
||||
timeout: int = None,
|
||||
):
|
||||
self.base_url = (base_url or LLM_URL).rstrip("/")
|
||||
self.model = model or LLM_MODEL
|
||||
self.timeout = timeout or LLM_TIMEOUT
|
||||
self._resolved_model = None # actual model name from /models endpoint
|
||||
self._resolved_model: Optional[str] = None
|
||||
|
||||
def _resolve_model(self) -> str:
|
||||
# ── Model resolution ───────────────────────────────────────
|
||||
|
||||
async def _resolve_model(self) -> str:
|
||||
"""Get the actual loaded model name from Ollama /models endpoint."""
|
||||
if self._resolved_model:
|
||||
return self._resolved_model
|
||||
try:
|
||||
status = self.check_connection()
|
||||
status = await self.check_connection()
|
||||
if status.get("connected") and status.get("active_model"):
|
||||
self._resolved_model = status["active_model"]
|
||||
logger.info("Resolved Ollama model: %s", self._resolved_model)
|
||||
@@ -66,11 +81,18 @@ class LLMClient:
|
||||
pass
|
||||
return self.model
|
||||
|
||||
def _build_request_body(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float, max_tokens: int,
|
||||
force_json: bool) -> dict:
|
||||
model_name = self._resolve_model()
|
||||
body = {
|
||||
# ── Request building ──────────────────────────────────────
|
||||
|
||||
async def _build_request_body(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
force_json: bool,
|
||||
) -> dict:
|
||||
model_name = await self._resolve_model()
|
||||
body: dict = {
|
||||
"model": model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
@@ -79,32 +101,27 @@ class LLMClient:
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
# Only request JSON format if using a model that supports it
|
||||
# Some models (esp. smaller ones) don't support response_format
|
||||
if force_json:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
return body
|
||||
|
||||
# ── Response parsing ──────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _strip_think_tags(text: str) -> str:
|
||||
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks from LLM output."""
|
||||
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks."""
|
||||
import re
|
||||
# Remove all <think>...</think> blocks (greedy, handles multiline)
|
||||
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
|
||||
return cleaned.strip()
|
||||
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
|
||||
|
||||
@staticmethod
|
||||
def _extract_json_object(text: str) -> Optional[dict]:
|
||||
"""Find and parse the first complete JSON object in text.
|
||||
Uses brace-counting to handle nested objects reliably."""
|
||||
"""Find and parse the first complete JSON object via brace-counting."""
|
||||
start = text.find('{')
|
||||
if start == -1:
|
||||
return None
|
||||
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
@@ -124,66 +141,56 @@ class LLMClient:
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidate = text[start:i+1]
|
||||
candidate = text[start : i + 1]
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Malformed — try next opening brace
|
||||
next_start = text.find('{', start + 1)
|
||||
if next_start != -1 and next_start < i:
|
||||
start = next_start
|
||||
depth = 1 # we're already past this brace
|
||||
return None
|
||||
depth = 1
|
||||
return None
|
||||
return None
|
||||
|
||||
def _parse_response(self, raw: dict) -> LLMResponse:
|
||||
"""Extract content and usage from OpenAI-format response.
|
||||
Handles DeepSeek-R1 <think> tags, markdown fences, and raw JSON."""
|
||||
"""Extract content and usage from an OpenAI-format response."""
|
||||
choice = raw.get("choices", [{}])[0]
|
||||
content = choice.get("message", {}).get("content", "")
|
||||
usage = raw.get("usage", {})
|
||||
|
||||
# Step 1: Strip <think>...</think> blocks (DeepSeek-R1)
|
||||
cleaned = self._strip_think_tags(content)
|
||||
|
||||
# Step 2: Try direct JSON parse on cleaned content
|
||||
parsed = None
|
||||
parsed: Optional[dict] = None
|
||||
# 1. Direct JSON parse
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Step 3: Try extracting from markdown fences
|
||||
# 2. Markdown fences
|
||||
if parsed is None and "```json" in cleaned:
|
||||
try:
|
||||
start = cleaned.index("```json") + 7
|
||||
end = cleaned.index("```", start)
|
||||
parsed = json.loads(cleaned[start:end].strip())
|
||||
s = cleaned.index("```json") + 7
|
||||
e = cleaned.index("```", s)
|
||||
parsed = json.loads(cleaned[s:e].strip())
|
||||
except (json.JSONDecodeError, ValueError, IndexError):
|
||||
pass
|
||||
if parsed is None and "```" in cleaned:
|
||||
try:
|
||||
start = cleaned.index("```") + 3
|
||||
end = cleaned.index("```", start)
|
||||
parsed = json.loads(cleaned[start:end].strip())
|
||||
s = cleaned.index("```") + 3
|
||||
e = cleaned.index("```", s)
|
||||
parsed = json.loads(cleaned[s:e].strip())
|
||||
except (json.JSONDecodeError, ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Step 4: Brute-force — find the first valid JSON object in the text
|
||||
# 3. Brace-counting extraction
|
||||
if parsed is None:
|
||||
parsed = self._extract_json_object(cleaned)
|
||||
|
||||
# Step 5: If still nothing, try on the original content (in case
|
||||
# think-tag stripping was too aggressive)
|
||||
# 4. Fallback on original content
|
||||
if parsed is None and content != cleaned:
|
||||
parsed = self._extract_json_object(content)
|
||||
|
||||
if parsed is not None:
|
||||
logger.info("Successfully parsed JSON from LLM response")
|
||||
else:
|
||||
logger.warning("Could not extract JSON from LLM response (%d chars)",
|
||||
len(content))
|
||||
|
||||
logger.warning(
|
||||
"Could not extract JSON from LLM response (%d chars)", len(content)
|
||||
)
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
parsed_json=parsed,
|
||||
@@ -192,94 +199,84 @@ class LLMClient:
|
||||
model=raw.get("model", self.model),
|
||||
)
|
||||
|
||||
def chat_sync(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
# ── Public API ─────────────────────────────────────────────
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float = None,
|
||||
max_tokens: int = None,
|
||||
force_json: bool = True,
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
Synchronous chat call to Ollama.
|
||||
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
|
||||
Async chat call to Ollama. Retries up to LLM_MAX_RETRIES.
|
||||
Never blocks the event loop — uses httpx.AsyncClient throughout.
|
||||
"""
|
||||
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
||||
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
||||
body = self._build_request_body(system_prompt, user_prompt,
|
||||
temp, tokens, force_json)
|
||||
|
||||
last_error = None
|
||||
body = await self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, force_json
|
||||
)
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||
try:
|
||||
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
|
||||
req = Request(
|
||||
f"{self.base_url}/chat/completions",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(float(self.timeout))
|
||||
) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
result = self._parse_response(raw)
|
||||
|
||||
# If we need JSON and didn't get it, retry
|
||||
if force_json and result.parsed_json is None:
|
||||
logger.warning("Attempt %d: LLM returned non-JSON, retrying", attempt)
|
||||
logger.warning(
|
||||
"Attempt %d: LLM returned non-JSON, retrying", attempt
|
||||
)
|
||||
last_error = ValueError("Non-JSON response from LLM")
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning("Attempt %d HTTP error %s", attempt, e.response.status_code)
|
||||
last_error = e
|
||||
# 400 with json format → retry without response_format constraint
|
||||
if e.response.status_code == 400 and force_json:
|
||||
logger.warning("Got 400 — retrying without response_format")
|
||||
force_json = False
|
||||
body = await self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Attempt %d failed: %s", attempt, e)
|
||||
last_error = e
|
||||
|
||||
# If 400 and we used json response_format, retry without it
|
||||
error_str = str(e)
|
||||
if "400" in error_str and force_json:
|
||||
logger.warning("Got 400 — retrying without response_format constraint")
|
||||
force_json = False
|
||||
body = self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False)
|
||||
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
import time
|
||||
time.sleep(2 * attempt) # Simple backoff
|
||||
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
import asyncio
|
||||
await asyncio.sleep(2 * attempt)
|
||||
raise ConnectionError(
|
||||
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
|
||||
)
|
||||
|
||||
async def chat(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
"""
|
||||
Async chat call — runs the sync call in a thread executor
|
||||
so it doesn't block the FastAPI event loop.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.chat_sync(system_prompt, user_prompt,
|
||||
temperature, max_tokens, force_json)
|
||||
)
|
||||
|
||||
def check_connection(self) -> dict:
|
||||
"""
|
||||
Check if Ollama is reachable. Returns status dict.
|
||||
"""
|
||||
async def check_connection(self) -> dict:
|
||||
"""Check if Ollama is reachable. Returns status dict."""
|
||||
try:
|
||||
req = Request(
|
||||
f"{self.base_url}/models",
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="GET",
|
||||
)
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
models = data.get("data", [])
|
||||
model_ids = [m.get("id", "unknown") for m in models]
|
||||
return {
|
||||
"connected": True,
|
||||
"models": model_ids,
|
||||
"active_model": model_ids[0] if model_ids else self.model,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/models",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
models = data.get("data", [])
|
||||
model_ids = [m.get("id", "unknown") for m in models]
|
||||
return {
|
||||
"connected": True,
|
||||
"models": model_ids,
|
||||
"active_model": model_ids[0] if model_ids else self.model,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"connected": False,
|
||||
|
||||
+140
-17
@@ -4,12 +4,9 @@ Commander AI Lab — Coach Data Models
|
||||
Pydantic v2 models for deck reports, coach requests/responses,
|
||||
and coaching session persistence.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Deck Report Models (mirrors Java DeckReport)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -17,16 +14,16 @@ from pydantic import BaseModel, Field
|
||||
class CardPerformance(BaseModel):
|
||||
"""Per-card aggregated stats from simulation data."""
|
||||
name: str
|
||||
drawnRate: float = 0.0 # Fraction of games where card was drawn
|
||||
castRate: float = 0.0 # Fraction of games where drawn AND cast
|
||||
drawnRate: float = 0.0
|
||||
castRate: float = 0.0
|
||||
keptInOpeningHandRate: float = 0.0
|
||||
deadCardRate: float = 0.0 # Drawn but never cast/used
|
||||
impactScore: float = 0.0 # (winRateWhenCast - overallWinRate) * castRate
|
||||
synergyScore: float = 0.0 # Co-occurrence uplift with other deck cards
|
||||
clunkinessScore: float = 0.0 # deadCardRate * (1 - castRate)
|
||||
deadCardRate: float = 0.0
|
||||
impactScore: float = 0.0
|
||||
synergyScore: float = 0.0
|
||||
clunkinessScore: float = 0.0
|
||||
avgTurnCast: Optional[float] = None
|
||||
avgDamageDealt: float = 0.0
|
||||
tags: List[str] = Field(default_factory=list) # ramp, draw, removal, etc.
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MatchupRecord(BaseModel):
|
||||
@@ -48,7 +45,7 @@ class DeckMeta(BaseModel):
|
||||
class DeckStructure(BaseModel):
|
||||
"""Deck composition breakdown."""
|
||||
landCount: int = 0
|
||||
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8) # CMC 0-7+
|
||||
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8)
|
||||
cardTypeCounts: Dict[str, int] = Field(default_factory=dict)
|
||||
functionalCounts: Dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
@@ -57,7 +54,7 @@ class ComboRecord(BaseModel):
|
||||
"""Known combo with performance data."""
|
||||
cardNames: List[str]
|
||||
winRateWhenAssembled: float = 0.0
|
||||
assemblyRate: float = 0.0 # Fraction of games where all pieces drawn
|
||||
assemblyRate: float = 0.0
|
||||
|
||||
|
||||
class DeckReport(BaseModel):
|
||||
@@ -84,10 +81,10 @@ class DeckReport(BaseModel):
|
||||
|
||||
class CoachGoals(BaseModel):
|
||||
"""User-specified coaching goals."""
|
||||
targetPowerLevel: Optional[int] = None # 1-10
|
||||
metaFocus: Optional[str] = None # aggro, control, combo, midrange, stax
|
||||
budget: Optional[str] = None # budget, medium, no-limit
|
||||
focusAreas: List[str] = Field(default_factory=list) # e.g., ["ramp", "card draw"]
|
||||
targetPowerLevel: Optional[int] = None
|
||||
metaFocus: Optional[str] = None
|
||||
budget: Optional[str] = None
|
||||
focusAreas: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CoachRequest(BaseModel):
|
||||
@@ -107,12 +104,120 @@ class SuggestedCut(BaseModel):
|
||||
class SuggestedAdd(BaseModel):
|
||||
"""A card the coach suggests adding."""
|
||||
cardName: str
|
||||
role: str = "" # ramp, draw, removal, finisher, etc.
|
||||
role: str = ""
|
||||
reason: str = ""
|
||||
synergyWith: List[str] = Field(default_factory=list)
|
||||
estimatedManaValue: Optional[float] = None
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Phase 2: Structured Analysis Models
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class UpgradePriorityItem(BaseModel):
|
||||
"""A single prioritized cut/add swap ranked by expected impact."""
|
||||
rank: int
|
||||
cut: str
|
||||
add: str
|
||||
reasoning: str = ""
|
||||
expectedImpact: str = ""
|
||||
|
||||
|
||||
class CommanderDependency(BaseModel):
|
||||
"""How reliant the deck is on having the commander in play."""
|
||||
score: int = 5
|
||||
dependentCards: List[str] = Field(default_factory=list)
|
||||
recoveryPlan: str = ""
|
||||
|
||||
|
||||
class MulliganAnalysis(BaseModel):
|
||||
"""Opening hand keepability assessment."""
|
||||
estimatedKeepRate: str = ""
|
||||
worstOffenders: List[str] = Field(default_factory=list)
|
||||
recommendation: str = ""
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Phase 3: Qualitative Analysis Models
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class RemovalCoverage(BaseModel):
|
||||
"""Categorized removal package by permanent type."""
|
||||
creatures: str = ""
|
||||
artifacts: str = ""
|
||||
enchantments: str = ""
|
||||
planeswalkers: str = ""
|
||||
lands: str = ""
|
||||
massRemoval: str = ""
|
||||
gaps: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RampQuality(BaseModel):
|
||||
"""Ramp package breakdown by subtype and fragility."""
|
||||
manaRocks: str = ""
|
||||
landFetch: str = ""
|
||||
manaDorks: str = ""
|
||||
costReducers: str = ""
|
||||
fragility: str = ""
|
||||
canReachFourByTurnThree: str = ""
|
||||
|
||||
|
||||
class DrawEngineProfile(BaseModel):
|
||||
"""Card draw sustainability analysis."""
|
||||
burstDraw: List[str] = Field(default_factory=list)
|
||||
repeatableEngines: List[str] = Field(default_factory=list)
|
||||
assessment: str = ""
|
||||
sustainability: str = ""
|
||||
|
||||
|
||||
class WinCondition(BaseModel):
|
||||
"""A named win condition with independence rating."""
|
||||
name: str
|
||||
cards: List[str] = Field(default_factory=list)
|
||||
independence: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
class AntiSynergyFlag(BaseModel):
|
||||
"""An internal conflict between cards in the deck."""
|
||||
cards: List[str] = Field(default_factory=list)
|
||||
conflict: str = ""
|
||||
severity: str = ""
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Phase 5: Pod Politics & Meta Awareness Models
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class PodPresence(BaseModel):
|
||||
"""Commander pod politics and threat assessment."""
|
||||
threatLevel: str = "" # early/medium/late/low
|
||||
politicalTools: List[str] = Field(default_factory=list)
|
||||
adversarialRating: str = "" # low/medium/high
|
||||
recommendation: str = ""
|
||||
|
||||
|
||||
class TempoAssessment(BaseModel):
|
||||
"""When the deck 'comes online' and whether that's fast enough."""
|
||||
peakTurn: int = 0
|
||||
profile: str = "" # early/mid/late
|
||||
survivalWindow: str = "" # Assessment of whether it survives to peak
|
||||
assessment: str = ""
|
||||
|
||||
|
||||
class MetaMatchup(BaseModel):
|
||||
"""Strategic reasoning about deck vs a meta archetype."""
|
||||
archetype: str # fast-combo, stax, control, aggro, etc.
|
||||
assessment: str = ""
|
||||
keyThreats: List[str] = Field(default_factory=list)
|
||||
keyAnswers: List[str] = Field(default_factory=list)
|
||||
overallRating: str = "" # favored/even/unfavored
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Coach Session (persisted result)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class CoachSession(BaseModel):
|
||||
"""
|
||||
Complete coaching session result.
|
||||
@@ -131,6 +236,24 @@ class CoachSession(BaseModel):
|
||||
promptTokens: int = 0
|
||||
completionTokens: int = 0
|
||||
goals: Optional[CoachGoals] = None
|
||||
isQuickDigest: bool = False # True for quick digest, False for full analysis
|
||||
|
||||
# Phase 2: Structured analysis fields
|
||||
upgradePriority: List[UpgradePriorityItem] = Field(default_factory=list)
|
||||
commanderDependency: Optional[CommanderDependency] = None
|
||||
mulliganAnalysis: Optional[MulliganAnalysis] = None
|
||||
|
||||
# Phase 3: Qualitative analysis fields
|
||||
removalCoverage: Optional[RemovalCoverage] = None
|
||||
rampQuality: Optional[RampQuality] = None
|
||||
drawEngineProfile: Optional[DrawEngineProfile] = None
|
||||
winConditions: List[WinCondition] = Field(default_factory=list)
|
||||
antiSynergyFlags: List[AntiSynergyFlag] = Field(default_factory=list)
|
||||
|
||||
# Phase 5: Pod politics & meta
|
||||
podPresence: Optional[PodPresence] = None
|
||||
tempoAssessment: Optional[TempoAssessment] = None
|
||||
metaMatchups: List[MetaMatchup] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ApplyRequest(BaseModel):
|
||||
|
||||
+377
-43
@@ -4,18 +4,24 @@ Commander AI Lab — Coach Prompt Template
|
||||
Constructs system and user prompts for the LLM coach.
|
||||
Injects deck report data, underperformer analysis, and
|
||||
candidate replacement cards into a structured template.
|
||||
|
||||
Phase 1 additions (#108):
|
||||
- Sim Insights section: deck-level opening hand quality, curve velocity,
|
||||
synergy outliers, and archetype weak spots surfaced directly in the
|
||||
user prompt so Claude reasons from numbers, not vibes.
|
||||
- _format_archetypes() annotates weak spots and strengths inline.
|
||||
- _format_top_cards() adds keptInOpeningHandRate per card.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from .models import DeckReport, CoachGoals, CardPerformance
|
||||
from .config import MAX_CANDIDATES_PER_UNDERPERFORMER, MAX_UNDERPERFORMERS
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# System Prompt
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
SYSTEM_PROMPT_TEMPLATE = """You are an expert Magic: The Gathering Commander deck coach.
|
||||
You analyze deck performance reports from simulation data and provide specific,
|
||||
@@ -27,44 +33,197 @@ RULES:
|
||||
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
|
||||
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
|
||||
- When suggesting replacements, prefer cards that fill the same functional role.
|
||||
- IMPORTANT: Suggest 3-5 DIFFERENT cards to cut and 3-5 DIFFERENT cards to add.
|
||||
- IMPORTANT: Suggest 5-8 DIFFERENT cards to cut and 5-8 DIFFERENT cards to add.
|
||||
- Each suggested add MUST be a different card — never suggest the same card twice.
|
||||
- Vary your suggestions across different functional roles (ramp, draw, removal, threats, utility).
|
||||
- If the deck is missing a functional role (e.g., no card draw, no removal), prioritize adding cards for that role.
|
||||
- If candidate replacements are provided from embeddings search, prefer those over generic suggestions.
|
||||
- For each suggested cut, provide a DETAILED reason citing specific stats (impact score, cast rate, dead card rate, clunkiness, synergy score).
|
||||
- For each suggested add, explain the SPECIFIC synergy with existing cards in the deck.
|
||||
- When reasoning about cuts, also consider keptInOpeningHandRate (low = bad in opening hands) and avgTurnCast (high = too slow for the curve).
|
||||
- When evaluating synergy, use the synergyScore: cards below 0.1 have weak co-occurrence with the rest of the deck.
|
||||
- Use perArchetypeWinRates to identify which matchup types this deck struggles with and tailor suggestions accordingly.
|
||||
- The Sim Insights section shows pre-computed deck-level signals — use these directly in your reasoning.
|
||||
- Cards flagged as [OPENING HAND TRAP] have keptInOpeningHandRate below the deck average — treat as mulligan liabilities.
|
||||
- Cards flagged as [LATE CASTER] have avgTurnCast > 6 — consider whether they fit the deck's tempo.
|
||||
- Cards flagged as [ISOLATED] have synergyScore in the bottom 3 of the deck — they may not support the gameplan.
|
||||
- Archetypes flagged as [WEAK SPOT] in the Archetype Win Rates section are where this deck loses most — prioritize fixes for those matchups.
|
||||
- Provide at least 5 heuristic hints covering different strategic dimensions.
|
||||
- Assess how dependent the deck is on the commander being in play.
|
||||
- Evaluate opening hand quality and identify cards that cause bad mulligans.
|
||||
- Rank your suggested swaps by priority so the user knows what to change first.
|
||||
- Categorize the removal package by permanent type and flag coverage gaps.
|
||||
- Evaluate the ramp package for fragility (creature/artifact-heavy vs land-based).
|
||||
- Distinguish one-time draw from repeatable draw engines.
|
||||
- Identify the top 3 win conditions and rate each for independence.
|
||||
- Flag internal anti-synergies or card conflicts.
|
||||
- Assess pod threat perception: does this deck draw too much hate too early?
|
||||
- Evaluate when the deck comes online (tempo profile) and whether that's viable.
|
||||
- Reason about matchups vs fast combo, stax/prison, and control archetypes.
|
||||
{goals_section}
|
||||
|
||||
You MUST respond ONLY with valid JSON matching this exact structure:
|
||||
{{
|
||||
"summary": "2-3 sentence overview of the deck's strengths and weaknesses",
|
||||
"summary": "4-6 sentence detailed overview of the deck's strengths, weaknesses, key synergies, and strategic identity. Reference specific win rates and simulation data.",
|
||||
"suggestedCuts": [
|
||||
{{
|
||||
"cardName": "Card To Remove",
|
||||
"reason": "Why this card underperforms",
|
||||
"replacementOptions": ["Better Card 1", "Better Card 2"]
|
||||
"reason": "Detailed explanation citing specific simulation stats (impact score, cast rate, dead card rate, clunkiness score, synergy score, avg turn cast) and why this card underperforms in this specific deck context",
|
||||
"replacementOptions": ["Better Card 1", "Better Card 2"],
|
||||
"functionalRole": "ramp|draw|removal|threat|utility|combo|protection",
|
||||
"estimatedImpact": "How cutting this card is expected to improve the deck"
|
||||
}}
|
||||
],
|
||||
"suggestedAdds": [
|
||||
{{
|
||||
"cardName": "Card To Add",
|
||||
"role": "ramp|draw|removal|threat|utility|combo|protection",
|
||||
"reason": "Why this card improves the deck",
|
||||
"synergyWith": ["Card In Deck 1", "Card In Deck 2"]
|
||||
"reason": "Detailed explanation of why this card improves the deck, referencing specific weaknesses it addresses",
|
||||
"synergyWith": ["Card In Deck 1", "Card In Deck 2"],
|
||||
"estimatedManaCost": "The mana value of the suggested card",
|
||||
"strategicRationale": "How this card fits into the overall game plan and addresses a specific gap"
|
||||
}}
|
||||
],
|
||||
"heuristicHints": [
|
||||
"Concise strategic tip based on data"
|
||||
"upgradePriority": [
|
||||
{{
|
||||
"rank": 1,
|
||||
"cut": "Card to remove",
|
||||
"add": "Card to add instead",
|
||||
"reasoning": "Why this is the single highest-impact swap",
|
||||
"expectedImpact": "high|medium|low"
|
||||
}}
|
||||
],
|
||||
"manaBaseAdvice": "Specific mana base improvement suggestion or null",
|
||||
"rawTextExplanation": "Detailed paragraph explaining the analysis and reasoning"
|
||||
"commanderDependency": {{
|
||||
"score": 5,
|
||||
"dependentCards": ["Card that is nearly dead without commander"],
|
||||
"recoveryPlan": "Strategy if the commander gets removed or tucked repeatedly"
|
||||
}},
|
||||
"mulliganAnalysis": {{
|
||||
"estimatedKeepRate": "Percentage estimate of keepable 7-card hands, e.g. ~70%",
|
||||
"worstOffenders": ["Card most responsible for unkeepable hands"],
|
||||
"recommendation": "What to change to improve opening hand quality"
|
||||
}},
|
||||
"manaCurveAnalysis": {{
|
||||
"currentAssessment": "Detailed analysis of the current mana curve distribution and its implications",
|
||||
"recommendations": "Specific suggestions for curve adjustments with reasoning",
|
||||
"idealRange": "What the curve should look like for this deck's strategy"
|
||||
}},
|
||||
"roleBreakdown": {{
|
||||
"ramp": "Assessment of ramp package - count, quality, and suggestions",
|
||||
"draw": "Assessment of card draw package",
|
||||
"removal": "Assessment of removal package",
|
||||
"threats": "Assessment of win conditions and threats",
|
||||
"protection": "Assessment of protection and interaction"
|
||||
}},
|
||||
"synergyAnalysis": [
|
||||
"Description of a key synergy chain in the deck and how well it performed in simulation"
|
||||
],
|
||||
"heuristicHints": [
|
||||
"Detailed strategic tip explaining the reasoning and data behind the suggestion"
|
||||
],
|
||||
"manaBaseAdvice": "Specific mana base improvement suggestion including land count, color fixing, and utility lands, or null if mana base is solid",
|
||||
"rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates and the [WEAK SPOT] archetypes flagged in Sim Insights, and (5) prioritized list of improvements ranked by expected impact on win rate",
|
||||
"removalCoverage": {{
|
||||
"creatures": "Assessment of creature removal",
|
||||
"artifacts": "Assessment of artifact removal",
|
||||
"enchantments": "Assessment of enchantment removal",
|
||||
"planeswalkers": "Assessment of planeswalker removal",
|
||||
"lands": "Assessment of land destruction",
|
||||
"massRemoval": "Assessment of board wipes",
|
||||
"gaps": ["Permanent types with zero or weak coverage"]
|
||||
}},
|
||||
"rampQuality": {{
|
||||
"manaRocks": "2-mana rocks, signets, etc.",
|
||||
"landFetch": "Cultivate, Kodama's Reach, etc.",
|
||||
"manaDorks": "Llanowar Elves, Birds, etc.",
|
||||
"costReducers": "Medallions, Helm of Awakening, etc.",
|
||||
"fragility": "Overall fragility assessment",
|
||||
"canReachFourByTurnThree": "yes/no with reasoning"
|
||||
}},
|
||||
"drawEngineProfile": {{
|
||||
"burstDraw": ["One-time draw spells"],
|
||||
"repeatableEngines": ["Sustained draw engines"],
|
||||
"assessment": "Overall draw quality",
|
||||
"sustainability": "Whether draw is sustainable or burst-only"
|
||||
}},
|
||||
"winConditions": [
|
||||
{{
|
||||
"name": "Name of win condition",
|
||||
"cards": ["Key cards involved"],
|
||||
"independence": "high|medium|low",
|
||||
"description": "How this win con functions"
|
||||
}}
|
||||
],
|
||||
"antiSynergyFlags": [
|
||||
{{
|
||||
"cards": ["Card A", "Card B"],
|
||||
"conflict": "Description of conflict",
|
||||
"severity": "high|medium|low"
|
||||
}}
|
||||
],
|
||||
"podPresence": {{
|
||||
"threatLevel": "early|medium|late|low",
|
||||
"politicalTools": ["Pillowfort or group effect cards"],
|
||||
"adversarialRating": "low|medium|high",
|
||||
"recommendation": "How to manage threat perception in a pod"
|
||||
}},
|
||||
"tempoAssessment": {{
|
||||
"peakTurn": 5,
|
||||
"profile": "early|mid|late",
|
||||
"survivalWindow": "Assessment of whether the deck survives to its power window",
|
||||
"assessment": "Overall tempo evaluation"
|
||||
}},
|
||||
"metaMatchups": [
|
||||
{{
|
||||
"archetype": "fast-combo|stax|control|aggro|midrange",
|
||||
"assessment": "How the deck fares against this archetype",
|
||||
"keyThreats": ["Cards/strategies that threaten this deck"],
|
||||
"keyAnswers": ["Cards in the deck that answer this archetype"],
|
||||
"overallRating": "favored|even|unfavored"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Quick Digest Prompt
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# User Prompt Builder
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
QUICK_DIGEST_SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander deck coach.
|
||||
Provide a FAST summary analysis of this deck. Be concise.
|
||||
|
||||
RULES:
|
||||
- NEVER suggest cutting the commander ({commander}).
|
||||
- Only suggest cards within the deck's color identity: {color_identity}.
|
||||
- Cards flagged [OPENING HAND TRAP] have low keptInOpeningHandRate — prime cut candidates.
|
||||
- Cards flagged [WEAK SPOT] in archetype win rates show where the deck loses most.
|
||||
{goals_section}
|
||||
|
||||
You MUST respond ONLY with valid JSON matching this exact structure:
|
||||
{{
|
||||
"summary": "3-sentence deck overview: what it does, main strength, main weakness.",
|
||||
"upgradePriority": [
|
||||
{{
|
||||
"rank": 1,
|
||||
"cut": "Card to remove",
|
||||
"add": "Card to add instead",
|
||||
"reasoning": "Why this is a high-impact swap",
|
||||
"expectedImpact": "high|medium|low"
|
||||
}}
|
||||
],
|
||||
"commanderDependency": {{
|
||||
"score": 5,
|
||||
"dependentCards": ["Card that is nearly dead without commander"],
|
||||
"recoveryPlan": "Brief recovery strategy"
|
||||
}}
|
||||
}}
|
||||
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# User Prompt Template
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
USER_PROMPT_TEMPLATE = """## Deck Report: {deck_name}
|
||||
Commander: {commander}
|
||||
@@ -79,19 +238,29 @@ Avg Game Length: {avg_game_length:.1f} turns
|
||||
- Card Types: {type_summary}
|
||||
- Functional Roles: {role_summary}
|
||||
|
||||
### Sim Insights (pre-computed deck-level signals)
|
||||
{sim_insights_section}
|
||||
|
||||
### Matchup Data
|
||||
{matchup_section}
|
||||
|
||||
### Archetype Win Rates
|
||||
{archetype_section}
|
||||
|
||||
### Top Performing Cards (by Impact Score)
|
||||
{top_cards}
|
||||
|
||||
### Underperforming Cards (candidates for cuts)
|
||||
{underperformer_section}
|
||||
|
||||
|
||||
### RAG-Retrieved Similar Cards (from ChromaDB)
|
||||
{rag_section}
|
||||
### Candidate Replacement Cards (from embeddings search)
|
||||
{candidate_section}
|
||||
|
||||
{combo_section}
|
||||
|
||||
Based on this simulation data, provide your coaching suggestions."""
|
||||
|
||||
|
||||
@@ -99,7 +268,6 @@ def _format_goals_section(goals: Optional[CoachGoals]) -> str:
|
||||
"""Format goals into system prompt rules."""
|
||||
if not goals:
|
||||
return "- Aim for a balanced, well-rounded deck."
|
||||
|
||||
lines = []
|
||||
if goals.targetPowerLevel is not None:
|
||||
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
|
||||
@@ -126,42 +294,148 @@ def _format_curve(buckets: List[int]) -> str:
|
||||
|
||||
|
||||
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
|
||||
"""Format the best performing cards."""
|
||||
"""Format the best performing cards with extended stats.
|
||||
|
||||
Phase 1: adds keptInOpeningHandRate so Claude sees hand quality for
|
||||
the good cards too, not just the underperformers.
|
||||
"""
|
||||
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
|
||||
lines = []
|
||||
for c in sorted_cards[:top_n]:
|
||||
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
|
||||
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}{tags}")
|
||||
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
|
||||
lines.append(
|
||||
f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
|
||||
f"synergy={c.synergyScore:.3f}, "
|
||||
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
|
||||
f"{turn_str}{tags}"
|
||||
)
|
||||
return "\n".join(lines) if lines else "No card performance data available."
|
||||
|
||||
|
||||
def _format_underperformers(cards: List[CardPerformance],
|
||||
underperformer_names: List[str]) -> str:
|
||||
"""Format underperforming cards with their stats."""
|
||||
"""Format underperforming cards with full available stats."""
|
||||
card_map = {c.name: c for c in cards}
|
||||
lines = []
|
||||
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
|
||||
c = card_map.get(name)
|
||||
if c:
|
||||
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"deadCardRate={c.deadCardRate:.1%}, "
|
||||
f"castRate={c.castRate:.1%}, "
|
||||
f"clunkiness={c.clunkinessScore:.3f}")
|
||||
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
|
||||
flags = []
|
||||
if c.keptInOpeningHandRate < 0.25:
|
||||
flags.append("OPENING HAND TRAP")
|
||||
if c.avgTurnCast is not None and c.avgTurnCast > 6:
|
||||
flags.append("LATE CASTER")
|
||||
if c.synergyScore < 0.1:
|
||||
flags.append("ISOLATED")
|
||||
flag_str = f" [{', '.join(flags)}]" if flags else ""
|
||||
lines.append(
|
||||
f"- {c.name}{flag_str}: impact={c.impactScore:.3f}, "
|
||||
f"deadCardRate={c.deadCardRate:.1%}, "
|
||||
f"castRate={c.castRate:.1%}, "
|
||||
f"clunkiness={c.clunkinessScore:.3f}, "
|
||||
f"synergy={c.synergyScore:.3f}, "
|
||||
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
|
||||
f"{turn_str}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"- {name}: (no detailed stats)")
|
||||
return "\n".join(lines) if lines else "No clear underperformers identified."
|
||||
|
||||
|
||||
def _format_sim_insights(cards: List[CardPerformance],
|
||||
archetype_rates: Dict[str, float]) -> str:
|
||||
"""Compute and format deck-level sim signals for the Sim Insights section.
|
||||
|
||||
Phase 1 (#108): surfaces pre-computed per-card stats as deck-level
|
||||
aggregates so Claude reasons from numbers directly.
|
||||
|
||||
Signals produced:
|
||||
1. Opening hand quality — deck avg keptInOpeningHandRate + worst offenders
|
||||
2. Curve velocity — castRate-weighted avg avgTurnCast + late-caster flags
|
||||
3. Synergy outliers — bottom-3 synergyScore cards flagged as [ISOLATED]
|
||||
4. Archetype weak spots — archetypes < 40% win rate flagged [WEAK SPOT]
|
||||
"""
|
||||
if not cards:
|
||||
return "No simulation data available."
|
||||
|
||||
lines = []
|
||||
|
||||
# ── 1. Opening hand quality ───────────────────────────────────────────
|
||||
hand_rates = [c.keptInOpeningHandRate for c in cards if c.keptInOpeningHandRate > 0]
|
||||
if hand_rates:
|
||||
avg_keep = sum(hand_rates) / len(hand_rates)
|
||||
lines.append(f"Opening Hand Quality: deck avg keptInOpeningHandRate = {avg_keep:.1%}")
|
||||
# Worst offenders: cards significantly below the deck average
|
||||
threshold = avg_keep * 0.6 # >40% below avg is an outlier
|
||||
offenders = sorted(
|
||||
[c for c in cards if c.keptInOpeningHandRate > 0 and c.keptInOpeningHandRate < threshold],
|
||||
key=lambda c: c.keptInOpeningHandRate
|
||||
)[:3]
|
||||
if offenders:
|
||||
lines.append(" Opening hand traps (keptInOpeningHandRate far below deck avg):")
|
||||
for c in offenders:
|
||||
lines.append(f" - {c.name}: {c.keptInOpeningHandRate:.1%} [OPENING HAND TRAP]")
|
||||
else:
|
||||
lines.append("Opening Hand Quality: no keptInOpeningHandRate data available")
|
||||
|
||||
# ── 2. Curve velocity ───────────────────────────────────────────────
|
||||
cast_cards = [c for c in cards if c.avgTurnCast is not None and c.castRate > 0]
|
||||
if cast_cards:
|
||||
total_weight = sum(c.castRate for c in cast_cards)
|
||||
if total_weight > 0:
|
||||
weighted_avg_turn = sum(
|
||||
c.avgTurnCast * c.castRate for c in cast_cards
|
||||
) / total_weight
|
||||
lines.append(f"Curve Velocity: castRate-weighted avg avgTurnCast = {weighted_avg_turn:.1f}")
|
||||
late_casters = sorted(
|
||||
[c for c in cast_cards if c.avgTurnCast > 6],
|
||||
key=lambda c: c.avgTurnCast,
|
||||
reverse=True
|
||||
)[:3]
|
||||
if late_casters:
|
||||
lines.append(" Late casters (avgTurnCast > 6 — may not fit the curve):")
|
||||
for c in late_casters:
|
||||
lines.append(f" - {c.name}: avgTurnCast={c.avgTurnCast:.1f} [LATE CASTER]")
|
||||
else:
|
||||
lines.append("Curve Velocity: no avgTurnCast data available")
|
||||
|
||||
# ── 3. Synergy outliers ──────────────────────────────────────────────
|
||||
synergy_cards = sorted(
|
||||
[c for c in cards if c.castRate > 0.05], # ignore near-never-cast cards
|
||||
key=lambda c: c.synergyScore
|
||||
)
|
||||
if synergy_cards:
|
||||
bottom3 = synergy_cards[:3]
|
||||
lines.append("Synergy Outliers (lowest synergyScore among cast cards):")
|
||||
for c in bottom3:
|
||||
lines.append(f" - {c.name}: synergyScore={c.synergyScore:.3f} [ISOLATED]")
|
||||
|
||||
# ── 4. Archetype weak spots ──────────────────────────────────────────
|
||||
if archetype_rates:
|
||||
weak = {k: v for k, v in archetype_rates.items() if v < 0.40}
|
||||
if weak:
|
||||
lines.append("Archetype Weak Spots (win rate < 40%):")
|
||||
for arch, wr in sorted(weak.items(), key=lambda x: x[1]):
|
||||
lines.append(f" - vs {arch}: {wr:.1%} [WEAK SPOT]")
|
||||
else:
|
||||
lines.append("Archetype Weak Spots: none (all matchups >= 40% win rate)")
|
||||
|
||||
return "\n".join(lines) if lines else "No sim insights available."
|
||||
|
||||
|
||||
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
||||
"""Format replacement candidates grouped by the card they'd replace."""
|
||||
if not candidates:
|
||||
return ("No embedding-based candidates available. "
|
||||
"Use your MTG knowledge to suggest cards that fit the deck's "
|
||||
"color identity, strategy, and missing functional roles. "
|
||||
"Suggest at least 3 different cards across different roles "
|
||||
"(ramp, draw, removal, threats, utility).")
|
||||
|
||||
return (
|
||||
"No embedding-based candidates available. "
|
||||
"Use your MTG knowledge to suggest cards that fit the deck's "
|
||||
"color identity, strategy, and missing functional roles. "
|
||||
"Suggest at least 3 different cards across different roles "
|
||||
"(ramp, draw, removal, threats, utility)."
|
||||
)
|
||||
lines = []
|
||||
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
|
||||
lines.append(f"\nReplacements for '{underperformer}':")
|
||||
@@ -179,14 +453,55 @@ def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_rag_cards(rag_cards: List[dict]) -> str:
|
||||
"""Format RAG-retrieved cards from ChromaDB vector search."""
|
||||
if not rag_cards:
|
||||
return "No RAG results available."
|
||||
lines = []
|
||||
for card in rag_cards[:15]:
|
||||
name = card.get("name", "Unknown")
|
||||
types = card.get("types", "")
|
||||
mv = card.get("mana_value", "?")
|
||||
mc = card.get("mana_cost", "")
|
||||
dist = card.get("distance", 0)
|
||||
text = card.get("text", "")[:80]
|
||||
lines.append(f"- {name} ({mc}, MV:{mv}, {types}) [distance: {dist:.3f}]")
|
||||
if text:
|
||||
lines.append(f" Oracle: {text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_matchups(report: DeckReport) -> str:
|
||||
"""Format matchup data."""
|
||||
"""Format per-opponent matchup data."""
|
||||
if not report.matchups:
|
||||
return "No matchup data available."
|
||||
lines = []
|
||||
for m in report.matchups:
|
||||
lines.append(f"- vs {m.opponentDeck}: {m.winRate:.1%} "
|
||||
f"({m.gamesPlayed} games)")
|
||||
lines.append(
|
||||
f"- vs {m.opponentDeck}: {m.winRate:.1%} "
|
||||
f"({m.gamesPlayed} games)"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_archetypes(report: DeckReport) -> str:
|
||||
"""Format per-archetype win rates from DeckMeta.
|
||||
|
||||
Phase 1: annotates weak spots ([WEAK SPOT] < 40%) and strengths
|
||||
([STRENGTH] >= 55%) so Claude sees the signal immediately.
|
||||
"""
|
||||
rates = report.meta.perArchetypeWinRates
|
||||
if not rates:
|
||||
return "No archetype breakdown available."
|
||||
lines = []
|
||||
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]):
|
||||
if wr < 0.40:
|
||||
tag = " [WEAK SPOT]"
|
||||
elif wr >= 0.55:
|
||||
tag = " [STRENGTH]"
|
||||
else:
|
||||
tag = ""
|
||||
lines.append(f"- vs {archetype}: {wr:.1%}{tag}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -197,14 +512,16 @@ def _format_combos(report: DeckReport) -> str:
|
||||
lines = ["### Known Combos"]
|
||||
for combo in report.knownCombos:
|
||||
cards = " + ".join(combo.cardNames)
|
||||
lines.append(f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
|
||||
f"assemblyRate={combo.assemblyRate:.1%}")
|
||||
lines.append(
|
||||
f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
|
||||
f"assemblyRate={combo.assemblyRate:.1%}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Main Builder Functions
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def build_system_prompt(report: DeckReport,
|
||||
goals: Optional[CoachGoals] = None) -> str:
|
||||
@@ -217,16 +534,29 @@ def build_system_prompt(report: DeckReport,
|
||||
)
|
||||
|
||||
|
||||
def build_user_prompt(report: DeckReport,
|
||||
candidates: Dict[str, List[dict]] = None) -> str:
|
||||
"""Build the user prompt with full deck report data."""
|
||||
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
|
||||
def build_quick_system_prompt(report: DeckReport,
|
||||
goals: Optional[CoachGoals] = None) -> str:
|
||||
"""Build the quick digest system prompt (~400-600 token target)."""
|
||||
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
|
||||
return QUICK_DIGEST_SYSTEM_PROMPT.format(
|
||||
commander=report.commander,
|
||||
color_identity=color_str,
|
||||
goals_section=_format_goals_section(goals),
|
||||
)
|
||||
|
||||
|
||||
def build_user_prompt(report: DeckReport,
|
||||
candidates: Dict[str, List[dict]] = None,
|
||||
rag_cards: List[dict] = None) -> str:
|
||||
"""Build the user prompt with full deck report data.
|
||||
|
||||
Phase 1: adds sim_insights_section computed from card-level stats.
|
||||
"""
|
||||
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
|
||||
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
|
||||
if report.structure.cardTypeCounts else ["unknown"]
|
||||
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
|
||||
if report.structure.functionalCounts else ["unknown"]
|
||||
|
||||
return USER_PROMPT_TEMPLATE.format(
|
||||
deck_name=report.deckId,
|
||||
commander=report.commander,
|
||||
@@ -238,10 +568,14 @@ def build_user_prompt(report: DeckReport,
|
||||
curve_summary=_format_curve(report.structure.curveBuckets),
|
||||
type_summary=", ".join(type_parts),
|
||||
role_summary=", ".join(role_parts),
|
||||
sim_insights_section=_format_sim_insights(
|
||||
report.cards, report.meta.perArchetypeWinRates),
|
||||
matchup_section=_format_matchups(report),
|
||||
archetype_section=_format_archetypes(report),
|
||||
top_cards=_format_top_cards(report.cards),
|
||||
underperformer_section=_format_underperformers(
|
||||
report.cards, report.underperformers),
|
||||
candidate_section=_format_candidates(candidates or {}),
|
||||
rag_section=_format_rag_cards(rag_cards or []),
|
||||
combo_section=_format_combos(report),
|
||||
)
|
||||
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
Commander AI Lab — Deck Generation System Prompt
|
||||
═════════════════════════════════════════════════
|
||||
Phase 1 changes:
|
||||
- Added explicit 99-card count enforcement rule
|
||||
- Clarified that commander counts separately (not in the 99)
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """You are an elite Magic: The Gathering Commander (EDH) deck builder with encyclopedic knowledge of every card ever printed, current metagame trends, and Commander Rules Committee bracket guidelines.
|
||||
@@ -9,6 +12,8 @@ Your task is to build a complete, legal 100-card Commander deck.
|
||||
|
||||
RULES:
|
||||
- Exactly 100 cards total: 1 commander + 99 other cards
|
||||
- The commander IS included in the cards list but counts as card #100
|
||||
- The other 99 cards must exactly match the SLOT BUDGET provided in the user prompt
|
||||
- No more than 1 copy of any card except basic lands
|
||||
- All cards must share the commander's color identity
|
||||
- All cards must be legal in Commander format
|
||||
@@ -16,6 +21,12 @@ RULES:
|
||||
- Include approximately: 10 ramp sources, 10 card draw sources, 8-10 targeted removal, 2-3 board wipes, 3-5 protection pieces
|
||||
- Build a cohesive strategy around the commander's abilities
|
||||
|
||||
CARD COUNT ENFORCEMENT (critical):
|
||||
- The user prompt will specify a SLOT BUDGET with exact per-category counts
|
||||
- You MUST fill every slot — returning fewer cards than the budget specifies will cause a validation error
|
||||
- If you cannot find a perfect fit for a slot, use the best available card for that role
|
||||
- Basic lands count toward the Land slot total
|
||||
|
||||
BRACKET RULES (Commander Rules Committee):
|
||||
- Bracket 1 (Casual): Precon-level. No tutors, no fast mana, no infinite combos, no mass land destruction. Game Changers: 0
|
||||
- Bracket 2 (Upgraded Casual): Moderate power. Limited tutors (1-2 total), no 2-card infinite combos, no fast mana except Sol Ring. Game Changers: 0-1
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
"""
|
||||
Commander AI Lab — Deck Generation User Prompt Builder
|
||||
═══════════════════════════════════════════════════════
|
||||
Constructs the user message for Perplexity deck generation
|
||||
from a DeckGenV3Request.
|
||||
Constructs the user message for LLM deck generation.
|
||||
|
||||
Phase 1 changes:
|
||||
- slot_budget parameter injected into prompt so LLM targets exactly 99 cards
|
||||
- Explicit card count requirement in prompt text
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
def build_slot_budget_segment(slot_budget: Dict[str, int]) -> str:
|
||||
"""
|
||||
Build the slot budget prompt segment.
|
||||
Informs the LLM of exact per-category card counts so it never undershoots.
|
||||
"""
|
||||
total = sum(slot_budget.values())
|
||||
lines = [f"SLOT BUDGET (you must fill EXACTLY {total} cards, excluding the commander):"]
|
||||
for cat, count in slot_budget.items():
|
||||
lines.append(f" {cat}: {count}")
|
||||
lines.append(f" TOTAL: {total}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"CRITICAL: Return exactly {total} cards in the JSON 'cards' array "
|
||||
"(not counting the commander). Every slot in the budget above must be filled. "
|
||||
"Do not return more or fewer cards."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_user_prompt(
|
||||
@@ -18,6 +40,7 @@ def build_user_prompt(
|
||||
budget_mode: str = "total",
|
||||
omit_cards: List[str] = None,
|
||||
collection_summary: str = "",
|
||||
slot_budget: Optional[Dict[str, int]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build the user prompt for deck generation.
|
||||
@@ -26,18 +49,19 @@ def build_user_prompt(
|
||||
commander: Commander card name
|
||||
commander_type: Type line from Scryfall
|
||||
color_identity: List of color letters, e.g. ["U", "B"]
|
||||
strategy: User-specified strategy focus (e.g. "zombie tokens")
|
||||
strategy: User-specified strategy focus
|
||||
target_bracket: Target bracket level 1-4
|
||||
budget_usd: Optional budget constraint
|
||||
budget_mode: "total" for total deck cost, "per_card" for per-card max
|
||||
omit_cards: Cards to exclude
|
||||
collection_summary: Pre-built collection summary text block
|
||||
slot_budget: Dict of category -> card count (must sum to 99)
|
||||
"""
|
||||
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
|
||||
|
||||
lines = [
|
||||
f"Build a complete 100-card Commander deck.",
|
||||
f"",
|
||||
"Build a complete 100-card Commander deck.",
|
||||
"",
|
||||
f"COMMANDER: {commander}",
|
||||
]
|
||||
|
||||
@@ -60,16 +84,21 @@ def build_user_prompt(
|
||||
lines.append(f"OMIT LIST (do NOT include these cards): {', '.join(omit_cards)}")
|
||||
|
||||
if collection_summary:
|
||||
lines.append(f"")
|
||||
lines.append("")
|
||||
lines.append(collection_summary)
|
||||
lines.append(f"")
|
||||
lines.append(f"STRONGLY PREFER cards from the collection when they fit the strategy.")
|
||||
lines.append("")
|
||||
lines.append("STRONGLY PREFER cards from the collection when they fit the strategy.")
|
||||
else:
|
||||
lines.append(f"")
|
||||
lines.append(f"No collection data available — suggest the best cards regardless of ownership.")
|
||||
lines.append("")
|
||||
lines.append("No collection data available — suggest the best cards regardless of ownership.")
|
||||
|
||||
lines.append(f"")
|
||||
lines.append(f"Build the deck as a structured JSON response. Include the commander in the cards list.")
|
||||
lines.append(f"Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
|
||||
# Inject slot budget so LLM knows exact target counts
|
||||
if slot_budget:
|
||||
lines.append("")
|
||||
lines.append(build_slot_budget_segment(slot_budget))
|
||||
|
||||
lines.append("")
|
||||
lines.append("Build the deck as a structured JSON response. Include the commander in the cards list.")
|
||||
lines.append("Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Substitution Prompt Templates
|
||||
═════════════════════════════════════════════════
|
||||
Prompts for the Perplexity fallback substitution pass.
|
||||
Prompts for the Claude Opus substitution pass.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Deck Generation JSON Schemas
|
||||
═════════════════════════════════════════════════
|
||||
Pydantic models and JSON Schema dicts for Perplexity
|
||||
Pydantic models and JSON Schema dicts for Claude Opus
|
||||
structured output deck generation.
|
||||
"""
|
||||
|
||||
@@ -58,7 +58,7 @@ class GeneratedDeckList(BaseModel):
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# JSON Schema dicts (for Perplexity response_format)
|
||||
# JSON Schema dicts (for Claude Opus response_format)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
DECK_CARD_SCHEMA = {
|
||||
|
||||
@@ -3,6 +3,10 @@ Commander AI Lab — Substitution Schemas
|
||||
════════════════════════════════════════
|
||||
Pydantic models and JSON Schema dicts for the
|
||||
Smart Substitution Engine output.
|
||||
|
||||
Phase 1 changes:
|
||||
- SubstitutionBatchResult gains padded_slots field
|
||||
- DeckCardWithStatus gains original_card field (was missing)
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
@@ -18,16 +22,17 @@ class CardAlternative(BaseModel):
|
||||
name: str = Field(description="Exact card name")
|
||||
similarity_score: float = Field(default=0.0, description="Cosine similarity score (0-1)")
|
||||
reason: str = Field(default="", description="Why this card is a good substitute")
|
||||
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'perplexity'")
|
||||
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'anthropic'")
|
||||
role_overlap: List[str] = Field(default_factory=list, description="Shared functional roles")
|
||||
cmc_delta: float = Field(default=0.0, description="CMC difference from original")
|
||||
|
||||
|
||||
class DeckCardWithStatus(BaseModel):
|
||||
"""A deck card enriched with ownership and substitution data."""
|
||||
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
name: str
|
||||
count: int = 1
|
||||
category: str = ""
|
||||
@@ -41,9 +46,15 @@ class DeckCardWithStatus(BaseModel):
|
||||
owned_qty: int = 0
|
||||
scryfall_id: str = ""
|
||||
status: str = Field(default="owned", description="owned | substituted | missing")
|
||||
original_card: Optional[str] = Field(default=None, description="Original card name if this is a substitution")
|
||||
original_card: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Original card name before substitution (None if not substituted)"
|
||||
)
|
||||
alternatives: List[CardAlternative] = Field(default_factory=list)
|
||||
selected_substitute: Optional[str] = Field(default=None, description="User-selected substitute name")
|
||||
selected_substitute: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The substitute card name chosen for this slot"
|
||||
)
|
||||
|
||||
|
||||
class SubstitutionRequest(BaseModel):
|
||||
@@ -61,11 +72,16 @@ class SubstitutionBatchResult(BaseModel):
|
||||
owned_count: int = 0
|
||||
substituted_count: int = 0
|
||||
missing_count: int = 0
|
||||
padded_slots: int = Field(
|
||||
default=0,
|
||||
description="Number of slots filled by the pad-to-99 circuit breaker. "
|
||||
"Any value > 0 indicates a bug in the substitution pipeline."
|
||||
)
|
||||
cards: List[DeckCardWithStatus] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# JSON Schema dicts (for Perplexity fallback substitution)
|
||||
# JSON Schema dicts (for LLM fallback substitution)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
SUBSTITUTION_RESPONSE_SCHEMA = {
|
||||
|
||||
+409
-125
@@ -1,19 +1,28 @@
|
||||
"""
|
||||
Commander AI Lab — V3 Deck Generator Service
|
||||
═════════════════════════════════════════════
|
||||
Generates Commander decks using Perplexity Sonar
|
||||
Generates Commander decks using Anthropic Claude
|
||||
structured JSON output, then runs ownership check
|
||||
and smart substitution.
|
||||
|
||||
Phase 1 changes:
|
||||
- smart_substitute() non-destructive contract (never returns None)
|
||||
- apply_substitutions() loop never silently drops cards
|
||||
- _pad_to_99() circuit breaker before BuildResult validation
|
||||
- Pre-validation count logging per category
|
||||
- Startup SLOT_BUDGET assertion
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
from ..clients.perplexity_client import PerplexityClient
|
||||
from ..clients.anthropic_client import AnthropicClient
|
||||
from ..schemas.deck_schema import DECK_LIST_SCHEMA, GeneratedDeckList
|
||||
from ..schemas.substitution_schema import (
|
||||
CardAlternative, DeckCardWithStatus, SubstitutionBatchResult,
|
||||
@@ -26,35 +35,261 @@ from ..prompts.substitution_prompt import (
|
||||
from ..config import (
|
||||
DECK_GEN_MODEL, DECK_GEN_TEMPERATURE, DECK_GEN_MAX_TOKENS,
|
||||
SUBSTITUTION_MIN_SIMILARITY, SUBSTITUTION_MAX_ALTERNATIVES,
|
||||
SUBSTITUTION_MODEL, SUBSTITUTION_USE_PPLX_FALLBACK,
|
||||
SUBSTITUTION_MODEL, SUBSTITUTION_USE_ANTHROPIC_FALLBACK,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("coach.deckgen")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Slot Budget — MUST sum to 99 (commander is the 100th card)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
SLOT_BUDGET: dict[str, int] = {
|
||||
"Land": 36,
|
||||
"Ramp": 10,
|
||||
"Card Draw": 10,
|
||||
"Removal": 8,
|
||||
"Board Wipe": 3,
|
||||
"Win Condition": 4,
|
||||
"Protection": 4,
|
||||
"Synergy": 20,
|
||||
"Creature": 2,
|
||||
"Utility": 2,
|
||||
}
|
||||
|
||||
# ── Startup assertion — catches misconfigured budgets at boot time ──
|
||||
_SLOT_BUDGET_TOTAL = sum(SLOT_BUDGET.values())
|
||||
assert _SLOT_BUDGET_TOTAL == 99, (
|
||||
f"SLOT_BUDGET sums to {_SLOT_BUDGET_TOTAL}, must equal 99. "
|
||||
f"Current distribution: {SLOT_BUDGET}"
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# SubstitutionMethod — tracks HOW each card was resolved
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class SubstitutionMethod(str, Enum):
|
||||
EXACT_MATCH = "exact_match" # Card found verbatim in collection
|
||||
FUZZY_MATCH = "fuzzy_match" # Case/punctuation-normalised match
|
||||
EMBEDDING_ANALOG = "embedding_analog" # Embedding similarity match
|
||||
ANTHROPIC_FALLBACK = "anthropic_fallback" # LLM suggested replacement
|
||||
ORIGINAL_KEPT = "original_kept" # Not in collection but Scryfall-valid
|
||||
BASIC_LAND_FALLBACK = "basic_land_fallback" # Last resort — slot becomes a basic
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubstitutionRecord:
|
||||
"""Audit trail entry for a single card's resolution."""
|
||||
original: str
|
||||
resolved: str
|
||||
method: SubstitutionMethod
|
||||
in_collection: bool
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Color → Basic Land mapping
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
_COLOR_TO_BASIC: dict[str, str] = {
|
||||
"W": "Plains",
|
||||
"U": "Island",
|
||||
"B": "Swamp",
|
||||
"R": "Mountain",
|
||||
"G": "Forest",
|
||||
}
|
||||
_BASIC_NAMES: set[str] = set(_COLOR_TO_BASIC.values()) | {"Wastes"}
|
||||
|
||||
|
||||
def _get_basic_land(color_identity: List[str]) -> str:
|
||||
"""Return the first appropriate basic land for the given color identity."""
|
||||
for color in color_identity:
|
||||
if color.upper() in _COLOR_TO_BASIC:
|
||||
return _COLOR_TO_BASIC[color.upper()]
|
||||
return "Wastes" # Colorless commander
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Non-destructive Smart Substitution
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def smart_substitute(
|
||||
card_name: str,
|
||||
collection: set[str],
|
||||
color_identity: List[str],
|
||||
embedding_index=None,
|
||||
) -> SubstitutionRecord:
|
||||
"""
|
||||
Non-destructive substitution — ALWAYS returns a valid card string.
|
||||
|
||||
Fallback chain:
|
||||
1. Exact match in collection
|
||||
2. Fuzzy match (case + punctuation normalised)
|
||||
3. Embedding similarity match (if index available)
|
||||
4. Keep original (Scryfall-valid, not in collection)
|
||||
5. Basic land for the commander's colors (last resort — NEVER None)
|
||||
"""
|
||||
# 1. Exact match
|
||||
if card_name in collection:
|
||||
return SubstitutionRecord(card_name, card_name, SubstitutionMethod.EXACT_MATCH, True)
|
||||
|
||||
# 2. Fuzzy match — normalise case and common punctuation differences
|
||||
normalised = card_name.lower().strip()
|
||||
for owned in collection:
|
||||
if owned.lower().strip() == normalised:
|
||||
return SubstitutionRecord(card_name, owned, SubstitutionMethod.FUZZY_MATCH, True)
|
||||
|
||||
# 3. Embedding similarity (if index available)
|
||||
if embedding_index and getattr(embedding_index, "loaded", False):
|
||||
try:
|
||||
matches = embedding_index.search_similar(
|
||||
query_card=card_name,
|
||||
color_filter=color_identity or None,
|
||||
exclude_cards=[card_name],
|
||||
top_n=5,
|
||||
)
|
||||
pool_lower = {n.lower() for n in collection}
|
||||
for match in matches:
|
||||
if match.name.lower() in pool_lower:
|
||||
return SubstitutionRecord(
|
||||
card_name, match.name, SubstitutionMethod.EMBEDDING_ANALOG, True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Embedding substitution failed for '%s': %s", card_name, e)
|
||||
|
||||
# 4. Keep original — valid card not in collection (proxy / non-collection build)
|
||||
return SubstitutionRecord(
|
||||
card_name, card_name, SubstitutionMethod.ORIGINAL_KEPT, False
|
||||
)
|
||||
|
||||
|
||||
def apply_substitutions(
|
||||
card_list: List[DeckCardWithStatus],
|
||||
collection: set[str],
|
||||
color_identity: List[str],
|
||||
embedding_index=None,
|
||||
) -> Tuple[List[DeckCardWithStatus], List[SubstitutionRecord]]:
|
||||
"""
|
||||
Apply smart substitution to a card list.
|
||||
|
||||
Guarantees: output list is NEVER shorter than input list.
|
||||
Logs a warning for every BASIC_LAND_FALLBACK.
|
||||
"""
|
||||
records: List[SubstitutionRecord] = []
|
||||
result: List[DeckCardWithStatus] = []
|
||||
|
||||
for card in card_list:
|
||||
rec = smart_substitute(card.name, collection, color_identity, embedding_index)
|
||||
records.append(rec)
|
||||
|
||||
if rec.method == SubstitutionMethod.BASIC_LAND_FALLBACK:
|
||||
logger.warning(
|
||||
"[substitution] BASIC_LAND_FALLBACK for '%s' — slot filled with '%s'",
|
||||
rec.original, rec.resolved,
|
||||
)
|
||||
|
||||
# Update the card in-place — never drop it
|
||||
updated = card.model_copy(update={
|
||||
"name": rec.resolved,
|
||||
"owned": rec.in_collection,
|
||||
"status": "owned" if rec.in_collection else
|
||||
("substituted" if rec.method != SubstitutionMethod.ORIGINAL_KEPT
|
||||
else "missing"),
|
||||
"original_card": card.name if rec.resolved != card.name else card.original_card,
|
||||
})
|
||||
result.append(updated)
|
||||
|
||||
fallback_count = sum(1 for r in records if r.method == SubstitutionMethod.BASIC_LAND_FALLBACK)
|
||||
if fallback_count:
|
||||
logger.error(
|
||||
"[substitution] %d cards fell back to basic lands — investigate LLM output quality",
|
||||
fallback_count,
|
||||
)
|
||||
|
||||
return result, records
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Pad-to-99 Circuit Breaker
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _pad_to_99(
|
||||
cards: List[DeckCardWithStatus],
|
||||
color_identity: List[str],
|
||||
target: int = 99,
|
||||
) -> Tuple[List[DeckCardWithStatus], int]:
|
||||
"""
|
||||
Emergency safety pad — should NEVER fire if substitution contract holds.
|
||||
|
||||
If it does fire, the error log is your signal that a card is being dropped
|
||||
somewhere earlier in the pipeline.
|
||||
|
||||
Returns (padded_cards, number_of_slots_padded).
|
||||
"""
|
||||
# Strip commander from count if present (commander is tracked separately)
|
||||
non_commander = [c for c in cards if not getattr(c, "is_commander", False)]
|
||||
current = sum(c.count for c in non_commander)
|
||||
shortfall = target - current
|
||||
|
||||
if shortfall <= 0:
|
||||
return cards, 0
|
||||
|
||||
logger.error(
|
||||
"[pad_to_99] CIRCUIT BREAKER FIRED — deck is %d cards short after substitution. "
|
||||
"Padding with basic lands. Check substitution pipeline for silent drops.",
|
||||
shortfall,
|
||||
)
|
||||
|
||||
basic = _get_basic_land(color_identity)
|
||||
padding_card = DeckCardWithStatus(
|
||||
name=basic,
|
||||
count=shortfall,
|
||||
category="Land",
|
||||
role_tags=[],
|
||||
reason="Emergency pad — basic land inserted by circuit breaker",
|
||||
estimated_price_usd=0.10,
|
||||
owned=True,
|
||||
owned_qty=shortfall,
|
||||
status="owned",
|
||||
)
|
||||
return cards + [padding_card], shortfall
|
||||
|
||||
|
||||
def _log_pre_validation_counts(cards: List[DeckCardWithStatus]) -> None:
|
||||
"""Log per-category card counts before Pydantic validation."""
|
||||
by_cat: dict[str, int] = {}
|
||||
for card in cards:
|
||||
cat = card.category or "Uncategorized"
|
||||
by_cat[cat] = by_cat.get(cat, 0) + card.count
|
||||
total = sum(by_cat.values())
|
||||
logger.info("[pre_validation] Category counts: %s", by_cat)
|
||||
logger.info("[pre_validation] Total: %d/99 (delta: %d)", total, 99 - total)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# DeckGeneratorV3
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class DeckGeneratorV3:
|
||||
"""
|
||||
V3 Deck Generator using Perplexity structured output.
|
||||
V3 Deck Generator using Anthropic Claude structured output.
|
||||
|
||||
Pipeline:
|
||||
1. Resolve commander via Scryfall
|
||||
2. Build collection summary
|
||||
3. Call Perplexity with structured JSON schema
|
||||
3. Call Anthropic Claude with structured JSON schema
|
||||
4. Validate card names via Scryfall
|
||||
5. Check ownership against collection DB
|
||||
6. Run Smart Substitution (embedding + Perplexity fallback)
|
||||
7. Return finalized deck with substitution data
|
||||
6. Run Smart Substitution (non-destructive)
|
||||
7. _pad_to_99 circuit breaker
|
||||
8. Pre-validation count logging
|
||||
9. Return finalized deck — guaranteed 99 cards
|
||||
"""
|
||||
|
||||
def __init__(self, pplx_client: PerplexityClient, db_conn_factory,
|
||||
def __init__(self, anthropic_client: AnthropicClient, db_conn_factory,
|
||||
embedding_index=None):
|
||||
"""
|
||||
Args:
|
||||
pplx_client: Configured PerplexityClient instance
|
||||
db_conn_factory: Callable that returns sqlite3.Connection
|
||||
embedding_index: Optional MTGEmbeddingIndex for substitution
|
||||
"""
|
||||
self.pplx = pplx_client
|
||||
self.anthropic = anthropic_client
|
||||
self.db_conn_factory = db_conn_factory
|
||||
self.embedding_index = embedding_index
|
||||
|
||||
@@ -77,9 +312,12 @@ class DeckGeneratorV3:
|
||||
"mana_cost": data.get("mana_cost", ""),
|
||||
"image_url": data.get("image_uris", {}).get("normal", ""),
|
||||
"oracle_text": data.get("oracle_text", ""),
|
||||
"scryfall_found": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Scryfall lookup failed for '%s': %s", commander_name, e)
|
||||
# Non-canonical commander (silver-bordered, custom, etc.)
|
||||
# Skip collection substitution for these — trust LLM output directly
|
||||
return {
|
||||
"name": commander_name,
|
||||
"scryfall_id": "",
|
||||
@@ -88,6 +326,7 @@ class DeckGeneratorV3:
|
||||
"mana_cost": "",
|
||||
"image_url": "",
|
||||
"oracle_text": "",
|
||||
"scryfall_found": False,
|
||||
}
|
||||
|
||||
# ── Step 2: Build Collection Summary ─────────────────────
|
||||
@@ -110,16 +349,10 @@ class DeckGeneratorV3:
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
# Filter by color identity
|
||||
ci_set = set(color_identity) if color_identity else None
|
||||
filtered = []
|
||||
for row in rows:
|
||||
name = row["name"]
|
||||
type_line = row["type_line"] or ""
|
||||
# Basic lands and colorless are always allowed
|
||||
if ci_set:
|
||||
# Simple color check from type_line and name
|
||||
# For proper filtering we'd check mana cost but this is good enough for summary
|
||||
card_colors = set()
|
||||
oracle = row["oracle_text"] or ""
|
||||
mana_cost = ""
|
||||
@@ -137,7 +370,6 @@ class DeckGeneratorV3:
|
||||
if not filtered:
|
||||
return ""
|
||||
|
||||
# Group by card type
|
||||
groups = {}
|
||||
for row in filtered:
|
||||
type_line = row["type_line"] or ""
|
||||
@@ -159,14 +391,12 @@ class DeckGeneratorV3:
|
||||
group = "Other"
|
||||
groups.setdefault(group, []).append(row)
|
||||
|
||||
# Sort each group by popularity/price and build text
|
||||
lines = [f"COLLECTION SUMMARY ({len(filtered)} cards in deck colors):"]
|
||||
for group_name in ["Creatures", "Instants", "Sorceries", "Artifacts",
|
||||
"Enchantments", "Planeswalkers", "Lands", "Other"]:
|
||||
cards = groups.get(group_name, [])
|
||||
if not cards:
|
||||
continue
|
||||
# Sort by price descending (higher value = likely better card)
|
||||
cards.sort(key=lambda r: r["tcg_price"] or 0, reverse=True)
|
||||
card_strs = []
|
||||
for c in cards[:max_per_group]:
|
||||
@@ -176,7 +406,7 @@ class DeckGeneratorV3:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Step 3: Generate Deck via Perplexity ─────────────────
|
||||
# ── Step 3: Generate Deck via Anthropic Claude ─────────────────
|
||||
def generate_deck(
|
||||
self,
|
||||
commander_name: str,
|
||||
@@ -193,24 +423,33 @@ class DeckGeneratorV3:
|
||||
|
||||
Returns dict with:
|
||||
- commander: dict with Scryfall data
|
||||
- raw_deck: parsed GeneratedDeckList from Perplexity
|
||||
- cards: list of DeckCardWithStatus (after ownership check)
|
||||
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
|
||||
- cards: list of DeckCardWithStatus (after ownership check + substitution)
|
||||
- stats: deck statistics
|
||||
- substitution_log: list of SubstitutionRecord dicts
|
||||
- padded_slots: int > 0 means circuit breaker fired
|
||||
- tokens_used: {prompt, completion}
|
||||
"""
|
||||
# Step 1: Resolve commander
|
||||
cmdr = self.resolve_commander(commander_name)
|
||||
color_identity = cmdr["color_identity"]
|
||||
logger.info("Commander: %s, CI: %s", cmdr["name"], color_identity)
|
||||
scryfall_found = cmdr.get("scryfall_found", True)
|
||||
logger.info("Commander: %s, CI: %s, Scryfall: %s",
|
||||
cmdr["name"], color_identity, scryfall_found)
|
||||
|
||||
# Step 2: Collection summary
|
||||
collection_block = ""
|
||||
if use_collection:
|
||||
if use_collection and scryfall_found:
|
||||
collection_block = self.build_collection_summary(color_identity)
|
||||
logger.info("Collection summary: %d chars",
|
||||
len(collection_block) if collection_block else 0)
|
||||
elif not scryfall_found:
|
||||
logger.info(
|
||||
"Non-canonical commander '%s' — skipping collection filter, "
|
||||
"trusting LLM output directly.", cmdr["name"]
|
||||
)
|
||||
|
||||
# Step 3: Build prompts
|
||||
# Step 3: Build prompts (with slot budget injected)
|
||||
user_prompt = build_user_prompt(
|
||||
commander=cmdr["name"],
|
||||
commander_type=cmdr["type_line"],
|
||||
@@ -221,13 +460,14 @@ class DeckGeneratorV3:
|
||||
budget_mode=budget_mode,
|
||||
omit_cards=omit_cards,
|
||||
collection_summary=collection_block,
|
||||
slot_budget=SLOT_BUDGET,
|
||||
)
|
||||
|
||||
# Step 4: Call Perplexity with structured output
|
||||
# Step 4: Call LLM with structured output
|
||||
use_model = model or DECK_GEN_MODEL
|
||||
logger.info("Calling Perplexity (%s) for deck generation...", use_model)
|
||||
logger.info("Calling LLM (%s) for deck generation...", use_model)
|
||||
|
||||
response = self.pplx.chat_structured(
|
||||
response = self.anthropic.chat_structured(
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
user_prompt=user_prompt,
|
||||
json_schema=DECK_LIST_SCHEMA,
|
||||
@@ -239,26 +479,29 @@ class DeckGeneratorV3:
|
||||
|
||||
if not response.ok:
|
||||
raise ValueError(
|
||||
f"Perplexity returned invalid response: {response.content[:300]}"
|
||||
f"LLM returned invalid response: {response.content[:300]}"
|
||||
)
|
||||
|
||||
raw_deck = response.parsed_json
|
||||
card_count = len(raw_deck.get("cards", []))
|
||||
truncated = card_count < 90 # Commander decks should have ~100 cards
|
||||
logger.info("Deck generated: %d cards, bracket %d%s",
|
||||
card_count,
|
||||
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
|
||||
" (TRUNCATED — response may have hit token limit)" if truncated else "")
|
||||
truncated = card_count < 90
|
||||
logger.info(
|
||||
"Deck generated: %d cards, bracket %s%s",
|
||||
card_count,
|
||||
(raw_deck.get("bracket", {}).get("level", "?") if isinstance(raw_deck.get("bracket"), dict)
|
||||
else raw_deck.get("bracket", "?")),
|
||||
" (TRUNCATED — hit token limit)" if truncated else "",
|
||||
)
|
||||
|
||||
# Step 5: Check ownership and build enriched card list
|
||||
# Step 5: Check ownership
|
||||
cards_with_status = self._check_ownership(raw_deck.get("cards", []))
|
||||
|
||||
# Step 5b: Fill basic lands to hit exactly 100 cards
|
||||
# Step 5b: Fill basic lands to reach target_total=100
|
||||
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
|
||||
|
||||
# Step 5c: Secondary safety-net deduplication
|
||||
seen = {}
|
||||
deduped = []
|
||||
# Step 5c: Secondary deduplication
|
||||
seen: dict[str, int] = {}
|
||||
deduped: List[DeckCardWithStatus] = []
|
||||
for c in cards_with_status:
|
||||
key = c.name.lower()
|
||||
if key in seen:
|
||||
@@ -269,7 +512,27 @@ class DeckGeneratorV3:
|
||||
deduped.append(c)
|
||||
cards_with_status = deduped
|
||||
|
||||
# Step 6: Compute stats
|
||||
# Step 6: Run non-destructive smart substitution
|
||||
# For non-canonical commanders (e.g. Sonic), skip collection filter
|
||||
owned_pool: set[str] = set()
|
||||
if use_collection and scryfall_found:
|
||||
conn = self.db_conn_factory()
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
|
||||
).fetchall()
|
||||
owned_pool = {r["name"] for r in rows}
|
||||
|
||||
cards_with_status, substitution_log = apply_substitutions(
|
||||
cards_with_status, owned_pool, color_identity, self.embedding_index
|
||||
)
|
||||
|
||||
# Step 7: Pad-to-99 circuit breaker
|
||||
cards_with_status, padded_slots = _pad_to_99(cards_with_status, color_identity)
|
||||
|
||||
# Step 8: Pre-validation count logging
|
||||
_log_pre_validation_counts(cards_with_status)
|
||||
|
||||
# Step 9: Compute stats
|
||||
stats = self._compute_stats(cards_with_status)
|
||||
|
||||
return {
|
||||
@@ -283,6 +546,12 @@ class DeckGeneratorV3:
|
||||
"archetype": raw_deck.get("archetype", ""),
|
||||
"reasoning": raw_deck.get("reasoning", {}),
|
||||
"estimated_total_usd": raw_deck.get("estimated_total_usd", 0),
|
||||
"substitution_log": [
|
||||
{"original": r.original, "resolved": r.resolved,
|
||||
"method": r.method.value, "in_collection": r.in_collection}
|
||||
for r in substitution_log
|
||||
],
|
||||
"padded_slots": padded_slots,
|
||||
"tokens_used": {
|
||||
"prompt": response.prompt_tokens,
|
||||
"completion": response.completion_tokens,
|
||||
@@ -296,13 +565,11 @@ class DeckGeneratorV3:
|
||||
def _check_ownership(self, cards: List[dict]) -> List[DeckCardWithStatus]:
|
||||
"""
|
||||
Cross-reference generated cards against the collection DB.
|
||||
Sets owned/scryfall_id/owned_qty and real price.
|
||||
Deduplicates cards by name (case-insensitive) — if the LLM
|
||||
returns the same card twice, the counts are merged.
|
||||
Deduplicates by name (case-insensitive).
|
||||
"""
|
||||
conn = self.db_conn_factory()
|
||||
enriched = []
|
||||
seen_names: dict[str, int] = {} # name_lower → index in enriched
|
||||
enriched: List[DeckCardWithStatus] = []
|
||||
seen_names: dict[str, int] = {}
|
||||
|
||||
for card in cards:
|
||||
name = card.get("name", "")
|
||||
@@ -314,10 +581,9 @@ class DeckGeneratorV3:
|
||||
if name_lower in seen_names:
|
||||
idx = seen_names[name_lower]
|
||||
enriched[idx].count += card.get("count", 1)
|
||||
logger.warning("Duplicate card '%s' merged (LLM returned it twice)", name)
|
||||
logger.warning("Duplicate card '%s' merged", name)
|
||||
continue
|
||||
|
||||
# Look up in collection
|
||||
row = conn.execute(
|
||||
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
|
||||
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
|
||||
@@ -348,11 +614,8 @@ class DeckGeneratorV3:
|
||||
return enriched
|
||||
|
||||
# ── Step 5b: Basic Land Fill ──────────────────────────────
|
||||
_COLOR_TO_BASIC = {
|
||||
'W': 'Plains', 'U': 'Island', 'B': 'Swamp',
|
||||
'R': 'Mountain', 'G': 'Forest',
|
||||
}
|
||||
_BASIC_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'}
|
||||
_COLOR_TO_BASIC = _COLOR_TO_BASIC
|
||||
_BASIC_NAMES = _BASIC_NAMES
|
||||
|
||||
def _fill_basic_lands(
|
||||
self,
|
||||
@@ -374,7 +637,7 @@ class DeckGeneratorV3:
|
||||
ci = [c.upper() for c in color_identity]
|
||||
basic_names = [self._COLOR_TO_BASIC[c] for c in ci if c in self._COLOR_TO_BASIC]
|
||||
if not basic_names:
|
||||
basic_names = ['Wastes']
|
||||
basic_names = ["Wastes"]
|
||||
|
||||
per_color = basics_needed // len(basic_names)
|
||||
remainder = basics_needed % len(basic_names)
|
||||
@@ -385,20 +648,20 @@ class DeckGeneratorV3:
|
||||
non_basics.append(DeckCardWithStatus(
|
||||
name=bname,
|
||||
count=qty,
|
||||
category='Land',
|
||||
category="Land",
|
||||
role_tags=[],
|
||||
reason='Basic land for mana fixing',
|
||||
reason="Basic land for mana fixing",
|
||||
estimated_price_usd=0.10,
|
||||
owned=True,
|
||||
owned_qty=qty,
|
||||
status='owned',
|
||||
status="owned",
|
||||
))
|
||||
|
||||
logger.info("Basic land fill: %d basics added across %d color(s)",
|
||||
basics_needed, len(basic_names))
|
||||
basics_needed, len(basic_names))
|
||||
return non_basics
|
||||
|
||||
# ── Step 6: Smart Substitution ───────────────────────────
|
||||
# ── Step 6: Smart Substitution (legacy wrapper for route compatibility) ──
|
||||
def run_substitution(
|
||||
self,
|
||||
cards: List[DeckCardWithStatus],
|
||||
@@ -408,15 +671,12 @@ class DeckGeneratorV3:
|
||||
"""
|
||||
Run the Smart Substitution Engine on missing cards.
|
||||
|
||||
Pipeline:
|
||||
a) Identify missing cards
|
||||
b) For each: try embedding similarity (free, fast)
|
||||
c) For low-confidence matches: batch Perplexity fallback
|
||||
d) Auto-select best substitute per card
|
||||
|
||||
Returns SubstitutionBatchResult with all cards updated.
|
||||
Uses the non-destructive apply_substitutions() under the hood.
|
||||
Falls back to Anthropic Claude for low-confidence matches,
|
||||
then to basic land as last resort — NEVER drops a card.
|
||||
"""
|
||||
color_identity = commander.get("color_identity", [])
|
||||
scryfall_found = commander.get("scryfall_found", True)
|
||||
missing = [c for c in cards if not c.owned]
|
||||
owned = [c for c in cards if c.owned]
|
||||
|
||||
@@ -431,47 +691,63 @@ class DeckGeneratorV3:
|
||||
|
||||
logger.info("Substitution: %d missing / %d owned", len(missing), len(owned))
|
||||
|
||||
# Build list of owned card names for substitution pool
|
||||
# Build owned pool
|
||||
conn = self.db_conn_factory()
|
||||
owned_pool_rows = conn.execute(
|
||||
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
|
||||
).fetchall()
|
||||
owned_pool = [r["name"] for r in owned_pool_rows]
|
||||
owned_pool = {r["name"] for r in owned_pool_rows}
|
||||
deck_names = {c.name.lower() for c in cards}
|
||||
available_pool = {n for n in owned_pool if n.lower() not in deck_names}
|
||||
|
||||
# Exclude cards already in the deck
|
||||
deck_names = set(c.name.lower() for c in cards)
|
||||
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
|
||||
|
||||
# a) Embedding pass
|
||||
needs_pplx_fallback = []
|
||||
# a) Embedding pass for missing cards
|
||||
needs_anthropic_fallback: List[DeckCardWithStatus] = []
|
||||
for card in missing:
|
||||
embedding_alts = self._embedding_substitution(
|
||||
card, color_identity, available_pool
|
||||
card, color_identity, list(available_pool)
|
||||
)
|
||||
card.alternatives = embedding_alts
|
||||
|
||||
# Check if best match meets threshold
|
||||
if embedding_alts and embedding_alts[0].similarity_score >= SUBSTITUTION_MIN_SIMILARITY:
|
||||
card.selected_substitute = embedding_alts[0].name
|
||||
card.status = "substituted"
|
||||
# Remove selected from pool
|
||||
if card.selected_substitute.lower() in [n.lower() for n in available_pool]:
|
||||
available_pool = [n for n in available_pool
|
||||
if n.lower() != card.selected_substitute.lower()]
|
||||
card.owned = True
|
||||
available_pool.discard(card.selected_substitute.lower())
|
||||
else:
|
||||
needs_pplx_fallback.append(card)
|
||||
needs_anthropic_fallback.append(card)
|
||||
|
||||
# b) Perplexity fallback for low-confidence matches
|
||||
if needs_pplx_fallback and SUBSTITUTION_USE_PPLX_FALLBACK:
|
||||
self._pplx_substitution(
|
||||
needs_pplx_fallback, available_pool,
|
||||
# b) Anthropic Claude fallback
|
||||
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
|
||||
self._anthropic_substitution(
|
||||
needs_anthropic_fallback, list(available_pool),
|
||||
commander, strategy
|
||||
)
|
||||
|
||||
# Count results
|
||||
# c) Any card STILL missing after both passes gets a basic land — never drop it
|
||||
padded = 0
|
||||
for card in missing:
|
||||
if card.status == "missing":
|
||||
basic = _get_basic_land(color_identity)
|
||||
logger.warning(
|
||||
"[substitution] '%s' unresolved after all passes — "
|
||||
"replacing with basic land '%s'",
|
||||
card.name, basic,
|
||||
)
|
||||
card.name = basic
|
||||
card.category = "Land"
|
||||
card.role_tags = []
|
||||
card.owned = True
|
||||
card.status = "substituted"
|
||||
card.original_card = card.name
|
||||
padded += 1
|
||||
|
||||
substituted = sum(1 for c in cards if c.status == "substituted")
|
||||
still_missing = sum(1 for c in cards if c.status == "missing")
|
||||
|
||||
# Validate count hasn't changed
|
||||
assert len(cards) == len(owned) + len(missing), \
|
||||
f"[substitution] Card count changed: {len(owned) + len(missing)} -> {len(cards)}"
|
||||
|
||||
return SubstitutionBatchResult(
|
||||
total_cards=len(cards),
|
||||
owned_count=len(owned),
|
||||
@@ -490,39 +766,37 @@ class DeckGeneratorV3:
|
||||
if not self.embedding_index or not self.embedding_index.loaded:
|
||||
return []
|
||||
|
||||
# Use the embeddings search to find similar cards
|
||||
matches = self.embedding_index.search_similar(
|
||||
query_card=card.name,
|
||||
color_filter=color_identity if color_identity else None,
|
||||
type_filter=card.category if card.category else None,
|
||||
exclude_cards=[card.name],
|
||||
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3, # Over-fetch, then filter to owned
|
||||
)
|
||||
try:
|
||||
matches = self.embedding_index.search_similar(
|
||||
query_card=card.name,
|
||||
color_filter=color_identity if color_identity else None,
|
||||
type_filter=card.category if card.category else None,
|
||||
exclude_cards=[card.name],
|
||||
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Embedding search failed for '%s': %s", card.name, e)
|
||||
return []
|
||||
|
||||
# Filter to only cards in the available pool
|
||||
pool_lower = set(n.lower() for n in available_pool)
|
||||
pool_lower = {n.lower() for n in available_pool}
|
||||
alternatives = []
|
||||
for match in matches:
|
||||
if match.name.lower() in pool_lower:
|
||||
# Compute role overlap
|
||||
match_roles = [] # Would need card's role_tags from DB
|
||||
role_overlap = list(set(card.role_tags) & set(match_roles)) if match_roles else []
|
||||
|
||||
role_overlap = list(set(card.role_tags) & set(getattr(match, "role_tags", [])))
|
||||
alternatives.append(CardAlternative(
|
||||
name=match.name,
|
||||
similarity_score=match.similarity,
|
||||
reason=f"Similar to {card.name} (cosine sim: {match.similarity:.3f})",
|
||||
source="embedding",
|
||||
role_overlap=role_overlap,
|
||||
cmc_delta=match.mana_value - (card.estimated_price_usd or 0),
|
||||
cmc_delta=getattr(match, "mana_value", 0) - (card.estimated_price_usd or 0),
|
||||
))
|
||||
|
||||
if len(alternatives) >= SUBSTITUTION_MAX_ALTERNATIVES:
|
||||
break
|
||||
|
||||
return alternatives
|
||||
|
||||
def _pplx_substitution(
|
||||
def _anthropic_substitution(
|
||||
self,
|
||||
missing_cards: List[DeckCardWithStatus],
|
||||
available_pool: List[str],
|
||||
@@ -530,13 +804,14 @@ class DeckGeneratorV3:
|
||||
strategy: str,
|
||||
):
|
||||
"""
|
||||
Batch Perplexity fallback for cards with low embedding confidence.
|
||||
Mutates the cards in-place with substitution data.
|
||||
Batch Anthropic Claude fallback for cards with low embedding confidence.
|
||||
Mutates cards in-place. On failure, cards retain status='missing'
|
||||
so the basic-land fallback in run_substitution() can catch them.
|
||||
"""
|
||||
if not available_pool:
|
||||
logger.warning("[anthropic_substitution] No available pool — skipping")
|
||||
return
|
||||
|
||||
# Build the request
|
||||
missing_dicts = [
|
||||
{
|
||||
"name": c.name,
|
||||
@@ -549,7 +824,7 @@ class DeckGeneratorV3:
|
||||
|
||||
user_prompt = build_substitution_prompt(
|
||||
missing_cards=missing_dicts,
|
||||
allowed_cards=available_pool[:200], # Limit to avoid token overflow
|
||||
allowed_cards=available_pool[:200],
|
||||
commander=commander.get("name", ""),
|
||||
color_identity=commander.get("color_identity", []),
|
||||
strategy=strategy,
|
||||
@@ -557,7 +832,7 @@ class DeckGeneratorV3:
|
||||
|
||||
try:
|
||||
from ..schemas.substitution_schema import SUBSTITUTION_RESPONSE_SCHEMA
|
||||
response = self.pplx.chat_structured(
|
||||
response = self.anthropic.chat_structured(
|
||||
system_prompt=SUBSTITUTION_SYSTEM,
|
||||
user_prompt=user_prompt,
|
||||
json_schema=SUBSTITUTION_RESPONSE_SCHEMA,
|
||||
@@ -568,13 +843,16 @@ class DeckGeneratorV3:
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
logger.warning("Perplexity substitution fallback failed — non-JSON response")
|
||||
logger.warning(
|
||||
"[anthropic_substitution] LLM returned non-JSON — "
|
||||
"%d cards will fall through to basic land fallback",
|
||||
len(missing_cards),
|
||||
)
|
||||
return
|
||||
|
||||
subs = response.parsed_json.get("substitutions", [])
|
||||
logger.info("Perplexity suggested %d substitutions", len(subs))
|
||||
logger.info("[anthropic_substitution] Received %d substitutions", len(subs))
|
||||
|
||||
# Apply substitutions
|
||||
card_by_name = {c.name.lower(): c for c in missing_cards}
|
||||
for sub in subs:
|
||||
orig = sub.get("original", "").lower()
|
||||
@@ -583,32 +861,38 @@ class DeckGeneratorV3:
|
||||
|
||||
if orig in card_by_name and substitute:
|
||||
card = card_by_name[orig]
|
||||
# Add as top alternative
|
||||
pplx_alt = CardAlternative(
|
||||
anthropic_alt = CardAlternative(
|
||||
name=substitute,
|
||||
similarity_score=0.80, # Nominal score for Perplexity suggestions
|
||||
similarity_score=0.80,
|
||||
reason=reason,
|
||||
source="perplexity",
|
||||
source="anthropic",
|
||||
role_overlap=sub.get("role_overlap", []),
|
||||
)
|
||||
# Insert at front
|
||||
card.alternatives.insert(0, pplx_alt)
|
||||
card.alternatives.insert(0, anthropic_alt)
|
||||
card.selected_substitute = substitute
|
||||
card.status = "substituted"
|
||||
card.owned = True
|
||||
card.original_card = card.name
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Perplexity substitution fallback error: %s", e)
|
||||
logger.error(
|
||||
"[anthropic_substitution] Exception: %s — "
|
||||
"%d cards will fall through to basic land fallback",
|
||||
e, len(missing_cards),
|
||||
)
|
||||
# Do NOT re-raise — cards stay status='missing' and
|
||||
# run_substitution()'s basic-land fallback will handle them
|
||||
|
||||
# ── Stats ────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _compute_stats(cards: List[DeckCardWithStatus]) -> dict:
|
||||
"""Compute deck statistics from the enriched card list."""
|
||||
by_category = {}
|
||||
by_status = {"owned": 0, "substituted": 0, "missing": 0}
|
||||
by_category: dict[str, int] = {}
|
||||
by_status: dict[str, int] = {"owned": 0, "substituted": 0, "missing": 0}
|
||||
total_price = 0.0
|
||||
owned_price = 0.0
|
||||
missing_price = 0.0
|
||||
role_counts = {}
|
||||
role_counts: dict[str, int] = {}
|
||||
|
||||
for card in cards:
|
||||
cat = card.category or "Other"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Phase 2: Dual-Brain AI Decision Engine
|
||||
@@ -0,0 +1,5 @@
|
||||
# Phase 6 - decision_logger.py
|
||||
# Logs per-decision: game_id, turn, phase, player_id, personality,
|
||||
# brain_used, prompt, raw_response, parsed_action, fallback_used
|
||||
# Configurable via LOG_AI_DECISIONS env var
|
||||
# See issue #173
|
||||
@@ -0,0 +1,15 @@
|
||||
# Phase 2 - game_orchestrator.py *** CORE PRIORITY ***
|
||||
# GameAIOrchestrator: select_brain(), assign_personalities(), get_action()
|
||||
# Brains: DeepSeek-R1-14B (complex) + GPT-OSS-20B (tactical)
|
||||
# ChromaDB RAG for per-player card knowledge
|
||||
# See issue #169
|
||||
|
||||
PERSONALITY_POOL = [
|
||||
"aggressive -- attack early and often, prioritize players with lowest life totals",
|
||||
"control -- counter key spells, hold mana open, only act when clearly advantageous",
|
||||
"combo -- develop mana and card advantage quietly, avoid conflict until ready to win",
|
||||
"political -- make deals, attack whoever is ahead, never fully commit to one target",
|
||||
"pillowfort -- build defensive pieces, let others fight, win slowly through attrition",
|
||||
"stompy -- play the biggest threats as fast as possible, ignore table politics",
|
||||
"tempo -- disrupt opponents early, trade efficiently, stay ahead on board",
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
# Phase 0: Card image cache and data enrichment
|
||||
from commander_ai_lab.cards.image_cache import get_image_cache, ImageCache, ImageCacheResult
|
||||
from commander_ai_lab.cards.card_data_enricher import enrich_card, enrich_card_list, enrich_game_state
|
||||
|
||||
__all__ = [
|
||||
"get_image_cache",
|
||||
"ImageCache",
|
||||
"ImageCacheResult",
|
||||
"enrich_card",
|
||||
"enrich_card_list",
|
||||
"enrich_game_state",
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Phase 0 — card_data_enricher.py
|
||||
|
||||
Injects art_url and art_url_back into card data dicts/DTOs
|
||||
before they are broadcast to Unity via WebSocket.
|
||||
|
||||
Used by:
|
||||
- Phase 1 game_state_parser.py (enriches cards in GameStateDTO)
|
||||
- Phase 3 WebSocket handler (enriches full game state before broadcast)
|
||||
|
||||
Lookup chain per card:
|
||||
1. scryfall_id present -> ImageCache.lookup_by_id()
|
||||
2. scryfall_id absent -> ImageCache.lookup_by_name() (Forge card name)
|
||||
3. Both fail -> Scryfall CDN fallback URL
|
||||
4. CDN fallback absent -> empty string (Unity shows card back texture)
|
||||
|
||||
See issue #167
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from commander_ai_lab.cards.image_cache import get_image_cache, ImageCacheResult
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.cards.card_data_enricher")
|
||||
|
||||
# Scryfall CDN fallback when both local cache and DB are unavailable
|
||||
_CDN_FRONT = "https://cards.scryfall.io/normal/front/{d1}/{d2}/{sid}.jpg"
|
||||
_CDN_BACK = "https://cards.scryfall.io/normal/back/{d1}/{d2}/{sid}.jpg"
|
||||
|
||||
|
||||
def _cdn_fallback_urls(scryfall_id: str) -> tuple[str, str | None]:
|
||||
if not scryfall_id:
|
||||
return "", None
|
||||
d1 = scryfall_id[0]
|
||||
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
|
||||
front = _CDN_FRONT.format(d1=d1, d2=d2, sid=scryfall_id)
|
||||
back = _CDN_BACK.format(d1=d1, d2=d2, sid=scryfall_id)
|
||||
return front, back
|
||||
|
||||
|
||||
def enrich_card(card: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Inject art_url and art_url_back into a single card dict.
|
||||
Mutates in place AND returns the card for convenience.
|
||||
|
||||
Input card dict is expected to have at minimum:
|
||||
- 'name': str (card name from Forge)
|
||||
- 'scryfall_id': str (optional but preferred)
|
||||
|
||||
After enrichment the card will have:
|
||||
- 'art_url': str front face image URL
|
||||
- 'art_url_back': str|None back face image URL (DFCs only)
|
||||
- 'has_back': bool
|
||||
"""
|
||||
# Skip if already enriched
|
||||
if card.get("art_url"):
|
||||
return card
|
||||
|
||||
cache = get_image_cache()
|
||||
result: ImageCacheResult | None = None
|
||||
|
||||
scryfall_id = card.get("scryfall_id", "")
|
||||
if scryfall_id:
|
||||
result = cache.lookup_by_id(scryfall_id)
|
||||
|
||||
if result is None:
|
||||
name = card.get("name", "")
|
||||
if name:
|
||||
result = cache.lookup_by_name(name)
|
||||
|
||||
if result is not None:
|
||||
card["art_url"] = result.front_url
|
||||
card["art_url_back"] = result.back_url
|
||||
card["has_back"] = result.has_back
|
||||
else:
|
||||
# Hard fallback: build CDN URL from scryfall_id if available
|
||||
front, back = _cdn_fallback_urls(scryfall_id)
|
||||
card["art_url"] = front
|
||||
card["art_url_back"] = back if back else None
|
||||
card["has_back"] = False
|
||||
if not front:
|
||||
log.warning("No art URL resolved for card: %s (id=%s)", card.get("name"), scryfall_id)
|
||||
|
||||
return card
|
||||
|
||||
|
||||
def enrich_card_list(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Enrich a list of card dicts. Mutates in place."""
|
||||
for card in cards:
|
||||
enrich_card(card)
|
||||
return cards
|
||||
|
||||
|
||||
def enrich_game_state(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Enrich all card lists in a full GameStateDTO dict.
|
||||
Walks all players and all zones: hand, battlefield, graveyard, exile, command_zone.
|
||||
Mutates in place AND returns state.
|
||||
"""
|
||||
zone_keys = ("hand", "battlefield", "graveyard", "exile", "command_zone")
|
||||
|
||||
for player in state.get("players", []):
|
||||
for zone in zone_keys:
|
||||
cards = player.get(zone)
|
||||
if isinstance(cards, list):
|
||||
enrich_card_list(cards)
|
||||
|
||||
# Also enrich the stack
|
||||
for stack_item in state.get("stack", []):
|
||||
card = stack_item.get("card")
|
||||
if isinstance(card, dict):
|
||||
enrich_card(card)
|
||||
|
||||
return state
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Phase 0 — image_cache.py
|
||||
|
||||
SQLite-backed card image lookup service.
|
||||
|
||||
Provides:
|
||||
ImageCache — sync lookup (used by download scripts, CLI tools)
|
||||
AsyncImageCache — async lookup (used by FastAPI request handlers)
|
||||
|
||||
Lookup priority:
|
||||
1. SQLite index by scryfall_id (exact, fast)
|
||||
2. SQLite index by card name (case-insensitive, Forge card name format)
|
||||
3. CDN fallback URL (scryfall.io) — no local file, but still a valid URL
|
||||
|
||||
See issue #167
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.cards.image_cache")
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
DB_PATH = PROJECT_ROOT / "data" / "card_image_index.db"
|
||||
STATIC_BASE_URL = "/static/card-images"
|
||||
|
||||
# Scryfall CDN URL pattern for fallback
|
||||
# Format: https://cards.scryfall.io/normal/front/{d1}/{d2}/{scryfall_id}.jpg
|
||||
# where d1/d2 are first two hex chars of the UUID
|
||||
def _cdn_url(scryfall_id: str, face: str = "front") -> str:
|
||||
"""Build a Scryfall CDN URL for a card face. Used as fallback."""
|
||||
d1 = scryfall_id[0] if scryfall_id else "0"
|
||||
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
|
||||
return f"https://cards.scryfall.io/normal/{face}/{d1}/{d2}/{scryfall_id}.jpg"
|
||||
|
||||
|
||||
class ImageCacheResult:
|
||||
"""Result of a card image lookup."""
|
||||
__slots__ = ("scryfall_id", "name_raw", "front_url", "back_url", "has_back", "is_local")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scryfall_id: str,
|
||||
name_raw: str,
|
||||
front_url: str,
|
||||
back_url: Optional[str],
|
||||
has_back: bool,
|
||||
is_local: bool,
|
||||
):
|
||||
self.scryfall_id = scryfall_id
|
||||
self.name_raw = name_raw
|
||||
self.front_url = front_url
|
||||
self.back_url = back_url
|
||||
self.has_back = has_back
|
||||
self.is_local = is_local
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"scryfall_id": self.scryfall_id,
|
||||
"name": self.name_raw,
|
||||
"art_url": self.front_url,
|
||||
"art_url_back": self.back_url,
|
||||
"has_back": self.has_back,
|
||||
"is_local": self.is_local,
|
||||
}
|
||||
|
||||
|
||||
class ImageCache:
|
||||
"""Synchronous SQLite-backed card image cache."""
|
||||
|
||||
def __init__(self, db_path: Path = DB_PATH, base_url: str = STATIC_BASE_URL):
|
||||
self._db_path = db_path
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
if not self._db_path.exists():
|
||||
log.warning(
|
||||
"Image index DB not found at %s. Run scripts/build_image_index.py first.",
|
||||
self._db_path
|
||||
)
|
||||
return None
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
return self._conn
|
||||
|
||||
def _row_to_result(self, row: sqlite3.Row) -> ImageCacheResult:
|
||||
scryfall_id = row["scryfall_id"]
|
||||
front_path = row["front_path"]
|
||||
back_path = row["back_path"]
|
||||
has_back = bool(row["has_back"])
|
||||
|
||||
if front_path:
|
||||
front_url = f"{self._base_url}/{scryfall_id}_front.jpg"
|
||||
back_url = f"{self._base_url}/{scryfall_id}_back.jpg" if has_back else None
|
||||
is_local = True
|
||||
else:
|
||||
# CDN fallback
|
||||
front_url = _cdn_url(scryfall_id, "front")
|
||||
back_url = _cdn_url(scryfall_id, "back") if has_back else None
|
||||
is_local = False
|
||||
|
||||
return ImageCacheResult(
|
||||
scryfall_id=scryfall_id,
|
||||
name_raw=row["name_raw"],
|
||||
front_url=front_url,
|
||||
back_url=back_url,
|
||||
has_back=has_back,
|
||||
is_local=is_local,
|
||||
)
|
||||
|
||||
def lookup_by_id(self, scryfall_id: str) -> Optional[ImageCacheResult]:
|
||||
"""Look up card by Scryfall UUID."""
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return self._cdn_fallback(scryfall_id)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM card_images WHERE scryfall_id = ?", (scryfall_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return self._row_to_result(row)
|
||||
return self._cdn_fallback(scryfall_id)
|
||||
|
||||
def lookup_by_name(self, name: str) -> Optional[ImageCacheResult]:
|
||||
"""Look up card by name (case-insensitive). Returns most recent printing."""
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT * FROM card_images WHERE name = ? LIMIT 1",
|
||||
(name.lower().strip(),)
|
||||
).fetchone()
|
||||
if row:
|
||||
return self._row_to_result(row)
|
||||
return None
|
||||
|
||||
def _cdn_fallback(self, scryfall_id: str) -> ImageCacheResult:
|
||||
"""Return CDN URLs when local cache is unavailable."""
|
||||
return ImageCacheResult(
|
||||
scryfall_id=scryfall_id,
|
||||
name_raw="",
|
||||
front_url=_cdn_url(scryfall_id, "front"),
|
||||
back_url=None,
|
||||
has_back=False,
|
||||
is_local=False,
|
||||
)
|
||||
|
||||
def close(self):
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
|
||||
# Module-level singleton for use by FastAPI route handlers
|
||||
_cache: Optional[ImageCache] = None
|
||||
|
||||
|
||||
def get_image_cache() -> ImageCache:
|
||||
"""Get the module-level ImageCache singleton."""
|
||||
global _cache
|
||||
if _cache is None:
|
||||
_cache = ImageCache()
|
||||
return _cache
|
||||
@@ -0,0 +1 @@
|
||||
# Phase 1: Forge subprocess bridge
|
||||
@@ -0,0 +1,3 @@
|
||||
# Phase 6 - game_result_logger.py
|
||||
# Captures winner, turn count, elimination order on Forge game end
|
||||
# See issue #173
|
||||
@@ -0,0 +1,3 @@
|
||||
# Phase 1 - game_state_parser.py
|
||||
# Converts Forge stdout text -> canonical GameStateDTO JSON
|
||||
# See issue #168
|
||||
@@ -0,0 +1,4 @@
|
||||
# Phase 1 - subprocess_manager.py
|
||||
# ForgeGameSession + ForgeSubprocessManager
|
||||
# Drives interactive Commander games via Forge stdin/stdout pipe
|
||||
# See issue #168
|
||||
@@ -0,0 +1 @@
|
||||
# Phase 6: ML Training Integration
|
||||
@@ -0,0 +1,4 @@
|
||||
# Phase 6 - training_data_builder.py
|
||||
# Converts decision_log + game_result -> (state, action, reward) tuples
|
||||
# Feeds existing PPO pipeline
|
||||
# See issue #173
|
||||
@@ -0,0 +1,2 @@
|
||||
# Runtime data directory
|
||||
# card_image_index.db, decision_logs/, game_results.jsonl, training_sets/
|
||||
@@ -137,7 +137,7 @@ if (-not (Test-Path $envFile)) {
|
||||
# Uncomment and fill in the values you need:
|
||||
|
||||
# XIMILAR_API_KEY=your_ximilar_key_here
|
||||
# PPLX_API_KEY=your_perplexity_key_here
|
||||
# ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
# FORGE_JAR=C:\Path\To\forge-gui-desktop.jar
|
||||
# FORGE_DIR=C:\Path\To\Forge
|
||||
# LAB_PORT=8080
|
||||
|
||||
+121
-66
@@ -3,9 +3,13 @@
|
||||
Commander AI Lab — FastAPI Backend
|
||||
Thin entry point: registers routers and starts uvicorn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
@@ -22,7 +26,7 @@ except ImportError:
|
||||
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
|
||||
import sys; sys.exit(1)
|
||||
|
||||
from models.state import CFG, COMMANDER_META, load_commander_meta
|
||||
from models.state import CFG, COMMANDER_META, load_commander_meta, validate_forge_config
|
||||
from services.database import init_collection_db
|
||||
from services.logging import setup_logging
|
||||
from services.precon_service import PRECON_INDEX, download_precon_database
|
||||
@@ -32,20 +36,88 @@ from routes.precon import router as precon_router
|
||||
from routes.import_routes import router as import_router
|
||||
from routes.lab import router as lab_router
|
||||
from routes.scanner import router as scanner_router
|
||||
from routes.rag import router as rag_router
|
||||
from routes.deepseek import router as deepseek_router
|
||||
from routes.deckgen import router as deckgen_router
|
||||
from routes.coach import router as coach_router, init_coach_service
|
||||
from routes.ml import router as ml_router
|
||||
from routes.ws_game import router as ws_game_router
|
||||
from routes.game import router as game_router
|
||||
# Phase 0: card art endpoints
|
||||
from routes.card_art import router as card_art_router
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
# ── CORS origins (env-configurable) ────────────────────────────
|
||||
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:8080"
|
||||
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
|
||||
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
|
||||
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
||||
|
||||
# ── App ────────────────────────────────────────────────────────
|
||||
app = FastAPI(title="Commander AI Lab API", version="3.0.0")
|
||||
|
||||
# ── Lifespan (replaces deprecated @app.on_event) ──────────────────────────────────────────────────
|
||||
@asynccontextmanager
|
||||
async def _lifespan(application: FastAPI):
|
||||
# ──── STARTUP ────
|
||||
if not CFG.precon_dir:
|
||||
CFG.precon_dir = _resolve_precon_dir()
|
||||
init_collection_db()
|
||||
validate_forge_config()
|
||||
if not PRECON_INDEX:
|
||||
download_precon_database()
|
||||
if not COMMANDER_META:
|
||||
load_commander_meta()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# RAG Phase 1: kick off Scryfall bulk download in background thread.
|
||||
from services.scryfall_bulk import ensure_bulk_db
|
||||
loop.run_in_executor(None, ensure_bulk_db)
|
||||
|
||||
# RAG Phase 2: kick off ChromaDB vector store build in background thread.
|
||||
from services.rag_store import ensure_rag_store
|
||||
loop.run_in_executor(None, ensure_rag_store)
|
||||
|
||||
# RAG Phase 3: background staleness monitor (rebuilds if >14 days old)
|
||||
async def _rag_staleness_monitor():
|
||||
"""Periodically check if the RAG index needs rebuilding."""
|
||||
import time as _time
|
||||
while True:
|
||||
await asyncio.sleep(6 * 3600) # check every 6 hours
|
||||
try:
|
||||
from services.rag_store import check_staleness, build_index
|
||||
info = check_staleness()
|
||||
if info.get("stale"):
|
||||
log.info("RAG staleness monitor: %s — triggering rebuild.", info["reason"])
|
||||
loop.run_in_executor(None, lambda: build_index(force=True))
|
||||
else:
|
||||
log.debug("RAG staleness monitor: index is current (%.1f days).", info.get("age_days", 0))
|
||||
except Exception as exc:
|
||||
log.warning("RAG staleness monitor failed: %s", exc)
|
||||
asyncio.ensure_future(_rag_staleness_monitor())
|
||||
|
||||
# Policy routes: register if ML policy model is available
|
||||
try:
|
||||
from ml.serving.policy_server import PolicyInferenceService
|
||||
from routes.policy import register_policy_routes
|
||||
_policy_svc = PolicyInferenceService()
|
||||
if _policy_svc.load():
|
||||
register_policy_routes(application, _policy_svc)
|
||||
log.info("Policy routes registered (/api/policy/*)")
|
||||
else:
|
||||
log.warning("Policy model not loaded — /api/policy/* routes inactive")
|
||||
except ImportError:
|
||||
log.info("Policy service not available (ml.serving not installed)")
|
||||
except Exception as e:
|
||||
log.warning("Policy route registration failed (non-fatal): %s", e)
|
||||
|
||||
yield # ← server is running
|
||||
|
||||
# ──── SHUTDOWN ────
|
||||
from services.async_db import async_close_db
|
||||
await async_close_db()
|
||||
|
||||
|
||||
# ── App ─────────────────────────────────────────────────────────────────────────────
|
||||
app = FastAPI(title="Commander AI Lab API", version="3.0.0", lifespan=_lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allowed_origins,
|
||||
@@ -60,33 +132,43 @@ app.include_router(precon_router)
|
||||
app.include_router(import_router)
|
||||
app.include_router(lab_router)
|
||||
app.include_router(scanner_router)
|
||||
app.include_router(rag_router)
|
||||
app.include_router(deepseek_router)
|
||||
app.include_router(deckgen_router)
|
||||
app.include_router(coach_router)
|
||||
app.include_router(ws_game_router)
|
||||
app.include_router(ml_router)
|
||||
app.include_router(game_router) # live game session routes
|
||||
app.include_router(card_art_router) # Phase 0: /card-art/* + /api/card-image-*
|
||||
|
||||
|
||||
# ── API endpoints ─────────────────────────────────────────────────────────────────────────────
|
||||
# NOTE: all @app.get routes MUST be registered before app.mount() calls.
|
||||
# Once StaticFiles is mounted at "/", it registers a wildcard catch-all that
|
||||
# shadows any routes added after it.
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for load balancers and Unity client polling."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _on_startup():
|
||||
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
|
||||
# Ensure precon_dir is resolved even when started via uvicorn directly
|
||||
if not CFG.precon_dir:
|
||||
CFG.precon_dir = _resolve_precon_dir()
|
||||
init_collection_db()
|
||||
if not PRECON_INDEX:
|
||||
download_precon_database()
|
||||
if not COMMANDER_META:
|
||||
load_commander_meta()
|
||||
# ── Static mounts (MUST come after all @app.get / router includes) ────────────────────────
|
||||
from pathlib import Path as _Path
|
||||
|
||||
# ── Static UI ──────────────────────────────────────────────────
|
||||
_legacy_ui_dir = Path(__file__).parent / "ui"
|
||||
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||
# Phase 0: Serve local card images at /static/card-images/
|
||||
# MUST be mounted before the legacy UI wildcard to avoid being shadowed.
|
||||
_card_images_dir = _Path(__file__).parent / "static" / "card-images"
|
||||
_card_images_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount(
|
||||
"/static/card-images",
|
||||
StaticFiles(directory=str(_card_images_dir)),
|
||||
name="card-images"
|
||||
)
|
||||
|
||||
# Legacy UI / SPA (wildcard — MUST come last)
|
||||
_legacy_ui_dir = _Path(__file__).parent / "ui"
|
||||
_spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||
|
||||
if _legacy_ui_dir.exists():
|
||||
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
|
||||
@@ -94,6 +176,7 @@ elif _spa_dir.exists():
|
||||
_spa_assets = _spa_dir / "assets"
|
||||
if _spa_assets.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(_spa_assets)), name="spa-assets")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def _spa_catchall(full_path: str):
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -102,7 +185,8 @@ elif _spa_dir.exists():
|
||||
return FileResponse(str(requested_file))
|
||||
return FileResponse(str(_spa_dir / "index.html"))
|
||||
|
||||
# ── Startup ────────────────────────────────────────────────────
|
||||
|
||||
# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────
|
||||
def _parse_args():
|
||||
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
|
||||
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
|
||||
@@ -112,10 +196,11 @@ def _parse_args():
|
||||
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
|
||||
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
|
||||
p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
|
||||
p.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", ""))
|
||||
p.add_argument("--anthropic-key", default=os.environ.get("ANTHROPIC_API_KEY", ""))
|
||||
p.add_argument("--verbose", "-v", action="store_true")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def _resolve_forge_decks_dir() -> str:
|
||||
import sys
|
||||
if sys.platform == "win32":
|
||||
@@ -133,52 +218,35 @@ def _resolve_forge_decks_dir() -> str:
|
||||
return str(candidate)
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_forge_dir() -> str:
|
||||
"""Auto-detect the Forge installation directory.
|
||||
|
||||
The Forge dir is the root of a Forge install — it typically contains
|
||||
a 'res' subdirectory with card data. We derive it from the JAR
|
||||
location (parent of 'target/') or look in common install paths.
|
||||
"""
|
||||
"""Auto-detect the Forge installation directory."""
|
||||
import sys
|
||||
|
||||
# 1. If forge_jar is already resolved, derive forge_dir from it.
|
||||
# JAR lives in <forge-root>/forge-gui-desktop/target/<jar>
|
||||
# so forge_dir = JAR's grandparent's parent.
|
||||
if CFG.forge_jar and os.path.isfile(CFG.forge_jar):
|
||||
jar_path = Path(CFG.forge_jar)
|
||||
# target/ -> forge-gui-desktop/ -> <forge-root>
|
||||
candidate = jar_path.parent.parent.parent
|
||||
if candidate.is_dir() and (candidate / "res").is_dir():
|
||||
return str(candidate)
|
||||
# Also try just one level up from target/
|
||||
candidate = jar_path.parent.parent
|
||||
# Check forge-gui sibling of forge-gui-desktop (res/ lives there)
|
||||
forge_gui_sibling = jar_path.parent.parent.parent / "forge-gui"
|
||||
if forge_gui_sibling.is_dir() and (forge_gui_sibling / "res").is_dir():
|
||||
return str(forge_gui_sibling)
|
||||
if candidate.is_dir() and (candidate / "res").is_dir():
|
||||
return str(candidate)
|
||||
# Fall back to the directory containing the JAR itself
|
||||
candidate = jar_path.parent
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 2. Check common sibling/child directories relative to project root
|
||||
project_root = Path(__file__).parent
|
||||
for candidate in [
|
||||
project_root / "forge",
|
||||
project_root.parent / "forge-repo",
|
||||
project_root.parent / "forge-repo" / "forge-gui",
|
||||
project_root.parent / "forge-repo" / "forge-gui",
|
||||
project_root.parent / "forge",
|
||||
project_root / "forge-gui-desktop",
|
||||
]:
|
||||
if candidate.is_dir():
|
||||
# Prefer directory that has 'res' subfolder
|
||||
if (candidate / "res").is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 3. Platform-specific user data directories
|
||||
if sys.platform == "win32":
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
if appdata:
|
||||
@@ -186,32 +254,25 @@ def _resolve_forge_dir() -> str:
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
home = Path.home()
|
||||
for candidate in [
|
||||
home / ".forge",
|
||||
home / "Forge",
|
||||
]:
|
||||
for candidate in [home / ".forge", home / "Forge"]:
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_forge_jar() -> str:
|
||||
"""Auto-detect the Forge GUI desktop JAR in common locations."""
|
||||
project_root = Path(__file__).parent
|
||||
|
||||
# Candidate directories where the Forge JAR might live
|
||||
candidates = [
|
||||
project_root / "forge-gui-desktop" / "target",
|
||||
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
|
||||
project_root.parent / "forge" / "forge-gui-desktop" / "target",
|
||||
]
|
||||
|
||||
# Also check FORGE_DIR/../forge-gui-desktop/target if FORGE_DIR is set
|
||||
forge_dir_env = os.environ.get("FORGE_DIR", "")
|
||||
if forge_dir_env:
|
||||
fd = Path(forge_dir_env)
|
||||
candidates.append(fd.parent / "forge-gui-desktop" / "target")
|
||||
candidates.append(fd / "target")
|
||||
|
||||
for target_dir in candidates:
|
||||
if target_dir.is_dir():
|
||||
for pattern in [
|
||||
@@ -225,6 +286,7 @@ def _resolve_forge_jar() -> str:
|
||||
return str(jars[0])
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_lab_jar() -> str:
|
||||
target_dir = Path(__file__).parent / "target"
|
||||
if target_dir.exists():
|
||||
@@ -239,36 +301,25 @@ def _resolve_lab_jar() -> str:
|
||||
return str(jars[0])
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_precon_dir() -> str:
|
||||
"""Auto-detect the precon-decks directory.
|
||||
|
||||
The precon-decks dir lives in the project root and contains downloaded
|
||||
.dck files plus precon-index.json. We check relative to this file
|
||||
(lab_api.py) first, then common sibling paths.
|
||||
"""
|
||||
"""Auto-detect the precon-decks directory."""
|
||||
project_root = Path(__file__).parent
|
||||
|
||||
# 1. Standard location: <project-root>/precon-decks
|
||||
candidate = project_root / "precon-decks"
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 2. Check parent directory (in case running from a subdirectory)
|
||||
candidate = project_root.parent / "precon-decks"
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 3. Derive from forge_dir if available
|
||||
if CFG.forge_dir:
|
||||
candidate = Path(CFG.forge_dir) / "precon-decks"
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 4. Fall back: create the directory in the project root so downloads work
|
||||
fallback = project_root / "precon-decks"
|
||||
fallback.mkdir(parents=True, exist_ok=True)
|
||||
return str(fallback)
|
||||
|
||||
|
||||
def main():
|
||||
args = _parse_args()
|
||||
setup_logging(logging.DEBUG if args.verbose else logging.INFO)
|
||||
@@ -280,7 +331,7 @@ def main():
|
||||
CFG.precon_dir = args.precon_dir or _resolve_precon_dir()
|
||||
CFG.port = args.port
|
||||
CFG.ximilar_api_key = args.ximilar_key
|
||||
CFG.pplx_api_key = args.pplx_key
|
||||
CFG.anthropic_api_key = args.anthropic_key
|
||||
|
||||
from services.forge_runner import get_java17
|
||||
j17 = get_java17()
|
||||
@@ -294,7 +345,7 @@ def main():
|
||||
log.info(f" Lab JAR: {CFG.lab_jar or 'NOT SET'}")
|
||||
log.info(f" Results: {CFG.results_dir}")
|
||||
log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET'}")
|
||||
log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET'}")
|
||||
log.info(f" Anthropic: {'configured' if CFG.anthropic_api_key else 'NOT SET'}")
|
||||
log.info(f" Java 17: {j17}")
|
||||
|
||||
load_commander_meta()
|
||||
@@ -302,8 +353,12 @@ def main():
|
||||
init_collection_db()
|
||||
init_coach_service()
|
||||
|
||||
# RAG Phase 1 + 2 are handled by _lifespan when uvicorn starts.
|
||||
# No need to call them here — doing so causes a double-run.
|
||||
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+65
-32
@@ -178,6 +178,23 @@ class StateEncoder:
|
||||
[0:29] - Global features (life, mana, turn, phase, etc.)
|
||||
[29:6173] - Zone embeddings (4 zones × 2 players × 768)
|
||||
[6173:6177] - Playstyle one-hot (4 dims)
|
||||
|
||||
Global feature layout per player block (14 features × 2 players + 1 turn = 29):
|
||||
idx 0 life (normalised /40)
|
||||
idx 1 cmdr_dmg (normalised /21)
|
||||
idx 2 mana (normalised /20, clamped)
|
||||
idx 3 cmdr_tax (normalised /10, clamped)
|
||||
idx 4 hand_size (normalised /15, clamped)
|
||||
idx 5 graveyard_size (normalised /100, clamped)
|
||||
idx 6 creatures (normalised /30, clamped)
|
||||
idx 7 total_power (normalised /100, clamped)
|
||||
└─ real Forge value when present;
|
||||
falls back to creatures*3 heuristic
|
||||
idx 8 is_active (binary 0/1)
|
||||
idx 9-12 phase one-hot (4 dims: main_1/combat/main_2/end)
|
||||
idx 13 lands (normalised /15, clamped)
|
||||
--- repeat for player 1 at idx 14-27 ---
|
||||
idx 28 turn (normalised /max_turns)
|
||||
"""
|
||||
|
||||
def __init__(self, card_index: CardEmbeddingIndex):
|
||||
@@ -197,7 +214,6 @@ class StateEncoder:
|
||||
"""
|
||||
players = decision.get("players", [])
|
||||
if len(players) < 2:
|
||||
# Pad to 2 players for 1v1
|
||||
while len(players) < 2:
|
||||
players.append(self._empty_player(len(players)))
|
||||
|
||||
@@ -218,55 +234,71 @@ class StateEncoder:
|
||||
return state.astype(np.float32)
|
||||
|
||||
def _encode_global(self, decision: dict, players: list) -> np.ndarray:
|
||||
"""Encode global scalar features. Shape: (29,)"""
|
||||
"""Encode global scalar features. Shape: (29,)
|
||||
|
||||
Per-player block (14 features each, 2 players = 28) + 1 turn feature.
|
||||
|
||||
Index 7 priority for total_power:
|
||||
1. p['total_power'] — real Forge value from Step 4 / _build_encoder_snapshot()
|
||||
2. p['creatures'] * 3 — legacy heuristic for old JSONL snapshots
|
||||
"""
|
||||
features = []
|
||||
|
||||
for p in players[:2]: # Exactly 2 players for 1v1
|
||||
# Per-player scalars (10 each)
|
||||
for p in players[:2]:
|
||||
# idx 0: life
|
||||
life = p.get("life", 40)
|
||||
features.append(life / 40.0) # Normalized life
|
||||
features.append(life / 40.0)
|
||||
|
||||
# idx 1: commander damage taken
|
||||
cmdr_dmg = p.get("cmdr_dmg", 0)
|
||||
features.append(cmdr_dmg / 21.0) # Normalized commander damage
|
||||
features.append(cmdr_dmg / 21.0)
|
||||
|
||||
# idx 2: mana available
|
||||
mana = p.get("mana", 0)
|
||||
features.append(min(mana / 20.0, 1.0)) # Normalized mana
|
||||
features.append(min(mana / 20.0, 1.0))
|
||||
|
||||
# idx 3: commander tax
|
||||
cmdr_tax = p.get("cmdr_tax", 0)
|
||||
features.append(min(cmdr_tax / 10.0, 1.0)) # Normalized tax
|
||||
features.append(min(cmdr_tax / 10.0, 1.0))
|
||||
|
||||
# idx 4: cards in hand
|
||||
hand_size = len(p.get("hand", []))
|
||||
features.append(min(hand_size / 15.0, 1.0))
|
||||
|
||||
# idx 5: graveyard size
|
||||
grave_size = len(p.get("graveyard", []))
|
||||
features.append(min(grave_size / 100.0, 1.0))
|
||||
|
||||
# idx 6: creatures on battlefield
|
||||
creatures = p.get("creatures", 0)
|
||||
features.append(min(creatures / 30.0, 1.0))
|
||||
|
||||
# Total power on board (not yet tracked accurately; use creatures as proxy)
|
||||
total_power = creatures * 3 # rough heuristic: avg 3 power
|
||||
# idx 7: total power on board
|
||||
# Use real Forge value when available (Step 4); fall back to heuristic
|
||||
# for legacy JSONL snapshots that predate Step 4.
|
||||
raw_power = p.get("total_power", 0)
|
||||
total_power = raw_power if raw_power > 0 else creatures * 3
|
||||
features.append(min(total_power / 100.0, 1.0))
|
||||
|
||||
# Is active player
|
||||
# idx 8: is active player
|
||||
is_active = 1.0 if p.get("seat") == decision.get("active_seat") else 0.0
|
||||
features.append(is_active)
|
||||
|
||||
# Land count (extra feature — helps distinguish ramp strategies)
|
||||
lands = p.get("lands", 0)
|
||||
features.append(min(lands / 15.0, 1.0))
|
||||
|
||||
# Phase one-hot (4 dims per player)
|
||||
# idx 9-12: phase one-hot (4 dims)
|
||||
phase_str = decision.get("phase", "main_1")
|
||||
phase_oh = [0.0] * 4
|
||||
try:
|
||||
phase_enum = GamePhase(phase_str)
|
||||
phase_oh[PHASE_TO_IDX[phase_enum]] = 1.0
|
||||
except (ValueError, KeyError):
|
||||
phase_oh[0] = 1.0 # Default to main_1
|
||||
phase_oh[0] = 1.0 # default to main_1
|
||||
features.extend(phase_oh)
|
||||
|
||||
# Turn number (1 feature, shared)
|
||||
# idx 13: land count
|
||||
lands = p.get("lands", 0)
|
||||
features.append(min(lands / 15.0, 1.0))
|
||||
|
||||
# idx 28: turn number (shared, 1 feature)
|
||||
turn = decision.get("turn", 1)
|
||||
features.append(min(turn / GAME_SCOPE.max_turns, 1.0))
|
||||
|
||||
@@ -290,8 +322,6 @@ class StateEncoder:
|
||||
for p in players[:2]:
|
||||
for zone_key in zone_keys:
|
||||
cards = p.get(zone_key, [])
|
||||
# mean_pool_zone returns a zero vector when cards is empty,
|
||||
# preserving the fixed 768-dim slot size.
|
||||
pooled = self.card_index.mean_pool_zone(cards)
|
||||
vectors.append(pooled)
|
||||
|
||||
@@ -304,24 +334,27 @@ class StateEncoder:
|
||||
style = Playstyle(playstyle_str.lower())
|
||||
vec[PLAYSTYLE_TO_IDX[style]] = 1.0
|
||||
except (ValueError, KeyError):
|
||||
# Default: midrange
|
||||
vec[PLAYSTYLE_TO_IDX[Playstyle.MIDRANGE]] = 1.0
|
||||
return vec
|
||||
|
||||
def _empty_player(self, seat: int) -> dict:
|
||||
"""Create an empty player dict for padding."""
|
||||
return {
|
||||
"seat": seat,
|
||||
"life": 40,
|
||||
"cmdr_dmg": 0,
|
||||
"mana": 0,
|
||||
"cmdr_tax": 0,
|
||||
"creatures": 0,
|
||||
"lands": 0,
|
||||
"hand": [],
|
||||
"battlefield": [],
|
||||
"graveyard": [],
|
||||
"command_zone": [],
|
||||
"seat": seat,
|
||||
"life": 40,
|
||||
"cmdr_dmg": 0,
|
||||
"mana": 0,
|
||||
"cmdr_tax": 0,
|
||||
"creatures": 0,
|
||||
"total_power": 0,
|
||||
"total_toughness": 0,
|
||||
"artifacts": 0,
|
||||
"enchantments": 0,
|
||||
"lands": 0,
|
||||
"hand": [],
|
||||
"battlefield": [],
|
||||
"graveyard": [],
|
||||
"command_zone": [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
+25
-1
@@ -16,6 +16,7 @@ Reuses `run_match`, `LearnedPolicy`, `HeuristicPolicy`,
|
||||
Usage:
|
||||
python -m ml.eval.elo_tracker
|
||||
python -m ml.eval.elo_tracker --episodes 50 --output data/elo_history.json
|
||||
python -m ml.eval.elo_tracker --top-n 10
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -285,12 +286,17 @@ def run_generation_tournament(
|
||||
episodes_per_matchup: int = 50,
|
||||
playstyle: str = "midrange",
|
||||
k_factor: float = DEFAULT_K,
|
||||
top_n: int = 10,
|
||||
) -> EloResult:
|
||||
"""
|
||||
Discover generation checkpoints and run an ELO tournament.
|
||||
|
||||
Looks for `gen-*/best_policy.pt` under checkpoint_dir.
|
||||
Also includes heuristic and random baselines.
|
||||
|
||||
Args:
|
||||
top_n: Only include the N most recent gen-* checkpoints.
|
||||
0 means include all (unbounded). Default is 10.
|
||||
"""
|
||||
policies: Dict[str, object] = {
|
||||
"heuristic": HeuristicPolicy(),
|
||||
@@ -299,7 +305,20 @@ def run_generation_tournament(
|
||||
|
||||
ckpt_path = Path(checkpoint_dir)
|
||||
if ckpt_path.exists():
|
||||
for gen_dir in sorted(ckpt_path.glob("gen-*")):
|
||||
gen_dirs = sorted(ckpt_path.glob("gen-*"))
|
||||
|
||||
# Apply top_n cutoff — keep only the latest N generation dirs
|
||||
if top_n > 0 and len(gen_dirs) > top_n:
|
||||
skipped = len(gen_dirs) - top_n
|
||||
logger.info(
|
||||
"top_n=%d: skipping %d older generation(s), using %s … %s",
|
||||
top_n, skipped,
|
||||
gen_dirs[-top_n].name,
|
||||
gen_dirs[-1].name,
|
||||
)
|
||||
gen_dirs = gen_dirs[-top_n:]
|
||||
|
||||
for gen_dir in gen_dirs:
|
||||
model_path = gen_dir / "best_policy.pt"
|
||||
if model_path.exists():
|
||||
name = gen_dir.name # e.g., "gen-001"
|
||||
@@ -363,6 +382,10 @@ def main():
|
||||
"--k-factor", type=float, default=32,
|
||||
help="ELO K-factor (default: 32)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top-n", type=int, default=10,
|
||||
help="Only include the N most recent gen checkpoints (0 = all, default: 10)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -373,6 +396,7 @@ def main():
|
||||
episodes_per_matchup=args.episodes,
|
||||
playstyle=args.playstyle,
|
||||
k_factor=args.k_factor,
|
||||
top_n=args.top_n,
|
||||
)
|
||||
|
||||
# Print results
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
"""
|
||||
Commander AI Lab — Forge Evaluator (Issue #153)
|
||||
═════════════════════════════════════════════════
|
||||
Reusable eval harness that:
|
||||
1. Starts the policy inference server pointing at a given checkpoint
|
||||
2. Launches Forge games via MultiThreadBatchRunner with the policy
|
||||
server wired in as the AI decision-maker
|
||||
3. Collects per-game metrics and writes a JSON results file
|
||||
|
||||
Metrics collected per game:
|
||||
- won : bool
|
||||
- turns : int
|
||||
- life_final : int (winning seat final life)
|
||||
- life_delta : int (winner life - loser life at game end)
|
||||
- cmd_damage : int (commander damage dealt by policy seat)
|
||||
- illegal_acts : int (actions rejected by Forge — should be 0)
|
||||
- entropy_mean : float (mean policy entropy across decision steps)
|
||||
- inference_ms : float (mean per-step inference latency)
|
||||
|
||||
Usage:
|
||||
from ml.eval.forge_evaluator import ForgeEvaluator, EvalConfig
|
||||
cfg = EvalConfig(
|
||||
checkpoint_path="ml/models/checkpoints/forge-trained/final.pt",
|
||||
forge_jar="forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar",
|
||||
deck_files=["decks/cedh_test_a.dck", "decks/cedh_test_b.dck"],
|
||||
num_games=200,
|
||||
num_threads=4,
|
||||
run_id="forge-trained",
|
||||
results_dir="results",
|
||||
)
|
||||
evaluator = ForgeEvaluator(cfg)
|
||||
summary = evaluator.run()
|
||||
print(summary)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
project_root = str(Path(__file__).parent.parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
logger = logging.getLogger("ml.eval.forge_evaluator")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Configuration
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class EvalConfig:
|
||||
"""All parameters needed for a single evaluation run."""
|
||||
|
||||
# Model
|
||||
checkpoint_path: str = "ml/models/checkpoints/best_policy.pt"
|
||||
embeddings_dir: str = "embeddings"
|
||||
device: str = None # None = auto-detect
|
||||
|
||||
# Forge
|
||||
forge_jar: str = "forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar"
|
||||
forge_work_dir: str = "forge"
|
||||
java_path: str = "java"
|
||||
deck_files: List[str] = field(default_factory=list)
|
||||
deck_names: List[str] = field(default_factory=list)
|
||||
policy_server_url: str = "http://localhost:8091"
|
||||
clock_seconds: int = 120
|
||||
|
||||
# Run
|
||||
num_games: int = 200
|
||||
num_threads: int = 4
|
||||
seed: Optional[int] = None
|
||||
run_id: str = ""
|
||||
results_dir: str = "results"
|
||||
|
||||
# Inference
|
||||
greedy: bool = True
|
||||
temperature: float = 1.0
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.run_id:
|
||||
self.run_id = str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Per-game result
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class GameRecord:
|
||||
game_index: int
|
||||
won: bool
|
||||
turns: int
|
||||
life_final_policy: int # life total of policy seat at game end
|
||||
life_final_opponent: int
|
||||
life_delta: int # policy life - opponent life
|
||||
commander_damage_dealt: int
|
||||
illegal_actions: int
|
||||
entropy_mean: float # mean per-step policy entropy
|
||||
inference_ms_mean: float # mean per-step inference latency
|
||||
decision_steps: int # number of decision points in this game
|
||||
error: str = ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Eval summary
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class EvalSummary:
|
||||
run_id: str
|
||||
checkpoint_path: str
|
||||
num_games: int
|
||||
wins: int
|
||||
losses: int
|
||||
win_rate: float
|
||||
win_rate_ci95_low: float
|
||||
win_rate_ci95_high: float
|
||||
avg_turns: float
|
||||
avg_life_delta: float
|
||||
avg_commander_damage: float
|
||||
avg_illegal_actions: float
|
||||
avg_entropy: float
|
||||
avg_inference_ms: float
|
||||
total_decision_steps: int
|
||||
elapsed_wall_s: float
|
||||
games_per_sec: float
|
||||
error_count: int
|
||||
result_file: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"EvalSummary run={self.run_id}\n"
|
||||
f" checkpoint : {self.checkpoint_path}\n"
|
||||
f" games : {self.num_games} ({self.wins}W / {self.losses}L)\n"
|
||||
f" win rate : {self.win_rate:.1%} "
|
||||
f"[{self.win_rate_ci95_low:.1%}, {self.win_rate_ci95_high:.1%}] 95% CI\n"
|
||||
f" avg turns : {self.avg_turns:.1f}\n"
|
||||
f" avg life Δ : {self.avg_life_delta:+.1f}\n"
|
||||
f" avg cmd dmg: {self.avg_commander_damage:.1f}\n"
|
||||
f" illegal : {self.avg_illegal_actions:.2f}/game\n"
|
||||
f" entropy : {self.avg_entropy:.4f}\n"
|
||||
f" latency : {self.avg_inference_ms:.1f} ms/step\n"
|
||||
f" throughput : {self.games_per_sec:.2f} games/sec\n"
|
||||
f" result file: {self.result_file}\n"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Main evaluator
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
class ForgeEvaluator:
|
||||
"""
|
||||
Orchestrates a full eval run:
|
||||
1. Spin up PolicyInferenceService on cfg.policy_server_url port
|
||||
2. Run cfg.num_games Forge games via MultiThreadBatchRunner
|
||||
3. Parse Forge JSONL output for per-game metrics
|
||||
4. Write results to {results_dir}/eval-{run_id}.json
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: EvalConfig):
|
||||
self.cfg = cfg
|
||||
self._records: List[GameRecord] = []
|
||||
self._result_path = os.path.join(
|
||||
cfg.results_dir, f"eval-{cfg.run_id}.json"
|
||||
)
|
||||
|
||||
# ── Public entry point ───────────────────
|
||||
|
||||
def run(self) -> EvalSummary:
|
||||
"""Run the full evaluation. Returns an EvalSummary."""
|
||||
t0 = time.time()
|
||||
os.makedirs(self.cfg.results_dir, exist_ok=True)
|
||||
|
||||
logger.info("[Eval %s] Starting %d-game eval run", self.cfg.run_id, self.cfg.num_games)
|
||||
logger.info("[Eval %s] Checkpoint: %s", self.cfg.run_id, self.cfg.checkpoint_path)
|
||||
|
||||
# Load policy service
|
||||
service = self._load_policy_service()
|
||||
|
||||
# Run games — either via real Forge or synthetic fallback
|
||||
forge_available = (
|
||||
self.cfg.forge_jar
|
||||
and Path(self.cfg.forge_jar).exists()
|
||||
and len(self.cfg.deck_files) >= 3
|
||||
)
|
||||
|
||||
if forge_available:
|
||||
self._records = self._run_forge_games(service)
|
||||
else:
|
||||
logger.warning(
|
||||
"[Eval %s] Forge JAR or decks not found — running synthetic eval.",
|
||||
self.cfg.run_id
|
||||
)
|
||||
self._records = self._run_synthetic_eval(service)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
summary = self._build_summary(elapsed)
|
||||
self._write_results(summary)
|
||||
logger.info("[Eval %s] Done. %s", self.cfg.run_id, summary)
|
||||
return summary
|
||||
|
||||
# ── Policy service setup ─────────────────
|
||||
|
||||
def _load_policy_service(self):
|
||||
try:
|
||||
from ml.serving.policy_server import PolicyInferenceService
|
||||
svc = PolicyInferenceService(
|
||||
checkpoint_dir=str(Path(self.cfg.checkpoint_path).parent),
|
||||
embeddings_dir=self.cfg.embeddings_dir,
|
||||
device=self.cfg.device,
|
||||
)
|
||||
svc.load()
|
||||
ok = svc.reload(self.cfg.checkpoint_path)
|
||||
if not ok:
|
||||
logger.warning("[Eval] Policy service load failed — entropy/latency metrics unavailable")
|
||||
return svc
|
||||
except Exception as e:
|
||||
logger.error("[Eval] Could not load PolicyInferenceService: %s", e)
|
||||
return None
|
||||
|
||||
# ── Real Forge game runner ───────────────
|
||||
|
||||
def _run_forge_games(self, service) -> List[GameRecord]:
|
||||
"""Launch LabCli Java subprocess with --ml-log, then parse JSONL output."""
|
||||
logger.info("[Eval %s] Launching Forge CLI subprocess for %d games", self.cfg.run_id, self.cfg.num_games)
|
||||
deck_args = []
|
||||
for i, df in enumerate(self.cfg.deck_files[:4]):
|
||||
deck_args.extend([f"--deck{i+1}", Path(df).stem])
|
||||
# Resolve Java 17
|
||||
java_exe = self.cfg.java_path
|
||||
for c in [r"C:\Program Files\Eclipse Adoptium\jdk-17.0.18.8-hotspot\bin\java.exe",
|
||||
r"C:\Program Files\Java\jdk-17\bin\java.exe"]:
|
||||
if Path(c).exists():
|
||||
java_exe = c
|
||||
break
|
||||
lab_jar = os.path.join(project_root, "target", "commander-ai-lab-1.0.0-SNAPSHOT.jar")
|
||||
if not Path(lab_jar).exists():
|
||||
logger.error("[Eval] Lab JAR not found at %s -- run mvn package -DskipTests first", lab_jar)
|
||||
return self._run_synthetic_eval(service)
|
||||
cmd = [
|
||||
java_exe, "-jar", lab_jar,
|
||||
"--forge-jar", self.cfg.forge_jar,
|
||||
"--forge-dir", self.cfg.forge_work_dir,
|
||||
*deck_args,
|
||||
"--games", str(self.cfg.num_games),
|
||||
"--threads", str(self.cfg.num_threads),
|
||||
"--clock", str(self.cfg.clock_seconds),
|
||||
"--ml-log",
|
||||
"--output", os.path.join(self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"),
|
||||
]
|
||||
if self.cfg.seed is not None:
|
||||
cmd.extend(["--seed", str(self.cfg.seed)])
|
||||
logger.info("[Eval %s] CMD: %s", self.cfg.run_id, " ".join(cmd))
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True,
|
||||
timeout=self.cfg.clock_seconds * self.cfg.num_games + 300)
|
||||
if result.returncode != 0:
|
||||
logger.warning("[Eval %s] Forge CLI exit %d: %s", self.cfg.run_id, result.returncode, result.stderr[:500])
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
logger.error("[Eval %s] Forge CLI failed: %s -- falling back to synthetic", self.cfg.run_id, e)
|
||||
return self._run_synthetic_eval(service)
|
||||
return self._parse_batch_json(service)
|
||||
|
||||
def _parse_batch_json(self, service) -> List[GameRecord]:
|
||||
"""Parse the batch JSON output from LabCli Java subprocess."""
|
||||
batch_path = os.path.join(
|
||||
self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"
|
||||
)
|
||||
if not Path(batch_path).exists():
|
||||
logger.warning("[Eval] Batch JSON not found at %s — falling back to synthetic", batch_path)
|
||||
return self._run_synthetic_eval(service)
|
||||
with open(batch_path) as f:
|
||||
batch = json.load(f)
|
||||
games_list = batch.get("games", [])
|
||||
if not games_list:
|
||||
logger.warning("[Eval] No games in batch JSON — falling back to synthetic")
|
||||
return self._run_synthetic_eval(service)
|
||||
records = []
|
||||
for idx, g in enumerate(games_list):
|
||||
won = g.get("winningSeat", -1) == 0
|
||||
turns = g.get("totalTurns", 0)
|
||||
pr = g.get("playerResults", [])
|
||||
lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40
|
||||
lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40
|
||||
records.append(GameRecord(
|
||||
game_index=idx,
|
||||
won=won,
|
||||
turns=turns,
|
||||
life_final_policy=lp,
|
||||
life_final_opponent=lo,
|
||||
life_delta=lp - lo,
|
||||
commander_damage_dealt=0,
|
||||
illegal_actions=0,
|
||||
entropy_mean=0.0,
|
||||
inference_ms_mean=0.0,
|
||||
decision_steps=turns,
|
||||
))
|
||||
if len(records) >= self.cfg.num_games:
|
||||
break
|
||||
logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records))
|
||||
return records
|
||||
|
||||
def _run_forge_via_subprocess(self, service) -> List[GameRecord]:
|
||||
"""Parse JSONL files produced by a separately-launched Forge batch."""
|
||||
records = []
|
||||
results_path = Path(self.cfg.results_dir)
|
||||
pattern = f"ml-decisions-forge-eval-{self.cfg.run_id}*.jsonl"
|
||||
|
||||
all_files = sorted(results_path.glob(pattern))
|
||||
if not all_files:
|
||||
logger.warning("[Eval] No JSONL files found matching %s — falling back to synthetic", pattern)
|
||||
return self._run_synthetic_eval(service)
|
||||
|
||||
from ml.training.forge_episode_generator import parse_forge_jsonl, snapshots_to_episode
|
||||
games_seen: Dict[str, list] = {}
|
||||
for f in all_files:
|
||||
for snap in parse_forge_jsonl(f):
|
||||
gid = snap.get("game_id", "g0")
|
||||
games_seen.setdefault(gid, []).append(snap)
|
||||
|
||||
for idx, (gid, snaps) in enumerate(games_seen.items()):
|
||||
records.append(self._snaps_to_record(idx, gid, snaps, service))
|
||||
if len(records) >= self.cfg.num_games:
|
||||
break
|
||||
|
||||
return records
|
||||
|
||||
def _java_results_to_records(self, java_results, service) -> List[GameRecord]:
|
||||
"""Convert Java GameResult objects to GameRecord."""
|
||||
records = []
|
||||
for jr in java_results:
|
||||
try:
|
||||
won = getattr(jr, "winningSeat", 0) == 0 # seat 0 = policy
|
||||
records.append(GameRecord(
|
||||
game_index=getattr(jr, "gameIndex", len(records)),
|
||||
won=won,
|
||||
turns=getattr(jr, "turns", 0),
|
||||
life_final_policy=getattr(jr, "lifeAtEnd", [40])[0] if hasattr(jr, "lifeAtEnd") else 40,
|
||||
life_final_opponent=getattr(jr, "lifeAtEnd", [40, 40])[1] if hasattr(jr, "lifeAtEnd") else 40,
|
||||
life_delta=0,
|
||||
commander_damage_dealt=0,
|
||||
illegal_actions=getattr(jr, "illegalActions", 0),
|
||||
entropy_mean=0.0,
|
||||
inference_ms_mean=0.0,
|
||||
decision_steps=0,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("[Eval] Skipping malformed GameResult: %s", e)
|
||||
return records
|
||||
|
||||
# ── Synthetic fallback ───────────────────
|
||||
|
||||
def _run_synthetic_eval(self, service) -> List[GameRecord]:
|
||||
"""Run synthetic games using self_play engine (no Forge required).
|
||||
|
||||
Used when Forge JAR is missing or deck files aren't configured.
|
||||
Metrics like illegal_actions and commander_damage are 0 in this mode.
|
||||
"""
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
|
||||
records = []
|
||||
try:
|
||||
from ml.training.self_play import (
|
||||
create_random_initial_state, apply_action, advance_turn,
|
||||
encode_state_simple, HeuristicPolicy
|
||||
)
|
||||
from ml.config.scope import NUM_ACTIONS, IDX_TO_ACTION
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
except ImportError as e:
|
||||
logger.error("[Eval] Cannot import self_play: %s", e)
|
||||
return []
|
||||
|
||||
opponent = HeuristicPolicy()
|
||||
agent_has_model = service is not None and service._loaded
|
||||
|
||||
for game_idx in range(self.cfg.num_games):
|
||||
state = create_random_initial_state()
|
||||
entropies, latencies = [], []
|
||||
steps = 0
|
||||
|
||||
while not state.game_over and steps < 60:
|
||||
sv = encode_state_simple(state, 0, "midrange")
|
||||
t0 = time.time()
|
||||
|
||||
if agent_has_model:
|
||||
snap = state.to_snapshot()
|
||||
result = service.predict(
|
||||
snap, playstyle="midrange",
|
||||
greedy=self.cfg.greedy,
|
||||
temperature=self.cfg.temperature,
|
||||
)
|
||||
action_idx = result.get("action_index", 7)
|
||||
probs_map = result.get("probabilities", {})
|
||||
probs = np.array(list(probs_map.values())) if probs_map else np.ones(NUM_ACTIONS) / NUM_ACTIONS
|
||||
else:
|
||||
action_idx = np.random.randint(NUM_ACTIONS)
|
||||
probs = np.ones(NUM_ACTIONS) / NUM_ACTIONS
|
||||
|
||||
latencies.append((time.time() - t0) * 1000)
|
||||
p = np.clip(probs, 1e-9, 1.0)
|
||||
p /= p.sum()
|
||||
entropies.append(float(-np.sum(p * np.log(p))))
|
||||
|
||||
state = apply_action(state, action_idx, 0)
|
||||
if not state.game_over:
|
||||
opp_sv = encode_state_simple(state, 1, "midrange")
|
||||
opp_a, _ = opponent.select_action(opp_sv)
|
||||
state = apply_action(state, opp_a, 1)
|
||||
if not state.game_over:
|
||||
state = advance_turn(state)
|
||||
steps += 1
|
||||
|
||||
won = state.winner == 0
|
||||
lp = state.players[0].life_total
|
||||
lo = state.players[1].life_total
|
||||
records.append(GameRecord(
|
||||
game_index=game_idx,
|
||||
won=won,
|
||||
turns=state.turn,
|
||||
life_final_policy=lp,
|
||||
life_final_opponent=lo,
|
||||
life_delta=lp - lo,
|
||||
commander_damage_dealt=0,
|
||||
illegal_actions=0,
|
||||
entropy_mean=float(np.mean(entropies)) if entropies else 0.0,
|
||||
inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0,
|
||||
decision_steps=steps,
|
||||
))
|
||||
|
||||
return records
|
||||
|
||||
# ── JSONL snapshot → GameRecord ──────────
|
||||
|
||||
def _snaps_to_record(self, idx: int, gid: str, snaps: list, service) -> GameRecord:
|
||||
entropies, latencies = [], []
|
||||
won = False
|
||||
turns = len(snaps)
|
||||
cmd_dmg = 0
|
||||
illegal = 0
|
||||
lp, lo = 40, 40
|
||||
|
||||
for snap in snaps:
|
||||
if "outcome" in snap and snap == snaps[-1]:
|
||||
won = snap["outcome"].get("winner_seat", -1) == 0
|
||||
lp = snap["outcome"].get("life_remaining", 40)
|
||||
lo = snap["outcome"].get("opp_life_remaining", 0)
|
||||
cmd_dmg = snap["outcome"].get("commander_damage_dealt", 0)
|
||||
|
||||
if service and service._loaded and "state_vector" in snap:
|
||||
t0 = time.time()
|
||||
result = service.predict(snap, playstyle="midrange", greedy=self.cfg.greedy)
|
||||
latencies.append((time.time() - t0) * 1000)
|
||||
probs_map = result.get("probabilities", {})
|
||||
if probs_map:
|
||||
p = np.clip(list(probs_map.values()), 1e-9, 1.0)
|
||||
p /= p.sum()
|
||||
entropies.append(float(-np.sum(p * np.log(p))))
|
||||
|
||||
return GameRecord(
|
||||
game_index=idx,
|
||||
won=won,
|
||||
turns=turns,
|
||||
life_final_policy=lp,
|
||||
life_final_opponent=lo,
|
||||
life_delta=lp - lo,
|
||||
commander_damage_dealt=cmd_dmg,
|
||||
illegal_actions=illegal,
|
||||
entropy_mean=float(np.mean(entropies)) if entropies else 0.0,
|
||||
inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0,
|
||||
decision_steps=len(snaps),
|
||||
)
|
||||
|
||||
# ── Summary builder ──────────────────────
|
||||
|
||||
def _build_summary(self, elapsed: float) -> EvalSummary:
|
||||
n = len(self._records)
|
||||
if n == 0:
|
||||
return EvalSummary(
|
||||
run_id=self.cfg.run_id,
|
||||
checkpoint_path=self.cfg.checkpoint_path,
|
||||
num_games=0, wins=0, losses=0,
|
||||
win_rate=0.0, win_rate_ci95_low=0.0, win_rate_ci95_high=0.0,
|
||||
avg_turns=0.0, avg_life_delta=0.0, avg_commander_damage=0.0,
|
||||
avg_illegal_actions=0.0, avg_entropy=0.0, avg_inference_ms=0.0,
|
||||
total_decision_steps=0, elapsed_wall_s=elapsed, games_per_sec=0.0,
|
||||
error_count=0, result_file=self._result_path,
|
||||
)
|
||||
|
||||
wins = sum(1 for r in self._records if r.won)
|
||||
losses = n - wins
|
||||
wr = wins / n
|
||||
|
||||
# Wilson score 95% CI
|
||||
z = 1.96
|
||||
denom = 1 + z**2 / n
|
||||
centre = (wr + z**2 / (2 * n)) / denom
|
||||
margin = (z * (wr * (1 - wr) / n + z**2 / (4 * n**2)) ** 0.5) / denom
|
||||
ci_lo = max(0.0, centre - margin)
|
||||
ci_hi = min(1.0, centre + margin)
|
||||
|
||||
errors = sum(1 for r in self._records if r.error)
|
||||
|
||||
return EvalSummary(
|
||||
run_id=self.cfg.run_id,
|
||||
checkpoint_path=self.cfg.checkpoint_path,
|
||||
num_games=n,
|
||||
wins=wins,
|
||||
losses=losses,
|
||||
win_rate=wr,
|
||||
win_rate_ci95_low=ci_lo,
|
||||
win_rate_ci95_high=ci_hi,
|
||||
avg_turns=float(np.mean([r.turns for r in self._records])),
|
||||
avg_life_delta=float(np.mean([r.life_delta for r in self._records])),
|
||||
avg_commander_damage=float(np.mean([r.commander_damage_dealt for r in self._records])),
|
||||
avg_illegal_actions=float(np.mean([r.illegal_actions for r in self._records])),
|
||||
avg_entropy=float(np.mean([r.entropy_mean for r in self._records])),
|
||||
avg_inference_ms=float(np.mean([r.inference_ms_mean for r in self._records])),
|
||||
total_decision_steps=sum(r.decision_steps for r in self._records),
|
||||
elapsed_wall_s=elapsed,
|
||||
games_per_sec=n / max(elapsed, 0.001),
|
||||
error_count=errors,
|
||||
result_file=self._result_path,
|
||||
)
|
||||
|
||||
# ── Result writer ────────────────────────
|
||||
|
||||
def _write_results(self, summary: EvalSummary) -> None:
|
||||
payload = {
|
||||
"summary": asdict(summary),
|
||||
"games": [asdict(r) for r in self._records],
|
||||
}
|
||||
os.makedirs(self.cfg.results_dir, exist_ok=True)
|
||||
with open(self._result_path, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path)
|
||||
@@ -223,7 +223,8 @@ def load_checkpoint(path: str, device: str = "cpu"):
|
||||
num_layers=config.get("num_layers", TRAINING_CONFIG.num_layers),
|
||||
dropout=config.get("dropout", TRAINING_CONFIG.dropout),
|
||||
)
|
||||
model.load_state_dict(checkpoint["model_state_dict"])
|
||||
state = {k: v for k, v in checkpoint["model_state_dict"].items() if not k.startswith("value_head")}
|
||||
model.load_state_dict(state, strict=False)
|
||||
model.to(device)
|
||||
|
||||
return model, checkpoint
|
||||
+338
-131
@@ -9,22 +9,27 @@ Proximal Policy Optimization (Clip) with:
|
||||
- Self-play episode collection between updates
|
||||
- Periodic checkpointing and evaluation
|
||||
- Win-rate tracking against opponent policies
|
||||
- Forge-based episode source via --data-source forge|mixed
|
||||
|
||||
Reference: Schulman et al., "Proximal Policy Optimization Algorithms", 2017
|
||||
|
||||
Usage:
|
||||
python -m ml.training.ppo_trainer --iterations 100 --episodes-per-iter 64
|
||||
python -m ml.training.ppo_trainer --load-supervised ml/models/checkpoints/best_policy.pt
|
||||
python -m ml.training.ppo_trainer --data-source forge --forge-results-dir results --iterations 50
|
||||
python -m ml.training.ppo_trainer --data-source mixed --forge-weight 0.7 --iterations 100
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -51,6 +56,114 @@ from ml.training.self_play import (
|
||||
logger = logging.getLogger("ml.ppo")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Forge data-source integration
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
def episodes_to_buffer(episodes, buffer: RolloutBuffer) -> int:
|
||||
"""Drain a list of ForgeEpisodes into an existing RolloutBuffer.
|
||||
|
||||
Returns the number of transitions added.
|
||||
"""
|
||||
added = 0
|
||||
for ep in episodes:
|
||||
for t in ep.transitions:
|
||||
if buffer.size >= buffer.buffer_size:
|
||||
break
|
||||
buffer.add(
|
||||
state=t.state,
|
||||
action=t.action,
|
||||
reward=t.reward,
|
||||
log_prob=t.log_prob,
|
||||
value=t.value,
|
||||
done=t.done,
|
||||
)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
class ForgeDataSource:
|
||||
"""Runs the Forge producer pipeline in a background thread and
|
||||
exposes a synchronous drain() method for the PPO training loop.
|
||||
|
||||
Usage::
|
||||
source = ForgeDataSource(results_dir="results", batch_id="run1", num_games=64)
|
||||
source.start()
|
||||
# ... in training loop ...
|
||||
episodes = source.drain()
|
||||
source.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
results_dir: str = "results",
|
||||
batch_id: str = "forge-batch",
|
||||
num_games: int = 256,
|
||||
reward_config: Optional[RewardConfig] = None,
|
||||
queue_size: int = 256,
|
||||
):
|
||||
self.results_dir = results_dir
|
||||
self.batch_id = batch_id
|
||||
self.num_games = num_games
|
||||
self.reward_config = reward_config or RewardConfig()
|
||||
self.queue_size = queue_size
|
||||
self._episodes: List = []
|
||||
self._lock = threading.Lock()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def start(self):
|
||||
"""Launch the background producer thread."""
|
||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("[ForgeDataSource] Producer started (results_dir=%s, batch_id=%s, num_games=%d)",
|
||||
self.results_dir, self.batch_id, self.num_games)
|
||||
|
||||
def _run(self):
|
||||
from ml.training.forge_episode_generator import ForgeEpisodeQueue, forge_producer
|
||||
|
||||
async def _produce():
|
||||
queue = ForgeEpisodeQueue(max_size=self.queue_size)
|
||||
produced = await forge_producer(
|
||||
queue=queue,
|
||||
results_dir=self.results_dir,
|
||||
batch_id=self.batch_id,
|
||||
num_games=self.num_games,
|
||||
reward_config=self.reward_config,
|
||||
)
|
||||
queue.close()
|
||||
# Drain remaining items
|
||||
while not queue.is_closed:
|
||||
ep = await queue.get(timeout=1.0)
|
||||
if ep is not None:
|
||||
with self._lock:
|
||||
self._episodes.append(ep)
|
||||
logger.info("[ForgeDataSource] Producer finished: %d episodes", produced)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(_produce())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
def drain(self) -> List:
|
||||
"""Return and clear all buffered episodes (thread-safe)."""
|
||||
with self._lock:
|
||||
eps = list(self._episodes)
|
||||
self._episodes.clear()
|
||||
return eps
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5.0)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# PPO Config
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
class PPOConfig:
|
||||
"""PPO hyperparameters."""
|
||||
|
||||
@@ -91,6 +204,13 @@ class PPOConfig:
|
||||
|
||||
# Supervised preload
|
||||
load_supervised: Optional[str] = None,
|
||||
|
||||
# Data source
|
||||
data_source: str = "synthetic", # synthetic | forge | mixed
|
||||
forge_results_dir: str = "results",
|
||||
forge_batch_id: str = "forge-batch",
|
||||
forge_num_games: int = 256,
|
||||
forge_weight: float = 0.7, # fraction of buffer from Forge when mixed
|
||||
):
|
||||
self.iterations = iterations
|
||||
self.episodes_per_iter = episodes_per_iter
|
||||
@@ -114,10 +234,19 @@ class PPOConfig:
|
||||
self.eval_every = eval_every
|
||||
self.eval_episodes = eval_episodes
|
||||
self.load_supervised = load_supervised
|
||||
self.data_source = data_source
|
||||
self.forge_results_dir = forge_results_dir
|
||||
self.forge_batch_id = forge_batch_id
|
||||
self.forge_num_games = forge_num_games
|
||||
self.forge_weight = max(0.0, min(1.0, forge_weight))
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# PPO Trainer
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
class PPOTrainer:
|
||||
"""Proximal Policy Optimization trainer with self-play."""
|
||||
"""Proximal Policy Optimization trainer with self-play and optional Forge data source."""
|
||||
|
||||
def __init__(self, config: PPOConfig):
|
||||
if not TORCH_AVAILABLE:
|
||||
@@ -126,28 +255,24 @@ class PPOTrainer:
|
||||
self.config = config
|
||||
self.device = self._detect_device()
|
||||
|
||||
# Create actor-critic model
|
||||
from ml.training.policy_network import PolicyValueNetwork
|
||||
self.model = PolicyValueNetwork(
|
||||
input_dim=STATE_DIMS.total_state_dim,
|
||||
hidden_dim=TRAINING_CONFIG.hidden_dim,
|
||||
num_actions=NUM_ACTIONS,
|
||||
num_layers=TRAINING_CONFIG.num_layers,
|
||||
dropout=0.0, # No dropout during RL
|
||||
dropout=0.0,
|
||||
).to(self.device)
|
||||
|
||||
# Optionally load from supervised checkpoint
|
||||
if config.load_supervised and os.path.exists(config.load_supervised):
|
||||
self._load_supervised_weights(config.load_supervised)
|
||||
|
||||
# Optimizer
|
||||
self.optimizer = torch.optim.Adam(
|
||||
self.model.parameters(),
|
||||
lr=config.learning_rate,
|
||||
eps=1e-5,
|
||||
)
|
||||
|
||||
# Learning rate scheduler
|
||||
self.scheduler = None
|
||||
if config.lr_schedule == "linear":
|
||||
self.scheduler = torch.optim.lr_scheduler.LinearLR(
|
||||
@@ -163,7 +288,6 @@ class PPOTrainer:
|
||||
eta_min=config.min_lr,
|
||||
)
|
||||
|
||||
# Rollout buffer
|
||||
self.buffer = RolloutBuffer(
|
||||
buffer_size=config.buffer_size,
|
||||
state_dim=STATE_DIMS.total_state_dim,
|
||||
@@ -171,16 +295,23 @@ class PPOTrainer:
|
||||
gae_lambda=config.gae_lambda,
|
||||
)
|
||||
|
||||
# Reward config
|
||||
self.reward_config = RewardConfig(
|
||||
gamma=config.gamma,
|
||||
gae_lambda=config.gae_lambda,
|
||||
)
|
||||
|
||||
# Opponent policy
|
||||
self.opponent = self._create_opponent(config.opponent)
|
||||
|
||||
# Tracking
|
||||
# Forge data source (started lazily in train())
|
||||
self._forge_source: Optional[ForgeDataSource] = None
|
||||
if config.data_source in ("forge", "mixed"):
|
||||
self._forge_source = ForgeDataSource(
|
||||
results_dir=config.forge_results_dir,
|
||||
batch_id=config.forge_batch_id,
|
||||
num_games=config.forge_num_games,
|
||||
reward_config=self.reward_config,
|
||||
)
|
||||
|
||||
self.iteration = 0
|
||||
self.total_steps = 0
|
||||
self.best_win_rate = 0.0
|
||||
@@ -196,13 +327,9 @@ class PPOTrainer:
|
||||
return "cpu"
|
||||
|
||||
def _load_supervised_weights(self, path: str):
|
||||
"""Load weights from a supervised PolicyNetwork checkpoint into the
|
||||
PolicyValueNetwork (ignoring the value head, which is new)."""
|
||||
logger.info("Loading supervised weights from %s", path)
|
||||
checkpoint = torch.load(path, map_location=self.device, weights_only=False)
|
||||
state_dict = checkpoint.get("model_state_dict", checkpoint)
|
||||
|
||||
# Filter out keys that don't match (e.g., value_head)
|
||||
model_dict = self.model.state_dict()
|
||||
compatible = {k: v for k, v in state_dict.items() if k in model_dict and v.shape == model_dict[k].shape}
|
||||
model_dict.update(compatible)
|
||||
@@ -216,14 +343,10 @@ class PPOTrainer:
|
||||
elif opponent_type == "heuristic":
|
||||
return HeuristicPolicy()
|
||||
elif opponent_type == "self":
|
||||
# Self-play: opponent uses a snapshot of the current policy
|
||||
return self._create_self_play_opponent()
|
||||
else:
|
||||
return HeuristicPolicy()
|
||||
return HeuristicPolicy()
|
||||
|
||||
def _create_self_play_opponent(self):
|
||||
"""Create an opponent that uses a frozen copy of the current policy."""
|
||||
|
||||
class SelfPlayPolicy:
|
||||
def __init__(self, model, device):
|
||||
self.model = model
|
||||
@@ -241,11 +364,99 @@ class PPOTrainer:
|
||||
|
||||
return SelfPlayPolicy(self.model, self.device)
|
||||
|
||||
def ppo_update(self) -> Dict:
|
||||
"""Run PPO update epochs on the collected rollout buffer.
|
||||
def _collect_forge_rollouts(self) -> Dict:
|
||||
"""Drain buffered Forge episodes into the RolloutBuffer.
|
||||
|
||||
Returns dict of loss metrics.
|
||||
Returns stats dict compatible with collect_rollouts() output.
|
||||
"""
|
||||
episodes = self._forge_source.drain()
|
||||
if not episodes:
|
||||
logger.warning("[Forge] No episodes available yet — waiting for JSONL output in %s",
|
||||
self.config.forge_results_dir)
|
||||
return {"steps": 0, "win_rate": 0.0, "avg_reward_per_ep": 0.0,
|
||||
"wins": 0, "losses": 0, "draws": 0, "source": "forge"}
|
||||
|
||||
added = episodes_to_buffer(episodes, self.buffer)
|
||||
wins = sum(1 for ep in episodes if ep.winner == 0)
|
||||
win_rate = wins / max(len(episodes), 1)
|
||||
avg_reward = sum(ep.total_reward for ep in episodes) / max(len(episodes), 1)
|
||||
logger.info("[Forge] Drained %d episodes (%d transitions) into buffer — WR=%.0f%% AvgR=%.3f",
|
||||
len(episodes), added, win_rate * 100, avg_reward)
|
||||
return {
|
||||
"steps": added,
|
||||
"win_rate": win_rate,
|
||||
"avg_reward_per_ep": avg_reward,
|
||||
"wins": wins,
|
||||
"losses": len(episodes) - wins,
|
||||
"draws": 0,
|
||||
"source": "forge",
|
||||
}
|
||||
|
||||
def _collect_rollouts_for_iter(self) -> Dict:
|
||||
"""Collect rollouts according to configured data_source.
|
||||
|
||||
- synthetic: pure self_play.collect_rollouts (original behaviour)
|
||||
- forge: pure Forge episode drain
|
||||
- mixed: Forge fills forge_weight fraction of buffer, synthetic fills remainder
|
||||
"""
|
||||
cfg = self.config
|
||||
self.buffer.reset()
|
||||
self.model.eval()
|
||||
|
||||
if cfg.data_source == "forge":
|
||||
stats = self._collect_forge_rollouts()
|
||||
|
||||
elif cfg.data_source == "mixed":
|
||||
forge_cap = int(cfg.buffer_size * cfg.forge_weight)
|
||||
synth_cap = cfg.buffer_size - forge_cap
|
||||
|
||||
# Temporarily shrink buffer for Forge slice
|
||||
original_size = self.buffer.buffer_size
|
||||
self.buffer.buffer_size = forge_cap
|
||||
forge_stats = self._collect_forge_rollouts()
|
||||
self.buffer.buffer_size = original_size
|
||||
|
||||
synth_eps = max(1, int(cfg.episodes_per_iter * (1.0 - cfg.forge_weight)))
|
||||
synth_stats = collect_rollouts(
|
||||
agent_model=self.model,
|
||||
opponent_policy=self.opponent,
|
||||
buffer=self.buffer,
|
||||
num_episodes=synth_eps,
|
||||
playstyle=cfg.playstyle,
|
||||
reward_config=self.reward_config,
|
||||
)
|
||||
stats = {
|
||||
"steps": forge_stats["steps"] + synth_stats["steps"],
|
||||
"win_rate": (
|
||||
forge_stats["win_rate"] * cfg.forge_weight
|
||||
+ synth_stats["win_rate"] * (1.0 - cfg.forge_weight)
|
||||
),
|
||||
"avg_reward_per_ep": (
|
||||
forge_stats["avg_reward_per_ep"] * cfg.forge_weight
|
||||
+ synth_stats["avg_reward_per_ep"] * (1.0 - cfg.forge_weight)
|
||||
),
|
||||
"wins": forge_stats["wins"] + synth_stats["wins"],
|
||||
"losses": forge_stats["losses"] + synth_stats["losses"],
|
||||
"draws": synth_stats.get("draws", 0),
|
||||
"source": "mixed",
|
||||
}
|
||||
|
||||
else: # synthetic (default — unchanged behaviour)
|
||||
stats = collect_rollouts(
|
||||
agent_model=self.model,
|
||||
opponent_policy=self.opponent,
|
||||
buffer=self.buffer,
|
||||
num_episodes=cfg.episodes_per_iter,
|
||||
playstyle=cfg.playstyle,
|
||||
reward_config=self.reward_config,
|
||||
)
|
||||
stats["source"] = "synthetic"
|
||||
|
||||
self.model.train()
|
||||
return stats
|
||||
|
||||
def ppo_update(self) -> Dict:
|
||||
"""Run PPO update epochs on the collected rollout buffer."""
|
||||
cfg = self.config
|
||||
total_policy_loss = 0.0
|
||||
total_value_loss = 0.0
|
||||
@@ -258,56 +469,42 @@ class PPOTrainer:
|
||||
for batch in self.buffer.get_batches(cfg.batch_size):
|
||||
states, actions, old_log_probs, advantages, returns = batch
|
||||
|
||||
# To tensors
|
||||
states_t = torch.from_numpy(states).to(self.device)
|
||||
actions_t = torch.from_numpy(actions).to(self.device)
|
||||
old_log_probs_t = torch.from_numpy(old_log_probs).to(self.device)
|
||||
advantages_t = torch.from_numpy(advantages).to(self.device)
|
||||
returns_t = torch.from_numpy(returns).to(self.device)
|
||||
|
||||
# Forward pass
|
||||
logits, values = self.model.forward_with_value(states_t)
|
||||
values = values.squeeze(-1)
|
||||
|
||||
# Action distribution
|
||||
probs = F.softmax(logits, dim=-1)
|
||||
dist = torch.distributions.Categorical(probs)
|
||||
new_log_probs = dist.log_prob(actions_t)
|
||||
entropy = dist.entropy().mean()
|
||||
|
||||
# --- Policy Loss (clipped surrogate) ---
|
||||
ratio = torch.exp(new_log_probs - old_log_probs_t)
|
||||
surr1 = ratio * advantages_t
|
||||
surr2 = torch.clamp(ratio, 1.0 - cfg.clip_epsilon, 1.0 + cfg.clip_epsilon) * advantages_t
|
||||
policy_loss = -torch.min(surr1, surr2).mean()
|
||||
|
||||
# --- Value Loss (clipped) ---
|
||||
if cfg.value_clip > 0:
|
||||
# Get old values from buffer for clipping
|
||||
old_values_t = torch.from_numpy(
|
||||
self.buffer.values[:self.buffer.size]
|
||||
).to(self.device)
|
||||
# Reindex to match batch
|
||||
# Since we use shuffled indices in get_batches, we clip against returns
|
||||
value_loss_unclipped = (values - returns_t) ** 2
|
||||
value_loss = 0.5 * value_loss_unclipped.mean()
|
||||
else:
|
||||
value_loss = 0.5 * ((values - returns_t) ** 2).mean()
|
||||
|
||||
# --- Total Loss ---
|
||||
loss = (
|
||||
policy_loss
|
||||
+ cfg.value_loss_coeff * value_loss
|
||||
- cfg.entropy_coeff * entropy
|
||||
)
|
||||
|
||||
# Optimize
|
||||
self.optimizer.zero_grad()
|
||||
loss.backward()
|
||||
nn.utils.clip_grad_norm_(self.model.parameters(), cfg.max_grad_norm)
|
||||
self.optimizer.step()
|
||||
|
||||
# Track metrics
|
||||
with torch.no_grad():
|
||||
approx_kl = ((ratio - 1) - torch.log(ratio)).mean().item()
|
||||
clip_frac = ((ratio - 1.0).abs() > cfg.clip_epsilon).float().mean().item()
|
||||
@@ -330,12 +527,8 @@ class PPOTrainer:
|
||||
}
|
||||
|
||||
def evaluate(self, num_episodes: int = 50) -> Dict:
|
||||
"""Evaluate the current policy against opponents.
|
||||
|
||||
Returns win rates and metrics.
|
||||
"""
|
||||
"""Evaluate the current policy against heuristic and random opponents."""
|
||||
self.model.eval()
|
||||
|
||||
results = {}
|
||||
for opp_name, opp_policy in [
|
||||
("heuristic", HeuristicPolicy()),
|
||||
@@ -363,18 +556,15 @@ class PPOTrainer:
|
||||
"avg_reward": stats["avg_reward_per_ep"],
|
||||
"avg_steps": stats["avg_steps_per_ep"],
|
||||
}
|
||||
|
||||
self.model.train()
|
||||
return results
|
||||
|
||||
def save_checkpoint(self, path: str = None, metrics: dict = None):
|
||||
"""Save PPO checkpoint."""
|
||||
if path is None:
|
||||
path = os.path.join(
|
||||
self.config.checkpoint_dir,
|
||||
f"ppo_iter_{self.iteration:04d}.pt",
|
||||
)
|
||||
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
torch.save({
|
||||
"iteration": self.iteration,
|
||||
@@ -388,6 +578,7 @@ class PPOTrainer:
|
||||
"learning_rate": self.config.learning_rate,
|
||||
"opponent": self.config.opponent,
|
||||
"playstyle": self.config.playstyle,
|
||||
"data_source": self.config.data_source,
|
||||
},
|
||||
"best_win_rate": self.best_win_rate,
|
||||
"model_config": {
|
||||
@@ -401,14 +592,7 @@ class PPOTrainer:
|
||||
logger.info("Checkpoint saved: %s", path)
|
||||
|
||||
def train(self, progress_callback=None) -> Dict:
|
||||
"""Run the full PPO training loop.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callable(iteration, metrics) for progress reporting
|
||||
|
||||
Returns:
|
||||
Training summary dict
|
||||
"""
|
||||
"""Run the full PPO training loop."""
|
||||
cfg = self.config
|
||||
logger.info("=" * 60)
|
||||
logger.info("PPO Training — Commander AI Lab")
|
||||
@@ -423,98 +607,94 @@ class PPOTrainer:
|
||||
logger.info(" Opponent: %s", cfg.opponent)
|
||||
logger.info(" Device: %s", self.device)
|
||||
logger.info(" Supervised: %s", cfg.load_supervised or "None")
|
||||
logger.info(" Data source: %s", cfg.data_source)
|
||||
if cfg.data_source in ("forge", "mixed"):
|
||||
logger.info(" Forge results: %s / %s", cfg.forge_results_dir, cfg.forge_batch_id)
|
||||
logger.info(" Forge weight: %.0f%%", cfg.forge_weight * 100)
|
||||
logger.info("")
|
||||
|
||||
# Start Forge producer background thread if needed
|
||||
if self._forge_source:
|
||||
self._forge_source.start()
|
||||
|
||||
t_start = time.time()
|
||||
|
||||
for iteration in range(1, cfg.iterations + 1):
|
||||
self.iteration = iteration
|
||||
t_iter = time.time()
|
||||
try:
|
||||
for iteration in range(1, cfg.iterations + 1):
|
||||
self.iteration = iteration
|
||||
t_iter = time.time()
|
||||
|
||||
# --- Collect rollouts ---
|
||||
self.model.eval()
|
||||
rollout_stats = collect_rollouts(
|
||||
agent_model=self.model,
|
||||
opponent_policy=self.opponent,
|
||||
buffer=self.buffer,
|
||||
num_episodes=cfg.episodes_per_iter,
|
||||
playstyle=cfg.playstyle,
|
||||
reward_config=self.reward_config,
|
||||
)
|
||||
self.total_steps += rollout_stats["steps"]
|
||||
self.model.train()
|
||||
rollout_stats = self._collect_rollouts_for_iter()
|
||||
self.total_steps += rollout_stats["steps"]
|
||||
|
||||
# --- PPO Update ---
|
||||
update_metrics = self.ppo_update()
|
||||
update_metrics = self.ppo_update()
|
||||
|
||||
# --- LR Schedule ---
|
||||
current_lr = self.optimizer.param_groups[0]["lr"]
|
||||
if self.scheduler:
|
||||
self.scheduler.step()
|
||||
current_lr = self.optimizer.param_groups[0]["lr"]
|
||||
if self.scheduler:
|
||||
self.scheduler.step()
|
||||
|
||||
# --- Evaluate ---
|
||||
eval_results = None
|
||||
if iteration % cfg.eval_every == 0 or iteration == 1:
|
||||
eval_results = self.evaluate(cfg.eval_episodes)
|
||||
heuristic_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
|
||||
eval_results = None
|
||||
if iteration % cfg.eval_every == 0 or iteration == 1:
|
||||
eval_results = self.evaluate(cfg.eval_episodes)
|
||||
heuristic_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
|
||||
if heuristic_wr > self.best_win_rate:
|
||||
self.best_win_rate = heuristic_wr
|
||||
best_path = os.path.join(cfg.checkpoint_dir, "best_ppo.pt")
|
||||
self.save_checkpoint(best_path, {"eval": eval_results})
|
||||
logger.info(" ★ New best win rate: %.1f%%", heuristic_wr * 100)
|
||||
|
||||
if heuristic_wr > self.best_win_rate:
|
||||
self.best_win_rate = heuristic_wr
|
||||
best_path = os.path.join(cfg.checkpoint_dir, "best_ppo.pt")
|
||||
self.save_checkpoint(best_path, {"eval": eval_results})
|
||||
logger.info(" ★ New best win rate: %.1f%%", heuristic_wr * 100)
|
||||
if iteration % cfg.save_every == 0:
|
||||
self.save_checkpoint()
|
||||
|
||||
# --- Save periodic checkpoint ---
|
||||
if iteration % cfg.save_every == 0:
|
||||
self.save_checkpoint()
|
||||
iter_time = time.time() - t_iter
|
||||
metrics = {
|
||||
"iteration": iteration,
|
||||
"steps": rollout_stats["steps"],
|
||||
"total_steps": self.total_steps,
|
||||
"win_rate": rollout_stats["win_rate"],
|
||||
"avg_reward": rollout_stats["avg_reward_per_ep"],
|
||||
"policy_loss": update_metrics["policy_loss"],
|
||||
"value_loss": update_metrics["value_loss"],
|
||||
"entropy": update_metrics["entropy"],
|
||||
"approx_kl": update_metrics["approx_kl"],
|
||||
"clip_fraction": update_metrics["clip_fraction"],
|
||||
"lr": current_lr,
|
||||
"iter_time_s": round(iter_time, 1),
|
||||
"data_source": rollout_stats.get("source", cfg.data_source),
|
||||
"eval": eval_results,
|
||||
}
|
||||
self.history.append(metrics)
|
||||
|
||||
# --- Log ---
|
||||
iter_time = time.time() - t_iter
|
||||
metrics = {
|
||||
"iteration": iteration,
|
||||
"steps": rollout_stats["steps"],
|
||||
"total_steps": self.total_steps,
|
||||
"win_rate": rollout_stats["win_rate"],
|
||||
"avg_reward": rollout_stats["avg_reward_per_ep"],
|
||||
"policy_loss": update_metrics["policy_loss"],
|
||||
"value_loss": update_metrics["value_loss"],
|
||||
"entropy": update_metrics["entropy"],
|
||||
"approx_kl": update_metrics["approx_kl"],
|
||||
"clip_fraction": update_metrics["clip_fraction"],
|
||||
"lr": current_lr,
|
||||
"iter_time_s": round(iter_time, 1),
|
||||
"eval": eval_results,
|
||||
}
|
||||
self.history.append(metrics)
|
||||
eval_str = ""
|
||||
if eval_results:
|
||||
h_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
|
||||
r_wr = eval_results.get("random", {}).get("win_rate", 0)
|
||||
eval_str = f" | eval: h={h_wr:.0%} r={r_wr:.0%}"
|
||||
|
||||
# Pretty log
|
||||
eval_str = ""
|
||||
if eval_results:
|
||||
h_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
|
||||
r_wr = eval_results.get("random", {}).get("win_rate", 0)
|
||||
eval_str = f" | eval: h={h_wr:.0%} r={r_wr:.0%}"
|
||||
src_tag = f"[{rollout_stats.get('source', '?')[:3]}]"
|
||||
logger.info(
|
||||
" [%3d/%d]%s wr=%.0f%% rew=%.3f ploss=%.4f vloss=%.4f ent=%.3f kl=%.4f%s (%.1fs)",
|
||||
iteration, cfg.iterations, src_tag,
|
||||
rollout_stats["win_rate"] * 100,
|
||||
rollout_stats["avg_reward_per_ep"],
|
||||
update_metrics["policy_loss"],
|
||||
update_metrics["value_loss"],
|
||||
update_metrics["entropy"],
|
||||
update_metrics["approx_kl"],
|
||||
eval_str,
|
||||
iter_time,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
" [%3d/%d] wr=%.0f%% rew=%.3f ploss=%.4f vloss=%.4f ent=%.3f kl=%.4f%s (%.1fs)",
|
||||
iteration, cfg.iterations,
|
||||
rollout_stats["win_rate"] * 100,
|
||||
rollout_stats["avg_reward_per_ep"],
|
||||
update_metrics["policy_loss"],
|
||||
update_metrics["value_loss"],
|
||||
update_metrics["entropy"],
|
||||
update_metrics["approx_kl"],
|
||||
eval_str,
|
||||
iter_time,
|
||||
)
|
||||
if progress_callback:
|
||||
progress_callback(iteration, metrics)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(iteration, metrics)
|
||||
finally:
|
||||
if self._forge_source:
|
||||
self._forge_source.stop()
|
||||
|
||||
# --- Final Save ---
|
||||
total_time = time.time() - t_start
|
||||
self.save_checkpoint()
|
||||
|
||||
# Save training history
|
||||
history_path = os.path.join(cfg.checkpoint_dir, "ppo_history.json")
|
||||
with open(history_path, "w") as f:
|
||||
json.dump(self.history, f, indent=2, default=str)
|
||||
@@ -526,6 +706,7 @@ class PPOTrainer:
|
||||
"final_win_rate": self.history[-1]["win_rate"] if self.history else 0,
|
||||
"total_time_s": round(total_time, 1),
|
||||
"device": self.device,
|
||||
"data_source": cfg.data_source,
|
||||
"checkpoint_dir": cfg.checkpoint_dir,
|
||||
"history_path": history_path,
|
||||
}
|
||||
@@ -536,6 +717,7 @@ class PPOTrainer:
|
||||
logger.info(" Total time: %.1f s", total_time)
|
||||
logger.info(" Total steps: %d", self.total_steps)
|
||||
logger.info(" Best win rate: %.1f%%", self.best_win_rate * 100)
|
||||
logger.info(" Data source: %s", cfg.data_source)
|
||||
logger.info(" History: %s", history_path)
|
||||
logger.info("=" * 60)
|
||||
|
||||
@@ -578,12 +760,32 @@ def main():
|
||||
parser.add_argument("--load-supervised", default=None,
|
||||
help="Path to supervised checkpoint to initialize from")
|
||||
|
||||
# Data source
|
||||
parser.add_argument(
|
||||
"--data-source", default="synthetic",
|
||||
choices=["synthetic", "forge", "mixed"],
|
||||
help="Episode source: synthetic (self-play), forge (Forge JSONL), mixed (blend)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--forge-results-dir", default="results",
|
||||
help="Directory where Forge writes ml-decisions-forge-*.jsonl files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--forge-batch-id", default="forge-batch",
|
||||
help="Batch ID prefix for Forge JSONL files (matches BatchRunner --batchId)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--forge-num-games", type=int, default=256,
|
||||
help="Total Forge games to consume before stopping the producer",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--forge-weight", type=float, default=0.7,
|
||||
help="Fraction of RolloutBuffer filled from Forge when --data-source mixed (0.0–1.0)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
)
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
config = PPOConfig(
|
||||
iterations=args.iterations,
|
||||
@@ -605,6 +807,11 @@ def main():
|
||||
eval_every=args.eval_every,
|
||||
eval_episodes=args.eval_episodes,
|
||||
load_supervised=args.load_supervised,
|
||||
data_source=args.data_source,
|
||||
forge_results_dir=args.forge_results_dir,
|
||||
forge_batch_id=args.forge_batch_id,
|
||||
forge_num_games=args.forge_num_games,
|
||||
forge_weight=args.forge_weight,
|
||||
)
|
||||
|
||||
trainer = PPOTrainer(config)
|
||||
|
||||
@@ -567,7 +567,7 @@ def collect_rollouts(
|
||||
total_losses = 0
|
||||
total_draws = 0
|
||||
warnings.warn(_TRAINING_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
|
||||
total_steps = 0
|
||||
total_steps = 0
|
||||
total_rewards = 0.0
|
||||
|
||||
for ep in range(num_episodes):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -25,9 +26,35 @@ class Config:
|
||||
port: int = 8080
|
||||
ximilar_api_key: str = ""
|
||||
pplx_api_key: str = ""
|
||||
anthropic_api_key: str = ""
|
||||
|
||||
|
||||
CFG = Config()
|
||||
# Pre-populate from env so ASGI mode (uvicorn lab_api:app) works without main()
|
||||
CFG.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
|
||||
|
||||
def validate_forge_config() -> None:
|
||||
"""Warn clearly at startup if FORGE_DIR is misconfigured."""
|
||||
if not CFG.forge_dir:
|
||||
log.warning("FORGE_DIR not set -- Forge card enrichment disabled. "
|
||||
"Set FORGE_DIR to your forge-repo root or forge-gui directory.")
|
||||
return
|
||||
base = Path(CFG.forge_dir)
|
||||
a = base / "forge-gui" / "res" / "cardsfolder"
|
||||
b = base / "res" / "cardsfolder"
|
||||
if not a.is_dir() and not b.is_dir():
|
||||
log.warning(
|
||||
"FORGE_DIR=%s is set but cardsfolder not found at:\n"
|
||||
" %s\n %s\n"
|
||||
"Check that FORGE_DIR points to forge-repo root or forge-gui/.",
|
||||
CFG.forge_dir, a, b
|
||||
)
|
||||
else:
|
||||
resolved = a if a.is_dir() else b
|
||||
log.info("Forge cardsfolder confirmed at %s", resolved)
|
||||
CFG.ximilar_api_key = os.environ.get("XIMILAR_API_KEY", "")
|
||||
CFG.pplx_api_key = os.environ.get("PPLX_API_KEY", "")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -49,7 +76,9 @@ class BatchState:
|
||||
self.sims_per_sec: float = 0.0
|
||||
|
||||
|
||||
import asyncio as _asyncio
|
||||
active_batches: dict[str, BatchState] = {}
|
||||
active_batches_lock = _asyncio.Lock()
|
||||
|
||||
|
||||
COMMANDER_META: dict = {}
|
||||
|
||||
+75
-4
@@ -25,6 +25,7 @@ import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -42,6 +43,9 @@ DEFAULT_CONFIG = {
|
||||
"time_limit_hours": 11.5, # stop after this many hours (leave buffer for training)
|
||||
"pause_between_batches": 10, # seconds to pause between batches
|
||||
"auto_train": True, # trigger ML training after all batches
|
||||
"auto_update_weights": True, # run update_weights.py after sims
|
||||
"weights_min_games": 10, # minimum games required for weight update
|
||||
"weights_lr": 0.05, # learning rate for weight update
|
||||
"train_epochs": 100, # training epochs
|
||||
"train_lr": 0.001, # learning rate
|
||||
"train_batch_size": 256, # training batch size
|
||||
@@ -229,6 +233,52 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
|
||||
time.sleep(poll_interval)
|
||||
|
||||
|
||||
def run_update_weights(cfg):
|
||||
"""
|
||||
Run ml.scripts.update_weights to nudge learned_weights.json from sim
|
||||
decision logs. This feeds the deck builder's sim_insights injection.
|
||||
|
||||
Returns True on success, False on failure (non-fatal — overnight run
|
||||
continues regardless).
|
||||
"""
|
||||
log.info("")
|
||||
log.info("── Auto-update sim weights ─────────────────────────────────")
|
||||
log.info("Running update_weights.py to refresh learned_weights.json...")
|
||||
|
||||
cmd = [
|
||||
sys.executable, "-m", "ml.scripts.update_weights",
|
||||
"--min-games", str(cfg["weights_min_games"]),
|
||||
"--lr", str(cfg["weights_lr"]),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120, # 2 min ceiling — this should complete in seconds
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Print the last ~800 chars of the weight report to the log
|
||||
output = (result.stdout or "").strip()
|
||||
if output:
|
||||
for line in output.splitlines()[-20:]:
|
||||
log.info(f" {line}")
|
||||
log.info("Sim weights updated — deck builder will use new insights on next build.")
|
||||
return True
|
||||
else:
|
||||
log.warning(f"update_weights.py exited with code {result.returncode}")
|
||||
if result.stderr:
|
||||
log.warning(result.stderr.strip()[-300:])
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
log.warning("update_weights.py timed out after 120s — skipping")
|
||||
return False
|
||||
except Exception as e:
|
||||
log.warning(f"update_weights.py failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def start_training(base_url, epochs, lr, batch_size, patience):
|
||||
"""Trigger ML training and return immediately."""
|
||||
resp = api_post(base_url, "/api/ml/train", {
|
||||
@@ -364,6 +414,7 @@ def run_overnight(cfg):
|
||||
else:
|
||||
log.info(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
|
||||
log.info(f" Engine: {engine}")
|
||||
log.info(f" Auto-update weights: {'Yes' if cfg['auto_update_weights'] else 'No'}")
|
||||
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
|
||||
log.info("")
|
||||
|
||||
@@ -440,6 +491,17 @@ def run_overnight(cfg):
|
||||
log.info(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
|
||||
log.info("")
|
||||
|
||||
# ── Auto-update sim weights ───────────────────────────────
|
||||
# Runs update_weights.py to nudge learned_weights.json from the
|
||||
# decision logs produced during this run. This feeds sim_insights
|
||||
# into the deck builder's Ollama prompts on the next deck build.
|
||||
# Runs before neural net training so both pipelines benefit from
|
||||
# the freshest possible data.
|
||||
if cfg["auto_update_weights"]:
|
||||
run_update_weights(cfg)
|
||||
else:
|
||||
log.info("Skipping sim weight update (--no-update-weights flag set)")
|
||||
|
||||
# ── Auto-train ────────────────────────────────────────────
|
||||
if cfg["auto_train"]:
|
||||
if decisions_after < 100:
|
||||
@@ -482,17 +544,17 @@ def main():
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Run DeepSeek sims for 11.5 hours, then train:
|
||||
# Run DeepSeek sims for 11.5 hours, then update weights + train:
|
||||
python overnight-run.py
|
||||
|
||||
# Run 20 batches of 50 games each, then train:
|
||||
# Run 20 batches of 50 games each, then update weights + train:
|
||||
python overnight-run.py --batches 20 --games 50
|
||||
|
||||
# Run for 6 hours with Java engine:
|
||||
python overnight-run.py --engine java --hours 6 --decks "Deck A" "Deck B" "Deck C"
|
||||
|
||||
# Just run sims, no training:
|
||||
python overnight-run.py --no-train
|
||||
# Just run sims, skip both weight update and training:
|
||||
python overnight-run.py --no-train --no-update-weights
|
||||
|
||||
# Custom training settings:
|
||||
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
|
||||
@@ -514,6 +576,12 @@ Examples:
|
||||
help="Deck names (auto-detected if not specified)")
|
||||
parser.add_argument("--no-train", action="store_true",
|
||||
help="Skip auto-training after sims")
|
||||
parser.add_argument("--no-update-weights", action="store_true",
|
||||
help="Skip updating learned_weights.json after sims")
|
||||
parser.add_argument("--weights-min-games", type=int, default=10,
|
||||
help="Minimum games required for weight update (default: 10)")
|
||||
parser.add_argument("--weights-lr", type=float, default=0.05,
|
||||
help="Learning rate for sim weight update (default: 0.05)")
|
||||
parser.add_argument("--epochs", type=int, default=100,
|
||||
help="Training epochs (default: 100)")
|
||||
parser.add_argument("--lr", type=float, default=0.001,
|
||||
@@ -536,6 +604,9 @@ Examples:
|
||||
cfg["time_limit_hours"] = args.hours
|
||||
cfg["pause_between_batches"] = args.pause
|
||||
cfg["auto_train"] = not args.no_train
|
||||
cfg["auto_update_weights"] = not args.no_update_weights
|
||||
cfg["weights_min_games"] = args.weights_min_games
|
||||
cfg["weights_lr"] = args.weights_lr
|
||||
cfg["train_epochs"] = args.epochs
|
||||
cfg["train_lr"] = args.lr
|
||||
cfg["train_batch_size"] = args.batch_size
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
[metadata]
|
||||
Name=20 Ways to Win
|
||||
[Commander]
|
||||
1 Go-Shintai of Life's Origin
|
||||
[Main]
|
||||
1 Alseid of Life's Bounty
|
||||
1 Approach of the Second Sun
|
||||
1 Arcane Signet
|
||||
1 Augur of Autumn
|
||||
1 Auriok Champion
|
||||
1 Austere Command
|
||||
1 Azorius Guildgate
|
||||
1 Baldur's Gate
|
||||
1 Biovisionary
|
||||
1 Black Dragon Gate
|
||||
1 Boros Guildgate
|
||||
1 Bountiful Promenade
|
||||
1 Brushland
|
||||
1 Chromatic Lantern
|
||||
1 Circuitous Route
|
||||
1 Citadel Gate
|
||||
1 Clever Concealment
|
||||
1 Cliffgate
|
||||
1 Command Tower
|
||||
1 Crib Swap
|
||||
1 Darksteel Citadel
|
||||
1 Darksteel Mutation
|
||||
1 Dimir Guildgate
|
||||
1 Drown in Dreams
|
||||
1 Dryad of the Ilysian Grove
|
||||
1 Eerie Ultimatum
|
||||
1 Exotic Orchard
|
||||
1 Faeburrow Elder
|
||||
1 Felidar Sovereign
|
||||
1 Fertile Ground
|
||||
1 Forbidden Orchard
|
||||
1 Forest
|
||||
1 Forgotten Ancient
|
||||
1 Gateway Plaza
|
||||
1 Ghostly Prison
|
||||
1 Ghoulish Impetus
|
||||
1 Golgari Guildgate
|
||||
1 Gond Gate
|
||||
1 Gruul Guildgate
|
||||
1 Halo Fountain
|
||||
1 Hangarback Walker
|
||||
1 Happily Ever After
|
||||
1 Heap Gate
|
||||
1 Heliod's Intervention
|
||||
1 Heliod, Sun-Crowned
|
||||
1 Helix Pinnacle
|
||||
1 Hellkite Tyrant
|
||||
1 Homeward Path
|
||||
1 Izzet Guildgate
|
||||
1 Kynaios and Tiro of Meletis
|
||||
1 Lightning Greaves
|
||||
1 Lignify
|
||||
1 Liliana's Contract
|
||||
1 Mangara, the Diplomat
|
||||
1 Manor Gate
|
||||
1 Maskwood Nexus
|
||||
1 Mayael's Aria
|
||||
1 Maze's End
|
||||
1 Mechanized Production
|
||||
1 Monologue Tax
|
||||
1 Morbid Opportunist
|
||||
1 Mystic Remora
|
||||
1 Orzhov Guildgate
|
||||
1 Overgrown Farmland
|
||||
1 Pariah
|
||||
1 Path of Ancestry
|
||||
1 Plains
|
||||
1 Plaza of Harmony
|
||||
1 Rakdos Guildgate
|
||||
1 Razorverge Thicket
|
||||
1 Reflecting Pool
|
||||
1 Revel in Riches
|
||||
1 Rite of Replication
|
||||
1 Sanctum Weaver
|
||||
1 Sea Gate
|
||||
1 Seedborn Muse
|
||||
1 Selesnya Guildgate
|
||||
1 Shalai, Voice of Plenty
|
||||
1 Shiny Impetus
|
||||
1 Simic Ascendancy
|
||||
1 Simic Guildgate
|
||||
1 Sol Ring
|
||||
1 Soul's Attendant
|
||||
1 Sphinx's Revelation
|
||||
1 Sun Titan
|
||||
1 Sunscorch Regent
|
||||
1 Supreme Verdict
|
||||
1 Taurean Mauler
|
||||
1 Test of Endurance
|
||||
1 The Eternal Wanderer
|
||||
1 The World Tree
|
||||
1 Thornglint Bridge
|
||||
1 Tireless Tracker
|
||||
1 Trace of Abundance
|
||||
1 Tragic Arrogance
|
||||
1 Triskaidekaphile
|
||||
1 Twenty-Toed Toad
|
||||
1 Wooded Bastion
|
||||
1 Yahenni, Undying Partisan
|
||||
@@ -0,0 +1,92 @@
|
||||
[metadata]
|
||||
Name=Abzan Armor
|
||||
[Commander]
|
||||
1 Felothar the Steadfast
|
||||
[Main]
|
||||
1 Betor, Ancestor's Voice
|
||||
1 Protector of the Wastes
|
||||
1 Reunion of the House
|
||||
1 Jaws of Defeat
|
||||
1 Tip the Scales
|
||||
1 Will of the Abzan
|
||||
1 Arbor Adherent
|
||||
1 Canopy Gargantuan
|
||||
1 Rampart Architect
|
||||
1 Tree of Redemption
|
||||
1 Ikra Shidiqi, the Usurper
|
||||
1 Baldin, Century Herdmaster
|
||||
1 Expel the Interlopers
|
||||
1 Indomitable Ancients
|
||||
1 Rhox Faithmender
|
||||
1 Shalai, Voice of Plenty
|
||||
1 Wakestone Gargoyle
|
||||
1 Wall of Reverence
|
||||
1 Welcoming Vampire
|
||||
1 Zetalpa, Primal Dawn
|
||||
1 Arasta of the Endless Web
|
||||
1 Assault Formation
|
||||
1 Hornet Nest
|
||||
1 Seedborn Muse
|
||||
1 Sylvan Caryatid
|
||||
1 Towering Titan
|
||||
1 Anguished Unmaking
|
||||
1 Dragonlord Dromoka
|
||||
1 Faeburrow Elder
|
||||
1 Shadrix Silverquill
|
||||
1 Sidar Kondo of Jamuraa
|
||||
1 Colfenor's Urn
|
||||
1 Staff of Compleation
|
||||
1 Weathered Sentinels
|
||||
1 Canopy Vista
|
||||
1 Exotic Orchard
|
||||
1 Fortified Village
|
||||
1 Isolated Chapel
|
||||
1 Overgrown Farmland
|
||||
1 Sungrass Prairie
|
||||
1 Sunpetal Grove
|
||||
1 Temple of Malady
|
||||
1 Temple of Plenty
|
||||
1 Temple of Silence
|
||||
1 Twilight Mire
|
||||
1 Woodland Cemetery
|
||||
1 Arcane Signet
|
||||
1 Sol Ring
|
||||
1 Command Tower
|
||||
1 Nyx-Fleece Ram
|
||||
1 Slaughter the Strong
|
||||
1 Swords to Plowshares
|
||||
1 Wall of Omens
|
||||
1 Wingmantle Chaplain
|
||||
1 Behind the Scenes
|
||||
1 Blight Pile
|
||||
1 Feed the Swarm
|
||||
1 Infernal Grasp
|
||||
1 Wall of Limbs
|
||||
1 Arboreal Grazer
|
||||
1 Axebane Guardian
|
||||
1 Carven Caryatid
|
||||
1 Evolving Wilds
|
||||
1 Jaddi Offshoot
|
||||
1 Overgrown Battlement
|
||||
1 Sandsteppe Citadel
|
||||
1 Tower Defense
|
||||
1 Wall of Blossoms
|
||||
1 Wall of Roots
|
||||
1 Despark
|
||||
1 Indulging Patrician
|
||||
1 Crashing Drawbridge
|
||||
1 Orzhov Signet
|
||||
1 Selesnya Signet
|
||||
1 Swiftfoot Boots
|
||||
1 Walking Bulwark
|
||||
1 Access Tunnel
|
||||
1 Bojuka Bog
|
||||
1 Deceptive Landscape
|
||||
1 Path of Ancestry
|
||||
1 Radiant Grove
|
||||
3 Plains
|
||||
3 Plains
|
||||
3 Swamp
|
||||
2 Swamp
|
||||
4 Forest
|
||||
3 Forest
|
||||
@@ -0,0 +1,90 @@
|
||||
[metadata]
|
||||
Name=Adaptive Enchantment
|
||||
[Commander]
|
||||
1 Estrid, the Masked
|
||||
[Main]
|
||||
1 Bruna, Light of Alabaster
|
||||
1 Eidolon of Blossoms
|
||||
1 Hydra Omnivore
|
||||
1 Ajani's Chosen
|
||||
1 Celestial Archon
|
||||
1 Silent Sentinel
|
||||
1 Boon Satyr
|
||||
1 Herald of the Pantheon
|
||||
1 Cold-Eyed Selkie
|
||||
1 Daxos of Meletis
|
||||
1 Elderwood Scion
|
||||
1 Archetype of Imagination
|
||||
1 Whitewater Naiads
|
||||
1 Aura Gnarlid
|
||||
1 Reclamation Sage
|
||||
1 Yavimaya Enchantress
|
||||
1 Martial Coup
|
||||
1 Phyrexian Rebirth
|
||||
1 Winds of Rath
|
||||
1 Creeping Renaissance
|
||||
1 Kruphix's Insight
|
||||
1 Dismantling Blow
|
||||
1 Bant Charm
|
||||
1 Sol Ring
|
||||
1 Sigil of the Empty Throne
|
||||
1 Dictate of Kruphix
|
||||
1 Bear Umbra
|
||||
1 Enchantress's Presence
|
||||
1 Epic Proportions
|
||||
1 Ground Seal
|
||||
1 Spawning Grounds
|
||||
1 Finest Hour
|
||||
1 Righteous Authority
|
||||
1 Sage's Reverie
|
||||
1 Soul Snare
|
||||
1 Unquestioned Authority
|
||||
1 Eel Umbra
|
||||
1 Vow of Flight
|
||||
1 Dawn's Reflection
|
||||
1 Fertile Ground
|
||||
1 Overgrowth
|
||||
1 Snake Umbra
|
||||
1 Vow of Wildness
|
||||
1 Wild Growth
|
||||
1 Unflinching Courage
|
||||
3 Plains
|
||||
3 Plains
|
||||
3 Plains
|
||||
2 Island
|
||||
2 Island
|
||||
2 Island
|
||||
3 Forest
|
||||
3 Forest
|
||||
2 Forest
|
||||
1 Azorius Chancery
|
||||
1 Blossoming Sands
|
||||
1 Command Tower
|
||||
1 Evolving Wilds
|
||||
1 Krosan Verge
|
||||
1 Meandering River
|
||||
1 Mosswort Bridge
|
||||
1 Seaside Citadel
|
||||
1 Selesnya Sanctuary
|
||||
1 Simic Growth Chamber
|
||||
1 Terramorphic Expanse
|
||||
1 Thornwood Falls
|
||||
1 Tranquil Cove
|
||||
1 Tranquil Expanse
|
||||
1 Woodland Stream
|
||||
1 Kestia, the Cultivator
|
||||
1 Tuvasa the Sunlit
|
||||
1 Empyrial Storm
|
||||
1 Heavenly Blademaster
|
||||
1 Estrid's Invocation
|
||||
1 Ever-Watching Threshold
|
||||
1 Octopus Umbra
|
||||
1 Genesis Storm
|
||||
1 Myth Unbound
|
||||
1 Nylea's Colossus
|
||||
1 Ravenous Slime
|
||||
1 Arixmethes, Slumbering Isle
|
||||
1 Loyal Unicorn
|
||||
1 Loyal Drake
|
||||
1 Loyal Guardian
|
||||
1 Forge of Heroes
|
||||
@@ -0,0 +1,95 @@
|
||||
[metadata]
|
||||
Name=Ahoy Mateys
|
||||
[Commander]
|
||||
1 Admiral Brass, Unsinkable
|
||||
[Main]
|
||||
1 Don Andres, the Renegade
|
||||
1 The Indomitable
|
||||
1 Storm Fleet Negotiator
|
||||
1 Francisco, Fowl Marauder
|
||||
1 The Grim Captain's Locker
|
||||
1 Skeleton Crew
|
||||
1 Broadside Bombardiers
|
||||
1 Gemcutter Buccaneer
|
||||
1 Arm-Mounted Anchor
|
||||
1 Amphin Mutineer
|
||||
1 Bident of Thassa
|
||||
1 Corsair Captain
|
||||
1 Evacuation
|
||||
1 Timestream Navigator
|
||||
1 Warkite Marauder
|
||||
1 Black Market Connections
|
||||
1 Blood Money
|
||||
1 Dire Fleet Ravager
|
||||
1 Fathom Fleet Captain
|
||||
1 Lethal Scheme
|
||||
1 Angrath's Marauders
|
||||
1 Blasphemous Act
|
||||
1 Captain Lannery Storm
|
||||
1 Captivating Crew
|
||||
1 Chaos Warp
|
||||
1 Coercive Recruiter
|
||||
1 Dire Fleet Daredevil
|
||||
1 Kari Zev, Skyship Raider
|
||||
1 Port Razer
|
||||
1 Shared Animosity
|
||||
1 Admiral Beckett Brass
|
||||
1 Hostage Taker
|
||||
1 King Narfi's Betrayal
|
||||
1 Prismari Command
|
||||
1 Zara, Renegade Recruiter
|
||||
1 Icon of Ancestry
|
||||
1 Vanquisher's Banner
|
||||
1 Choked Estuary
|
||||
1 Desolate Lighthouse
|
||||
1 Exotic Orchard
|
||||
1 Foreboding Ruins
|
||||
1 Frostboil Snarl
|
||||
1 Geier Reach Sanitarium
|
||||
1 Nephalia Drownyard
|
||||
1 Smoldering Marsh
|
||||
1 Sulfur Falls
|
||||
1 Sunken Hollow
|
||||
1 Azure Fleet Admiral
|
||||
1 Enterprising Scallywag
|
||||
1 Daring Saboteur
|
||||
1 Departed Deckhand
|
||||
1 Distant Melody
|
||||
1 Ghost of Ramirez DePietro
|
||||
1 Malcolm, Keen-Eyed Navigator
|
||||
1 Merchant Raiders
|
||||
1 Siren Stormtamer
|
||||
1 Spectral Sailor
|
||||
1 Windfall
|
||||
1 Feed the Swarm
|
||||
1 Pitiless Plunderer
|
||||
1 Breeches, Brazen Plunderer
|
||||
1 Faithless Looting
|
||||
1 Rakdos Charm
|
||||
1 Ramirez DePietro, Pillager
|
||||
1 Arcane Signet
|
||||
1 Commander's Sphere
|
||||
1 Dimir Signet
|
||||
1 Herald's Horn
|
||||
1 Izzet Signet
|
||||
1 Rakdos Signet
|
||||
1 Sol Ring
|
||||
1 Wayfarer's Bauble
|
||||
1 Command Tower
|
||||
1 Crumbling Necropolis
|
||||
1 Evolving Wilds
|
||||
1 Path of Ancestry
|
||||
1 Port of Karfell
|
||||
1 Rogue's Passage
|
||||
1 Secluded Courtyard
|
||||
1 Terramorphic Expanse
|
||||
1 Thriving Bluff
|
||||
1 Thriving Isle
|
||||
1 Thriving Moor
|
||||
1 Unclaimed Territory
|
||||
3 Island
|
||||
3 Island
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
3 Mountain
|
||||
2 Mountain
|
||||
@@ -0,0 +1,78 @@
|
||||
[metadata]
|
||||
Name=Angels: They're Just Like Us but Cooler and with Wings
|
||||
[Commander]
|
||||
1 Gisela, the Broken Blade
|
||||
[Main]
|
||||
1 Bruna, the Fading Light
|
||||
1 Ajani, Strength of the Pride
|
||||
1 Angel of Destiny
|
||||
1 Angel of Finality
|
||||
1 Angel of Serenity
|
||||
1 Angel of the Ruins
|
||||
1 Angel of Vitality
|
||||
1 Angelic Accord
|
||||
1 Angelic Field Marshal
|
||||
1 Arcane Signet
|
||||
1 Arch of Orazca
|
||||
1 Archangel of Thune
|
||||
1 Archangel of Tithes
|
||||
1 Arden Angel
|
||||
1 Austere Command
|
||||
1 Bishop of Wings
|
||||
1 Bonders' Enclave
|
||||
1 Breathkeeper Seraph
|
||||
1 Cleansing Nova
|
||||
1 Commander's Plate
|
||||
1 Cosmos Elixir
|
||||
1 Court of Grace
|
||||
1 Dawn of Hope
|
||||
1 Dawnbreak Reclaimer
|
||||
1 Dismantling Wave
|
||||
1 Emeria Shepherd
|
||||
1 Emeria, the Sky Ruin
|
||||
1 Endless Atlas
|
||||
1 Entreat the Angels
|
||||
1 Everflowing Chalice
|
||||
1 Giada, Font of Hope
|
||||
1 Griffin Aerie
|
||||
1 Heirloom Blade
|
||||
1 Invoke the Divine
|
||||
1 Karoo
|
||||
1 Keeper of the Accord
|
||||
1 Kindred Boon
|
||||
1 Lightning Greaves
|
||||
1 Marble Diamond
|
||||
1 Mazemind Tome
|
||||
1 Mind Stone
|
||||
1 Myriad Landscape
|
||||
1 Nykthos Paragon
|
||||
1 Nykthos, Shrine to Nyx
|
||||
1 Oketra's Monument
|
||||
1 Path of Ancestry
|
||||
1 Path to Exile
|
||||
1 Pearl Medallion
|
||||
8 Plains
|
||||
8 Plains
|
||||
7 Plains
|
||||
7 Plains
|
||||
1 Righteous Valkyrie
|
||||
1 Search for Glory
|
||||
1 Sephara, Sky's Blade
|
||||
1 Seraph Sanctuary
|
||||
1 Serra Ascendant
|
||||
1 Shattered Angel
|
||||
1 Sol Ring
|
||||
1 Speaker of the Heavens
|
||||
1 Starnheim Aspirant
|
||||
1 Sunblast Angel
|
||||
1 Swiftfoot Boots
|
||||
1 Sword of the Animist
|
||||
1 Swords to Plowshares
|
||||
1 Thalia's Lancers
|
||||
1 The Book of Exalted Deeds
|
||||
1 Tome of Legends
|
||||
1 Urza's Incubator
|
||||
1 Valkyrie Harbinger
|
||||
1 Vanquisher's Banner
|
||||
1 War Room
|
||||
1 Well of Lost Dreams
|
||||
@@ -0,0 +1,90 @@
|
||||
[metadata]
|
||||
Name=Animated Army
|
||||
[Commander]
|
||||
1 Bello, Bard of the Brambles
|
||||
[Main]
|
||||
1 Wildsear, Scouring Maw
|
||||
1 Domri, Anarch of Bolas
|
||||
1 Etali, Primal Storm
|
||||
1 Prosperous Bandit
|
||||
1 Pyreswipe Hawk
|
||||
1 Alchemist's Talent
|
||||
1 Berserkers' Onslaught
|
||||
1 Outpost Siege
|
||||
1 Rain of Riches
|
||||
1 Chaos Warp
|
||||
1 Sunbird's Invocation
|
||||
1 Gratuitous Violence
|
||||
1 Warstorm Surge
|
||||
1 Starstorm
|
||||
1 Lotus Cobra
|
||||
1 Evercoat Ursine
|
||||
1 Brightcap Badger // Fungus Frolic
|
||||
1 Trailtracker Scout
|
||||
1 Thickest in the Thicket
|
||||
1 Ghalta, Primal Hunger
|
||||
1 Esika's Chariot
|
||||
1 Unnatural Growth
|
||||
1 Greater Good
|
||||
1 Kodama of the East Tree
|
||||
1 Grothama, All-Devouring
|
||||
1 Rampaging Baloths
|
||||
1 Bootleggers' Stash
|
||||
1 Primeval Bounty
|
||||
1 Gilded Lotus
|
||||
1 Spine of Ish Sah
|
||||
1 Temple of Abandon
|
||||
1 Karplusan Forest
|
||||
1 Exotic Orchard
|
||||
1 Sheltered Thicket
|
||||
1 Game Trail
|
||||
1 Raging Ravine
|
||||
1 Copperline Gorge
|
||||
1 Mossfire Valley
|
||||
1 Cinder Glade
|
||||
1 Rootbound Crag
|
||||
1 Mosswort Bridge
|
||||
1 Blasphemous Act
|
||||
1 Llanowar Loamspeaker
|
||||
1 Tendershoot Dryad
|
||||
1 Goreclaw, Terror of Qal Sisma
|
||||
1 Path of Discovery
|
||||
1 Decimate
|
||||
1 Rolling Hamsphere
|
||||
1 Teapot Slinger
|
||||
1 Explore
|
||||
1 Farseek
|
||||
1 Cultivate
|
||||
1 Grumgully, the Generous
|
||||
1 Wandertale Mentor
|
||||
1 Thought Vessel
|
||||
1 Arcane Signet
|
||||
1 Wooded Ridgeline
|
||||
1 Big Score
|
||||
1 Abrade
|
||||
1 Rampant Growth
|
||||
1 Sakura-Tribe Elder
|
||||
1 Beast Within
|
||||
1 Garruk's Packleader
|
||||
1 Harmonize
|
||||
1 Garruk's Uprising
|
||||
1 Gruul Signet
|
||||
1 Burnished Hart
|
||||
1 Hedron Archive
|
||||
1 Fellwar Stone
|
||||
1 Thran Dynamo
|
||||
1 Sol Ring
|
||||
1 Mind Stone
|
||||
1 Talisman of Impulse
|
||||
1 Terramorphic Expanse
|
||||
1 Path of Ancestry
|
||||
1 Gruul Turf
|
||||
1 Evolving Wilds
|
||||
1 Forgotten Cave
|
||||
1 Tranquil Thicket
|
||||
1 Command Tower
|
||||
1 Reliquary Tower
|
||||
4 Mountain
|
||||
4 Mountain
|
||||
5 Forest
|
||||
5 Forest
|
||||
@@ -0,0 +1,95 @@
|
||||
[metadata]
|
||||
Name=Arcane Maelstrom
|
||||
[Commander]
|
||||
1 Kalamax, the Stormsire
|
||||
[Main]
|
||||
1 Jace, Architect of Thought
|
||||
1 Xyris, the Writhing Storm
|
||||
1 Haldan, Avid Arcanist
|
||||
1 Pako, Arcane Retriever
|
||||
1 Eon Frolicker
|
||||
1 Nascent Metamorph
|
||||
1 Glademuse
|
||||
1 Ravenous Gigantotherium
|
||||
1 Lunar Mystic
|
||||
1 Niblis of Frost
|
||||
1 Talrand, Sky Summoner
|
||||
1 Charmbreaker Devils
|
||||
1 Dualcaster Mage
|
||||
1 Etali, Primal Storm
|
||||
1 Goblin Dark-Dwellers
|
||||
1 Djinn Illuminatus
|
||||
1 Melek, Izzet Paragon
|
||||
1 Rashmi, Eternities Crafter
|
||||
1 Wort, the Raidmother
|
||||
1 Solemn Simulacrum
|
||||
1 Murmuring Mystic
|
||||
1 Crackling Drake
|
||||
1 Surreal Memoir
|
||||
1 Decoy Gambit
|
||||
1 Deflecting Swat
|
||||
1 Curious Herd
|
||||
1 Chaos Warp
|
||||
1 Comet Storm
|
||||
1 Commune with Lava
|
||||
1 Starstorm
|
||||
1 Strength of the Tajuru
|
||||
1 Artifact Mutation
|
||||
1 Prophetic Bolt
|
||||
1 Clash of Titans
|
||||
1 Channeled Force
|
||||
1 Chemister's Insight
|
||||
1 Frantic Search
|
||||
1 Whiplash Trap
|
||||
1 Crop Rotation
|
||||
1 Evolution Charm
|
||||
1 Harrow
|
||||
1 Hunter's Insight
|
||||
1 Hunting Pack
|
||||
1 Natural Connection
|
||||
1 Slice in Twain
|
||||
1 Tribute to the Wild
|
||||
1 Growth Spiral
|
||||
1 Temur Charm
|
||||
1 Lavabrink Floodgates
|
||||
1 Twinning Staff
|
||||
1 Bonder's Ornament
|
||||
1 Arcane Signet
|
||||
1 Commander's Sphere
|
||||
1 Lightning Greaves
|
||||
1 Sol Ring
|
||||
1 Swarm Intelligence
|
||||
1 Primal Empathy
|
||||
1 Psychic Impetus
|
||||
1 Shiny Impetus
|
||||
1 Predatory Impetus
|
||||
1 Wilderness Reclamation
|
||||
1 Cinder Glade
|
||||
1 Desolate Lighthouse
|
||||
1 Exotic Orchard
|
||||
1 Kessig Wolf Run
|
||||
1 Mossfire Valley
|
||||
1 Mosswort Bridge
|
||||
1 Oran-Rief, the Vastwood
|
||||
1 Scavenger Grounds
|
||||
1 Yavimaya Coast
|
||||
1 Command Tower
|
||||
1 Frontier Bivouac
|
||||
1 Gruul Turf
|
||||
1 Halimar Depths
|
||||
1 Izzet Boilerworks
|
||||
1 Myriad Landscape
|
||||
1 Rupture Spire
|
||||
1 Simic Growth Chamber
|
||||
1 Rugged Highlands
|
||||
1 Swiftwater Cliffs
|
||||
1 Thornwood Falls
|
||||
3 Forest
|
||||
3 Forest
|
||||
2 Forest
|
||||
2 Island
|
||||
2 Island
|
||||
1 Island
|
||||
2 Mountain
|
||||
2 Mountain
|
||||
1 Mountain
|
||||
@@ -0,0 +1,93 @@
|
||||
[metadata]
|
||||
Name=Arcane Wizardry
|
||||
[Commander]
|
||||
1 Inalla, Archmage Ritualist
|
||||
[Main]
|
||||
1 Kess, Dissident Mage
|
||||
1 Mairsil, the Pretender
|
||||
1 Galecaster Colossus
|
||||
1 Magus of the Mind
|
||||
1 Portal Mage
|
||||
1 Vindictive Lich
|
||||
1 Izzet Chemister
|
||||
1 Taigam, Sidisi's Hand
|
||||
1 Havengul Lich
|
||||
1 Marchesa, the Black Rose
|
||||
1 Vela the Night-Clad
|
||||
1 Arcanis the Omnipotent
|
||||
1 Azami, Lady of Scrolls
|
||||
1 Body Double
|
||||
1 Harbinger of the Tides
|
||||
1 Serendib Sorcerer
|
||||
1 Apprentice Necromancer
|
||||
1 Magus of the Abyss
|
||||
1 Puppeteer Clique
|
||||
1 Etherium-Horn Sorcerer
|
||||
1 Mercurial Chemister
|
||||
1 Nin, the Pain Artist
|
||||
1 Niv-Mizzet, the Firemind
|
||||
1 Shadowmage Infiltrator
|
||||
1 Bloodline Necromancer
|
||||
1 Archaeomancer
|
||||
1 Merchant of Secrets
|
||||
1 Sea Gate Oracle
|
||||
1 Corpse Augur
|
||||
1 Izzet Chronarch
|
||||
1 Nivix Guildmage
|
||||
1 Kindred Dominance
|
||||
1 Clone Legion
|
||||
1 Spelltwine
|
||||
1 Decree of Pain
|
||||
1 Necromantic Selection
|
||||
1 Comet Storm
|
||||
1 Polymorphist's Jest
|
||||
1 Chaos Warp
|
||||
1 Memory Plunder
|
||||
1 Silumgar's Command
|
||||
1 Into the Roil
|
||||
1 Opportunity
|
||||
1 Reality Shift
|
||||
1 Go for the Throat
|
||||
1 Cauldron Dance
|
||||
1 Crosis's Charm
|
||||
1 Rakdos Charm
|
||||
1 Terminate
|
||||
1 Nevinyrral's Disk
|
||||
1 Mirror of the Forebears
|
||||
1 Commander's Sphere
|
||||
1 Darksteel Ingot
|
||||
1 Fellwar Stone
|
||||
1 Sol Ring
|
||||
1 Unstable Obelisk
|
||||
1 Worn Powerstone
|
||||
1 Shifting Shadow
|
||||
1 Curse of Verbosity
|
||||
1 Curse of Disturbance
|
||||
1 Curse of Opulence
|
||||
1 Exotic Orchard
|
||||
1 Mystifying Maze
|
||||
4 Island
|
||||
3 Island
|
||||
3 Island
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
2 Mountain
|
||||
1 Mountain
|
||||
1 Mountain
|
||||
1 Path of Ancestry
|
||||
1 Command Tower
|
||||
1 Crumbling Necropolis
|
||||
1 Dimir Aqueduct
|
||||
1 Dismal Backwater
|
||||
1 Evolving Wilds
|
||||
1 Grixis Panorama
|
||||
1 Izzet Boilerworks
|
||||
1 Jwar Isle Refuge
|
||||
1 Rakdos Carnarium
|
||||
1 Swiftwater Cliffs
|
||||
1 Temple of the False God
|
||||
1 Terramorphic Expanse
|
||||
1 Vivid Crag
|
||||
1 Vivid Creek
|
||||
1 Vivid Marsh
|
||||
@@ -0,0 +1,85 @@
|
||||
[metadata]
|
||||
Name=Arm for Battle
|
||||
[Commander]
|
||||
1 Wyleth, Soul of Steel
|
||||
[Main]
|
||||
1 Oreskos Explorer
|
||||
1 Relic Seeker
|
||||
1 Sram, Senior Edificer
|
||||
1 Brass Squire
|
||||
1 Danitha Capashen, Paragon
|
||||
1 Dualcaster Mage
|
||||
1 Flickerwisp
|
||||
1 Ironclad Slayer
|
||||
1 Kor Cartographer
|
||||
1 Odric, Lunarch Marshal
|
||||
1 Tiana, Ship's Caretaker
|
||||
1 Condemn
|
||||
1 Expedite
|
||||
1 Swords to Plowshares
|
||||
1 Abrade
|
||||
1 Boros Charm
|
||||
1 Comet Storm
|
||||
1 Dawn Charm
|
||||
1 Deflecting Palm
|
||||
1 Disenchant
|
||||
1 Fists of Flame
|
||||
1 Temur Battle Rage
|
||||
1 Valorous Stance
|
||||
1 Generous Gift
|
||||
1 Unbreakable Formation
|
||||
1 Volcanic Fallout
|
||||
1 Wear // Tear
|
||||
1 White Sun's Zenith
|
||||
1 Master Warcraft
|
||||
1 Return to Dust
|
||||
1 Wild Ricochet
|
||||
1 Word of Seizing
|
||||
1 Response // Resurgence
|
||||
1 Jaya's Immolating Inferno
|
||||
1 Martial Coup
|
||||
1 Relentless Assault
|
||||
1 Winds of Rath
|
||||
1 Bonesplitter
|
||||
1 Explorer's Scope
|
||||
1 Sol Ring
|
||||
1 Blackblade Reforged
|
||||
1 Blazing Sunsteel
|
||||
1 Boros Signet
|
||||
1 Hero's Blade
|
||||
1 Mask of Avacyn
|
||||
1 Ring of Thune
|
||||
1 Ring of Valkas
|
||||
1 Swiftfoot Boots
|
||||
1 Fireshrieker
|
||||
1 Haunted Cloak
|
||||
1 Loxodon Warhammer
|
||||
1 Sunforger
|
||||
1 Sword of Vengeance
|
||||
1 Sigarda's Aid
|
||||
1 Spirit Mantle
|
||||
1 Timely Ward
|
||||
1 Unquestioned Authority
|
||||
1 Faith Unbroken
|
||||
1 On Serra's Wings
|
||||
1 Boros Garrison
|
||||
1 Boros Guildgate
|
||||
1 Command Tower
|
||||
1 Encroaching Wastes
|
||||
1 Evolving Wilds
|
||||
1 Forgotten Cave
|
||||
1 Memorial to War
|
||||
5 Mountain
|
||||
4 Mountain
|
||||
1 Myriad Landscape
|
||||
7 Plains
|
||||
7 Plains
|
||||
1 Rogue's Passage
|
||||
1 Rupture Spire
|
||||
1 Secluded Steppe
|
||||
1 Slayers' Stronghold
|
||||
1 Stone Quarry
|
||||
1 Sunhome, Fortress of the Legion
|
||||
1 Terramorphic Expanse
|
||||
1 Transguild Promenade
|
||||
1 Wind-Scarred Crag
|
||||
@@ -0,0 +1,100 @@
|
||||
[metadata]
|
||||
Name=Aura of Courage
|
||||
[Commander]
|
||||
1 Galea, Kindler of Hope
|
||||
[Main]
|
||||
1 Angel of Finality
|
||||
1 Puresteel Paladin
|
||||
1 Realm-Cloaked Giant // Cast Off
|
||||
1 Sram, Senior Edificer
|
||||
1 Prognostic Sphinx
|
||||
1 Cold-Eyed Selkie
|
||||
1 Fleecemane Lion
|
||||
1 Knight of Autumn
|
||||
1 Riverwise Augur
|
||||
1 Acidic Slime
|
||||
1 Paradise Druid
|
||||
1 Winds of Rath
|
||||
1 Serum Visions
|
||||
1 Nature's Lore
|
||||
1 Heroic Intervention
|
||||
1 Valorous Stance
|
||||
1 Brainstorm
|
||||
1 Bant Charm
|
||||
1 Argentum Armor
|
||||
1 Basilisk Collar
|
||||
1 Masterwork of Ingenuity
|
||||
1 Moonsilver Spear
|
||||
1 Sword of the Animist
|
||||
1 Behemoth Sledge
|
||||
1 Arcane Signet
|
||||
1 Colossus Hammer
|
||||
1 Explorer's Scope
|
||||
1 Sol Ring
|
||||
1 Swiftfoot Boots
|
||||
1 Viridian Longbow
|
||||
1 Imprisoned in the Moon
|
||||
1 Greater Good
|
||||
1 Verdant Embrace
|
||||
1 Angelic Gift
|
||||
1 Gryff's Boon
|
||||
1 Curse of Verbosity
|
||||
1 Eel Umbra
|
||||
1 Psychic Impetus
|
||||
1 Abundant Growth
|
||||
1 Fertile Ground
|
||||
1 Kenrith's Transformation
|
||||
1 Rancor
|
||||
1 Utopia Sprawl
|
||||
1 Wild Growth
|
||||
1 Shielding Plax
|
||||
1 Canopy Vista
|
||||
1 Exotic Orchard
|
||||
1 Fortified Village
|
||||
1 Lumbering Falls
|
||||
1 Port Town
|
||||
1 Prairie Stream
|
||||
1 Skycloud Expanse
|
||||
1 Sungrass Prairie
|
||||
1 Evolving Wilds
|
||||
1 Azorius Chancery
|
||||
1 Bant Panorama
|
||||
1 Command Tower
|
||||
1 Flood Plain
|
||||
1 Grasslands
|
||||
1 Halimar Depths
|
||||
1 Mishra's Factory
|
||||
1 Path of Ancestry
|
||||
1 Seaside Citadel
|
||||
1 Simic Growth Chamber
|
||||
1 Terramorphic Expanse
|
||||
1 Thriving Grove
|
||||
1 Thriving Heath
|
||||
1 Thriving Isle
|
||||
1 Vitu-Ghazi, the City-Tree
|
||||
1 Island
|
||||
1 Island
|
||||
1 Island
|
||||
1 Island
|
||||
2 Forest
|
||||
2 Forest
|
||||
2 Forest
|
||||
2 Forest
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Storvald, Frost Giant Jarl
|
||||
1 Fey Steed
|
||||
1 Holy Avenger
|
||||
1 Mantle of the Ancients
|
||||
1 Robe of Stars
|
||||
1 Valiant Endeavor
|
||||
1 Diviner's Portent
|
||||
1 Netherese Puzzle-Ward
|
||||
1 Winged Boots
|
||||
1 Belt of Giant Strength
|
||||
1 Song of Inspiration
|
||||
1 Catti-brie of Mithral Hall
|
||||
1 Ride the Avalanche
|
||||
1 Clay Golem
|
||||
1 Ebony Fly
|
||||
1 Sword of Hours
|
||||
@@ -0,0 +1,96 @@
|
||||
[metadata]
|
||||
Name=Bedecked Brokers
|
||||
[Commander]
|
||||
1 Perrie, the Pulverizer
|
||||
[Main]
|
||||
1 Kros, Defense Contractor
|
||||
1 Ajani Unyielding
|
||||
1 Angelic Sleuth
|
||||
1 Aven Courier
|
||||
1 Aven Mimeomancer
|
||||
1 Avenging Huntbonder
|
||||
1 Bribe Taker
|
||||
1 Crystalline Giant
|
||||
1 Devoted Druid
|
||||
1 Denry Klin, Editor in Chief
|
||||
1 Evolution Sage
|
||||
1 Fathom Mage
|
||||
1 Forgotten Ancient
|
||||
1 Grateful Apparition
|
||||
1 Incubation Druid
|
||||
1 Jenara, Asura of War
|
||||
1 Luminarch Aspirant
|
||||
1 Park Heights Maverick
|
||||
1 Rishkar, Peema Renegade
|
||||
1 Roalesk, Apex Hybrid
|
||||
1 Scavenging Ooze
|
||||
1 Shield Broker
|
||||
1 Skyboon Evangelist
|
||||
1 Skyship Plunderer
|
||||
1 Slippery Bogbonder
|
||||
1 Steelbane Hydra
|
||||
1 Thrummingbird
|
||||
1 Vorel of the Hull Clade
|
||||
1 Wall of Roots
|
||||
1 Wickerbough Elder
|
||||
1 Wingspan Mentor
|
||||
1 Declaration in Stone
|
||||
1 Damning Verdict
|
||||
1 Planar Outburst
|
||||
1 Tezzeret's Gambit
|
||||
1 Rishkar's Expertise
|
||||
1 Urban Evolution
|
||||
1 Bant Charm
|
||||
1 Brokers Charm
|
||||
1 Brokers Confluence
|
||||
1 Contractual Safeguard
|
||||
1 Exotic Pets
|
||||
1 Generous Gift
|
||||
1 Storm of Forms
|
||||
1 Family's Favor
|
||||
1 Hoofprints of the Stag
|
||||
1 Primal Empathy
|
||||
1 Resourceful Defense
|
||||
1 Together Forever
|
||||
1 Agent's Toolkit
|
||||
1 Arcane Signet
|
||||
1 Commander's Sphere
|
||||
1 Everflowing Chalice
|
||||
1 Fellwar Stone
|
||||
1 Gavel of the Righteous
|
||||
1 Midnight Clock
|
||||
1 Oblivion Stone
|
||||
1 Oracle's Vault
|
||||
1 Power Conduit
|
||||
1 Sol Ring
|
||||
1 Swiftfoot Boots
|
||||
1 Ash Barrens
|
||||
1 Bant Panorama
|
||||
1 Brokers Hideout
|
||||
1 Canopy Vista
|
||||
1 Command Tower
|
||||
1 Exotic Orchard
|
||||
1 Flooded Grove
|
||||
1 Fortified Village
|
||||
1 Gavony Township
|
||||
1 Karn's Bastion
|
||||
1 Littjara Mirrorlake
|
||||
1 Llanowar Reborn
|
||||
1 Myriad Landscape
|
||||
1 Nesting Grounds
|
||||
1 Path of Ancestry
|
||||
1 Port Town
|
||||
1 Prairie Stream
|
||||
1 Seaside Citadel
|
||||
1 Skycloud Expanse
|
||||
1 Sungrass Prairie
|
||||
1 Temple of Mystery
|
||||
1 Vivid Creek
|
||||
1 Vivid Grove
|
||||
1 Vivid Meadow
|
||||
3 Forest
|
||||
2 Forest
|
||||
2 Island
|
||||
2 Island
|
||||
3 Plains
|
||||
2 Plains
|
||||
@@ -0,0 +1,92 @@
|
||||
[metadata]
|
||||
Name=Blame Game
|
||||
[Commander]
|
||||
1 Nelly Borca, Impulsive Accuser
|
||||
[Main]
|
||||
1 Feather, Radiant Arbiter
|
||||
1 Immortal Obligation
|
||||
1 Otherworldly Escort
|
||||
1 Redemption Arc
|
||||
1 Trouble in Pairs
|
||||
1 Havoc Eater
|
||||
1 Hot Pursuit
|
||||
1 Mob Verdict
|
||||
1 Prisoner's Dilemma
|
||||
1 Take the Bait
|
||||
1 Ransom Note
|
||||
1 Angel of the Ruins
|
||||
1 Comeuppance
|
||||
1 Darien, King of Kjeldor
|
||||
1 Duelist's Heritage
|
||||
1 Elspeth, Sun's Champion
|
||||
1 Keeper of the Accord
|
||||
1 Loran of the Third Path
|
||||
1 Promise of Loyalty
|
||||
1 Selfless Squire
|
||||
1 Sevinne's Reclamation
|
||||
1 Smuggler's Share
|
||||
1 Stalking Leonin
|
||||
1 Sun Titan
|
||||
1 Windborn Muse
|
||||
1 Winds of Rath
|
||||
1 Agitator Ant
|
||||
1 Brash Taunter
|
||||
1 Disrupt Decorum
|
||||
1 Etali, Primal Storm
|
||||
1 Fiendish Duo
|
||||
1 Frontier Warmonger
|
||||
1 Kazuul, Tyrant of the Cliffs
|
||||
1 Spectacular Showdown
|
||||
1 Vengeful Ancestor
|
||||
1 Anya, Merciless Angel
|
||||
1 Boros Reckoner
|
||||
1 Deflecting Palm
|
||||
1 Gisela, Blade of Goldnight
|
||||
1 Ancient Stone Idol
|
||||
1 Solemn Simulacrum
|
||||
1 Steel Hellkite
|
||||
1 Tome of Legends
|
||||
1 Castle Ardenvale
|
||||
1 Exotic Orchard
|
||||
1 Furycalm Snarl
|
||||
1 Kher Keep
|
||||
1 Labyrinth of Skophos
|
||||
1 Needle Spires
|
||||
1 Scavenger Grounds
|
||||
1 Slayers' Stronghold
|
||||
1 Temple of Triumph
|
||||
1 Throne of the High City
|
||||
1 War Room
|
||||
1 Ghostly Prison
|
||||
1 Gideon's Sacrifice
|
||||
1 Martial Impetus
|
||||
1 Orzhov Advokist
|
||||
1 Seal of Cleansing
|
||||
1 Soul Snare
|
||||
1 Vow of Duty
|
||||
1 Wall of Omens
|
||||
1 Curse of Opulence
|
||||
1 Rite of the Raging Storm
|
||||
1 Shiny Impetus
|
||||
1 Vow of Lightning
|
||||
1 Arcane Signet
|
||||
1 Bloodthirsty Blade
|
||||
1 Fellwar Stone
|
||||
1 Mind Stone
|
||||
1 Sol Ring
|
||||
1 Talisman of Conviction
|
||||
1 Thought Vessel
|
||||
1 Access Tunnel
|
||||
1 Ash Barrens
|
||||
1 Boros Garrison
|
||||
1 Command Tower
|
||||
1 Escape Tunnel
|
||||
1 Myriad Landscape
|
||||
1 Reliquary Tower
|
||||
1 Rogue's Passage
|
||||
1 Sunhome, Fortress of the Legion
|
||||
1 Temple of the False God
|
||||
5 Plains
|
||||
4 Plains
|
||||
4 Mountain
|
||||
3 Mountain
|
||||
@@ -0,0 +1,89 @@
|
||||
[metadata]
|
||||
Name=Blight Curse
|
||||
[Commander]
|
||||
1 Auntie Ool, Cursewretch
|
||||
[Main]
|
||||
1 The Reaper, King No More
|
||||
1 Wickersmith's Tools
|
||||
1 Sinister Gnarlbark
|
||||
1 Eventide's Shadow
|
||||
1 Aberrant Return
|
||||
1 Dread Tiller
|
||||
1 Grave Venerations
|
||||
1 Oft-Nabbed Goat
|
||||
1 Village Pillagers
|
||||
1 Puca's Covenant
|
||||
1 Ferrafor, Young Yew
|
||||
1 Tree of Perdition
|
||||
1 Vernal Fen
|
||||
1 Festering Thicket
|
||||
1 Necroskitter
|
||||
1 Ignoble Hierarch
|
||||
1 Nesting Grounds
|
||||
1 Massacre Girl, Known Killer
|
||||
1 Grave Titan
|
||||
1 Vraska, Betrayal's Sting
|
||||
1 The Scorpion God
|
||||
1 Liliana, Death Wielder
|
||||
1 Chimil, the Inner Sun
|
||||
1 Carnifex Demon
|
||||
1 Puppeteer Clique
|
||||
1 Dusk Urchins
|
||||
1 Black Sun's Zenith
|
||||
1 Painful Truths
|
||||
1 Persist
|
||||
1 Midnight Banshee
|
||||
1 Chain Reaction
|
||||
1 Archfiend of Ifnir
|
||||
1 Channeler Initiate
|
||||
1 Hapatra, Vizier of Poisons
|
||||
1 Glissa Sunslayer
|
||||
1 Everlasting Torment
|
||||
1 Assassin's Trophy
|
||||
1 Grim Poppet
|
||||
1 Canyon Slough
|
||||
1 Sheltered Thicket
|
||||
1 Exotic Orchard
|
||||
1 Smoldering Marsh
|
||||
1 Cinder Glade
|
||||
1 Dragonskull Summit
|
||||
1 Rootbound Crag
|
||||
1 Woodland Cemetery
|
||||
1 Lasting Tarfire
|
||||
1 Burning Curiosity
|
||||
1 Hoarder's Greed
|
||||
1 Blowfly Infestation
|
||||
1 Fire Covenant
|
||||
1 Sol Ring
|
||||
1 Arcane Signet
|
||||
1 Command Tower
|
||||
1 Cathartic Reunion
|
||||
1 Skinrender
|
||||
1 Soul Snuffers
|
||||
1 Incremental Blight
|
||||
1 Infernal Grasp
|
||||
1 Evolution Sage
|
||||
1 Devoted Druid
|
||||
1 Flourishing Defenses
|
||||
1 Harmonize
|
||||
1 Putrefy
|
||||
1 Binding the Old Gods
|
||||
1 Kulrath Knight
|
||||
1 Contagion Clasp
|
||||
1 Savage Lands
|
||||
1 Ifnir Deadlands
|
||||
1 Night's Whisper
|
||||
1 Wickerbough Elder
|
||||
1 Terminate
|
||||
1 Commander's Sphere
|
||||
1 Cathartic Pyre
|
||||
1 Evolving Wilds
|
||||
1 Terramorphic Expanse
|
||||
1 Path of Ancestry
|
||||
1 Riveteers Overlook
|
||||
1 Rakdos Carnarium
|
||||
1 Gruul Turf
|
||||
1 Golgari Rot Farm
|
||||
8 Swamp
|
||||
4 Mountain
|
||||
6 Forest
|
||||
@@ -0,0 +1,87 @@
|
||||
[metadata]
|
||||
Name=Blood Rites
|
||||
[Commander]
|
||||
1 Clavileño, First of the Blessed
|
||||
[Main]
|
||||
1 Carmen, Cruel Skymarcher
|
||||
1 Charismatic Conqueror
|
||||
1 Elenda's Hierophant
|
||||
1 March of the Canonized
|
||||
1 Redemption Choir
|
||||
1 Dusk Legion Sergeant
|
||||
1 Master of Dark Rites
|
||||
1 Promise of Aclazotz // Foul Rebirth
|
||||
1 Order of Sacred Dusk
|
||||
1 Austere Command
|
||||
1 Kindred Boon
|
||||
1 Mavren Fein, Dusk Apostle
|
||||
1 Radiant Destiny
|
||||
1 Welcoming Vampire
|
||||
1 Bloodghast
|
||||
1 Bloodtracker
|
||||
1 Butcher of Malakir
|
||||
1 Champion of Dusk
|
||||
1 Cordial Vampire
|
||||
1 Crossway Troublemakers
|
||||
1 Damn
|
||||
1 Drana, Liberator of Malakir
|
||||
1 Exquisite Blood
|
||||
1 Glass-Cast Heart
|
||||
1 New Blood
|
||||
1 Nighthawk Scavenger
|
||||
1 Olivia's Wrath
|
||||
1 Pact of the Serpent
|
||||
1 Patron of the Vein
|
||||
1 Sanctum Seeker
|
||||
1 Timothar, Baron of Bats
|
||||
1 Twilight Prophet
|
||||
1 Yahenni, Undying Partisan
|
||||
1 Elenda, the Dusk Rose
|
||||
1 Sorin, Lord of Innistrad
|
||||
1 Utter End
|
||||
1 Vona, Butcher of Magan
|
||||
1 Blade of the Bloodchief
|
||||
1 Isolated Chapel
|
||||
1 Shineshadow Snarl
|
||||
1 Temple of Silence
|
||||
1 Vault of the Archangel
|
||||
1 Voldaren Estate
|
||||
1 Windbrisk Heights
|
||||
1 Martyr of Dusk
|
||||
1 Return to Dust
|
||||
1 Swords to Plowshares
|
||||
1 Blood Artist
|
||||
1 Bloodline Necromancer
|
||||
1 Dusk Legion Zealot
|
||||
1 Falkenrath Noble
|
||||
1 Indulgent Aristocrat
|
||||
1 Oathsworn Vampire
|
||||
1 Village Rites
|
||||
1 Viscera Seer
|
||||
1 Bartolomé del Presidio
|
||||
1 Cruel Celebrant
|
||||
1 Etchings of the Chosen
|
||||
1 Legion Lieutenant
|
||||
1 Arcane Signet
|
||||
1 Commander's Sphere
|
||||
1 Heirloom Blade
|
||||
1 Mind Stone
|
||||
1 Orzhov Signet
|
||||
1 Sol Ring
|
||||
1 Swiftfoot Boots
|
||||
1 Talisman of Hierarchy
|
||||
1 Wayfarer's Bauble
|
||||
1 Bojuka Bog
|
||||
1 Command Tower
|
||||
1 Myriad Landscape
|
||||
1 Orzhov Basilica
|
||||
1 Path of Ancestry
|
||||
1 Rogue's Passage
|
||||
1 Secluded Courtyard
|
||||
1 Tainted Field
|
||||
1 Temple of the False God
|
||||
1 Unclaimed Territory
|
||||
4 Plains
|
||||
4 Plains
|
||||
7 Swamp
|
||||
6 Swamp
|
||||
@@ -0,0 +1,95 @@
|
||||
[metadata]
|
||||
Name=Breed Lethality
|
||||
[Commander]
|
||||
1 Atraxa, Praetors' Voice
|
||||
[Main]
|
||||
1 Thrummingbird
|
||||
1 Festercreep
|
||||
1 Scavenging Ooze
|
||||
1 Abzan Falconer
|
||||
1 Orzhov Advokist
|
||||
1 Tuskguard Captain
|
||||
1 Necroplasm
|
||||
1 Champion of Lambholt
|
||||
1 Reyhan, Last of the Abzan
|
||||
1 Vorel of the Hull Clade
|
||||
1 Crystalline Crawler
|
||||
1 Custodi Soulbinders
|
||||
1 Forgotten Ancient
|
||||
1 Bane of the Living
|
||||
1 Ishai, Ojutai Dragonspeaker
|
||||
1 Corpsejack Menace
|
||||
1 Fathom Mage
|
||||
1 Master Biomancer
|
||||
1 Elite Scaleguard
|
||||
1 Reveillark
|
||||
1 Deepglow Skate
|
||||
1 Kalonian Hydra
|
||||
1 Ikra Shidiqi, the Usurper
|
||||
1 Vulturous Zombie
|
||||
1 Juniper Order Ranger
|
||||
1 Ghave, Guru of Spores
|
||||
1 Enduring Scalelord
|
||||
1 Manifold Insights
|
||||
1 Languish
|
||||
1 Tezzeret's Gambit
|
||||
1 Migratory Route
|
||||
1 Merciless Eviction
|
||||
1 Spitting Image
|
||||
1 Sublime Exhalation
|
||||
1 Duneblast
|
||||
1 Treasure Cruise
|
||||
1 Disdainful Stroke
|
||||
1 Solidarity of Heroes
|
||||
1 Grip of Phyresis
|
||||
1 Inspiring Call
|
||||
1 Mortify
|
||||
1 Putrefy
|
||||
1 Ancient Excavation
|
||||
1 Mirrorweave
|
||||
1 Sylvan Reclamation
|
||||
1 Sol Ring
|
||||
1 Fellwar Stone
|
||||
1 Golgari Signet
|
||||
1 Orzhov Signet
|
||||
1 Simic Signet
|
||||
1 Commander's Sphere
|
||||
1 Darksteel Ingot
|
||||
1 Cauldron of Souls
|
||||
1 Astral Cornucopia
|
||||
1 Hardened Scales
|
||||
1 Brave the Sands
|
||||
1 Duelist's Heritage
|
||||
1 Bred for the Hunt
|
||||
1 Citadel Siege
|
||||
1 Cathars' Crusade
|
||||
1 Arcane Sanctum
|
||||
1 Ash Barrens
|
||||
1 Azorius Chancery
|
||||
1 Command Tower
|
||||
1 Darkwater Catacombs
|
||||
1 Dreadship Reef
|
||||
1 Evolving Wilds
|
||||
1 Exotic Orchard
|
||||
1 Golgari Rot Farm
|
||||
1 Murmuring Bosk
|
||||
1 Opal Palace
|
||||
1 Opulent Palace
|
||||
1 Sandsteppe Citadel
|
||||
1 Seaside Citadel
|
||||
1 Sungrass Prairie
|
||||
1 Temple of the False God
|
||||
1 Terramorphic Expanse
|
||||
1 Underground River
|
||||
2 Plains
|
||||
2 Plains
|
||||
1 Plains
|
||||
2 Island
|
||||
1 Island
|
||||
1 Island
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
1 Swamp
|
||||
3 Forest
|
||||
2 Forest
|
||||
2 Forest
|
||||
@@ -0,0 +1,104 @@
|
||||
[metadata]
|
||||
Name=Breed Lethality
|
||||
[Commander]
|
||||
1 Atraxa, Praetors' Voice
|
||||
[Main]
|
||||
1 Thrummingbird
|
||||
1 Festercreep
|
||||
1 Scavenging Ooze
|
||||
1 Abzan Falconer
|
||||
1 Orzhov Advokist
|
||||
1 Tuskguard Captain
|
||||
1 Necroplasm
|
||||
1 Champion of Lambholt
|
||||
1 Reyhan, Last of the Abzan
|
||||
1 Vorel of the Hull Clade
|
||||
1 Crystalline Crawler
|
||||
1 Custodi Soulbinders
|
||||
1 Forgotten Ancient
|
||||
1 Bane of the Living
|
||||
1 Ishai, Ojutai Dragonspeaker
|
||||
1 Corpsejack Menace
|
||||
1 Fathom Mage
|
||||
1 Master Biomancer
|
||||
1 Elite Scaleguard
|
||||
1 Reveillark
|
||||
1 Deepglow Skate
|
||||
1 Kalonian Hydra
|
||||
1 Ikra Shidiqi, the Usurper
|
||||
1 Vulturous Zombie
|
||||
1 Juniper Order Ranger
|
||||
1 Ghave, Guru of Spores
|
||||
1 Enduring Scalelord
|
||||
1 Manifold Insights
|
||||
1 Languish
|
||||
1 Tezzeret's Gambit
|
||||
1 Migratory Route
|
||||
1 Merciless Eviction
|
||||
1 Spitting Image
|
||||
1 Sublime Exhalation
|
||||
1 Duneblast
|
||||
1 Treasure Cruise
|
||||
1 Disdainful Stroke
|
||||
1 Solidarity of Heroes
|
||||
1 Grip of Phyresis
|
||||
1 Inspiring Call
|
||||
1 Mortify
|
||||
1 Putrefy
|
||||
1 Ancient Excavation
|
||||
1 Mirrorweave
|
||||
1 Sylvan Reclamation
|
||||
1 Sol Ring
|
||||
1 Fellwar Stone
|
||||
1 Golgari Signet
|
||||
1 Orzhov Signet
|
||||
1 Simic Signet
|
||||
1 Commander's Sphere
|
||||
1 Darksteel Ingot
|
||||
1 Cauldron of Souls
|
||||
1 Astral Cornucopia
|
||||
1 Hardened Scales
|
||||
1 Brave the Sands
|
||||
1 Duelist's Heritage
|
||||
1 Bred for the Hunt
|
||||
1 Citadel Siege
|
||||
1 Cathars' Crusade
|
||||
1 Arcane Sanctum
|
||||
1 Ash Barrens
|
||||
1 Azorius Chancery
|
||||
1 Command Tower
|
||||
1 Darkwater Catacombs
|
||||
1 Dreadship Reef
|
||||
1 Evolving Wilds
|
||||
1 Exotic Orchard
|
||||
1 Golgari Rot Farm
|
||||
1 Murmuring Bosk
|
||||
1 Opal Palace
|
||||
1 Opulent Palace
|
||||
1 Sandsteppe Citadel
|
||||
1 Seaside Citadel
|
||||
1 Sungrass Prairie
|
||||
1 Temple of the False God
|
||||
1 Terramorphic Expanse
|
||||
1 Underground River
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Island
|
||||
1 Island
|
||||
1 Island
|
||||
1 Island
|
||||
1 Swamp
|
||||
1 Swamp
|
||||
1 Swamp
|
||||
1 Swamp
|
||||
1 Swamp
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
@@ -0,0 +1,78 @@
|
||||
[metadata]
|
||||
Name=Buckle Up
|
||||
[Commander]
|
||||
1 Kotori, Pilot Prodigy
|
||||
[Main]
|
||||
1 Shorikai, Genesis Engine
|
||||
1 Jace, Architect of Thought
|
||||
1 Drumbellower
|
||||
1 Ironsoul Enforcer
|
||||
1 Cyberdrive Awakener
|
||||
1 Kappa Cannoneer
|
||||
1 Katsumasa, the Animator
|
||||
1 Research Thief
|
||||
1 Aeronaut Admiral
|
||||
1 Cataclysmic Gearhulk
|
||||
1 Indomitable Archangel
|
||||
1 Myrsmith
|
||||
1 Sram, Senior Edificer
|
||||
1 Teshar, Ancestor's Apostle
|
||||
1 Emry, Lurker of the Loch
|
||||
1 Etherium Sculptor
|
||||
1 Master of Etherium
|
||||
1 Riddlesmith
|
||||
1 Sai, Master Thopterist
|
||||
1 Vedalken Engineer
|
||||
1 Whirler Rogue
|
||||
1 Arcanist's Owl
|
||||
1 Hanna, Ship's Navigator
|
||||
1 Raff Capashen, Ship's Mage
|
||||
1 Foundry Inspector
|
||||
1 Gold Myr
|
||||
1 Shimmer Myr
|
||||
1 Silver Myr
|
||||
1 Solemn Simulacrum
|
||||
1 Organic Extinction
|
||||
1 Universal Surveillance
|
||||
1 Thoughtcast
|
||||
1 Dance of the Manse
|
||||
1 Release to Memory
|
||||
1 Access Denied
|
||||
1 Armed and Armored
|
||||
1 Crush Contraband
|
||||
1 Dispatch
|
||||
1 Generous Gift
|
||||
1 Swords to Plowshares
|
||||
1 Reality Shift
|
||||
1 Aerial Surveyor
|
||||
1 Imposter Mech
|
||||
1 Imperial Recovery Unit
|
||||
1 Mobilizer Mech
|
||||
1 Prodigy's Prototype
|
||||
1 Surgehacker Mech
|
||||
1 Parhelion II
|
||||
1 Arcane Signet
|
||||
1 Azorius Signet
|
||||
1 Colossal Plow
|
||||
1 Cultivator's Caravan
|
||||
1 Fellwar Stone
|
||||
1 Mirage Mirror
|
||||
1 Peacewalker Colossus
|
||||
1 Raiders' Karve
|
||||
1 Skysovereign, Consul Flagship
|
||||
1 Smuggler's Copter
|
||||
1 Sol Ring
|
||||
1 Weatherlight
|
||||
1 Swift Reconfiguration
|
||||
1 Thopter Spy Network
|
||||
1 Command Tower
|
||||
1 Exotic Orchard
|
||||
1 Port Town
|
||||
1 Prairie Stream
|
||||
1 Skycloud Expanse
|
||||
1 Spire of Industry
|
||||
1 Temple of Enlightenment
|
||||
8 Plains
|
||||
7 Plains
|
||||
8 Island
|
||||
7 Island
|
||||
@@ -0,0 +1,79 @@
|
||||
[metadata]
|
||||
Name=Built From Scratch
|
||||
[Commander]
|
||||
1 Daretti, Scrap Savant
|
||||
[Main]
|
||||
1 Goblin Welder
|
||||
1 Epochrasite
|
||||
1 Myr Retriever
|
||||
1 Myr Sire
|
||||
1 Bottle Gnomes
|
||||
1 Cathodion
|
||||
1 Junk Diver
|
||||
1 Palladium Myr
|
||||
1 Pilgrim's Eye
|
||||
1 Tuktuk the Explorer
|
||||
1 Dualcaster Mage
|
||||
1 Feldon of the Third Path
|
||||
1 Solemn Simulacrum
|
||||
1 Flametongue Kavu
|
||||
1 Beetleback Chief
|
||||
1 Ingot Chewer
|
||||
1 Steel Hellkite
|
||||
1 Wurmcoil Engine
|
||||
1 Spitebellows
|
||||
1 Hoard-Smelter Dragon
|
||||
1 Warmonger Hellkite
|
||||
1 Myr Battlesphere
|
||||
1 Pentavus
|
||||
1 Tyrant's Familiar
|
||||
1 Bosh, Iron Golem
|
||||
1 Bogardan Hellkite
|
||||
1 Faithless Looting
|
||||
1 Whipflare
|
||||
1 Scrap Mastery
|
||||
1 Incite Rebellion
|
||||
1 Blasphemous Act
|
||||
1 Impact Resonance
|
||||
1 Chaos Warp
|
||||
1 Volcanic Offering
|
||||
1 Word of Seizing
|
||||
1 Magmaquake
|
||||
1 Starstorm
|
||||
1 Everflowing Chalice
|
||||
1 Panic Spellbomb
|
||||
1 Sol Ring
|
||||
1 Wayfarer's Bauble
|
||||
1 Fire Diamond
|
||||
1 Ichor Wellspring
|
||||
1 Liquimetal Coating
|
||||
1 Mind Stone
|
||||
1 Mycosynth Wellspring
|
||||
1 Ruby Medallion
|
||||
1 Swiftfoot Boots
|
||||
1 Commander's Sphere
|
||||
1 Jalum Tome
|
||||
1 Pristine Talisman
|
||||
1 Unstable Obelisk
|
||||
1 Trading Post
|
||||
1 Caged Sun
|
||||
1 Dreamstone Hedron
|
||||
1 Loreseeker's Stone
|
||||
1 Spine of Ish Sah
|
||||
1 Darksteel Citadel
|
||||
1 Great Furnace
|
||||
1 Bitter Feud
|
||||
1 Arcane Lighthouse
|
||||
1 Buried Ruin
|
||||
1 Dormant Volcano
|
||||
1 Flamekin Village
|
||||
1 Forgotten Cave
|
||||
1 Ghost Quarter
|
||||
1 Phyrexia's Core
|
||||
1 Reliquary Tower
|
||||
1 Smoldering Crater
|
||||
1 Temple of the False God
|
||||
8 Mountain
|
||||
7 Mountain
|
||||
7 Mountain
|
||||
7 Mountain
|
||||
@@ -0,0 +1,81 @@
|
||||
[metadata]
|
||||
Name=Built From Scratch
|
||||
[Commander]
|
||||
1 Daretti, Scrap Savant
|
||||
[Main]
|
||||
1 Goblin Welder
|
||||
1 Epochrasite
|
||||
1 Myr Retriever
|
||||
1 Myr Sire
|
||||
1 Bottle Gnomes
|
||||
1 Cathodion
|
||||
1 Junk Diver
|
||||
1 Palladium Myr
|
||||
1 Pilgrim's Eye
|
||||
1 Tuktuk the Explorer
|
||||
1 Dualcaster Mage
|
||||
1 Feldon of the Third Path
|
||||
1 Solemn Simulacrum
|
||||
1 Flametongue Kavu
|
||||
1 Beetleback Chief
|
||||
1 Ingot Chewer
|
||||
1 Steel Hellkite
|
||||
1 Wurmcoil Engine
|
||||
1 Spitebellows
|
||||
1 Hoard-Smelter Dragon
|
||||
1 Warmonger Hellkite
|
||||
1 Myr Battlesphere
|
||||
1 Pentavus
|
||||
1 Tyrant's Familiar
|
||||
1 Bosh, Iron Golem
|
||||
1 Bogardan Hellkite
|
||||
1 Faithless Looting
|
||||
1 Whipflare
|
||||
1 Scrap Mastery
|
||||
1 Incite Rebellion
|
||||
1 Blasphemous Act
|
||||
1 Impact Resonance
|
||||
1 Chaos Warp
|
||||
1 Volcanic Offering
|
||||
1 Word of Seizing
|
||||
1 Magmaquake
|
||||
1 Starstorm
|
||||
1 Everflowing Chalice
|
||||
1 Panic Spellbomb
|
||||
1 Sol Ring
|
||||
1 Wayfarer's Bauble
|
||||
1 Fire Diamond
|
||||
1 Ichor Wellspring
|
||||
1 Liquimetal Coating
|
||||
1 Mind Stone
|
||||
1 Mycosynth Wellspring
|
||||
1 Ruby Medallion
|
||||
1 Swiftfoot Boots
|
||||
1 Commander's Sphere
|
||||
1 Jalum Tome
|
||||
1 Pristine Talisman
|
||||
1 Unstable Obelisk
|
||||
1 Trading Post
|
||||
1 Caged Sun
|
||||
1 Dreamstone Hedron
|
||||
1 Loreseeker's Stone
|
||||
1 Spine of Ish Sah
|
||||
1 Darksteel Citadel
|
||||
1 Great Furnace
|
||||
1 Bitter Feud
|
||||
1 Arcane Lighthouse
|
||||
1 Buried Ruin
|
||||
1 Dormant Volcano
|
||||
1 Flamekin Village
|
||||
1 Forgotten Cave
|
||||
1 Ghost Quarter
|
||||
1 Phyrexia's Core
|
||||
1 Reliquary Tower
|
||||
1 Smoldering Crater
|
||||
1 Temple of the False God
|
||||
5 Mountain
|
||||
5 Mountain
|
||||
5 Mountain
|
||||
5 Mountain
|
||||
5 Mountain
|
||||
4 Mountain
|
||||
@@ -0,0 +1,94 @@
|
||||
[metadata]
|
||||
Name=Cabaretti Cacophony
|
||||
[Commander]
|
||||
1 Kitt Kanto, Mayhem Diva
|
||||
[Main]
|
||||
1 Phabine, Boss's Confidant
|
||||
1 Leafkin Druid
|
||||
1 Sakura-Tribe Elder
|
||||
1 Agitator Ant
|
||||
1 Bess, Soul Nourisher
|
||||
1 Champion of Lambholt
|
||||
1 Magus of the Wheel
|
||||
1 Orzhov Advokist
|
||||
1 Rumor Gatherer
|
||||
1 Scute Swarm
|
||||
1 Selvala, Explorer Returned
|
||||
1 Wood Elves
|
||||
1 Zurzoth, Chaos Rider
|
||||
1 Arasta of the Endless Web
|
||||
1 Life of the Party
|
||||
1 Master of Ceremonies
|
||||
1 Rose Room Treasurer
|
||||
1 Sizzling Soloist
|
||||
1 Boss's Chauffeur
|
||||
1 Gahiji, Honored One
|
||||
1 Kazuul, Tyrant of the Cliffs
|
||||
1 Thunderfoot Baloth
|
||||
1 Cabaretti Confluence
|
||||
1 Camaraderie
|
||||
1 Cultivate
|
||||
1 Fell the Mighty
|
||||
1 Harmonize
|
||||
1 Indulge // Excess
|
||||
1 Martial Coup
|
||||
1 Seize the Spotlight
|
||||
1 Shamanic Revelation
|
||||
1 Sylvan Offering
|
||||
1 Vivien's Stampede
|
||||
1 Artifact Mutation
|
||||
1 Aura Mutation
|
||||
1 Beast Within
|
||||
1 Boros Charm
|
||||
1 Cabaretti Charm
|
||||
1 Crash the Party
|
||||
1 Call the Coppercoats
|
||||
1 Grand Crescendo
|
||||
1 March of the Multitudes
|
||||
1 Path to Exile
|
||||
1 Sol Ring
|
||||
1 Arcane Signet
|
||||
1 Bloodthirsty Blade
|
||||
1 Fellwar Stone
|
||||
1 Idol of Oblivion
|
||||
1 Commander's Sphere
|
||||
1 Scepter of Celebration
|
||||
1 False Floor
|
||||
1 Intangible Virtue
|
||||
1 Awakening Zone
|
||||
1 Beastmaster Ascension
|
||||
1 Duelist's Heritage
|
||||
1 Killer Service
|
||||
1 Prosperous Partnership
|
||||
1 Felidar Retreat
|
||||
1 Outpost Siege
|
||||
1 Assemble the Legion
|
||||
1 Sandwurm Convergence
|
||||
1 Ash Barrens
|
||||
1 Cabaretti Courtyard
|
||||
1 Canopy Vista
|
||||
1 Castle Ardenvale
|
||||
1 Castle Embereth
|
||||
1 Cinder Glade
|
||||
1 Command Tower
|
||||
1 Exotic Orchard
|
||||
1 Fortified Village
|
||||
1 Game Trail
|
||||
1 Jungle Shrine
|
||||
1 Mossfire Valley
|
||||
1 Myriad Landscape
|
||||
1 Naya Panorama
|
||||
1 Path of Ancestry
|
||||
1 Rugged Prairie
|
||||
1 Sungrass Prairie
|
||||
1 Temple of Triumph
|
||||
1 Thriving Bluff
|
||||
1 Thriving Grove
|
||||
1 Thriving Heath
|
||||
1 Windbrisk Heights
|
||||
2 Plains
|
||||
2 Plains
|
||||
2 Mountain
|
||||
2 Mountain
|
||||
4 Forest
|
||||
4 Forest
|
||||
@@ -0,0 +1,88 @@
|
||||
[metadata]
|
||||
Name=Call the Spirits
|
||||
[Commander]
|
||||
1 Daxos the Returned
|
||||
[Main]
|
||||
1 Oreskos Explorer
|
||||
1 Karlov of the Ghost Council
|
||||
1 Underworld Coinsmith
|
||||
1 Burnished Hart
|
||||
1 Bastion Protector
|
||||
1 Dawnglare Invoker
|
||||
1 Ghostblade Eidolon
|
||||
1 Kor Sanctifiers
|
||||
1 Monk Idealist
|
||||
1 Mesa Enchantress
|
||||
1 Nighthowler
|
||||
1 Corpse Augur
|
||||
1 Fate Unraveler
|
||||
1 Ajani's Chosen
|
||||
1 Doomwake Giant
|
||||
1 Dreadbringer Lampads
|
||||
1 Celestial Ancient
|
||||
1 Celestial Archon
|
||||
1 Herald of the Host
|
||||
1 Banshee of the Dread Choir
|
||||
1 Thief of Blood
|
||||
1 Treasury Thrull
|
||||
1 Sandstone Oracle
|
||||
1 Silent Sentinel
|
||||
1 Teysa, Envoy of Ghosts
|
||||
1 Ancient Craving
|
||||
1 Gild
|
||||
1 Dawn to Dusk
|
||||
1 Righteous Confluence
|
||||
1 Open the Vaults
|
||||
1 Deadly Tempest
|
||||
1 Death Grasp
|
||||
1 Sol Ring
|
||||
1 Wayfarer's Bauble
|
||||
1 Lightning Greaves
|
||||
1 Orzhov Signet
|
||||
1 Thought Vessel
|
||||
1 Crystal Chimes
|
||||
1 Orzhov Cluestone
|
||||
1 Phyrexian Reclamation
|
||||
1 Seal of Cleansing
|
||||
1 Grave Peril
|
||||
1 Banishing Light
|
||||
1 Cage of Hands
|
||||
1 Karmic Justice
|
||||
1 Vow of Duty
|
||||
1 Fallen Ideal
|
||||
1 Seal of Doom
|
||||
1 Vow of Malice
|
||||
1 Aura of Silence
|
||||
1 Grasp of Fate
|
||||
1 Shielded by Faith
|
||||
1 Phyrexian Arena
|
||||
1 Underworld Connections
|
||||
1 Daxos's Torment
|
||||
1 Marshal's Anthem
|
||||
1 Dictate of Heliod
|
||||
1 Sigil of the Empty Throne
|
||||
1 Black Market
|
||||
1 Necromancer's Covenant
|
||||
1 Barren Moor
|
||||
1 Command Tower
|
||||
1 Evolving Wilds
|
||||
1 Ghost Quarter
|
||||
1 New Benalia
|
||||
1 Orzhov Basilica
|
||||
1 Orzhov Guildgate
|
||||
1 Rogue's Passage
|
||||
1 Scoured Barrens
|
||||
1 Secluded Steppe
|
||||
1 Tainted Field
|
||||
1 Temple of the False God
|
||||
1 Terramorphic Expanse
|
||||
1 Vivid Marsh
|
||||
1 Vivid Meadow
|
||||
3 Plains
|
||||
3 Plains
|
||||
3 Plains
|
||||
2 Plains
|
||||
4 Swamp
|
||||
3 Swamp
|
||||
3 Swamp
|
||||
3 Swamp
|
||||
@@ -0,0 +1,83 @@
|
||||
[metadata]
|
||||
Name=Chaos Incarnate
|
||||
[Commander]
|
||||
1 Kardur, Doomscourge
|
||||
[Main]
|
||||
1 Archfiend of Depravity
|
||||
1 Bloodgift Demon
|
||||
1 Deadly Tempest
|
||||
1 Dredge the Mire
|
||||
1 Ob Nixilis Reignited
|
||||
1 Profane Command
|
||||
1 Rakshasa Debaser
|
||||
1 Reign of the Pit
|
||||
1 Sangromancer
|
||||
1 Scythe Specter
|
||||
1 Sepulchral Primordial
|
||||
1 Soul Shatter
|
||||
1 Titan Hunter
|
||||
1 Blasphemous Act
|
||||
1 Brash Taunter
|
||||
1 Chaos Warp
|
||||
1 Combustible Gearhulk
|
||||
1 Dictate of the Twin Gods
|
||||
1 Fiery Confluence
|
||||
1 Geode Rager
|
||||
1 Kazuul, Tyrant of the Cliffs
|
||||
1 Magmatic Force
|
||||
1 Sunbird's Invocation
|
||||
1 Tectonic Giant
|
||||
1 Wild Ricochet
|
||||
1 Wildfire Devils
|
||||
1 Kaervek the Merciless
|
||||
1 Spiteful Visions
|
||||
1 Stormfist Crusader
|
||||
1 Theater of Horrors
|
||||
1 Coveted Jewel
|
||||
1 Solemn Simulacrum
|
||||
1 Foreboding Ruins
|
||||
1 Smoldering Marsh
|
||||
1 Stensia Bloodhall
|
||||
1 Temple of Malice
|
||||
1 Ambition's Cost
|
||||
1 Feed the Swarm
|
||||
1 Indulgent Tormentor
|
||||
1 Read the Bones
|
||||
1 Sign in Blood
|
||||
1 Syphon Mind
|
||||
1 Vampire Nighthawk
|
||||
1 Abrade
|
||||
1 Explosion of Riches
|
||||
1 Guttersnipe
|
||||
1 Hate Mirage
|
||||
1 Mana Geyser
|
||||
1 Thermo-Alchemist
|
||||
1 Breath of Malfegor
|
||||
1 Rakdos Charm
|
||||
1 Terminate
|
||||
1 Unlicensed Disintegration
|
||||
1 Arcane Signet
|
||||
1 Burnished Hart
|
||||
1 Commander's Sphere
|
||||
1 Lightning Greaves
|
||||
1 Nihil Spellbomb
|
||||
1 Rakdos Signet
|
||||
1 Sol Ring
|
||||
1 Talisman of Indulgence
|
||||
1 Wayfarer's Bauble
|
||||
1 Worn Powerstone
|
||||
1 Akoum Refuge
|
||||
1 Bloodfell Caves
|
||||
1 Cinder Barrens
|
||||
1 Command Tower
|
||||
1 Molten Slagheap
|
||||
1 Myriad Landscape
|
||||
1 Urborg Volcano
|
||||
4 Swamp
|
||||
4 Swamp
|
||||
4 Swamp
|
||||
3 Swamp
|
||||
4 Mountain
|
||||
4 Mountain
|
||||
3 Mountain
|
||||
3 Mountain
|
||||
@@ -0,0 +1,87 @@
|
||||
[metadata]
|
||||
Name=Corrupting Influence
|
||||
[Commander]
|
||||
1 Ixhel, Scion of Atraxa
|
||||
[Main]
|
||||
1 Bilious Skulldweller
|
||||
1 Blight Mamba
|
||||
1 Blightbelly Rat
|
||||
1 Cankerbloom
|
||||
1 Contaminant Grafter
|
||||
1 Evolution Sage
|
||||
1 Glissa's Retriever
|
||||
1 Grateful Apparition
|
||||
1 Ichor Rats
|
||||
1 Ichorclaw Myr
|
||||
1 Mycosynth Fiend
|
||||
1 Myr Convert
|
||||
1 Norn's Choirmaster
|
||||
1 Pestilent Syphoner
|
||||
1 Phyrexian Swarmlord
|
||||
1 Plague Myr
|
||||
1 Plague Stinger
|
||||
1 Scavenging Ooze
|
||||
1 Venomous Brutalizer
|
||||
1 Viridian Corrupter
|
||||
1 Vishgraz, the Doomhive
|
||||
1 Windborn Muse
|
||||
1 Caress of Phyrexia
|
||||
1 Culling Ritual
|
||||
1 Cultivate
|
||||
1 Expand the Sphere
|
||||
1 Feed the Infection
|
||||
1 Fumigate
|
||||
1 Geth's Summons
|
||||
1 Infectious Inquiry
|
||||
1 Merciless Eviction
|
||||
1 Night's Whisper
|
||||
1 Noxious Assault
|
||||
1 Painful Truths
|
||||
1 Phyresis Outbreak
|
||||
1 Phyrexian Rebirth
|
||||
1 Unnatural Restoration
|
||||
1 Vat Emergence
|
||||
1 Wurmquake
|
||||
1 Beast Within
|
||||
1 Carrion Call
|
||||
1 Mortify
|
||||
1 Noxious Revival
|
||||
1 Putrefy
|
||||
1 Swords to Plowshares
|
||||
1 Vraska's Fall
|
||||
1 Ghostly Prison
|
||||
1 Moldervine Reclamation
|
||||
1 Norn's Decree
|
||||
1 Arcane Signet
|
||||
1 Chromatic Lantern
|
||||
1 Commander's Sphere
|
||||
1 Contagion Clasp
|
||||
1 Fellwar Stone
|
||||
1 Glistening Sphere
|
||||
1 Golgari Signet
|
||||
1 Grafted Exoskeleton
|
||||
1 Norn's Annex
|
||||
1 Phyrexian Atlas
|
||||
1 Sol Ring
|
||||
1 Trailblazer's Boots
|
||||
1 Bojuka Bog
|
||||
1 Canopy Vista
|
||||
1 Command Tower
|
||||
1 Exotic Orchard
|
||||
1 Fortified Village
|
||||
1 Karn's Bastion
|
||||
1 Krosan Verge
|
||||
1 Myriad Landscape
|
||||
1 Necroblossom Snarl
|
||||
1 Path of Ancestry
|
||||
1 Sandsteppe Citadel
|
||||
1 Shineshadow Snarl
|
||||
1 Sungrass Prairie
|
||||
1 Tainted Field
|
||||
1 Tainted Wood
|
||||
1 Temple of Malady
|
||||
1 Temple of Plenty
|
||||
1 Temple of Silence
|
||||
6 Plains
|
||||
6 Swamp
|
||||
8 Forest
|
||||
@@ -0,0 +1,104 @@
|
||||
[metadata]
|
||||
Name=Counter Blitz (FINAL FANTASY X)
|
||||
[Commander]
|
||||
1 Tidus, Yuna's Guardian
|
||||
[Main]
|
||||
1 Yuna, Grand Summoner
|
||||
1 Auron, Venerated Guardian
|
||||
1 Chocobo Knights
|
||||
1 Gatta and Luzzu
|
||||
1 Lord Jyscal Guado
|
||||
1 Protection Magic
|
||||
1 Summon: Ixion
|
||||
1 Summon: Yojimbo
|
||||
1 Summoner's Sending
|
||||
1 Blitzball Stadium
|
||||
1 Lulu, Stern Guardian
|
||||
1 O'aka, Traveling Merchant
|
||||
1 Rikku, Resourceful Guardian
|
||||
1 Summon: Valefor
|
||||
1 Maester Seymour
|
||||
1 Sphere Grid
|
||||
1 Summon: Magus Sisters
|
||||
1 Tromell, Seymour's Butler
|
||||
1 Yuna's Decision
|
||||
1 Yuna's Whistle
|
||||
1 Kimahri, Valiant Guardian
|
||||
1 Shelinda, Yevon Acolyte
|
||||
1 Sin, Unending Cataclysm
|
||||
1 Wakka, Devoted Guardian
|
||||
1 Collective Effort
|
||||
1 Damning Verdict
|
||||
1 Farewell
|
||||
1 Luminous Broodmoth
|
||||
1 Promise of Loyalty
|
||||
1 Resourceful Defense
|
||||
1 Scholar of New Horizons
|
||||
1 Sunscorch Regent
|
||||
1 Together Forever
|
||||
1 Chasm Skulker
|
||||
1 Inexorable Tide
|
||||
1 Pull from Tomorrow
|
||||
1 Bane of Progress
|
||||
1 Fight Rigging
|
||||
1 Forgotten Ancient
|
||||
1 Generous Patron
|
||||
1 Gyre Sage
|
||||
1 Hardened Scales
|
||||
1 Incubation Druid
|
||||
1 Path of Discovery
|
||||
1 Rampant Rejuvenator
|
||||
1 Tireless Tracker
|
||||
1 Altered Ego
|
||||
1 Endless Detour
|
||||
1 Fathom Mage
|
||||
1 Walking Ballista
|
||||
1 Brushland
|
||||
1 Canopy Vista
|
||||
1 Exotic Orchard
|
||||
1 Flooded Grove
|
||||
1 Fortified Village
|
||||
1 Glacial Fortress
|
||||
1 Hinterland Harbor
|
||||
1 Overflowing Basin
|
||||
1 Port Town
|
||||
1 Prairie Stream
|
||||
1 Skycloud Expanse
|
||||
1 Sungrass Prairie
|
||||
1 Sunpetal Grove
|
||||
1 Temple of Enlightenment
|
||||
1 Temple of Mystery
|
||||
1 Temple of Plenty
|
||||
1 Vineglimmer Snarl
|
||||
1 Destroy Evil
|
||||
1 Grateful Apparition
|
||||
1 Path to Exile
|
||||
1 An Offer You Can't Refuse
|
||||
1 Duskshell Crawler
|
||||
1 Farseek
|
||||
1 Inspiring Call
|
||||
1 Three Visits
|
||||
1 Bred for the Hunt
|
||||
1 Arcane Signet
|
||||
1 Everflowing Chalice
|
||||
1 Sol Ring
|
||||
1 Ash Barrens
|
||||
1 Evolving Wilds
|
||||
1 Forge of Heroes
|
||||
1 Idyllic Beachfront
|
||||
1 Nesting Grounds
|
||||
1 Path of Ancestry
|
||||
1 Radiant Grove
|
||||
1 Seaside Citadel
|
||||
1 Tangled Islet
|
||||
1 Temple of the False God
|
||||
1 Command Tower
|
||||
1 Island
|
||||
1 Island
|
||||
1 Island
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Plains
|
||||
@@ -0,0 +1,98 @@
|
||||
[metadata]
|
||||
Name=Counter Blitz Collector's Edition (FINAL FANTASY X)
|
||||
[Commander]
|
||||
1 Tidus, Yuna's Guardian
|
||||
[Main]
|
||||
1 Altered Ego
|
||||
1 An Offer You Can't Refuse
|
||||
1 Arcane Signet
|
||||
1 Ash Barrens
|
||||
1 Auron, Venerated Guardian
|
||||
1 Bane of Progress
|
||||
1 Blitzball Stadium
|
||||
1 Bred for the Hunt
|
||||
1 Brushland
|
||||
1 Canopy Vista
|
||||
1 Chasm Skulker
|
||||
1 Chocobo Knights
|
||||
1 Collective Effort
|
||||
1 Command Tower
|
||||
1 Damning Verdict
|
||||
1 Destroy Evil
|
||||
1 Duskshell Crawler
|
||||
1 Endless Detour
|
||||
1 Everflowing Chalice
|
||||
1 Evolving Wilds
|
||||
1 Exotic Orchard
|
||||
1 Farewell
|
||||
1 Farseek
|
||||
1 Fathom Mage
|
||||
1 Fight Rigging
|
||||
1 Flooded Grove
|
||||
3 Forest
|
||||
1 Forge of Heroes
|
||||
1 Forgotten Ancient
|
||||
1 Fortified Village
|
||||
1 Gatta and Luzzu
|
||||
1 Generous Patron
|
||||
1 Glacial Fortress
|
||||
1 Grateful Apparition
|
||||
1 Gyre Sage
|
||||
1 Hardened Scales
|
||||
1 Hinterland Harbor
|
||||
1 Idyllic Beachfront
|
||||
1 Incubation Druid
|
||||
1 Inexorable Tide
|
||||
1 Inspiring Call
|
||||
3 Island
|
||||
1 Kimahri, Valiant Guardian
|
||||
1 Lord Jyscal Guado
|
||||
1 Lulu, Stern Guardian
|
||||
1 Luminous Broodmoth
|
||||
1 Maester Seymour
|
||||
1 Nesting Grounds
|
||||
1 O'aka, Traveling Merchant
|
||||
1 Overflowing Basin
|
||||
1 Path of Ancestry
|
||||
1 Path of Discovery
|
||||
1 Path to Exile
|
||||
3 Plains
|
||||
1 Port Town
|
||||
1 Prairie Stream
|
||||
1 Promise of Loyalty
|
||||
1 Protection Magic
|
||||
1 Pull from Tomorrow
|
||||
1 Radiant Grove
|
||||
1 Rampant Rejuvenator
|
||||
1 Resourceful Defense
|
||||
1 Rikku, Resourceful Guardian
|
||||
1 Scholar of New Horizons
|
||||
1 Seaside Citadel
|
||||
1 Shelinda, Yevon Acolyte
|
||||
1 Sin, Unending Cataclysm
|
||||
1 Skycloud Expanse
|
||||
1 Sol Ring
|
||||
1 Sphere Grid
|
||||
1 Summon: Ixion
|
||||
1 Summon: Magus Sisters
|
||||
1 Summon: Valefor
|
||||
1 Summon: Yojimbo
|
||||
1 Summoner's Sending
|
||||
1 Sungrass Prairie
|
||||
1 Sunpetal Grove
|
||||
1 Sunscorch Regent
|
||||
1 Tangled Islet
|
||||
1 Temple of Enlightenment
|
||||
1 Temple of Mystery
|
||||
1 Temple of Plenty
|
||||
1 Temple of the False God
|
||||
1 Three Visits
|
||||
1 Tireless Tracker
|
||||
1 Together Forever
|
||||
1 Tromell, Seymour's Butler
|
||||
1 Vineglimmer Snarl
|
||||
1 Wakka, Devoted Guardian
|
||||
1 Walking Ballista
|
||||
1 Yuna, Grand Summoner
|
||||
1 Yuna's Decision
|
||||
1 Yuna's Whistle
|
||||
@@ -0,0 +1,101 @@
|
||||
[metadata]
|
||||
Name=Counter Intelligence
|
||||
[Commander]
|
||||
1 Inspirit, Flagship Vessel
|
||||
[Main]
|
||||
1 Kilo, Apogee Mind
|
||||
1 Patrolling Peacemaker
|
||||
1 Insight Engine
|
||||
1 Uthros Research Craft
|
||||
1 Depthshaker Titan
|
||||
1 Long-Range Sensor
|
||||
1 Moxite Refinery
|
||||
1 Solar Array
|
||||
1 Surge Conductor
|
||||
1 Glittering Massif
|
||||
1 Radiant Summit
|
||||
1 Swan Song
|
||||
1 Chaos Warp
|
||||
1 Cloud Key
|
||||
1 Gavel of the Righteous
|
||||
1 Battlefield Forge
|
||||
1 Angel of the Ruins
|
||||
1 Fumigate
|
||||
1 Organic Extinction
|
||||
1 Resourceful Defense
|
||||
1 Chrome Host Seedshark
|
||||
1 Cyberdrive Awakener
|
||||
1 Deepglow Skate
|
||||
1 Emry, Lurker of the Loch
|
||||
1 Kappa Cannoneer
|
||||
1 Phyrexian Metamorph
|
||||
1 Pull from Tomorrow
|
||||
1 Ripples of Potential
|
||||
1 Tekuthal, Inquiry Dominus
|
||||
1 Thought Monitor
|
||||
1 Universal Surveillance
|
||||
1 Chain Reaction
|
||||
1 Alibou, Ancient Witness
|
||||
1 Jhoira, Weatherlight Captain
|
||||
1 Wake the Past
|
||||
1 Astral Cornucopia
|
||||
1 Crystalline Crawler
|
||||
1 Darksteel Reactor
|
||||
1 Empowered Autogenerator
|
||||
1 Hangarback Walker
|
||||
1 Lux Artillery
|
||||
1 Lux Cannon
|
||||
1 Steel Overseer
|
||||
1 Threefold Thunderhulk
|
||||
1 Titan Forge
|
||||
1 Adarkar Wastes
|
||||
1 Cascade Bluffs
|
||||
1 Clifftop Retreat
|
||||
1 Exotic Orchard
|
||||
1 Glacial Fortress
|
||||
1 Irrigated Farmland
|
||||
1 Karn's Bastion
|
||||
1 The Mycosynth Gardens
|
||||
1 Rugged Prairie
|
||||
1 Shivan Reef
|
||||
1 Skycloud Expanse
|
||||
1 Spire of Industry
|
||||
1 Sulfur Falls
|
||||
1 Temple of Enlightenment
|
||||
1 Temple of Epiphany
|
||||
1 Temple of Triumph
|
||||
1 Swords to Plowshares
|
||||
1 Tezzeret's Gambit
|
||||
1 Thirst for Knowledge
|
||||
1 Arcane Signet
|
||||
1 Pentad Prism
|
||||
1 Sol Ring
|
||||
1 Command Tower
|
||||
1 Dispatch
|
||||
1 Etherium Sculptor
|
||||
1 Experimental Augury
|
||||
1 Thrummingbird
|
||||
1 Enthusiastic Mechanaut
|
||||
1 Coretapper
|
||||
1 Etched Oracle
|
||||
1 Everflowing Chalice
|
||||
1 Golem Foundry
|
||||
1 Mindless Automaton
|
||||
1 Soul-Guide Lantern
|
||||
1 Ancient Den
|
||||
1 Buried Ruin
|
||||
1 Evolving Wilds
|
||||
1 Great Furnace
|
||||
1 Lonely Sandbar
|
||||
1 Mystic Monastery
|
||||
1 Razortide Bridge
|
||||
1 Rustvale Bridge
|
||||
1 Seat of the Synod
|
||||
1 Secluded Steppe
|
||||
1 Silverbluff Bridge
|
||||
2 Plains
|
||||
1 Plains
|
||||
2 Island
|
||||
1 Island
|
||||
2 Mountain
|
||||
1 Mountain
|
||||
@@ -0,0 +1,90 @@
|
||||
[metadata]
|
||||
Name=Counterpunch
|
||||
[Commander]
|
||||
1 Ghave, Guru of Spores
|
||||
[Main]
|
||||
1 Aquastrand Spider
|
||||
1 Deadly Recluse
|
||||
1 Sakura-Tribe Elder
|
||||
1 Scavenging Ooze
|
||||
1 Monk Realist
|
||||
1 Selesnya Evangel
|
||||
1 Golgari Guildmage
|
||||
1 Selesnya Guildmage
|
||||
1 Nantuko Husk
|
||||
1 Fertilid
|
||||
1 Spawnwrithe
|
||||
1 Vampire Nighthawk
|
||||
1 Spike Feeder
|
||||
1 Yavimaya Elder
|
||||
1 Squallmonger
|
||||
1 Penumbra Spider
|
||||
1 Sigil Captain
|
||||
1 Shriekmaw
|
||||
1 Dark Hatchling
|
||||
1 Teneb, the Harvester
|
||||
1 Hornet Queen
|
||||
1 Vish Kal, Blood Arbiter
|
||||
1 Symbiotic Wurm
|
||||
1 Celestial Force
|
||||
1 Karador, Ghost Chieftain
|
||||
1 Chorus of the Conclave
|
||||
1 Alliance of Arms
|
||||
1 Cultivate
|
||||
1 Harmonize
|
||||
1 Syphon Flesh
|
||||
1 Bestial Menace
|
||||
1 Hex
|
||||
1 Hour of Reckoning
|
||||
1 Death Mutation
|
||||
1 Storm Herd
|
||||
1 Doom Blade
|
||||
1 Tribute to the Wild
|
||||
1 Footbottom Feast
|
||||
1 Afterlife
|
||||
1 Mortify
|
||||
1 Nemesis Trap
|
||||
1 Cobra Trap
|
||||
1 Skullclamp
|
||||
1 Sol Ring
|
||||
1 Golgari Signet
|
||||
1 Lightning Greaves
|
||||
1 Orzhov Signet
|
||||
1 Selesnya Signet
|
||||
1 Darksteel Ingot
|
||||
1 Acorn Catapult
|
||||
1 Soul Snare
|
||||
1 Fists of Ironwood
|
||||
1 Necrogenesis
|
||||
1 Vow of Malice
|
||||
1 Awakening Zone
|
||||
1 Vow of Wildness
|
||||
1 Oblivion Ring
|
||||
1 Vow of Duty
|
||||
1 Attrition
|
||||
1 Aura Shards
|
||||
1 Barren Moor
|
||||
1 Command Tower
|
||||
1 Evolving Wilds
|
||||
1 Golgari Rot Farm
|
||||
1 Orzhov Basilica
|
||||
1 Rupture Spire
|
||||
1 Secluded Steppe
|
||||
1 Selesnya Sanctuary
|
||||
1 Temple of the False God
|
||||
1 Tranquil Thicket
|
||||
1 Vivid Grove
|
||||
1 Vivid Marsh
|
||||
1 Vivid Meadow
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
3 Forest
|
||||
3 Forest
|
||||
2 Forest
|
||||
2 Forest
|
||||
2 Plains
|
||||
2 Plains
|
||||
2 Plains
|
||||
2 Plains
|
||||
@@ -0,0 +1,82 @@
|
||||
[metadata]
|
||||
Name=Coven Counters
|
||||
[Commander]
|
||||
1 Leinore, Autumn Sovereign
|
||||
[Main]
|
||||
1 Angel of Glory's Rise
|
||||
1 Bastion Protector
|
||||
1 Custodi Soulbinders
|
||||
1 Dearly Departed
|
||||
1 Herald of War
|
||||
1 Knight of the White Orchid
|
||||
1 Mikaeus, the Lunarch
|
||||
1 Odric, Master Tactician
|
||||
1 Riders of Gavony
|
||||
1 Victory's Envoy
|
||||
1 Champion of Lambholt
|
||||
1 Gyre Sage
|
||||
1 Kessig Cagebreakers
|
||||
1 Somberwald Sage
|
||||
1 Verdurous Gearhulk
|
||||
1 Wild Beastmaster
|
||||
1 Heron's Grace Champion
|
||||
1 Sigarda, Heron's Grace
|
||||
1 Abzan Falconer
|
||||
1 Ainok Bond-Kin
|
||||
1 Elite Scaleguard
|
||||
1 Orzhov Advokist
|
||||
1 Avacyn's Pilgrim
|
||||
1 Eternal Witness
|
||||
1 Yavimaya Elder
|
||||
1 Enduring Scalelord
|
||||
1 Juniper Order Ranger
|
||||
1 Trostani's Summoner
|
||||
1 Cleansing Nova
|
||||
1 Hour of Reckoning
|
||||
1 Shamanic Revelation
|
||||
1 Bestial Menace
|
||||
1 Biogenic Upgrade
|
||||
1 Growth Spasm
|
||||
1 Unbreakable Formation
|
||||
1 Return to Dust
|
||||
1 Swords to Plowshares
|
||||
1 Beast Within
|
||||
1 Inspiring Call
|
||||
1 Lifecrafter's Bestiary
|
||||
1 Arcane Signet
|
||||
1 Sol Ring
|
||||
1 Swiftfoot Boots
|
||||
1 Talisman of Unity
|
||||
1 Citadel Siege
|
||||
1 Death's Presence
|
||||
1 Canopy Vista
|
||||
1 Exotic Orchard
|
||||
1 Fortified Village
|
||||
1 Sungrass Prairie
|
||||
1 Temple of Plenty
|
||||
1 Blighted Woodland
|
||||
1 Command Tower
|
||||
1 Krosan Verge
|
||||
1 Myriad Landscape
|
||||
1 Path of Ancestry
|
||||
1 Rogue's Passage
|
||||
1 Selesnya Sanctuary
|
||||
1 Temple of the False God
|
||||
12 Forest
|
||||
12 Plains
|
||||
1 Kyler, Sigardian Emissary
|
||||
1 Celestial Judgment
|
||||
1 Curse of Conformity
|
||||
1 Moorland Rescuer
|
||||
1 Sigarda's Vanguard
|
||||
1 Stalwart Pathlighter
|
||||
1 Wall of Mourning
|
||||
1 Celebrate the Harvest
|
||||
1 Curse of Clinging Webs
|
||||
1 Heronblade Elite
|
||||
1 Kurbis, Harvest Celebrant
|
||||
1 Ruinous Intrusion
|
||||
1 Sigardian Zealot
|
||||
1 Somberwald Beastmaster
|
||||
1 Dawnhart Wardens
|
||||
1 Moonsilver Key
|
||||
@@ -3,85 +3,88 @@ Name=Creative Energy
|
||||
[Commander]
|
||||
1 Satya, Aetherflux Genius
|
||||
[Main]
|
||||
1 Adarkar Wastes
|
||||
1 Aether Hub
|
||||
1 Cayth, Famed Mechanist
|
||||
1 Sphinx of the Revelation
|
||||
1 Filigree Racer
|
||||
1 Localized Destruction
|
||||
1 Aether Refinery
|
||||
1 Hourglass of the Lost
|
||||
1 Salvation Colossus
|
||||
1 Blaster Hulk
|
||||
1 Razorfield Ripper
|
||||
1 Silverquill Lecturer
|
||||
1 Overclocked Electromancer
|
||||
1 Stone Idol Generator
|
||||
1 Aurora Shifter
|
||||
1 Conversion Apparatus
|
||||
1 Gonti's Aether Heart
|
||||
1 Lightning Runner
|
||||
1 Aethertide Whale
|
||||
1 Aethergeode Miner
|
||||
1 Aethersphere Harvester
|
||||
1 Aethersquall Ancient
|
||||
1 Aetherstorm Roc
|
||||
1 Aethertide Whale
|
||||
1 Aetherworks Marvel
|
||||
1 Akroma's Will
|
||||
1 Amped Raptor
|
||||
1 Angel of Invention
|
||||
1 Arcane Signet
|
||||
1 Aurora Shifter
|
||||
1 Austere Command
|
||||
1 Azorius Chancery
|
||||
1 Battlefield Forge
|
||||
1 Bespoke Battlewagon
|
||||
1 Bident of Thassa
|
||||
1 Blaster Hulk
|
||||
1 Temple of Enlightenment
|
||||
1 Battlefield Forge
|
||||
1 Myr Battlesphere
|
||||
1 Grenzo, Havoc Raiser
|
||||
1 Brudiclad, Telchor Engineer
|
||||
1 Burnished Hart
|
||||
1 Castle Vantress
|
||||
1 Cayth, Famed Mechanist
|
||||
1 Coveted Jewel
|
||||
1 Goldspan Dragon
|
||||
1 Legion Loyalty
|
||||
1 Mystic Gate
|
||||
1 Akroma's Will
|
||||
1 Adarkar Wastes
|
||||
1 Shivan Reef
|
||||
1 Coalition Relic
|
||||
1 Combustible Gearhulk
|
||||
1 Command Tower
|
||||
1 Aetherworks Marvel
|
||||
1 Angel of Invention
|
||||
1 Confiscation Coup
|
||||
1 Conversion Apparatus
|
||||
1 Coveted Jewel
|
||||
1 Decoction Module
|
||||
1 Demolition Field
|
||||
1 Era of Innovation
|
||||
1 Farewell
|
||||
1 Filigree Racer
|
||||
1 Aetherstorm Roc
|
||||
1 Aethersquall Ancient
|
||||
1 Frostboil Snarl
|
||||
1 Austere Command
|
||||
1 Furycalm Snarl
|
||||
1 Glimmer of Genius
|
||||
1 Goldspan Dragon
|
||||
1 Gonti's Aether Heart
|
||||
1 Grenzo, Havoc Raiser
|
||||
1 Hourglass of the Lost
|
||||
1 Izzet Boilerworks
|
||||
1 Izzet Generatorium
|
||||
1 Jolted Awake
|
||||
1 Legion Loyalty
|
||||
1 Lightning Runner
|
||||
1 Localized Destruction
|
||||
1 Midnight Clock
|
||||
1 Myr Battlesphere
|
||||
1 Mystic Gate
|
||||
1 Mystic Monastery
|
||||
1 Overclocked Electromancer
|
||||
1 Port Town
|
||||
1 Prairie Stream
|
||||
1 Professional Face-Breaker
|
||||
1 Razorfield Ripper
|
||||
1 Roil Cartographer
|
||||
1 Salvation Colossus
|
||||
1 Scurry of Gremlins
|
||||
1 Shivan Reef
|
||||
1 Silverquill Lecturer
|
||||
1 Skyclave Apparition
|
||||
1 Sol Ring
|
||||
1 Solar Transformer
|
||||
1 Solemn Simulacrum
|
||||
1 Sphinx of the Revelation
|
||||
1 Stone Idol Generator
|
||||
1 Swords to Plowshares
|
||||
1 Talisman of Conviction
|
||||
1 Talisman of Creativity
|
||||
1 Talisman of Progress
|
||||
1 Temple of Enlightenment
|
||||
1 Temple of Epiphany
|
||||
1 Farewell
|
||||
1 Port Town
|
||||
1 Temple of Triumph
|
||||
1 Tezzeret's Gambit
|
||||
1 Solemn Simulacrum
|
||||
1 Temple of Epiphany
|
||||
1 Prairie Stream
|
||||
1 Skyclave Apparition
|
||||
1 Castle Vantress
|
||||
1 Jolted Awake
|
||||
1 Bespoke Battlewagon
|
||||
1 Roil Cartographer
|
||||
1 Unstable Amulet
|
||||
1 Wayfarer's Bauble
|
||||
1 Amped Raptor
|
||||
1 Solar Transformer
|
||||
1 Scurry of Gremlins
|
||||
1 Izzet Generatorium
|
||||
1 Talisman of Conviction
|
||||
1 Tezzeret's Gambit
|
||||
1 Izzet Boilerworks
|
||||
1 Azorius Chancery
|
||||
1 Talisman of Progress
|
||||
1 Command Tower
|
||||
1 Burnished Hart
|
||||
1 Arcane Signet
|
||||
1 Sol Ring
|
||||
1 Era of Innovation
|
||||
1 Aether Hub
|
||||
1 Decoction Module
|
||||
1 Glimmer of Genius
|
||||
1 Whirler Virtuoso
|
||||
7 Plains
|
||||
7 Island
|
||||
6 Mountain
|
||||
1 Talisman of Creativity
|
||||
1 Swords to Plowshares
|
||||
1 Wayfarer's Bauble
|
||||
1 Demolition Field
|
||||
1 Mystic Monastery
|
||||
5 Plains
|
||||
5 Plains
|
||||
3 Island
|
||||
2 Island
|
||||
3 Mountain
|
||||
2 Mountain
|
||||
@@ -0,0 +1,90 @@
|
||||
[metadata]
|
||||
Name=Creative Energy Collector's Edition
|
||||
[Commander]
|
||||
1 Satya, Aetherflux Genius
|
||||
[Main]
|
||||
1 Cayth, Famed Mechanist
|
||||
1 Sphinx of the Revelation
|
||||
1 Filigree Racer
|
||||
1 Localized Destruction
|
||||
1 Aether Refinery
|
||||
1 Hourglass of the Lost
|
||||
1 Salvation Colossus
|
||||
1 Blaster Hulk
|
||||
1 Razorfield Ripper
|
||||
1 Silverquill Lecturer
|
||||
1 Overclocked Electromancer
|
||||
1 Stone Idol Generator
|
||||
1 Aurora Shifter
|
||||
1 Conversion Apparatus
|
||||
1 Gonti's Aether Heart
|
||||
1 Lightning Runner
|
||||
1 Aethertide Whale
|
||||
1 Aethergeode Miner
|
||||
1 Aethersphere Harvester
|
||||
1 Bident of Thassa
|
||||
1 Temple of Enlightenment
|
||||
1 Battlefield Forge
|
||||
1 Myr Battlesphere
|
||||
1 Grenzo, Havoc Raiser
|
||||
1 Brudiclad, Telchor Engineer
|
||||
1 Coveted Jewel
|
||||
1 Goldspan Dragon
|
||||
1 Legion Loyalty
|
||||
1 Mystic Gate
|
||||
1 Akroma's Will
|
||||
1 Adarkar Wastes
|
||||
1 Shivan Reef
|
||||
1 Coalition Relic
|
||||
1 Combustible Gearhulk
|
||||
1 Aetherworks Marvel
|
||||
1 Angel of Invention
|
||||
1 Confiscation Coup
|
||||
1 Aetherstorm Roc
|
||||
1 Aethersquall Ancient
|
||||
1 Frostboil Snarl
|
||||
1 Austere Command
|
||||
1 Furycalm Snarl
|
||||
1 Midnight Clock
|
||||
1 Professional Face-Breaker
|
||||
1 Farewell
|
||||
1 Port Town
|
||||
1 Temple of Triumph
|
||||
1 Solemn Simulacrum
|
||||
1 Temple of Epiphany
|
||||
1 Prairie Stream
|
||||
1 Skyclave Apparition
|
||||
1 Castle Vantress
|
||||
1 Jolted Awake
|
||||
1 Bespoke Battlewagon
|
||||
1 Roil Cartographer
|
||||
1 Unstable Amulet
|
||||
1 Amped Raptor
|
||||
1 Solar Transformer
|
||||
1 Scurry of Gremlins
|
||||
1 Izzet Generatorium
|
||||
1 Talisman of Conviction
|
||||
1 Tezzeret's Gambit
|
||||
1 Izzet Boilerworks
|
||||
1 Azorius Chancery
|
||||
1 Talisman of Progress
|
||||
1 Command Tower
|
||||
1 Burnished Hart
|
||||
1 Arcane Signet
|
||||
1 Sol Ring
|
||||
1 Era of Innovation
|
||||
1 Aether Hub
|
||||
1 Decoction Module
|
||||
1 Glimmer of Genius
|
||||
1 Whirler Virtuoso
|
||||
1 Talisman of Creativity
|
||||
1 Swords to Plowshares
|
||||
1 Wayfarer's Bauble
|
||||
1 Demolition Field
|
||||
1 Mystic Monastery
|
||||
5 Plains
|
||||
5 Plains
|
||||
3 Island
|
||||
2 Island
|
||||
3 Mountain
|
||||
2 Mountain
|
||||
@@ -0,0 +1,93 @@
|
||||
[metadata]
|
||||
Name=Dance of the Elements
|
||||
[Commander]
|
||||
1 Ashling, the Limitless
|
||||
[Main]
|
||||
1 Mass of Mysteries
|
||||
1 Elemental Spectacle
|
||||
1 Springleaf Parade
|
||||
1 Jubilation
|
||||
1 Impulsivity
|
||||
1 Lamentation
|
||||
1 Belonging
|
||||
1 Subterfuge
|
||||
1 Rain-Slicked Copse
|
||||
1 Sodden Verdure
|
||||
1 Abundant Countryside
|
||||
1 Endurance
|
||||
1 Fury
|
||||
1 Haunting Voyage
|
||||
1 Avenger of Zendikar
|
||||
1 Cavalier of Thorns
|
||||
1 Greenwarden of Murasa
|
||||
1 Selvala, Heart of the Wilds
|
||||
1 Titan of Industry
|
||||
1 Muldrotha, the Gravetide
|
||||
1 Omnath, Locus of Rage
|
||||
1 Yarok, the Desecrated
|
||||
1 Omnath, Locus of the Roil
|
||||
1 Timeless Lotus
|
||||
1 Shatter the Sky
|
||||
1 Hoofprints of the Stag
|
||||
1 Slithermuse
|
||||
1 Descendants' Fury
|
||||
1 Blasphemous Act
|
||||
1 Cream of the Crop
|
||||
1 Kindred Summons
|
||||
1 Bane of Progress
|
||||
1 Realmwalker
|
||||
1 Return of the Wildspeaker
|
||||
1 Faeburrow Elder
|
||||
1 Vernal Sovereign
|
||||
1 Horde of Notions
|
||||
1 Maelstrom Wanderer
|
||||
1 Jegantha, the Wellspring
|
||||
1 Chromatic Lantern
|
||||
1 Primal Beyond
|
||||
1 Raging Ravine
|
||||
1 Exotic Orchard
|
||||
1 Flamekin Village
|
||||
1 Path to Exile
|
||||
1 Mulldrifter
|
||||
1 Reality Shift
|
||||
1 Shriekmaw
|
||||
1 Shimmercreep
|
||||
1 Flamebraider
|
||||
1 Crib Swap
|
||||
1 Secluded Courtyard
|
||||
1 Sol Ring
|
||||
1 Command Tower
|
||||
1 Arcane Signet
|
||||
1 Distant Melody
|
||||
1 Incandescent Soulstoke
|
||||
1 Eclipsed Flamekin
|
||||
1 Foundation Breaker
|
||||
1 Garruk's Uprising
|
||||
1 Risen Reef
|
||||
1 Fellwar Stone
|
||||
1 Unclaimed Territory
|
||||
1 Ancient Ziggurat
|
||||
1 Frontier Bivouac
|
||||
1 Sandsteppe Citadel
|
||||
1 Savage Lands
|
||||
1 Opulent Palace
|
||||
1 Seaside Citadel
|
||||
1 Jungle Shrine
|
||||
1 Smokebraider
|
||||
1 Ingot Chewer
|
||||
1 Fertile Ground
|
||||
1 Abundant Growth
|
||||
1 Kodama's Reach
|
||||
1 Cultivate
|
||||
1 Path of Ancestry
|
||||
1 Thriving Grove
|
||||
1 Thriving Heath
|
||||
1 Thriving Isle
|
||||
1 Opal Palace
|
||||
1 Thriving Bluff
|
||||
1 Thriving Moor
|
||||
2 Plains
|
||||
2 Island
|
||||
2 Swamp
|
||||
2 Mountain
|
||||
8 Forest
|
||||
@@ -0,0 +1,99 @@
|
||||
[metadata]
|
||||
Name=Deadly Disguise
|
||||
[Commander]
|
||||
1 Kaust, Eyes of the Glade
|
||||
[Main]
|
||||
1 Duskana, the Rage Mother
|
||||
1 True Identity
|
||||
1 Unexplained Absence
|
||||
1 Veiled Ascension
|
||||
1 Boltbender
|
||||
1 Showstopping Surprise
|
||||
1 Tesak, Judith's Hellhound
|
||||
1 Experiment Twelve
|
||||
1 Printlifter Ooze
|
||||
1 Panoptic Projektor
|
||||
1 Ransom Note
|
||||
1 Ugin's Mastery
|
||||
1 Austere Command
|
||||
1 Dusk // Dawn
|
||||
1 Exalted Angel
|
||||
1 Fell the Mighty
|
||||
1 Hidden Dragonslayer
|
||||
1 Master of Pearls
|
||||
1 Mastery of the Unseen
|
||||
1 Mirror Entity
|
||||
1 Welcoming Vampire
|
||||
1 Akroma, Angel of Fury
|
||||
1 Ashcloud Phoenix
|
||||
1 Chaos Warp
|
||||
1 Imperial Hellkite
|
||||
1 Jeska's Will
|
||||
1 Neheb, the Eternal
|
||||
1 Scourge of the Throne
|
||||
1 Beast Whisperer
|
||||
1 Deathmist Raptor
|
||||
1 Den Protector
|
||||
1 Hooded Hydra
|
||||
1 Krosan Cloudscraper
|
||||
1 Krosan Colossus
|
||||
1 Obscuring Aether
|
||||
1 Ohran Frostfang
|
||||
1 Return of the Wildspeaker
|
||||
1 Root Elemental
|
||||
1 Saryth, the Viper's Fang
|
||||
1 Seedborn Muse
|
||||
1 Temur War Shaman
|
||||
1 Thelonite Hermit
|
||||
1 Toski, Bearer of Secrets
|
||||
1 Trail of Mystery
|
||||
1 Whisperwood Elemental
|
||||
1 Yedora, Grave Gardener
|
||||
1 Decimate
|
||||
1 Sidar Kondo of Jamuraa
|
||||
1 Lifecrafter's Bestiary
|
||||
1 Scroll of Fate
|
||||
1 Canopy Vista
|
||||
1 Cinder Glade
|
||||
1 Exotic Orchard
|
||||
1 Fortified Village
|
||||
1 Furycalm Snarl
|
||||
1 Game Trail
|
||||
1 Kessig Wolf Run
|
||||
1 Mossfire Valley
|
||||
1 Mosswort Bridge
|
||||
1 Scattered Groves
|
||||
1 Sheltered Thicket
|
||||
1 Shrine of the Forsaken Gods
|
||||
1 Sungrass Prairie
|
||||
1 Temple of Abandon
|
||||
1 Temple of Plenty
|
||||
1 Temple of Triumph
|
||||
1 Path to Exile
|
||||
1 Ainok Survivalist
|
||||
1 Broodhatch Nantuko
|
||||
1 Nervous Gardener
|
||||
1 Nantuko Vigilante
|
||||
1 Nature's Lore
|
||||
1 Sakura-Tribe Elder
|
||||
1 Salt Road Ambushers
|
||||
1 Three Visits
|
||||
1 Wild Growth
|
||||
1 Arcane Signet
|
||||
1 Sol Ring
|
||||
1 Boros Garrison
|
||||
1 Command Tower
|
||||
1 Branch of Vitu-Ghazi
|
||||
1 Gruul Turf
|
||||
1 Jungle Shrine
|
||||
1 Krosan Verge
|
||||
1 Sacred Peaks
|
||||
1 Selesnya Sanctuary
|
||||
1 Temple of the False God
|
||||
1 Zoetic Cavern
|
||||
2 Plains
|
||||
2 Plains
|
||||
2 Mountain
|
||||
1 Mountain
|
||||
2 Forest
|
||||
2 Forest
|
||||
@@ -0,0 +1,96 @@
|
||||
[metadata]
|
||||
Name=Deep Clue Sea
|
||||
[Commander]
|
||||
1 Morska, Undersea Sleuth
|
||||
[Main]
|
||||
1 Sophia, Dogged Detective
|
||||
1 Armed with Proof
|
||||
1 Merchant of Truth
|
||||
1 Serene Sleuth
|
||||
1 Detective of the Month
|
||||
1 Follow the Bodies
|
||||
1 Tangletrove Kelp
|
||||
1 Innocuous Researcher
|
||||
1 On the Trail
|
||||
1 Knowledge Is Power
|
||||
1 Ransom Note
|
||||
1 Aerial Extortionist
|
||||
1 Bennie Bracks, Zoologist
|
||||
1 Farewell
|
||||
1 Fumigate
|
||||
1 Organic Extinction
|
||||
1 Search the Premises
|
||||
1 Alandra, Sky Dreamer
|
||||
1 Confirm Suspicions
|
||||
1 Ethereal Investigator
|
||||
1 Finale of Revelation
|
||||
1 Kappa Cannoneer
|
||||
1 Mechanized Production
|
||||
1 Nadir Kraken
|
||||
1 Shimmer Dragon
|
||||
1 Teferi's Ageless Insight
|
||||
1 Tezzeret, Betrayer of Flesh
|
||||
1 Thought Monitor
|
||||
1 Hornet Queen
|
||||
1 Jolrael, Mwonvuli Recluse
|
||||
1 Killer Service
|
||||
1 Tireless Tracker
|
||||
1 Adrix and Nev, Twincasters
|
||||
1 Chulane, Teller of Tales
|
||||
1 Disorder in the Court
|
||||
1 Esix, Fractal Bloom
|
||||
1 Hydroid Krasis
|
||||
1 Koma, Cosmos Serpent
|
||||
1 Lonis, Cryptozoologist
|
||||
1 Selvala, Explorer Returned
|
||||
1 Academy Manufactor
|
||||
1 Idol of Oblivion
|
||||
1 Inspiring Statuary
|
||||
1 Nettlecyst
|
||||
1 Psychosis Crawler
|
||||
1 Canopy Vista
|
||||
1 Exotic Orchard
|
||||
1 Irrigated Farmland
|
||||
1 Prairie Stream
|
||||
1 Scattered Groves
|
||||
1 Skycloud Expanse
|
||||
1 Spire of Industry
|
||||
1 Sungrass Prairie
|
||||
1 Temple of Enlightenment
|
||||
1 Temple of Mystery
|
||||
1 Temple of Plenty
|
||||
1 Swords to Plowshares
|
||||
1 Erdwal Illuminator
|
||||
1 Junk Winder
|
||||
1 Ongoing Investigation
|
||||
1 Whirler Rogue
|
||||
1 Graf Mole
|
||||
1 Ulvenwald Mysteries
|
||||
1 Wilderness Reclamation
|
||||
1 Wavesifter
|
||||
1 Arcane Signet
|
||||
1 Azorius Signet
|
||||
1 Simic Signet
|
||||
1 Sol Ring
|
||||
1 Talisman of Curiosity
|
||||
1 Talisman of Progress
|
||||
1 Talisman of Unity
|
||||
1 Azorius Chancery
|
||||
1 Magnifying Glass
|
||||
1 Command Tower
|
||||
1 Krosan Verge
|
||||
1 Lonely Sandbar
|
||||
1 Path of Ancestry
|
||||
1 Reliquary Tower
|
||||
1 Seaside Citadel
|
||||
1 Secluded Steppe
|
||||
1 Selesnya Sanctuary
|
||||
1 Simic Growth Chamber
|
||||
1 Temple of the False God
|
||||
1 Tranquil Thicket
|
||||
2 Plains
|
||||
1 Plains
|
||||
3 Island
|
||||
3 Island
|
||||
3 Forest
|
||||
2 Forest
|
||||
@@ -0,0 +1,93 @@
|
||||
[metadata]
|
||||
Name=Desert Bloom
|
||||
[Commander]
|
||||
1 Yuma, Proud Protector
|
||||
[Main]
|
||||
1 Kirri, Talented Sprout
|
||||
1 Scavenger Grounds
|
||||
1 Sun Titan
|
||||
1 Omnath, Locus of Rage
|
||||
1 Descend upon the Sinful
|
||||
1 Chromatic Lantern
|
||||
1 Marshal's Anthem
|
||||
1 Sheltered Thicket
|
||||
1 Scute Swarm
|
||||
1 Hour of Promise
|
||||
1 Oracle of Mul Daya
|
||||
1 Ramunap Excavator
|
||||
1 Scattered Groves
|
||||
1 World Shaper
|
||||
1 Nesting Dragon
|
||||
1 Turntimber Sower
|
||||
1 Sevinne's Reclamation
|
||||
1 Ancient Greenwarden
|
||||
1 Titania, Protector of Argoth
|
||||
1 Return of the Wildspeaker
|
||||
1 Perennial Behemoth
|
||||
1 Avenger of Zendikar
|
||||
1 Hazezon, Shaper of Sand
|
||||
1 Escape to the Wilds
|
||||
1 Heaven // Earth
|
||||
1 Genesis Hydra
|
||||
1 Sunscorched Divide
|
||||
1 The Mending of Dominaria
|
||||
1 Decimate
|
||||
1 Sand Scout
|
||||
1 Embrace the Unknown
|
||||
1 Dune Chanter
|
||||
1 Cataclysmic Prospecting
|
||||
1 Vengeful Regrowth
|
||||
1 Angel of Indemnity
|
||||
1 Cactus Preserve
|
||||
1 Rumbleweed
|
||||
1 Terramorphic Expanse
|
||||
1 Evolving Wilds
|
||||
1 Swiftfoot Boots
|
||||
1 Explore
|
||||
1 Sol Ring
|
||||
1 Satyr Wayfinder
|
||||
1 Perpetual Timepiece
|
||||
1 Crawling Sensation
|
||||
1 Painted Bluffs
|
||||
1 Command Tower
|
||||
1 Magmatic Insight
|
||||
1 Krosan Verge
|
||||
1 Desert of the True
|
||||
1 Skullwinder
|
||||
1 Desert of the Indomitable
|
||||
1 Jungle Shrine
|
||||
1 Bitter Reunion
|
||||
1 Desert of the Fervent
|
||||
1 Valorous Stance
|
||||
1 Dunes of the Dead
|
||||
1 Shefet Dunes
|
||||
1 Hashep Oasis
|
||||
1 Elvish Rejuvenator
|
||||
1 Winding Way
|
||||
1 Springbloom Druid
|
||||
1 Arcane Signet
|
||||
1 Unholy Heat
|
||||
1 Thrilling Discovery
|
||||
1 Electric Revelation
|
||||
1 Eccentric Farmer
|
||||
1 Harrow
|
||||
1 Ramunap Ruins
|
||||
1 Path to Exile
|
||||
1 Requisition Raid
|
||||
1 Bovine Intervention
|
||||
1 Map the Frontier
|
||||
1 Conduit Pylons
|
||||
1 Mirage Mesa
|
||||
1 Wreck and Rebuild
|
||||
1 Angel of the Ruins
|
||||
1 Bristling Backwoods
|
||||
1 Creosote Heath
|
||||
1 Abraded Bluffs
|
||||
1 Scaretiller
|
||||
1 Nantuko Cultivator
|
||||
3 Plains
|
||||
3 Plains
|
||||
2 Mountain
|
||||
2 Mountain
|
||||
4 Forest
|
||||
3 Forest
|
||||
@@ -0,0 +1,98 @@
|
||||
[metadata]
|
||||
Name=Devour for Power
|
||||
[Commander]
|
||||
1 The Mimeoplasm
|
||||
[Main]
|
||||
1 Nezumi Graverobber // Nighteyes the Desecrator
|
||||
1 Skullbriar, the Walking Grave
|
||||
1 Riddlekeeper
|
||||
1 Fleshbag Marauder
|
||||
1 Eternal Witness
|
||||
1 Troll Ascetic
|
||||
1 Yavimaya Elder
|
||||
1 Solemn Simulacrum
|
||||
1 Brawn
|
||||
1 Wonder
|
||||
1 Sewer Nemesis
|
||||
1 Gravedigger
|
||||
1 Lhurgoyf
|
||||
1 Dreamborn Muse
|
||||
1 Mortivore
|
||||
1 Desecrator Hag
|
||||
1 Mulldrifter
|
||||
1 Acidic Slime
|
||||
1 Vulturous Zombie
|
||||
1 Dark Hatchling
|
||||
1 Extractor Demon
|
||||
1 Scythe Specter
|
||||
1 Wrexial, the Risen Deep
|
||||
1 Vorosh, the Hunter
|
||||
1 Triskelavus
|
||||
1 Slipstream Eel
|
||||
1 Butcher of Malakir
|
||||
1 Patron of the Nezumi
|
||||
1 Damia, Sage of Stone
|
||||
1 Szadek, Lord of Secrets
|
||||
1 Avatar of Woe
|
||||
1 Artisan of Kozilek
|
||||
1 Minds Aglow
|
||||
1 Shared Trauma
|
||||
1 Sign in Blood
|
||||
1 Stitch Together
|
||||
1 Cultivate
|
||||
1 Windfall
|
||||
1 Buried Alive
|
||||
1 Syphon Mind
|
||||
1 Unnerve
|
||||
1 Rise from the Grave
|
||||
1 Syphon Flesh
|
||||
1 Living Death
|
||||
1 Tribute to the Wild
|
||||
1 Spell Crumple
|
||||
1 Fact or Fiction
|
||||
1 Relic Crush
|
||||
1 Sol Ring
|
||||
1 Dimir Signet
|
||||
1 Golgari Signet
|
||||
1 Lightning Greaves
|
||||
1 Simic Signet
|
||||
1 Oblivion Stone
|
||||
1 Vow of Wildness
|
||||
1 Vow of Flight
|
||||
1 Vow of Malice
|
||||
1 Memory Erosion
|
||||
1 Grave Pact
|
||||
1 Barren Moor
|
||||
1 Command Tower
|
||||
1 Dimir Aqueduct
|
||||
1 Dreadship Reef
|
||||
1 Golgari Rot Farm
|
||||
1 Jwar Isle Refuge
|
||||
1 Lonely Sandbar
|
||||
1 Rupture Spire
|
||||
1 Simic Growth Chamber
|
||||
1 Svogthos, the Restless Tomb
|
||||
1 Temple of the False God
|
||||
1 Terramorphic Expanse
|
||||
1 Tranquil Thicket
|
||||
2 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
2 Island
|
||||
1 Island
|
||||
1 Island
|
||||
1 Island
|
||||
1 Island
|
||||
1 Island
|
||||
1 Island
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
1 Swamp
|
||||
1 Swamp
|
||||
1 Swamp
|
||||
@@ -0,0 +1,89 @@
|
||||
[metadata]
|
||||
Name=Devour for Power
|
||||
[Commander]
|
||||
1 The Mimeoplasm
|
||||
[Main]
|
||||
1 Nezumi Graverobber // Nighteyes the Desecrator
|
||||
1 Skullbriar, the Walking Grave
|
||||
1 Riddlekeeper
|
||||
1 Fleshbag Marauder
|
||||
1 Eternal Witness
|
||||
1 Troll Ascetic
|
||||
1 Yavimaya Elder
|
||||
1 Solemn Simulacrum
|
||||
1 Brawn
|
||||
1 Wonder
|
||||
1 Sewer Nemesis
|
||||
1 Gravedigger
|
||||
1 Lhurgoyf
|
||||
1 Dreamborn Muse
|
||||
1 Mortivore
|
||||
1 Desecrator Hag
|
||||
1 Mulldrifter
|
||||
1 Acidic Slime
|
||||
1 Vulturous Zombie
|
||||
1 Dark Hatchling
|
||||
1 Extractor Demon
|
||||
1 Scythe Specter
|
||||
1 Wrexial, the Risen Deep
|
||||
1 Vorosh, the Hunter
|
||||
1 Triskelavus
|
||||
1 Slipstream Eel
|
||||
1 Butcher of Malakir
|
||||
1 Patron of the Nezumi
|
||||
1 Damia, Sage of Stone
|
||||
1 Szadek, Lord of Secrets
|
||||
1 Avatar of Woe
|
||||
1 Artisan of Kozilek
|
||||
1 Minds Aglow
|
||||
1 Shared Trauma
|
||||
1 Sign in Blood
|
||||
1 Stitch Together
|
||||
1 Cultivate
|
||||
1 Windfall
|
||||
1 Buried Alive
|
||||
1 Syphon Mind
|
||||
1 Unnerve
|
||||
1 Rise from the Grave
|
||||
1 Syphon Flesh
|
||||
1 Living Death
|
||||
1 Tribute to the Wild
|
||||
1 Spell Crumple
|
||||
1 Fact or Fiction
|
||||
1 Relic Crush
|
||||
1 Sol Ring
|
||||
1 Dimir Signet
|
||||
1 Golgari Signet
|
||||
1 Lightning Greaves
|
||||
1 Simic Signet
|
||||
1 Oblivion Stone
|
||||
1 Vow of Wildness
|
||||
1 Vow of Flight
|
||||
1 Vow of Malice
|
||||
1 Memory Erosion
|
||||
1 Grave Pact
|
||||
1 Barren Moor
|
||||
1 Command Tower
|
||||
1 Dimir Aqueduct
|
||||
1 Dreadship Reef
|
||||
1 Golgari Rot Farm
|
||||
1 Jwar Isle Refuge
|
||||
1 Lonely Sandbar
|
||||
1 Rupture Spire
|
||||
1 Simic Growth Chamber
|
||||
1 Svogthos, the Restless Tomb
|
||||
1 Temple of the False God
|
||||
1 Terramorphic Expanse
|
||||
1 Tranquil Thicket
|
||||
2 Forest
|
||||
2 Forest
|
||||
2 Forest
|
||||
2 Forest
|
||||
2 Island
|
||||
2 Island
|
||||
2 Island
|
||||
2 Island
|
||||
3 Swamp
|
||||
3 Swamp
|
||||
3 Swamp
|
||||
2 Swamp
|
||||
@@ -0,0 +1,82 @@
|
||||
[metadata]
|
||||
Name=Draconic Destruction
|
||||
[Commander]
|
||||
1 Atarka, World Render
|
||||
[Main]
|
||||
1 Akoum Hellkite
|
||||
1 Chain Reaction
|
||||
1 Crucible of Fire
|
||||
1 Demanding Dragon
|
||||
1 Dragonkin Berserker
|
||||
1 Dragonmaster Outcast
|
||||
1 Drakuseth, Maw of Flames
|
||||
1 Dream Pillager
|
||||
1 Flameblast Dragon
|
||||
1 Hoard-Smelter Dragon
|
||||
1 Magmaquake
|
||||
1 Mordant Dragon
|
||||
1 Runehorn Hellkite
|
||||
1 Sarkhan, the Dragonspeaker
|
||||
1 Scourge of Valkas
|
||||
1 Spit Flame
|
||||
1 Sweltering Suns
|
||||
1 Thunderbreak Regent
|
||||
1 Thundermaw Hellkite
|
||||
1 Tyrant's Familiar
|
||||
1 Verix Bladewing
|
||||
1 Foe-Razer Regent
|
||||
1 Frontier Siege
|
||||
1 Hunter's Prowess
|
||||
1 Primal Might
|
||||
1 Shamanic Revelation
|
||||
1 Clan Defiance
|
||||
1 Harbinger of the Hunt
|
||||
1 Dragon's Hoard
|
||||
1 Steel Hellkite
|
||||
1 Cinder Glade
|
||||
1 Game Trail
|
||||
1 Haven of the Spirit Dragon
|
||||
1 Temple of Abandon
|
||||
1 Dragon Mage
|
||||
1 Dragon Tempest
|
||||
1 Dragonlord's Servant
|
||||
1 Dragonspeaker Shaman
|
||||
1 Furnace Whelp
|
||||
1 Provoke the Trolls
|
||||
1 Rapacious Dragon
|
||||
1 Unleash Fury
|
||||
1 Vandalblast
|
||||
1 Beast Within
|
||||
1 Blossoming Defense
|
||||
1 Cultivate
|
||||
1 Drumhunter
|
||||
1 Elemental Bond
|
||||
1 Garruk's Uprising
|
||||
1 Harmonize
|
||||
1 Hunter's Insight
|
||||
1 Loaming Shaman
|
||||
1 Return to Nature
|
||||
1 Sakura-Tribe Elder
|
||||
1 Draconic Disciple
|
||||
1 Fires of Yavimaya
|
||||
1 Savage Ventmaw
|
||||
1 Arcane Signet
|
||||
1 Atarka Monument
|
||||
1 Commander's Sphere
|
||||
1 Sol Ring
|
||||
1 Swiftfoot Boots
|
||||
1 Talisman of Impulse
|
||||
1 Command Tower
|
||||
1 Kazandu Refuge
|
||||
1 Path of Ancestry
|
||||
1 Rugged Highlands
|
||||
1 Shivan Oasis
|
||||
1 Timber Gorge
|
||||
5 Mountain
|
||||
5 Mountain
|
||||
4 Mountain
|
||||
4 Mountain
|
||||
3 Forest
|
||||
3 Forest
|
||||
3 Forest
|
||||
3 Forest
|
||||
@@ -0,0 +1,88 @@
|
||||
[metadata]
|
||||
Name=Draconic Dissent
|
||||
[Commander]
|
||||
1 Firkraag, Cunning Instigator
|
||||
[Main]
|
||||
1 Will Kenrith
|
||||
1 Rowan Kenrith
|
||||
1 Keiga, the Tide Star
|
||||
1 Pursued Whale
|
||||
1 Sly Instigator
|
||||
1 Agitator Ant
|
||||
1 Avatar of Slaughter
|
||||
1 Brash Taunter
|
||||
1 Chaos Dragon
|
||||
1 Drakuseth, Maw of Flames
|
||||
1 Geode Rager
|
||||
1 Goblin Spymaster
|
||||
1 Kazuul, Tyrant of the Cliffs
|
||||
1 Ryusei, the Falling Star
|
||||
1 Territorial Hellkite
|
||||
1 Thunder Dragon
|
||||
1 Vengeful Ancestor
|
||||
1 Warmonger Hellkite
|
||||
1 Niv-Mizzet, Parun
|
||||
1 Solemn Simulacrum
|
||||
1 Steel Hellkite
|
||||
1 Stuffy Doll
|
||||
1 Burnished Hart
|
||||
1 Sprite Dragon
|
||||
1 Aether Gale
|
||||
1 Curse of the Swine
|
||||
1 Blasphemous Act
|
||||
1 Chain Reaction
|
||||
1 Disrupt Decorum
|
||||
1 Compulsive Research
|
||||
1 Domineering Will
|
||||
1 Reins of Power
|
||||
1 Chaos Warp
|
||||
1 Midnight Clock
|
||||
1 Dragon's Hoard
|
||||
1 Arcane Signet
|
||||
1 Mind Stone
|
||||
1 Wayfarer's Bauble
|
||||
1 Bloodthirsty Blade
|
||||
1 Fellwar Stone
|
||||
1 Hedron Archive
|
||||
1 Izzet Signet
|
||||
1 Sol Ring
|
||||
1 Talisman of Creativity
|
||||
1 Dissipation Field
|
||||
1 The Akroan War
|
||||
1 Psychic Impetus
|
||||
1 Shiny Impetus
|
||||
1 Curse of Verbosity
|
||||
1 Propaganda
|
||||
1 Curse of Opulence
|
||||
1 Castle Vantress
|
||||
1 Desolate Lighthouse
|
||||
1 Kher Keep
|
||||
1 Temple of Epiphany
|
||||
1 Wandering Fumarole
|
||||
1 Command Tower
|
||||
1 Ash Barrens
|
||||
1 Izzet Boilerworks
|
||||
1 Myriad Landscape
|
||||
1 Path of Ancestry
|
||||
1 Prismari Campus
|
||||
1 Reliquary Tower
|
||||
1 Temple of the False God
|
||||
1 Terrain Generator
|
||||
3 Island
|
||||
3 Island
|
||||
3 Island
|
||||
3 Island
|
||||
3 Mountain
|
||||
3 Mountain
|
||||
3 Mountain
|
||||
3 Mountain
|
||||
1 Baeloth Barrityl, Entertainer
|
||||
1 Clan Crafter
|
||||
1 Artificer Class
|
||||
1 Astral Dragon
|
||||
1 Mocking Doppelganger
|
||||
1 Bothersome Quasit
|
||||
1 Death Kiss
|
||||
1 Loot Dispute
|
||||
1 Spectacular Showdown
|
||||
1 Angler Turtle
|
||||
@@ -0,0 +1,101 @@
|
||||
[metadata]
|
||||
Name=Draconic Domination
|
||||
[Commander]
|
||||
1 The Ur-Dragon
|
||||
[Main]
|
||||
1 O-Kagachi, Vengeful Kami
|
||||
1 Ramos, Dragon Engine
|
||||
1 Scalelord Reckoner
|
||||
1 Boneyard Scourge
|
||||
1 Territorial Hellkite
|
||||
1 Taigam, Ojutai Master
|
||||
1 Wasitora, Nekoru Queen
|
||||
1 Ryusei, the Falling Star
|
||||
1 Scourge of Valkas
|
||||
1 Utvara Hellkite
|
||||
1 Sunscorch Regent
|
||||
1 Deathbringer Regent
|
||||
1 Hellkite Charger
|
||||
1 Tyrant's Familiar
|
||||
1 Atarka, World Render
|
||||
1 Bladewing the Risen
|
||||
1 Broodmate Dragon
|
||||
1 Crosis, the Purger
|
||||
1 Dromoka, the Eternal
|
||||
1 Intet, the Dreamer
|
||||
1 Kolaghan, the Storm's Fury
|
||||
1 Niv-Mizzet, Dracogenius
|
||||
1 Ojutai, Soul of Winter
|
||||
1 Scion of the Ur-Dragon
|
||||
1 Silumgar, the Drifting Death
|
||||
1 Spellbound Dragon
|
||||
1 Teneb, the Harvester
|
||||
1 Steel Hellkite
|
||||
1 Orator of Ojutai
|
||||
1 Dragonlord's Servant
|
||||
1 Dragonspeaker Shaman
|
||||
1 Savage Ventmaw
|
||||
1 Fortunate Few
|
||||
1 Fractured Identity
|
||||
1 Crux of Fate
|
||||
1 Painful Truths
|
||||
1 Earthquake
|
||||
1 Cultivate
|
||||
1 Farseek
|
||||
1 Kodama's Reach
|
||||
1 Rain of Thorns
|
||||
1 Fist of Suns
|
||||
1 Herald's Horn
|
||||
1 Mirror of the Forebears
|
||||
1 Armillary Sphere
|
||||
1 Commander's Sphere
|
||||
1 Darksteel Ingot
|
||||
1 Dreamstone Hedron
|
||||
1 Lightning Greaves
|
||||
1 Nihil Spellbomb
|
||||
1 Sol Ring
|
||||
1 Wayfarer's Bauble
|
||||
1 Kindred Discovery
|
||||
1 Monastery Siege
|
||||
1 Palace Siege
|
||||
1 Crucible of Fire
|
||||
1 Dragon Tempest
|
||||
1 Frontier Siege
|
||||
1 Curse of Verbosity
|
||||
1 Curse of Opulence
|
||||
1 Curse of Bounty
|
||||
1 Elemental Bond
|
||||
1 Crucible of the Spirit Dragon
|
||||
1 Haven of the Spirit Dragon
|
||||
2 Mountain
|
||||
2 Mountain
|
||||
2 Mountain
|
||||
1 Swamp
|
||||
1 Swamp
|
||||
1 Swamp
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Island
|
||||
1 Island
|
||||
1 Island
|
||||
1 Path of Ancestry
|
||||
1 Arcane Sanctum
|
||||
1 Command Tower
|
||||
1 Crumbling Necropolis
|
||||
1 Frontier Bivouac
|
||||
1 Jungle Shrine
|
||||
1 Mystic Monastery
|
||||
1 Nomad Outpost
|
||||
1 Opulent Palace
|
||||
1 Sandsteppe Citadel
|
||||
1 Savage Lands
|
||||
1 Seaside Citadel
|
||||
1 Vivid Crag
|
||||
1 Vivid Creek
|
||||
1 Vivid Grove
|
||||
1 Vivid Marsh
|
||||
1 Vivid Meadow
|
||||
@@ -0,0 +1,85 @@
|
||||
[metadata]
|
||||
Name=Draconic Rage
|
||||
[Commander]
|
||||
1 Vrondiss, Rage of Ancients
|
||||
[Main]
|
||||
1 Bogardan Hellkite
|
||||
1 Demanding Dragon
|
||||
1 Dragonmaster Outcast
|
||||
1 Hoard-Smelter Dragon
|
||||
1 Opportunistic Dragon
|
||||
1 Scourge of Valkas
|
||||
1 Shivan Hellkite
|
||||
1 Skyline Despot
|
||||
1 Skyship Stalker
|
||||
1 Taurean Mauler
|
||||
1 Terror of Mount Velus
|
||||
1 Thunderbreak Regent
|
||||
1 Chameleon Colossus
|
||||
1 Atarka, World Render
|
||||
1 Anger
|
||||
1 Dragonlord's Servant
|
||||
1 Savage Ventmaw
|
||||
1 Dragonspeaker Shaman
|
||||
1 Chain Reaction
|
||||
1 Rishkar's Expertise
|
||||
1 Shamanic Revelation
|
||||
1 Rile
|
||||
1 Cultivate
|
||||
1 Explore
|
||||
1 Rampant Growth
|
||||
1 Magmaquake
|
||||
1 Spit Flame
|
||||
1 Decree of Savagery
|
||||
1 Kindred Summons
|
||||
1 Return of the Wildspeaker
|
||||
1 Beast Within
|
||||
1 Return to Nature
|
||||
1 Dragon's Hoard
|
||||
1 Arcane Signet
|
||||
1 Commander's Sphere
|
||||
1 Gruul Signet
|
||||
1 Heirloom Blade
|
||||
1 Sol Ring
|
||||
1 Gratuitous Violence
|
||||
1 Outpost Siege
|
||||
1 Warstorm Surge
|
||||
1 Colossal Majesty
|
||||
1 Garruk's Uprising
|
||||
1 Cinder Glade
|
||||
1 Crucible of the Spirit Dragon
|
||||
1 Exotic Orchard
|
||||
1 Game Trail
|
||||
1 Haven of the Spirit Dragon
|
||||
1 Mossfire Valley
|
||||
1 Mosswort Bridge
|
||||
1 Command Tower
|
||||
1 Desert
|
||||
1 Gruul Turf
|
||||
1 Path of Ancestry
|
||||
3 Mountain
|
||||
3 Mountain
|
||||
3 Mountain
|
||||
3 Mountain
|
||||
4 Forest
|
||||
4 Forest
|
||||
4 Forest
|
||||
3 Forest
|
||||
1 Klauth, Unrivaled Ancient
|
||||
1 Berserker's Frenzy
|
||||
1 Chaos Dragon
|
||||
1 Maddening Hex
|
||||
1 Vengeful Ancestor
|
||||
1 Bag of Tricks
|
||||
1 Druid of Purification
|
||||
1 Indomitable Might
|
||||
1 Neverwinter Hydra
|
||||
1 Wild Endeavor
|
||||
1 Dragonborn Champion
|
||||
1 Klauth's Will
|
||||
1 Wulfgar of Icewind Dale
|
||||
1 Barbarian Class
|
||||
1 Earth-Cult Elemental
|
||||
1 Component Pouch
|
||||
1 Sword of Hours
|
||||
1 Underdark Rift
|
||||
@@ -0,0 +1,97 @@
|
||||
[metadata]
|
||||
Name=Dungeons of Death
|
||||
[Commander]
|
||||
1 Sefris of the Hidden Ways
|
||||
[Main]
|
||||
1 Cataclysmic Gearhulk
|
||||
1 Eternal Dragon
|
||||
1 Karmic Guide
|
||||
1 Sun Titan
|
||||
1 Sunblast Angel
|
||||
1 Champion of Wits
|
||||
1 Curator of Mysteries
|
||||
1 Phantasmal Image
|
||||
1 Doomed Necromancer
|
||||
1 Ashen Rider
|
||||
1 Baleful Strix
|
||||
1 Hostage Taker
|
||||
1 Solemn Simulacrum
|
||||
1 Ronom Unicorn
|
||||
1 Wall of Omens
|
||||
1 Merfolk Looter
|
||||
1 Mulldrifter
|
||||
1 Murder of Crows
|
||||
1 Plaguecrafter
|
||||
1 Reassembling Skeleton
|
||||
1 Shriekmaw
|
||||
1 Cloudblazer
|
||||
1 Necrotic Sliver
|
||||
1 Obsessive Stitcher
|
||||
1 Burnished Hart
|
||||
1 Meteor Golem
|
||||
1 Necromantic Selection
|
||||
1 Unburial Rites
|
||||
1 Victimize
|
||||
1 Utter End
|
||||
1 Swords to Plowshares
|
||||
1 Forbidden Alchemy
|
||||
1 Despark
|
||||
1 Vanish into Memory
|
||||
1 Arcane Signet
|
||||
1 Commander's Sphere
|
||||
1 Fellwar Stone
|
||||
1 Sol Ring
|
||||
1 Wayfarer's Bauble
|
||||
1 Lightning Greaves
|
||||
1 Propaganda
|
||||
1 Choked Estuary
|
||||
1 Darkwater Catacombs
|
||||
1 Exotic Orchard
|
||||
1 Geier Reach Sanitarium
|
||||
1 High Market
|
||||
1 Nimbus Maze
|
||||
1 Port Town
|
||||
1 Prairie Stream
|
||||
1 Sunken Hollow
|
||||
1 Evolving Wilds
|
||||
1 Arcane Sanctum
|
||||
1 Azorius Chancery
|
||||
1 Command Tower
|
||||
1 Dimir Aqueduct
|
||||
1 Esper Panorama
|
||||
1 Orzhov Basilica
|
||||
1 Terramorphic Expanse
|
||||
1 Thriving Heath
|
||||
1 Thriving Isle
|
||||
1 Thriving Moor
|
||||
2 Plains
|
||||
2 Plains
|
||||
2 Plains
|
||||
1 Plains
|
||||
2 Island
|
||||
1 Island
|
||||
1 Island
|
||||
1 Island
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
1 Swamp
|
||||
1 Nihiloor
|
||||
1 Immovable Rod
|
||||
1 Radiant Solar
|
||||
1 Revivify
|
||||
1 Thorough Investigation
|
||||
1 Arcane Endeavor
|
||||
1 Minn, Wily Illusionist
|
||||
1 Phantom Steed
|
||||
1 Rod of Absorption
|
||||
1 Grave Endeavor
|
||||
1 Wand of Orcus
|
||||
1 Extract Brain
|
||||
1 Midnight Pathlighter
|
||||
1 Minimus Containment
|
||||
1 Hama Pashar, Ruin Seeker
|
||||
1 Dungeon Map
|
||||
1 Bucknard's Everfull Purse
|
||||
1 Clay Golem
|
||||
1 Component Pouch
|
||||
@@ -0,0 +1,104 @@
|
||||
[metadata]
|
||||
Name=Eldrazi Incursion
|
||||
[Commander]
|
||||
1 Ulalek, Fused Atrocity
|
||||
[Main]
|
||||
1 Azlask, the Swelling Scourge
|
||||
1 Ulamog's Dreadsire
|
||||
1 Benthic Anomaly
|
||||
1 Eldritch Immunity
|
||||
1 Selective Obliteration
|
||||
1 Angelic Aberration
|
||||
1 Bismuth Mindrender
|
||||
1 Hideous Taskmaster
|
||||
1 Twins of Discord
|
||||
1 Mutated Cultist
|
||||
1 Eldrazi Confluence
|
||||
1 Spawnbed Protector
|
||||
1 Chittering Dispatcher
|
||||
1 Inversion Behemoth
|
||||
1 Ugin, the Ineffable
|
||||
1 Eldrazi Conscription
|
||||
1 Cascading Cataracts
|
||||
1 Eldrazi Monument
|
||||
1 Sire of Stagnation
|
||||
1 Shrine of the Forsaken Gods
|
||||
1 Ugin's Insight
|
||||
1 Drowner of Hope
|
||||
1 Return of the Wildspeaker
|
||||
1 Tendo Ice Bridge
|
||||
1 Battlefield Forge
|
||||
1 Llanowar Wastes
|
||||
1 Brushland
|
||||
1 Underground River
|
||||
1 Oblivion Sower
|
||||
1 Adarkar Wastes
|
||||
1 Karplusan Forest
|
||||
1 Yavimaya Coast
|
||||
1 Sulfurous Springs
|
||||
1 Caves of Koilos
|
||||
1 Shivan Reef
|
||||
1 Imprisoned in the Moon
|
||||
1 Temple of Malady
|
||||
1 Temple of Silence
|
||||
1 Morophon, the Boundless
|
||||
1 Kozilek's Return
|
||||
1 World Breaker
|
||||
1 Ruins of Oran-Rief
|
||||
1 Sifter of Skulls
|
||||
1 Endbringer
|
||||
1 Corrupted Crossroads
|
||||
1 Deepfathom Skulker
|
||||
1 Vile Redeemer
|
||||
1 Eldrazi Displacer
|
||||
1 Mystic Forge
|
||||
1 All Is Dust
|
||||
1 Rishkar's Expertise
|
||||
1 Awakening Zone
|
||||
1 Eldrazi Temple
|
||||
1 Elder Deep-Fiend
|
||||
1 Bonders' Enclave
|
||||
1 Exotic Orchard
|
||||
1 Forsaken Monument
|
||||
1 Snapping Voidcraw
|
||||
1 Herald's Horn
|
||||
1 Everflowing Chalice
|
||||
1 Glaring Fleshraker
|
||||
1 Wastescape Battlemage
|
||||
1 Crib Swap
|
||||
1 Titans' Vanguard
|
||||
1 Talisman of Resilience
|
||||
1 Hedron Archive
|
||||
1 Spawning Bed
|
||||
1 Ulamog's Nullifier
|
||||
1 Twisted Landscape
|
||||
1 Tranquil Landscape
|
||||
1 Tectonic Edge
|
||||
1 Reliquary Tower
|
||||
1 Sol Ring
|
||||
1 Artisan of Kozilek
|
||||
1 Opal Palace
|
||||
1 Dreamstone Hedron
|
||||
1 Arcane Signet
|
||||
1 Tomb of the Spirit Dragon
|
||||
1 Secluded Courtyard
|
||||
1 Idol of Oblivion
|
||||
1 Wastes
|
||||
1 Talisman of Curiosity
|
||||
1 Talisman of Impulse
|
||||
1 Warping Wail
|
||||
1 Talisman of Dominance
|
||||
1 Command Tower
|
||||
1 Ash Barrens
|
||||
1 Ancient Stirrings
|
||||
1 Ulamog's Crusher
|
||||
1 Garruk's Uprising
|
||||
1 Suffer the Past
|
||||
1 Skittering Invasion
|
||||
1 Path of Ancestry
|
||||
1 Unclaimed Territory
|
||||
1 Plains
|
||||
1 Island
|
||||
1 Swamp
|
||||
1 Mountain
|
||||
1 Forest
|
||||
@@ -0,0 +1,104 @@
|
||||
[metadata]
|
||||
Name=Eldrazi Incursion Collector's Edition
|
||||
[Commander]
|
||||
1 Ulalek, Fused Atrocity
|
||||
[Main]
|
||||
1 Azlask, the Swelling Scourge
|
||||
1 Ulamog's Dreadsire
|
||||
1 Benthic Anomaly
|
||||
1 Eldritch Immunity
|
||||
1 Selective Obliteration
|
||||
1 Angelic Aberration
|
||||
1 Bismuth Mindrender
|
||||
1 Hideous Taskmaster
|
||||
1 Twins of Discord
|
||||
1 Mutated Cultist
|
||||
1 Eldrazi Confluence
|
||||
1 Spawnbed Protector
|
||||
1 Chittering Dispatcher
|
||||
1 Inversion Behemoth
|
||||
1 Ugin, the Ineffable
|
||||
1 Eldrazi Conscription
|
||||
1 Cascading Cataracts
|
||||
1 Eldrazi Monument
|
||||
1 Sire of Stagnation
|
||||
1 Shrine of the Forsaken Gods
|
||||
1 Ugin's Insight
|
||||
1 Drowner of Hope
|
||||
1 Return of the Wildspeaker
|
||||
1 Tendo Ice Bridge
|
||||
1 Battlefield Forge
|
||||
1 Llanowar Wastes
|
||||
1 Brushland
|
||||
1 Underground River
|
||||
1 Oblivion Sower
|
||||
1 Adarkar Wastes
|
||||
1 Karplusan Forest
|
||||
1 Yavimaya Coast
|
||||
1 Sulfurous Springs
|
||||
1 Caves of Koilos
|
||||
1 Shivan Reef
|
||||
1 Imprisoned in the Moon
|
||||
1 Temple of Malady
|
||||
1 Temple of Silence
|
||||
1 Morophon, the Boundless
|
||||
1 Kozilek's Return
|
||||
1 World Breaker
|
||||
1 Ruins of Oran-Rief
|
||||
1 Sifter of Skulls
|
||||
1 Endbringer
|
||||
1 Corrupted Crossroads
|
||||
1 Deepfathom Skulker
|
||||
1 Vile Redeemer
|
||||
1 Eldrazi Displacer
|
||||
1 Mystic Forge
|
||||
1 All Is Dust
|
||||
1 Rishkar's Expertise
|
||||
1 Awakening Zone
|
||||
1 Eldrazi Temple
|
||||
1 Elder Deep-Fiend
|
||||
1 Bonders' Enclave
|
||||
1 Exotic Orchard
|
||||
1 Forsaken Monument
|
||||
1 Snapping Voidcraw
|
||||
1 Herald's Horn
|
||||
1 Everflowing Chalice
|
||||
1 Glaring Fleshraker
|
||||
1 Wastescape Battlemage
|
||||
1 Crib Swap
|
||||
1 Titans' Vanguard
|
||||
1 Talisman of Resilience
|
||||
1 Hedron Archive
|
||||
1 Spawning Bed
|
||||
1 Ulamog's Nullifier
|
||||
1 Twisted Landscape
|
||||
1 Tranquil Landscape
|
||||
1 Tectonic Edge
|
||||
1 Reliquary Tower
|
||||
1 Sol Ring
|
||||
1 Artisan of Kozilek
|
||||
1 Opal Palace
|
||||
1 Dreamstone Hedron
|
||||
1 Arcane Signet
|
||||
1 Tomb of the Spirit Dragon
|
||||
1 Secluded Courtyard
|
||||
1 Idol of Oblivion
|
||||
1 Wastes
|
||||
1 Talisman of Curiosity
|
||||
1 Talisman of Impulse
|
||||
1 Warping Wail
|
||||
1 Talisman of Dominance
|
||||
1 Command Tower
|
||||
1 Ash Barrens
|
||||
1 Ancient Stirrings
|
||||
1 Ulamog's Crusher
|
||||
1 Garruk's Uprising
|
||||
1 Suffer the Past
|
||||
1 Skittering Invasion
|
||||
1 Path of Ancestry
|
||||
1 Unclaimed Territory
|
||||
1 Plains
|
||||
1 Island
|
||||
1 Swamp
|
||||
1 Mountain
|
||||
1 Forest
|
||||
@@ -3,88 +3,89 @@ Name=Eldrazi Unbound
|
||||
[Commander]
|
||||
1 Zhulodok, Void Gorger
|
||||
[Main]
|
||||
1 Abstruse Archaic
|
||||
1 All Is Dust
|
||||
1 Ancient Stone Idol
|
||||
1 Arcane Lighthouse
|
||||
1 Arch of Orazca
|
||||
1 Artisan of Kozilek
|
||||
1 Bane of Bala Ged
|
||||
1 Blast Zone
|
||||
1 Bonders' Enclave
|
||||
1 Burnished Hart
|
||||
1 Calamity of the Titans
|
||||
1 Crashing Drawbridge
|
||||
1 Darksteel Monolith
|
||||
1 Desecrate Reality
|
||||
1 Dreamstone Hedron
|
||||
1 Duplicant
|
||||
1 Eldrazi Temple
|
||||
1 Endbringer
|
||||
1 Endless Atlas
|
||||
1 Endless One
|
||||
1 Everflowing Chalice
|
||||
1 Fireshrieker
|
||||
1 Flayer of Loyalties
|
||||
1 Forge of Heroes
|
||||
1 Forsaken Monument
|
||||
1 Geier Reach Sanitarium
|
||||
1 Geode Golem
|
||||
1 Guildless Commons
|
||||
1 Hangarback Walker
|
||||
1 Hedron Archive
|
||||
1 Investigator's Journal
|
||||
1 It That Betrays
|
||||
1 Kaldra Compleat
|
||||
1 Omarthis, Ghostfire Initiate
|
||||
1 Kozilek, the Great Distortion
|
||||
1 Lightning Greaves
|
||||
1 Mage-Ring Network
|
||||
1 Abstruse Archaic
|
||||
1 Calamity of the Titans
|
||||
1 Desecrate Reality
|
||||
1 Flayer of Loyalties
|
||||
1 Rise of the Eldrazi
|
||||
1 Skittering Cicada
|
||||
1 Ugin's Mastery
|
||||
1 Darksteel Monolith
|
||||
1 All Is Dust
|
||||
1 Endbringer
|
||||
1 Endless One
|
||||
1 It That Betrays
|
||||
1 Matter Reshaper
|
||||
1 Oblivion Sower
|
||||
1 Ugin, the Ineffable
|
||||
1 Ancient Stone Idol
|
||||
1 Duplicant
|
||||
1 Endless Atlas
|
||||
1 Forsaken Monument
|
||||
1 Hangarback Walker
|
||||
1 Investigator's Journal
|
||||
1 Kaldra Compleat
|
||||
1 Mazemind Tome
|
||||
1 Metalwork Colossus
|
||||
1 Meteor Golem
|
||||
1 Mind Stone
|
||||
1 Mirage Mirror
|
||||
1 Mirrorpool
|
||||
1 Myriad Construct
|
||||
1 Mystic Forge
|
||||
1 Not of This World
|
||||
1 Oblivion Sower
|
||||
1 Omarthis, Ghostfire Initiate
|
||||
1 Ornithopter of Paradise
|
||||
1 Palladium Myr
|
||||
1 Perilous Vault
|
||||
1 Phyrexian Triniform
|
||||
1 Reliquary Tower
|
||||
1 Rise of the Eldrazi
|
||||
1 Rogue's Passage
|
||||
1 Solemn Simulacrum
|
||||
1 Soul of New Phyrexia
|
||||
1 Steel Hellkite
|
||||
1 Stonecoil Serpent
|
||||
1 Transmogrifying Wand
|
||||
1 Arch of Orazca
|
||||
1 Blast Zone
|
||||
1 Bonders' Enclave
|
||||
1 Geier Reach Sanitarium
|
||||
1 Mirrorpool
|
||||
1 Ruins of Oran-Rief
|
||||
1 Scaretiller
|
||||
1 Scavenger Grounds
|
||||
1 Sea Gate Wreckage
|
||||
1 Shrine of the Forsaken Gods
|
||||
1 Skittering Cicada
|
||||
1 Tyrite Sanctum
|
||||
1 War Room
|
||||
1 Burnished Hart
|
||||
1 Geode Golem
|
||||
1 Lightning Greaves
|
||||
1 Meteor Golem
|
||||
1 Sol Ring
|
||||
1 Solemn Simulacrum
|
||||
1 Soul of New Phyrexia
|
||||
1 Spatial Contortion
|
||||
1 Steel Hellkite
|
||||
1 Stonecoil Serpent
|
||||
1 Suspicious Bookcase
|
||||
1 Temple of the False God
|
||||
1 Thought Vessel
|
||||
1 Thran Dynamo
|
||||
1 Titan's Presence
|
||||
1 Tomb of the Spirit Dragon
|
||||
1 Transmogrifying Wand
|
||||
1 Tyrite Sanctum
|
||||
1 Ugin's Mastery
|
||||
1 Ugin, the Ineffable
|
||||
1 Unstable Obelisk
|
||||
1 Reliquary Tower
|
||||
1 Rogue's Passage
|
||||
1 Artisan of Kozilek
|
||||
1 Bane of Bala Ged
|
||||
1 Not of This World
|
||||
1 Spatial Contortion
|
||||
1 Titan's Presence
|
||||
1 Warping Wail
|
||||
1 Crashing Drawbridge
|
||||
1 Dreamstone Hedron
|
||||
1 Everflowing Chalice
|
||||
1 Fireshrieker
|
||||
1 Hedron Archive
|
||||
1 Mind Stone
|
||||
1 Ornithopter of Paradise
|
||||
1 Palladium Myr
|
||||
1 Scaretiller
|
||||
1 Suspicious Bookcase
|
||||
1 Worn Powerstone
|
||||
1 Arcane Lighthouse
|
||||
1 Eldrazi Temple
|
||||
1 Forge of Heroes
|
||||
1 Guildless Commons
|
||||
1 Mage-Ring Network
|
||||
1 Temple of the False God
|
||||
1 Tomb of the Spirit Dragon
|
||||
1 Urza's Mine
|
||||
1 Urza's Power Plant
|
||||
1 Urza's Tower
|
||||
1 War Room
|
||||
1 Warping Wail
|
||||
1 Worn Powerstone
|
||||
15 Wastes
|
||||
8 Wastes
|
||||
7 Wastes
|
||||
@@ -0,0 +1,82 @@
|
||||
[metadata]
|
||||
Name=Elven Council
|
||||
[Commander]
|
||||
1 Galadriel, Elven-Queen
|
||||
[Main]
|
||||
1 Gandalf, Westward Voyager
|
||||
1 Raise the Palisade
|
||||
1 Trap the Trespassers
|
||||
1 Arwen, Weaver of Hope
|
||||
1 Galadhrim Ambush
|
||||
1 Haldir, Lórien Lieutenant
|
||||
1 Legolas Greenleaf
|
||||
1 Mirkwood Elk
|
||||
1 Travel Through Caradhras
|
||||
1 Windswift Slice
|
||||
1 Círdan the Shipwright
|
||||
1 Elrond of the White Council
|
||||
1 Erestor of the Council
|
||||
1 Mirkwood Trapper
|
||||
1 Radagast, Wizard of Wilds
|
||||
1 Sail into the West
|
||||
1 Song of Eärendil
|
||||
1 Lothlórien Blade
|
||||
1 Model of Unity
|
||||
1 Colossal Whale
|
||||
1 Devastation Tide
|
||||
1 Mystic Confluence
|
||||
1 Plea for Power
|
||||
1 Swan Song
|
||||
1 Asceticism
|
||||
1 Elvish Archdruid
|
||||
1 Elvish Piper
|
||||
1 Elvish Warmaster
|
||||
1 Genesis Wave
|
||||
1 Heroic Intervention
|
||||
1 Hornet Queen
|
||||
1 Inscription of Abundance
|
||||
1 Overwhelming Stampede
|
||||
1 Realm Seekers
|
||||
1 Seeds of Renewal
|
||||
1 Sylvan Offering
|
||||
1 Exotic Orchard
|
||||
1 Flooded Grove
|
||||
1 Hinterland Harbor
|
||||
1 Rejuvenating Springs
|
||||
1 Vineglimmer Snarl
|
||||
1 Lórien Revealed
|
||||
1 Celeborn the Wise
|
||||
1 Elven Farsight
|
||||
1 Wose Pathfinder
|
||||
1 Learn from the Past
|
||||
1 Opt
|
||||
1 Preordain
|
||||
1 Arbor Elf
|
||||
1 Beast Within
|
||||
1 Cultivate
|
||||
1 Elvish Mystic
|
||||
1 Elvish Visionary
|
||||
1 Farhaven Elf
|
||||
1 Mirror of Galadriel
|
||||
1 Lignify
|
||||
1 Paradise Druid
|
||||
1 Rampant Growth
|
||||
1 Reclamation Sage
|
||||
1 Wood Elves
|
||||
1 Growth Spiral
|
||||
1 Arcane Signet
|
||||
1 Commander's Sphere
|
||||
1 Lightning Greaves
|
||||
1 Sol Ring
|
||||
1 Whispersilk Cloak
|
||||
1 Ash Barrens
|
||||
1 Command Tower
|
||||
1 Field of Ruin
|
||||
1 Lonely Sandbar
|
||||
1 Thornwood Falls
|
||||
1 Tranquil Thicket
|
||||
1 Woodland Stream
|
||||
6 Island
|
||||
5 Island
|
||||
8 Forest
|
||||
7 Forest
|
||||
@@ -4,68 +4,38 @@ Name=Elven Empire
|
||||
1 Lathril, Blade of the Elves
|
||||
[Main]
|
||||
1 Abomination of Llanowar
|
||||
1 Ambition's Cost
|
||||
1 Arcane Signet
|
||||
1 Beast Whisperer
|
||||
1 Binding the Old Gods
|
||||
1 Bounty of Skemfar
|
||||
1 Canopy Tactician
|
||||
1 Casualties of War
|
||||
1 Command Tower
|
||||
1 Crown of Skemfar
|
||||
1 Cultivator of Blades
|
||||
1 Dwynen, Gilt-Leaf Daen
|
||||
1 Elderfang Ritualist
|
||||
1 Elderfang Venom
|
||||
1 Elven Ambush
|
||||
1 Elvish Archdruid
|
||||
1 Elvish Mystic
|
||||
1 Elvish Promenade
|
||||
1 Elvish Rejuvenator
|
||||
1 End-Raze Forerunners
|
||||
1 Eyeblight Cullers
|
||||
1 Eyeblight Massacre
|
||||
1 Farhaven Elf
|
||||
1 Foul Orchard
|
||||
1 Golgari Findbroker
|
||||
1 Golgari Guildgate
|
||||
1 Golgari Rot Farm
|
||||
1 Harald, King of Skemfar
|
||||
1 Harvest Season
|
||||
1 Imperious Perfect
|
||||
1 Jagged-Scar Archers
|
||||
1 Jaspera Sentinel
|
||||
1 Jungle Hollow
|
||||
1 Llanowar Tribe
|
||||
1 Lys Alana Huntmaster
|
||||
1 Lys Alana Scarblade
|
||||
1 Marwyn, the Nurturer
|
||||
1 Masked Admirers
|
||||
1 Miara, Thorn of the Glade
|
||||
1 Moldervine Reclamation
|
||||
1 Myriad Landscape
|
||||
1 Nullmage Shepherd
|
||||
1 Numa, Joraga Chieftain
|
||||
1 Pact of the Serpent
|
||||
1 Path of Ancestry
|
||||
1 Poison the Cup
|
||||
1 Poison-Tip Archer
|
||||
1 Pride of the Perfect
|
||||
1 Prowess of the Fair
|
||||
1 Putrefy
|
||||
1 Reclamation Sage
|
||||
1 Return Upon the Tide
|
||||
1 Rhys the Exiled
|
||||
1 Roots of Wisdom
|
||||
1 Ruthless Winnower
|
||||
1 Serpent's Soul-Jar
|
||||
1 Shaman of the Pack
|
||||
1 Skemfar Elderhall
|
||||
1 Skemfar Shadowsage
|
||||
1 Sol Ring
|
||||
1 Springbloom Druid
|
||||
1 Sylvan Messenger
|
||||
1 Tergrid's Shadow
|
||||
1 Timberwatch Elf
|
||||
1 Twinblade Assassins
|
||||
1 Voice of Many
|
||||
@@ -73,6 +43,35 @@ Name=Elven Empire
|
||||
1 Wirewood Channeler
|
||||
1 Wolverine Riders
|
||||
1 Wood Elves
|
||||
1 Elven Ambush
|
||||
1 Poison the Cup
|
||||
1 Putrefy
|
||||
1 Tergrid's Shadow
|
||||
1 Ambition's Cost
|
||||
1 Bounty of Skemfar
|
||||
1 Casualties of War
|
||||
1 Eyeblight Massacre
|
||||
1 Elvish Promenade
|
||||
1 Harvest Season
|
||||
1 Pact of the Serpent
|
||||
1 Return Upon the Tide
|
||||
1 Roots of Wisdom
|
||||
1 Arcane Signet
|
||||
1 Serpent's Soul-Jar
|
||||
1 Sol Ring
|
||||
1 Binding the Old Gods
|
||||
1 Crown of Skemfar
|
||||
1 Elderfang Venom
|
||||
1 Pride of the Perfect
|
||||
1 Prowess of the Fair
|
||||
1 Moldervine Reclamation
|
||||
1 Command Tower
|
||||
14 Swamp
|
||||
14 Forest
|
||||
16 Forest
|
||||
1 Foul Orchard
|
||||
1 Golgari Guildgate
|
||||
1 Golgari Rot Farm
|
||||
1 Jungle Hollow
|
||||
1 Myriad Landscape
|
||||
1 Path of Ancestry
|
||||
1 Skemfar Elderhall
|
||||
13 Swamp
|
||||
@@ -0,0 +1,91 @@
|
||||
[metadata]
|
||||
Name=Enduring Enchantments
|
||||
[Commander]
|
||||
1 Anikthea, Hand of Erebos
|
||||
[Main]
|
||||
1 Narci, Fable Singer
|
||||
1 Battle at the Helvault
|
||||
1 Boon of the Spirit Realm
|
||||
1 Ondu Spiritdancer
|
||||
1 Cacophony Unleashed
|
||||
1 Demon of Fate's Design
|
||||
1 Ghoulish Impetus
|
||||
1 Composer of Spring
|
||||
1 Nyxborn Behemoth
|
||||
1 Archon of Sun's Grace
|
||||
1 Felidar Retreat
|
||||
1 Grasp of Fate
|
||||
1 Heliod, God of the Sun
|
||||
1 Mesa Enchantress
|
||||
1 Sigil of the Empty Throne
|
||||
1 Starfield Mystic
|
||||
1 Starfield of Nyx
|
||||
1 Cunning Rhetoric
|
||||
1 Doomwake Giant
|
||||
1 Dreadhorde Invasion
|
||||
1 Erebos, Bleak-Hearted
|
||||
1 Abundance
|
||||
1 Arasta of the Endless Web
|
||||
1 Courser of Kruphix
|
||||
1 Dryad of the Ilysian Grove
|
||||
1 Eidolon of Blossoms
|
||||
1 Enchantress's Presence
|
||||
1 Herald of the Pantheon
|
||||
1 The Mending of Dominaria
|
||||
1 Sanctum Weaver
|
||||
1 Sandwurm Convergence
|
||||
1 Setessan Champion
|
||||
1 Verduran Enchantress
|
||||
1 Battle for Bretagard
|
||||
1 Calix, Destiny's Hand
|
||||
1 Culling Ritual
|
||||
1 Mirari's Wake
|
||||
1 Sythis, Harvest's Hand
|
||||
1 Canopy Vista
|
||||
1 Exotic Orchard
|
||||
1 Fortified Village
|
||||
1 Necroblossom Snarl
|
||||
1 Shineshadow Snarl
|
||||
1 Sungrass Prairie
|
||||
1 Temple of Malady
|
||||
1 Temple of Plenty
|
||||
1 Temple of Silence
|
||||
1 Path to Exile
|
||||
1 Extinguish All Hope
|
||||
1 Kodama's Reach
|
||||
1 Arcane Signet
|
||||
1 Sol Ring
|
||||
1 Ash Barrens
|
||||
1 Command Tower
|
||||
1 Cast Out
|
||||
1 Love Song of Night and Day
|
||||
1 Omen of the Sun
|
||||
1 Spirited Companion
|
||||
1 The Eldest Reborn
|
||||
1 Mindwrack Harpy
|
||||
1 The Binding of the Titans
|
||||
1 Destiny Spinner
|
||||
1 Farseek
|
||||
1 Font of Fertility
|
||||
1 Greater Tanuki
|
||||
1 Khalni Heart Expedition
|
||||
1 Nessian Wanderer
|
||||
1 Omen of the Hunt
|
||||
1 Rampant Growth
|
||||
1 Binding the Old Gods
|
||||
1 Jukai Naturalist
|
||||
1 Nyx Weaver
|
||||
1 Satyr Enchanter
|
||||
1 Golgari Rot Farm
|
||||
1 Krosan Verge
|
||||
1 Orzhov Basilica
|
||||
1 Sandsteppe Citadel
|
||||
1 Selesnya Sanctuary
|
||||
1 Tainted Field
|
||||
1 Tainted Wood
|
||||
3 Plains
|
||||
3 Plains
|
||||
3 Swamp
|
||||
2 Swamp
|
||||
4 Forest
|
||||
4 Forest
|
||||
@@ -0,0 +1,94 @@
|
||||
[metadata]
|
||||
Name=Enhanced Evolution
|
||||
[Commander]
|
||||
1 Otrimi, the Ever-Playful
|
||||
[Main]
|
||||
1 Nissa, Steward of Elements
|
||||
1 Zaxara, the Exemplary
|
||||
1 Cazur, Ruthless Stalker
|
||||
1 Ukkima, Stalking Shadow
|
||||
1 Souvenir Snatcher
|
||||
1 Tidal Barracuda
|
||||
1 Boneyard Mycodrax
|
||||
1 Mindleecher
|
||||
1 Capricopian
|
||||
1 Sawtusk Demolisher
|
||||
1 Beast Whisperer
|
||||
1 Genesis Hydra
|
||||
1 Hungering Hydra
|
||||
1 Masked Admirers
|
||||
1 Predator Ooze
|
||||
1 Vastwood Hydra
|
||||
1 Vorapede
|
||||
1 Cold-Eyed Selkie
|
||||
1 Wydwen, the Biting Gale
|
||||
1 Silent Arbiter
|
||||
1 Archipelagore
|
||||
1 Dreamtail Heron
|
||||
1 Pouncing Shoreshark
|
||||
1 Cavern Whisperer
|
||||
1 Chittering Harvester
|
||||
1 Insatiable Hemophage
|
||||
1 Auspicious Starrix
|
||||
1 Fertilid
|
||||
1 Glowstone Recluse
|
||||
1 Migratory Greathorn
|
||||
1 Boneyard Lurker
|
||||
1 Trumpeting Gnarr
|
||||
1 Illusory Ambusher
|
||||
1 Mulldrifter
|
||||
1 Shriekmaw
|
||||
1 Heroes' Bane
|
||||
1 Reclamation Sage
|
||||
1 Yavimaya Dryad
|
||||
1 Trygon Predator
|
||||
1 Dredge the Mire
|
||||
1 Mind Spring
|
||||
1 Deadly Tempest
|
||||
1 Profane Command
|
||||
1 Animist's Awakening
|
||||
1 Find // Finality
|
||||
1 Gaze of Granite
|
||||
1 Villainous Wealth
|
||||
1 Migration Path
|
||||
1 Kodama's Reach
|
||||
1 Deadly Rollick
|
||||
1 Beast Within
|
||||
1 Krosan Grip
|
||||
1 Putrefy
|
||||
1 Manascape Refractor
|
||||
1 Lifecrafter's Bestiary
|
||||
1 Bonder's Ornament
|
||||
1 Arcane Signet
|
||||
1 Sol Ring
|
||||
1 Psychic Impetus
|
||||
1 Parasitic Impetus
|
||||
1 Predatory Impetus
|
||||
1 Propaganda
|
||||
1 Darkwater Catacombs
|
||||
1 Endless Sands
|
||||
1 Exotic Orchard
|
||||
1 Llanowar Wastes
|
||||
1 Sunken Hollow
|
||||
1 Blighted Woodland
|
||||
1 Command Tower
|
||||
1 Dimir Aqueduct
|
||||
1 Golgari Rot Farm
|
||||
1 Mortuary Mire
|
||||
1 Myriad Landscape
|
||||
1 Opulent Palace
|
||||
1 Rogue's Passage
|
||||
1 Simic Growth Chamber
|
||||
1 Soaring Seacliff
|
||||
1 Temple of the False God
|
||||
1 Dismal Backwater
|
||||
1 Jungle Hollow
|
||||
1 Thornwood Falls
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
1 Swamp
|
||||
4 Forest
|
||||
4 Forest
|
||||
3 Forest
|
||||
1 Island
|
||||
1 Island
|
||||
@@ -0,0 +1,96 @@
|
||||
[metadata]
|
||||
Name=Entropic Uprising
|
||||
[Commander]
|
||||
1 Yidris, Maelstrom Wielder
|
||||
[Main]
|
||||
1 Satyr Wayfinder
|
||||
1 Wall of Blossoms
|
||||
1 Coiling Oracle
|
||||
1 Thrasios, Triton Hero
|
||||
1 Goblin Spymaster
|
||||
1 Spellheart Chimera
|
||||
1 Vial Smasher the Fierce
|
||||
1 Academy Elite
|
||||
1 Gamekeeper
|
||||
1 Sangromancer
|
||||
1 Bloodbraid Elf
|
||||
1 Horizon Chimera
|
||||
1 Kydele, Chosen of Kruphix
|
||||
1 Glint-Eye Nephilim
|
||||
1 Aeon Chronicler
|
||||
1 Guiltfeeder
|
||||
1 Consuming Aberration
|
||||
1 Nath of the Gilt-Leaf
|
||||
1 Runehorn Hellkite
|
||||
1 Etherium-Horn Sorcerer
|
||||
1 Dragon Mage
|
||||
1 Blood Tyrant
|
||||
1 Wheel of Fate
|
||||
1 Windfall
|
||||
1 Parting Thoughts
|
||||
1 Far Wanderings
|
||||
1 Past in Flames
|
||||
1 Whispering Madness
|
||||
1 Decimate
|
||||
1 Devastation Tide
|
||||
1 Reforge the Soul
|
||||
1 Worm Harvest
|
||||
1 Spelltwine
|
||||
1 Whims of the Fates
|
||||
1 Grave Upheaval
|
||||
1 Cruel Entertainment
|
||||
1 Ghastly Conscription
|
||||
1 Volcanic Vision
|
||||
1 Treasure Cruise
|
||||
1 Treacherous Terrain
|
||||
1 Army of the Damned
|
||||
1 In Garruk's Wake
|
||||
1 Chain of Vapor
|
||||
1 Rakdos Charm
|
||||
1 Chaos Warp
|
||||
1 Ancient Excavation
|
||||
1 Evacuation
|
||||
1 Bituminous Blast
|
||||
1 Curtains' Call
|
||||
1 Sol Ring
|
||||
1 Fellwar Stone
|
||||
1 Rakdos Signet
|
||||
1 Simic Signet
|
||||
1 Chromatic Lantern
|
||||
1 Commander's Sphere
|
||||
1 Whispersilk Cloak
|
||||
1 Boompile
|
||||
1 Burgeoning
|
||||
1 Waste Not
|
||||
1 Frenzied Fugue
|
||||
1 Ash Barrens
|
||||
1 Command Tower
|
||||
1 Crumbling Necropolis
|
||||
1 Dismal Backwater
|
||||
1 Evolving Wilds
|
||||
1 Exotic Orchard
|
||||
1 Frontier Bivouac
|
||||
1 Jungle Hollow
|
||||
1 Opulent Palace
|
||||
1 Rakdos Carnarium
|
||||
1 Reliquary Tower
|
||||
1 Rugged Highlands
|
||||
1 Rupture Spire
|
||||
1 Savage Lands
|
||||
1 Shadowblood Ridge
|
||||
1 Simic Growth Chamber
|
||||
1 Swiftwater Cliffs
|
||||
1 Terramorphic Expanse
|
||||
1 Thornwood Falls
|
||||
2 Island
|
||||
2 Island
|
||||
1 Island
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
1 Swamp
|
||||
2 Mountain
|
||||
2 Mountain
|
||||
1 Mountain
|
||||
2 Forest
|
||||
2 Forest
|
||||
1 Forest
|
||||
@@ -0,0 +1,92 @@
|
||||
[metadata]
|
||||
Name=Eternal Bargain
|
||||
[Commander]
|
||||
1 Oloro, Ageless Ascetic
|
||||
[Main]
|
||||
1 Ajani's Pridemate
|
||||
1 Augury Adept
|
||||
1 Azorius Herald
|
||||
1 Disciple of Griselbrand
|
||||
1 Diviner Spirit
|
||||
1 Divinity of Pride
|
||||
1 Filigree Angel
|
||||
1 Hooded Horror
|
||||
1 Kongming, "Sleeping Dragon"
|
||||
1 Marrow Bats
|
||||
1 Myr Battlesphere
|
||||
1 Phyrexian Delver
|
||||
1 Phyrexian Gargantua
|
||||
1 Raven Familiar
|
||||
1 Razor Hippogriff
|
||||
1 Serene Master
|
||||
1 Serra Avatar
|
||||
1 Sharding Sphinx
|
||||
1 Sharuum the Hegemon
|
||||
1 Sphinx of the Steel Wind
|
||||
1 Stormscape Battlemage
|
||||
1 Sydri, Galvanic Genius
|
||||
1 Tidal Force
|
||||
1 Tidehollow Strix
|
||||
1 Tower Gargoyle
|
||||
1 Vizkopa Guildmage
|
||||
1 Wall of Reverence
|
||||
1 Brilliant Plan
|
||||
1 Death Grasp
|
||||
1 Deep Analysis
|
||||
1 Famine
|
||||
1 Order of Succession
|
||||
1 Survival Cache
|
||||
1 Tempt with Immortality
|
||||
1 Toxic Deluge
|
||||
1 Dromar's Charm
|
||||
1 Lim-Dûl's Vault
|
||||
1 Reckless Spite
|
||||
1 Spinal Embrace
|
||||
1 Crawlspace
|
||||
1 Nevinyrral's Disk
|
||||
1 Nihil Spellbomb
|
||||
1 Obelisk of Esper
|
||||
1 Pristine Talisman
|
||||
1 Sol Ring
|
||||
1 Sun Droplet
|
||||
1 Swiftfoot Boots
|
||||
1 Thopter Foundry
|
||||
1 Well of Lost Dreams
|
||||
1 Act of Authority
|
||||
1 Cradle of Vitality
|
||||
1 Curse of Inertia
|
||||
1 Curse of Shallow Graves
|
||||
1 Curse of the Forsaken
|
||||
1 Darksteel Mutation
|
||||
1 Greed
|
||||
1 Phyrexian Reclamation
|
||||
1 Sanguine Bond
|
||||
1 Arcane Sanctum
|
||||
1 Azorius Chancery
|
||||
1 Azorius Guildgate
|
||||
1 Barren Moor
|
||||
1 Command Tower
|
||||
1 Dimir Guildgate
|
||||
1 Esper Panorama
|
||||
1 Evolving Wilds
|
||||
2 Island
|
||||
2 Island
|
||||
1 Island
|
||||
1 Island
|
||||
1 Jwar Isle Refuge
|
||||
1 Lonely Sandbar
|
||||
1 Opal Palace
|
||||
1 Orzhov Basilica
|
||||
1 Orzhov Guildgate
|
||||
3 Plains
|
||||
2 Plains
|
||||
2 Plains
|
||||
2 Plains
|
||||
1 Rupture Spire
|
||||
1 Springjack Pasture
|
||||
3 Swamp
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
2 Swamp
|
||||
1 Temple of the False God
|
||||
1 Transguild Promenade
|
||||
@@ -0,0 +1,93 @@
|
||||
[metadata]
|
||||
Name=Eternal Might
|
||||
[Commander]
|
||||
1 Temmet, Naktamun's Will
|
||||
[Main]
|
||||
1 Hashaton, Scarab's Fist
|
||||
1 On Wings of Gold
|
||||
1 Priest of the Crossing
|
||||
1 Renewed Solidarity
|
||||
1 Wizened Mentor
|
||||
1 Prophet of the Scarab
|
||||
1 Rhet-Tomb Mystic
|
||||
1 Lost Monarch of Ifnir
|
||||
1 Accursed Duneyard
|
||||
1 Commence the Endgame
|
||||
1 Cryptbreaker
|
||||
1 Grave Titan
|
||||
1 Gravecrawler
|
||||
1 Midnight Reaper
|
||||
1 Murderous Rider // Swift End
|
||||
1 Zombie Master
|
||||
1 Angel of Sanctions
|
||||
1 Dusk // Dawn
|
||||
1 God-Eternal Oketra
|
||||
1 Timeless Dragon
|
||||
1 Champion of Wits
|
||||
1 Forgotten Creation
|
||||
1 Pull from Tomorrow
|
||||
1 Vizier of Many Faces
|
||||
1 Archfiend of Ifnir
|
||||
1 Cemetery Reaper
|
||||
1 Crowded Crypt
|
||||
1 Damn
|
||||
1 Dread Summons
|
||||
1 Dreadhorde Invasion
|
||||
1 Liliana, Death's Majesty
|
||||
1 Never // Return
|
||||
1 Plague Belcher
|
||||
1 Rot Hulk
|
||||
1 The Scarab God
|
||||
1 God-Pharaoh's Gift
|
||||
1 Maskwood Nexus
|
||||
1 Adarkar Wastes
|
||||
1 Caves of Koilos
|
||||
1 Drowned Catacomb
|
||||
1 Exotic Orchard
|
||||
1 Fetid Pools
|
||||
1 Glacial Fortress
|
||||
1 Irrigated Farmland
|
||||
1 Isolated Chapel
|
||||
1 Prairie Stream
|
||||
1 Sunken Hollow
|
||||
1 Temple of Deceit
|
||||
1 Temple of Silence
|
||||
1 Underground River
|
||||
1 Unholy Grotto
|
||||
1 Swords to Plowshares
|
||||
1 Corpse Augur
|
||||
1 Corpse Knight
|
||||
1 Arcane Signet
|
||||
1 Sol Ring
|
||||
1 Command Tower
|
||||
1 Path of Ancestry
|
||||
1 Binding Mummy
|
||||
1 Cast Out
|
||||
1 Eternal Skylord
|
||||
1 Fleshbag Marauder
|
||||
1 Gempalm Polluter
|
||||
1 Lord of the Accursed
|
||||
1 Twisted Abomination
|
||||
1 Undead Augur
|
||||
1 Despark
|
||||
1 Gleaming Overseer
|
||||
1 Lazotep Chancellor
|
||||
1 Wayward Servant
|
||||
1 Bontu's Monument
|
||||
1 Commander's Sphere
|
||||
1 Dimir Signet
|
||||
1 Gate to the Afterlife
|
||||
1 Orzhov Signet
|
||||
1 Talisman of Dominance
|
||||
1 Talisman of Hierarchy
|
||||
1 Arcane Sanctum
|
||||
1 Ash Barrens
|
||||
1 Desert of the Glorified
|
||||
1 Desert of the Mindful
|
||||
1 Desert of the True
|
||||
1 Evolving Wilds
|
||||
1 Orzhov Basilica
|
||||
1 Terramorphic Expanse
|
||||
5 Plains
|
||||
4 Island
|
||||
5 Swamp
|
||||
@@ -0,0 +1,96 @@
|
||||
[metadata]
|
||||
Name=Evasive Maneuvers
|
||||
[Commander]
|
||||
1 Derevi, Empyrial Tactician
|
||||
[Main]
|
||||
1 Acidic Slime
|
||||
1 Aerie Mystics
|
||||
1 Angel of Finality
|
||||
1 Azami, Lady of Scrolls
|
||||
1 Bane of Progress
|
||||
1 Deceiver Exarch
|
||||
1 Diviner Spirit
|
||||
1 Djinn of Infinite Deceits
|
||||
1 Dungeon Geists
|
||||
1 Farhaven Elf
|
||||
1 Fiend Hunter
|
||||
1 Flickerwisp
|
||||
1 Hada Spy Patrol
|
||||
1 Karmic Guide
|
||||
1 Kazandu Tuskcaller
|
||||
1 Lu Xun, Scholar General
|
||||
1 Mirror Entity
|
||||
1 Mistmeadow Witch
|
||||
1 Murkfiend Liege
|
||||
1 Phantom Nantuko
|
||||
1 Pilgrim's Eye
|
||||
1 Roon of the Hidden Realm
|
||||
1 Rubinia Soulsinger
|
||||
1 Selesnya Guildmage
|
||||
1 Skyward Eye Prophets
|
||||
1 Stonecloaker
|
||||
1 Thornwind Faeries
|
||||
1 Winged Coatl
|
||||
1 Wonder
|
||||
1 Borrowing 100,000 Arrows
|
||||
1 Kirtar's Wrath
|
||||
1 Restore
|
||||
1 Tempt with Glory
|
||||
1 Wash Out
|
||||
1 Aethermage's Touch
|
||||
1 Arcane Denial
|
||||
1 Blue Sun's Zenith
|
||||
1 Krosan Grip
|
||||
1 Selesnya Charm
|
||||
1 Unexpectedly Absent
|
||||
1 Azorius Keyrune
|
||||
1 Basalt Monolith
|
||||
1 Conjurer's Closet
|
||||
1 Darksteel Ingot
|
||||
1 Leonin Bladetrap
|
||||
1 Selesnya Signet
|
||||
1 Simic Signet
|
||||
1 Sol Ring
|
||||
1 Surveyor's Scope
|
||||
1 Swiftfoot Boots
|
||||
1 Sword of the Paruns
|
||||
1 Thousand-Year Elixir
|
||||
1 Thunderstaff
|
||||
1 Control Magic
|
||||
1 Curse of Inertia
|
||||
1 Curse of Predation
|
||||
1 Curse of the Forsaken
|
||||
1 Darksteel Mutation
|
||||
1 Flickerform
|
||||
1 Leafdrake Roost
|
||||
1 Presence of Gond
|
||||
1 Azorius Chancery
|
||||
1 Azorius Guildgate
|
||||
1 Bant Panorama
|
||||
1 Command Tower
|
||||
1 Evolving Wilds
|
||||
1 Faerie Conclave
|
||||
2 Forest
|
||||
2 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
2 Island
|
||||
2 Island
|
||||
2 Island
|
||||
1 Island
|
||||
1 Opal Palace
|
||||
2 Plains
|
||||
2 Plains
|
||||
2 Plains
|
||||
1 Plains
|
||||
1 Rupture Spire
|
||||
1 Saltcrusted Steppe
|
||||
1 Seaside Citadel
|
||||
1 Secluded Steppe
|
||||
1 Sejiri Refuge
|
||||
1 Selesnya Guildgate
|
||||
1 Selesnya Sanctuary
|
||||
1 Simic Guildgate
|
||||
1 Temple of the False God
|
||||
1 Terramorphic Expanse
|
||||
1 Transguild Promenade
|
||||
@@ -0,0 +1,101 @@
|
||||
[metadata]
|
||||
Name=Evasive Maneuvers
|
||||
[Commander]
|
||||
1 Derevi, Empyrial Tactician
|
||||
[Main]
|
||||
1 Acidic Slime
|
||||
1 Aerie Mystics
|
||||
1 Angel of Finality
|
||||
1 Azami, Lady of Scrolls
|
||||
1 Bane of Progress
|
||||
1 Deceiver Exarch
|
||||
1 Diviner Spirit
|
||||
1 Djinn of Infinite Deceits
|
||||
1 Dungeon Geists
|
||||
1 Farhaven Elf
|
||||
1 Fiend Hunter
|
||||
1 Flickerwisp
|
||||
1 Hada Spy Patrol
|
||||
1 Karmic Guide
|
||||
1 Kazandu Tuskcaller
|
||||
1 Lu Xun, Scholar General
|
||||
1 Mirror Entity
|
||||
1 Mistmeadow Witch
|
||||
1 Murkfiend Liege
|
||||
1 Phantom Nantuko
|
||||
1 Pilgrim's Eye
|
||||
1 Roon of the Hidden Realm
|
||||
1 Rubinia Soulsinger
|
||||
1 Selesnya Guildmage
|
||||
1 Skyward Eye Prophets
|
||||
1 Stonecloaker
|
||||
1 Thornwind Faeries
|
||||
1 Winged Coatl
|
||||
1 Wonder
|
||||
1 Borrowing 100,000 Arrows
|
||||
1 Kirtar's Wrath
|
||||
1 Restore
|
||||
1 Tempt with Glory
|
||||
1 Wash Out
|
||||
1 Aethermage's Touch
|
||||
1 Arcane Denial
|
||||
1 Blue Sun's Zenith
|
||||
1 Krosan Grip
|
||||
1 Selesnya Charm
|
||||
1 Unexpectedly Absent
|
||||
1 Azorius Keyrune
|
||||
1 Basalt Monolith
|
||||
1 Conjurer's Closet
|
||||
1 Darksteel Ingot
|
||||
1 Leonin Bladetrap
|
||||
1 Selesnya Signet
|
||||
1 Simic Signet
|
||||
1 Sol Ring
|
||||
1 Surveyor's Scope
|
||||
1 Swiftfoot Boots
|
||||
1 Sword of the Paruns
|
||||
1 Thousand-Year Elixir
|
||||
1 Thunderstaff
|
||||
1 Control Magic
|
||||
1 Curse of Inertia
|
||||
1 Curse of Predation
|
||||
1 Curse of the Forsaken
|
||||
1 Darksteel Mutation
|
||||
1 Flickerform
|
||||
1 Leafdrake Roost
|
||||
1 Presence of Gond
|
||||
1 Azorius Chancery
|
||||
1 Azorius Guildgate
|
||||
1 Bant Panorama
|
||||
1 Command Tower
|
||||
1 Evolving Wilds
|
||||
1 Faerie Conclave
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
2 Island
|
||||
2 Island
|
||||
2 Island
|
||||
1 Island
|
||||
1 Opal Palace
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Rupture Spire
|
||||
1 Saltcrusted Steppe
|
||||
1 Seaside Citadel
|
||||
1 Secluded Steppe
|
||||
1 Sejiri Refuge
|
||||
1 Selesnya Guildgate
|
||||
1 Selesnya Sanctuary
|
||||
1 Simic Guildgate
|
||||
1 Temple of the False God
|
||||
1 Terramorphic Expanse
|
||||
1 Transguild Promenade
|
||||
@@ -0,0 +1,104 @@
|
||||
[metadata]
|
||||
Name=Everyone's Invited!
|
||||
[Commander]
|
||||
1 Morophon, the Boundless
|
||||
[Main]
|
||||
1 Raise the Palisade
|
||||
1 Bitterblossom
|
||||
1 Taurean Mauler
|
||||
1 Avenger of Zendikar
|
||||
1 Coat of Arms
|
||||
1 Kindred Summons
|
||||
1 Maskwood Nexus
|
||||
1 Sol Ring
|
||||
1 Tendershoot Dryad
|
||||
1 Adarkar Wastes
|
||||
1 Amoeboid Changeling
|
||||
1 Ancient Amphitheater
|
||||
1 Arcane Adaptation
|
||||
1 Arcane Denial
|
||||
1 Arcane Sanctum
|
||||
1 Arcane Signet
|
||||
1 Atla Palani, Nest Tender
|
||||
1 Beast Within
|
||||
1 Black Market Connections
|
||||
1 Bloodline Pretender
|
||||
1 Brenard, Ginger Sculptor
|
||||
1 Bruse Tarl, Roving Rancher
|
||||
1 Brushland
|
||||
1 Chameleon Colossus
|
||||
1 Cloudshredder Sliver
|
||||
1 Command Tower
|
||||
1 Crib Swap
|
||||
1 Cultivate
|
||||
1 Darkwater Catacombs
|
||||
1 Distant Melody
|
||||
1 Double Down
|
||||
1 Exotic Orchard
|
||||
1 Fabled Passage
|
||||
1 Farseek
|
||||
1 Feline Sovereign
|
||||
1 Fire-Belly Changeling
|
||||
1 Frontier Bivouac
|
||||
1 Gemhide Sliver
|
||||
1 Gilt-Leaf Palace
|
||||
1 Graveshifter
|
||||
1 Guardian Gladewalker
|
||||
1 Harabaz Druid
|
||||
1 Harper Recruiter
|
||||
1 Impostor of the Sixth Pride
|
||||
1 Jungle Shrine
|
||||
1 Karplusan Forest
|
||||
1 Kindred Discovery
|
||||
1 Kindred Dominance
|
||||
1 Kinsbaile Cavalier
|
||||
1 Kirri, Talented Sprout
|
||||
1 Kodama's Reach
|
||||
1 Llanowar Wastes
|
||||
1 Magda, Brazen Outlaw
|
||||
1 Manaweft Sliver
|
||||
1 Masked Vandal
|
||||
1 Mirror Entity
|
||||
1 Moritte of the Frost
|
||||
1 Mossfire Valley
|
||||
1 Mothdust Changeling
|
||||
1 Murmuring Bosk
|
||||
1 Mutavault
|
||||
1 Nameless Inversion
|
||||
1 Opulent Palace
|
||||
1 Overflowing Basin
|
||||
1 Path of Ancestry
|
||||
1 Pongify
|
||||
1 Realmbreaker, the Invasion Tree
|
||||
1 Realmwalker
|
||||
1 Rin and Seri, Inseparable
|
||||
1 Risen Reef
|
||||
1 Rukarumel, Biologist
|
||||
1 Seaside Citadel
|
||||
1 Secluded Glen
|
||||
1 Shapesharer
|
||||
1 Shields of Velis Vel
|
||||
1 Skeletal Changeling
|
||||
1 Sophia, Dogged Detective
|
||||
1 Spoils of Adventure
|
||||
1 Stick Together
|
||||
1 Sungrass Prairie
|
||||
1 Tazri, Beacon of Unity
|
||||
1 The Bears of Littjara
|
||||
1 The World Tree
|
||||
1 Universal Automaton
|
||||
1 Unsettled Mariner
|
||||
1 Wanderwine Hub
|
||||
1 Yavimaya Coast
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Forest
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Plains
|
||||
1 Mountain
|
||||
1 Mountain
|
||||
1 Island
|
||||
1 Island
|
||||
1 Island
|
||||
1 Swamp
|
||||
@@ -0,0 +1,89 @@
|
||||
[metadata]
|
||||
Name=Exit from Exile
|
||||
[Commander]
|
||||
1 Faldorn, Dread Wolf Herald
|
||||
[Main]
|
||||
1 Vivien, Champion of the Wilds
|
||||
1 Xenagos, the Reveler
|
||||
1 Aurora Phoenix
|
||||
1 Bonecrusher Giant // Stomp
|
||||
1 Dire Fleet Daredevil
|
||||
1 Dream Pillager
|
||||
1 Etali, Primal Storm
|
||||
1 Greater Gargadon
|
||||
1 Izzet Chemister
|
||||
1 Laelia, the Blade Reforged
|
||||
1 Tectonic Giant
|
||||
1 Urabrask the Hidden
|
||||
1 Wild-Magic Sorcerer
|
||||
1 Arasta of the Endless Web
|
||||
1 Battle Mammoth
|
||||
1 End-Raze Forerunners
|
||||
1 Hornet Queen
|
||||
1 Lovestruck Beast // Heart's Desire
|
||||
1 Managorger Hydra
|
||||
1 Sweet-Gum Recluse
|
||||
1 Embereth Shieldbreaker // Battle Display
|
||||
1 Sakura-Tribe Elder
|
||||
1 Bloodbraid Elf
|
||||
1 Grumgully, the Generous
|
||||
1 Ignite the Future
|
||||
1 Jeska's Will
|
||||
1 Mizzium Mortars
|
||||
1 Ezuri's Predation
|
||||
1 Escape to the Wilds
|
||||
1 Nature's Lore
|
||||
1 Light Up the Stage
|
||||
1 Beanstalk Giant // Fertile Footsteps
|
||||
1 Cultivate
|
||||
1 Explore
|
||||
1 Kodama's Reach
|
||||
1 Search for Tomorrow
|
||||
1 Terramorph
|
||||
1 Three Visits
|
||||
1 Return of the Wildspeaker
|
||||
1 Demon Bolt
|
||||
1 Beast Within
|
||||
1 Natural Reclamation
|
||||
1 Chaos Wand
|
||||
1 Arcane Signet
|
||||
1 Sol Ring
|
||||
1 Outpost Siege
|
||||
1 Stolen Strategy
|
||||
1 Warstorm Surge
|
||||
1 Primeval Bounty
|
||||
1 Sandwurm Convergence
|
||||
1 Castle Embereth
|
||||
1 Cinder Glade
|
||||
1 Game Trail
|
||||
1 Kessig Wolf Run
|
||||
1 Mossfire Valley
|
||||
1 Mosswort Bridge
|
||||
1 Raging Ravine
|
||||
1 Spinerock Knoll
|
||||
1 Temple of Abandon
|
||||
1 Command Tower
|
||||
1 Ash Barrens
|
||||
1 Blighted Woodland
|
||||
1 Gruul Turf
|
||||
1 Highland Forest
|
||||
1 Myriad Landscape
|
||||
1 Temple of the False God
|
||||
3 Mountain
|
||||
3 Mountain
|
||||
3 Mountain
|
||||
2 Mountain
|
||||
3 Forest
|
||||
3 Forest
|
||||
3 Forest
|
||||
3 Forest
|
||||
1 Durnan of the Yawning Portal
|
||||
1 Passionate Archaeologist
|
||||
1 Delayed Blast Fireball
|
||||
1 Nalfeshnee
|
||||
1 Green Slime
|
||||
1 Journey to the Lost City
|
||||
1 Tlincalli Hunter // Retrieve Prey
|
||||
1 Venture Forth
|
||||
1 Sarevok's Tome
|
||||
1 Volcanic Torrent
|
||||
@@ -3,84 +3,86 @@ Name=Explorers of the Deep
|
||||
[Commander]
|
||||
1 Hakbal of the Surging Soul
|
||||
[Main]
|
||||
1 Aetherize
|
||||
1 Alchemist's Refuge
|
||||
1 Arcane Signet
|
||||
1 Beast Within
|
||||
1 Benthic Biomancer
|
||||
1 Branching Evolution
|
||||
1 Bygone Marvels
|
||||
1 Cold-Eyed Selkie
|
||||
1 Commander's Sphere
|
||||
1 Commit // Memory
|
||||
1 Command Tower
|
||||
1 Coralhelm Commander
|
||||
1 Curse of the Swine
|
||||
1 Deeproot Elite
|
||||
1 Deeproot Historian
|
||||
1 Deeproot Waters
|
||||
1 Emperor Mihail II
|
||||
1 Evolution Sage
|
||||
1 Explore
|
||||
1 Growth Spiral
|
||||
1 Hardened Scales
|
||||
1 Herald of Secret Streams
|
||||
1 Hinterland Harbor
|
||||
1 Inspiring Call
|
||||
1 Karn's Bastion
|
||||
1 Kiora's Follower
|
||||
1 Kindred Discovery
|
||||
1 Kodama's Reach
|
||||
1 Kopala, Warden of Waves
|
||||
1 Kumena, Tyrant of Orazca
|
||||
1 Llanowar Reborn
|
||||
1 Master of the Pearl Trident
|
||||
1 Merfolk Cave-Diver
|
||||
1 Merfolk Mistbinder
|
||||
1 Merfolk Skydiver
|
||||
1 Merfolk Sovereign
|
||||
1 Merrow Reejerey
|
||||
1 Metallic Mimic
|
||||
1 Xolatoyac, the Smiling Flood
|
||||
1 Mist Dancer
|
||||
1 Mosswort Bridge
|
||||
1 Myriad Landscape
|
||||
1 Nicanzil, Current Conductor
|
||||
1 Path of Ancestry
|
||||
1 Prime Speaker Zegana
|
||||
1 Quandrix Command
|
||||
1 Rapid Hybridization
|
||||
1 Ravenform
|
||||
1 Realmwalker
|
||||
1 Reflections of Littjara
|
||||
1 Reliquary Tower
|
||||
1 Ripples of Potential
|
||||
1 Rogue's Passage
|
||||
1 Ruinous Intrusion
|
||||
1 Sage of Fables
|
||||
1 Seafloor Oracle
|
||||
1 Secluded Courtyard
|
||||
1 Simic Ascendancy
|
||||
1 Simic Growth Chamber
|
||||
1 Simic Signet
|
||||
1 Singer of Swift Rivers
|
||||
1 Sol Ring
|
||||
1 Stonybrook Banneret
|
||||
1 Surgespanner
|
||||
1 Svyelun of Sea and Sky
|
||||
1 Swiftfoot Boots
|
||||
1 Tatyova, Benthic Druid
|
||||
1 Temple of Mystery
|
||||
1 Temple of the False God
|
||||
1 Thassa, God of the Sea
|
||||
1 Thieving Skydiver
|
||||
1 Tishana, Voice of Thunder
|
||||
1 Wave Goodbye
|
||||
1 Bygone Marvels
|
||||
1 Deeproot Historian
|
||||
1 Topography Tracker
|
||||
1 Tributary Instructor
|
||||
1 Unclaimed Territory
|
||||
1 Vineglimmer Snarl
|
||||
1 Singer of Swift Rivers
|
||||
1 Benthic Biomancer
|
||||
1 Commit // Memory
|
||||
1 Coralhelm Commander
|
||||
1 Curse of the Swine
|
||||
1 Emperor Mihail II
|
||||
1 Herald of Secret Streams
|
||||
1 Kindred Discovery
|
||||
1 Kopala, Warden of Waves
|
||||
1 Master of the Pearl Trident
|
||||
1 Merfolk Sovereign
|
||||
1 Reflections of Littjara
|
||||
1 Seafloor Oracle
|
||||
1 Surgespanner
|
||||
1 Svyelun of Sea and Sky
|
||||
1 Thassa, God of the Sea
|
||||
1 Thieving Skydiver
|
||||
1 Branching Evolution
|
||||
1 Deeproot Elite
|
||||
1 Hardened Scales
|
||||
1 Realmwalker
|
||||
1 Ruinous Intrusion
|
||||
1 Cold-Eyed Selkie
|
||||
1 Kumena, Tyrant of Orazca
|
||||
1 Prime Speaker Zegana
|
||||
1 Quandrix Command
|
||||
1 Simic Ascendancy
|
||||
1 Tishana, Voice of Thunder
|
||||
1 Vorel of the Hull Clade
|
||||
1 Wave Goodbye
|
||||
1 Xolatoyac, the Smiling Flood
|
||||
1 Zegana, Utopian Speaker
|
||||
10 Island
|
||||
10 Forest
|
||||
1 Metallic Mimic
|
||||
1 Alchemist's Refuge
|
||||
1 Hinterland Harbor
|
||||
1 Karn's Bastion
|
||||
1 Mosswort Bridge
|
||||
1 Temple of Mystery
|
||||
1 Vineglimmer Snarl
|
||||
1 Merfolk Cave-Diver
|
||||
1 Aetherize
|
||||
1 Deeproot Waters
|
||||
1 Merrow Reejerey
|
||||
1 Rapid Hybridization
|
||||
1 Ravenform
|
||||
1 Sage of Fables
|
||||
1 Stonybrook Banneret
|
||||
1 Beast Within
|
||||
1 Nicanzil, Current Conductor
|
||||
1 Evolution Sage
|
||||
1 Explore
|
||||
1 Inspiring Call
|
||||
1 Kodama's Reach
|
||||
1 Growth Spiral
|
||||
1 Kiora's Follower
|
||||
1 Merfolk Mistbinder
|
||||
1 Merfolk Skydiver
|
||||
1 Tatyova, Benthic Druid
|
||||
1 Arcane Signet
|
||||
1 Commander's Sphere
|
||||
1 Simic Signet
|
||||
1 Sol Ring
|
||||
1 Swiftfoot Boots
|
||||
1 Command Tower
|
||||
1 Llanowar Reborn
|
||||
1 Myriad Landscape
|
||||
1 Path of Ancestry
|
||||
1 Reliquary Tower
|
||||
1 Rogue's Passage
|
||||
1 Secluded Courtyard
|
||||
1 Simic Growth Chamber
|
||||
1 Temple of the False God
|
||||
1 Unclaimed Territory
|
||||
4 Forest
|
||||
3 Forest
|
||||
7 Island
|
||||
6 Island
|
||||
@@ -0,0 +1,83 @@
|
||||
[metadata]
|
||||
Name=Exquisite Invention
|
||||
[Commander]
|
||||
1 Saheeli, the Gifted
|
||||
[Main]
|
||||
1 Maverick Thopterist
|
||||
1 Soul of New Phyrexia
|
||||
1 Inkwell Leviathan
|
||||
1 Sharding Sphinx
|
||||
1 Hellkite Igniter
|
||||
1 Bosh, Iron Golem
|
||||
1 Darksteel Juggernaut
|
||||
1 Duplicant
|
||||
1 Myr Battlesphere
|
||||
1 Psychosis Crawler
|
||||
1 Scuttling Doom Engine
|
||||
1 Steel Hellkite
|
||||
1 Thopter Assembly
|
||||
1 Etherium Sculptor
|
||||
1 Whirler Rogue
|
||||
1 Chief of the Foundry
|
||||
1 Pilgrim's Eye
|
||||
1 Saheeli's Artistry
|
||||
1 Blasphemous Act
|
||||
1 Reverse Engineer
|
||||
1 Tidings
|
||||
1 Chaos Warp
|
||||
1 Magmaquake
|
||||
1 Into the Roil
|
||||
1 Thirst for Knowledge
|
||||
1 Blinkmoth Urn
|
||||
1 Mimic Vat
|
||||
1 Mirrorworks
|
||||
1 Prototype Portal
|
||||
1 Unwinding Clock
|
||||
1 Commander's Sphere
|
||||
1 Dreamstone Hedron
|
||||
1 Hedron Archive
|
||||
1 Izzet Signet
|
||||
1 Magnifying Glass
|
||||
1 Mind Stone
|
||||
1 Prismatic Lens
|
||||
1 Scrabbling Claws
|
||||
1 Sol Ring
|
||||
1 Swiftfoot Boots
|
||||
1 Unstable Obelisk
|
||||
1 Vessel of Endless Rest
|
||||
1 Worn Powerstone
|
||||
1 Darksteel Citadel
|
||||
1 Great Furnace
|
||||
1 Seat of the Synod
|
||||
1 Thopter Spy Network
|
||||
5 Island
|
||||
5 Island
|
||||
5 Island
|
||||
4 Mountain
|
||||
4 Mountain
|
||||
4 Mountain
|
||||
1 Buried Ruin
|
||||
1 Command Tower
|
||||
1 Foundry of the Consuls
|
||||
1 Highland Lake
|
||||
1 Izzet Boilerworks
|
||||
1 Izzet Guildgate
|
||||
1 Swiftwater Cliffs
|
||||
1 Brudiclad, Telchor Engineer
|
||||
1 Tawnos, Urza's Apprentice
|
||||
1 Echo Storm
|
||||
1 Vedalken Humiliator
|
||||
1 Enchanter's Bane
|
||||
1 Saheeli's Directive
|
||||
1 Treasure Nabber
|
||||
1 Varchild, Betrayer of Kjeldor
|
||||
1 Ancient Stone Idol
|
||||
1 Coveted Jewel
|
||||
1 Endless Atlas
|
||||
1 Retrofitter Foundry
|
||||
1 Aether Gale
|
||||
1 Loyal Drake
|
||||
1 Loyal Apprentice
|
||||
1 Geode Golem
|
||||
1 Thopter Engineer
|
||||
1 Forge of Heroes
|
||||
@@ -0,0 +1,98 @@
|
||||
[metadata]
|
||||
Name=Faceless Menace
|
||||
[Commander]
|
||||
1 Kadena, Slinking Sorcerer
|
||||
[Main]
|
||||
1 Vraska the Unseen
|
||||
1 Rayami, First of the Fallen
|
||||
1 Volrath, the Shapestealer
|
||||
1 Kadena's Silencer
|
||||
1 Thought Sponge
|
||||
1 Thieving Amalgam
|
||||
1 Apex Altisaur
|
||||
1 Grismold, the Dreadsower
|
||||
1 Deathmist Raptor
|
||||
1 Hooded Hydra
|
||||
1 Chromeshell Crab
|
||||
1 Ixidron
|
||||
1 Kheru Spellsnatcher
|
||||
1 Stratus Dancer
|
||||
1 Thousand Winds
|
||||
1 Vesuvan Shapeshifter
|
||||
1 Bane of the Living
|
||||
1 Grim Haruspex
|
||||
1 Silumgar Assassin
|
||||
1 Den Protector
|
||||
1 Seedborn Muse
|
||||
1 Thelonite Hermit
|
||||
1 Sagu Mauler
|
||||
1 Voice of Many
|
||||
1 Scaretiller
|
||||
1 Willbender
|
||||
1 Skinthinner
|
||||
1 Ainok Survivalist
|
||||
1 Great Oak Guardian
|
||||
1 Nantuko Vigilante
|
||||
1 Sakura-Tribe Elder
|
||||
1 Icefeather Aven
|
||||
1 Road of Return
|
||||
1 Ghastly Conscription
|
||||
1 Hex
|
||||
1 Overwhelming Stampede
|
||||
1 Tempt with Discovery
|
||||
1 Mire in Misery
|
||||
1 Tezzeret's Gambit
|
||||
1 Cultivate
|
||||
1 Explore
|
||||
1 Farseek
|
||||
1 Urban Evolution
|
||||
1 Sudden Substitution
|
||||
1 Biomass Mutation
|
||||
1 Leadership Vacuum
|
||||
1 Echoing Truth
|
||||
1 Reality Shift
|
||||
1 Putrefy
|
||||
1 Sultai Charm
|
||||
1 Pendant of Prosperity
|
||||
1 Scroll of Fate
|
||||
1 Strionic Resonator
|
||||
1 Sol Ring
|
||||
1 Thran Dynamo
|
||||
1 Gift of Doom
|
||||
1 Trail of Mystery
|
||||
1 Bounty of the Luxa
|
||||
1 Secret Plans
|
||||
1 Darkwater Catacombs
|
||||
1 Exotic Orchard
|
||||
1 Llanowar Wastes
|
||||
1 Shrine of the Forsaken Gods
|
||||
1 Sunken Hollow
|
||||
1 Thespian's Stage
|
||||
1 Yavimaya Coast
|
||||
2 Island
|
||||
2 Island
|
||||
1 Island
|
||||
1 Swamp
|
||||
1 Swamp
|
||||
1 Swamp
|
||||
3 Forest
|
||||
2 Forest
|
||||
2 Forest
|
||||
1 Ash Barrens
|
||||
1 Bojuka Bog
|
||||
1 Command Tower
|
||||
1 Dimir Aqueduct
|
||||
1 Evolving Wilds
|
||||
1 Foul Orchard
|
||||
1 Golgari Guildgate
|
||||
1 Golgari Rot Farm
|
||||
1 Jungle Hollow
|
||||
1 Myriad Landscape
|
||||
1 Opulent Palace
|
||||
1 Reliquary Tower
|
||||
1 Simic Growth Chamber
|
||||
1 Simic Guildgate
|
||||
1 Temple of the False God
|
||||
1 Terramorphic Expanse
|
||||
1 Thornwood Falls
|
||||
1 Woodland Stream
|
||||
@@ -0,0 +1,83 @@
|
||||
[metadata]
|
||||
Name=Fae Dominion
|
||||
[Commander]
|
||||
1 Tegwyll, Duke of Splendor
|
||||
[Main]
|
||||
1 Alela, Cunning Conqueror
|
||||
1 Archmage of Echoes
|
||||
1 Malleable Impostor
|
||||
1 Misleading Signpost
|
||||
1 Shadow Puppeteers
|
||||
1 Blightwing Bandit
|
||||
1 Faerie Bladecrafter
|
||||
1 Nettling Nuisance
|
||||
1 Tegwyll's Scouring
|
||||
1 Brazen Borrower // Petty Theft
|
||||
1 Dig Through Time
|
||||
1 Faerie Formation
|
||||
1 Glen Elendra Archmage
|
||||
1 Hullbreaker Horror
|
||||
1 Illusionist's Gambit
|
||||
1 Midnight Clock
|
||||
1 Perplexing Test
|
||||
1 Reflections of Littjara
|
||||
1 Scion of Oona
|
||||
1 Sower of Temptation
|
||||
1 Theoretical Duplication
|
||||
1 Kindred Dominance
|
||||
1 Nightmare Unmaking
|
||||
1 Puppeteer Clique
|
||||
1 Rankle, Master of Pranks
|
||||
1 Thrilling Encore
|
||||
1 Glen Elendra Liege
|
||||
1 Nymris, Oona's Trickster
|
||||
1 Oona, Queen of the Fae
|
||||
1 Choked Estuary
|
||||
1 Darkwater Catacombs
|
||||
1 Exotic Orchard
|
||||
1 Secluded Glen
|
||||
1 Sunken Hollow
|
||||
1 Temple of Deceit
|
||||
1 Mocking Sprite
|
||||
1 Picklock Prankster // Free the Fae
|
||||
1 Spell Stutter
|
||||
1 Obyra, Dreaming Duelist
|
||||
1 Spellscorn Coven // Take It Back
|
||||
1 Arcane Denial
|
||||
1 Cloud of Faeries
|
||||
1 Consider
|
||||
1 Distant Melody
|
||||
1 Fact or Fiction
|
||||
1 Faerie Seer
|
||||
1 Frantic Search
|
||||
1 Hypnotic Sprite // Mesmeric Glare
|
||||
1 Keep Watch
|
||||
1 Nightveil Sprite
|
||||
1 Opt
|
||||
1 Quickling
|
||||
1 Reality Shift
|
||||
1 Reconnaissance Mission
|
||||
1 Repulse
|
||||
1 Run Away Together
|
||||
1 Snap
|
||||
1 Reckless Spite
|
||||
1 Halo Forager
|
||||
1 Arcane Signet
|
||||
1 Dimir Signet
|
||||
1 Fellwar Stone
|
||||
1 Mind Stone
|
||||
1 Sol Ring
|
||||
1 Talisman of Dominance
|
||||
1 Wayfarer's Bauble
|
||||
1 Bojuka Bog
|
||||
1 Command Tower
|
||||
1 Dimir Aqueduct
|
||||
1 Faerie Conclave
|
||||
1 Myriad Landscape
|
||||
1 Path of Ancestry
|
||||
1 Tainted Isle
|
||||
1 Temple of the False God
|
||||
7 Island
|
||||
6 Island
|
||||
6 Swamp
|
||||
6 Swamp
|
||||
@@ -0,0 +1,97 @@
|
||||
[metadata]
|
||||
Name=Family Matters
|
||||
[Commander]
|
||||
1 Zinnia, Valley's Voice
|
||||
[Main]
|
||||
1 Arthur, Marigold Knight
|
||||
1 Elspeth, Sun's Champion
|
||||
1 Jazal Goldmane
|
||||
1 Martial Coup
|
||||
1 Storm of Souls
|
||||
1 Selfless Spirit
|
||||
1 Murmuration
|
||||
1 Blade Splicer
|
||||
1 Hanged Executioner
|
||||
1 Loyal Warhound
|
||||
1 Restoration Angel
|
||||
1 Jacked Rabbit
|
||||
1 Skyclave Apparition
|
||||
1 Dusk // Dawn
|
||||
1 Boss's Chauffeur
|
||||
1 Angel of the Ruins
|
||||
1 Luminous Broodmoth
|
||||
1 Sun Titan
|
||||
1 Pollywog Prodigy
|
||||
1 Fortune Teller's Talent
|
||||
1 Aether Channeler
|
||||
1 Pull from Tomorrow
|
||||
1 Shield Broker
|
||||
1 Stolen by the Fae
|
||||
1 Rapid Augmenter
|
||||
1 Bident of Thassa
|
||||
1 Curiosity Crafter
|
||||
1 Devilish Valet
|
||||
1 Siege-Gang Commander
|
||||
1 Echoing Assault
|
||||
1 Agate Instigator
|
||||
1 Calamity of Cinders
|
||||
1 Rose Room Treasurer
|
||||
1 Combat Celebrant
|
||||
1 Inferno Titan
|
||||
1 Time Wipe
|
||||
1 Solemn Simulacrum
|
||||
1 Helm of the Host
|
||||
1 Glacial Fortress
|
||||
1 Adarkar Wastes
|
||||
1 Temple of Enlightenment
|
||||
1 Castle Ardenvale
|
||||
1 Seachrome Coast
|
||||
1 Sulfur Falls
|
||||
1 Cascade Bluffs
|
||||
1 Exotic Orchard
|
||||
1 Clifftop Retreat
|
||||
1 Shivan Reef
|
||||
1 Temple of Triumph
|
||||
1 Battlefield Forge
|
||||
1 Skycloud Expanse
|
||||
1 Temple of Epiphany
|
||||
1 Ferrous Lake
|
||||
1 Rugged Prairie
|
||||
1 Sunscorched Divide
|
||||
1 Spirited Companion
|
||||
1 Inspiring Overseer
|
||||
1 Cut a Deal
|
||||
1 Path to Exile
|
||||
1 Illusory Ambusher
|
||||
1 Rowdy Research
|
||||
1 Plumecreed Escort
|
||||
1 Rapid Hybridization
|
||||
1 Junk Winder
|
||||
1 Aetherize
|
||||
1 Chart a Course
|
||||
1 Tetsuko Umezawa, Fugitive
|
||||
1 Thopter Engineer
|
||||
1 Cloudblazer
|
||||
1 Arcane Signet
|
||||
1 Boros Signet
|
||||
1 Ornithopter of Paradise
|
||||
1 Azorius Signet
|
||||
1 Izzet Signet
|
||||
1 Circuit Mender
|
||||
1 Fellwar Stone
|
||||
1 Sol Ring
|
||||
1 Mind Stone
|
||||
1 Terramorphic Expanse
|
||||
1 Path of Ancestry
|
||||
1 Thriving Heath
|
||||
1 Evolving Wilds
|
||||
1 Thriving Isle
|
||||
1 Thriving Bluff
|
||||
1 Command Tower
|
||||
1 Mystic Monastery
|
||||
3 Plains
|
||||
2 Plains
|
||||
2 Island
|
||||
2 Island
|
||||
2 Mountain
|
||||
2 Mountain
|
||||
Loaded 100 of 293 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user