Compare commits
155
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54cc5e9b2e | ||
|
|
9b79d18201 | ||
|
|
45ca961064 | ||
|
|
69bdd94ef2 | ||
|
|
8fbebc21f3 | ||
|
|
7cfdecb2c3 | ||
|
|
38f3837f5a | ||
|
|
681888adcd | ||
|
|
88c0d94d39 | ||
|
|
4fdddf332e | ||
|
|
213b9dc32d | ||
|
|
2af1d58104 | ||
|
|
547a84d8c2 | ||
|
|
71b86b74eb | ||
|
|
d00093897a | ||
|
|
0b01f1c467 | ||
|
|
65873555bc | ||
|
|
8995d743af | ||
|
|
14ff543135 | ||
|
|
ea23f97b9c | ||
|
|
a23fdc55a4 | ||
|
|
4cae97ab44 | ||
|
|
d308e8f413 | ||
|
|
44123f7622 | ||
|
|
9eaa41a4ae | ||
|
|
409aba26f4 | ||
|
|
30c3ab5a89 | ||
|
|
b7f9c119cb | ||
|
|
bbb7f7a966 | ||
|
|
ef739e1f49 | ||
|
|
920d7d9787 | ||
|
|
52d51ebd5e | ||
|
|
db3675aa6a | ||
|
|
99044342c2 | ||
|
|
d5fd648139 | ||
|
|
022f89478d | ||
|
|
336f913d4b | ||
|
|
661847a4d8 | ||
|
|
e710fd5e03 | ||
|
|
85af163123 | ||
|
|
f2e87feb4e | ||
|
|
948f8e0f8f | ||
|
|
84d56bf901 | ||
|
|
74d24d606c | ||
|
|
98e1092ebc | ||
|
|
9338ac881b | ||
|
|
c3ff9f8888 | ||
|
|
e4f35a2d8b | ||
|
|
084742a56a | ||
|
|
45a413837b | ||
|
|
018852c92d | ||
|
|
4ca21e5e29 | ||
|
|
28665bf663 | ||
|
|
83d05294c8 | ||
|
|
e8f8766d59 | ||
|
|
a69b273f5f | ||
|
|
e55ac9aebf | ||
|
|
d2b86ef2ed | ||
|
|
081dd66121 | ||
|
|
6d89d5bb08 | ||
|
|
f29a5a31b5 | ||
|
|
370d4cd92f | ||
|
|
e90efe1654 | ||
|
|
bf701397b0 | ||
|
|
7750b43494 | ||
|
|
fa58463cde | ||
|
|
94b1e050d8 | ||
|
|
8bfde0230b | ||
|
|
4214831e59 | ||
|
|
e2568c731c | ||
|
|
d6863b6d7e | ||
|
|
1fc99a5d4a | ||
|
|
eed2c7f126 | ||
|
|
7a40b5deb8 | ||
|
|
af8970f3a8 | ||
|
|
53fd1c9257 | ||
|
|
6ead75f96b | ||
|
|
535bdd7c70 | ||
|
|
d98e6331e9 | ||
|
|
2b3196267f | ||
|
|
433e8bc4d7 | ||
|
|
d74dd0a6bf | ||
|
|
8037802dff | ||
|
|
ca6c23a881 | ||
|
|
a81772c68a | ||
|
|
99ca498b88 | ||
|
|
395ad06cdd | ||
|
|
e4a6ce644d | ||
|
|
5411fd6bf6 | ||
|
|
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 |
No files matched your search
+22
-31
@@ -1,36 +1,27 @@
|
|||||||
# Commander AI Lab - Environment Variables
|
# Commander AI Lab — Environment Variables
|
||||||
# Copy this file to .env and fill in your API keys.
|
# Copy this file to .env and fill in your values.
|
||||||
# .env is gitignored and will NOT be committed.
|
# start-lab.bat will auto-load .env if it exists.
|
||||||
|
|
||||||
# Ximilar API Key (card scanner / visual AI recognition)
|
# ── API Keys ──────────────────────────────────────────────────────
|
||||||
# Get yours at: https://app.ximilar.com/
|
|
||||||
XIMILAR_API_KEY=your-ximilar-api-key-here
|
|
||||||
|
|
||||||
# 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_API_KEY=your-anthropic-api-key-here
|
||||||
# Anthropic model for deck generation and coaching (Claude Opus recommended)
|
XIMILAR_API_KEY=your-ximilar-key-here
|
||||||
ANTHROPIC_MODEL=claude-opus-4-20250514
|
|
||||||
# Coach chat provider: "local" (Ollama) or "anthropic" (Claude API)
|
|
||||||
COACH_PROVIDER=anthropic
|
|
||||||
|
|
||||||
# CORS allowed origins (comma-separated)
|
# ── Forge JAR Path ────────────────────────────────────────────────
|
||||||
# Default: http://localhost:5173,http://localhost:8080
|
# Point this to your Forge GUI desktop fat JAR.
|
||||||
# Add your LAN IP if accessing from another machine on your network
|
FORGE_JAR_PATH=D:/ForgeCommander/Forge-Repo/forge-gui-desktop/target/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
|
||||||
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
|
|
||||||
|
|
||||||
# ── Dual-Model AI Brain ─────────────────────────────────────
|
# IMPORTANT: Forge MUST be launched from the forge-gui directory (not forge-gui-desktop/target).
|
||||||
# Primary brain: GPT-OSS 20B (turn planner / decision maker)
|
# The JAR looks for res/ relative to its working directory. Without this, you get
|
||||||
BRAIN_API_BASE=http://localhost:11434
|
# ExceptionInInitializerError on startup.
|
||||||
BRAIN_MODEL=gpt-oss:20b
|
FORGE_WORKING_DIR=D:/ForgeCommander/Forge-Repo/forge-gui
|
||||||
BRAIN_TEMPERATURE=0.3
|
|
||||||
BRAIN_MAX_TOKENS=1024
|
|
||||||
BRAIN_TIMEOUT=120.0
|
|
||||||
|
|
||||||
# Validator brain: DeepSeek-R1-Distill-Qwen-14B (legality + explanation)
|
# ── Deck Paths ────────────────────────────────────────────────────
|
||||||
VALIDATOR_ENABLED=true
|
# Directory containing your precon .dck files.
|
||||||
VALIDATOR_API_BASE=http://localhost:11434
|
# Forge reads deck files directly from this folder.
|
||||||
VALIDATOR_MODEL=deepseek-r1:14b
|
#
|
||||||
VALIDATOR_TEMPERATURE=0.0
|
# IMPORTANT: lab_api.py reads PRECON_DIR (not PRECON_DECKS_DIR).
|
||||||
VALIDATOR_MAX_TOKENS=2048
|
# PRECON_DECKS_DIR is kept below for backwards compat but is NOT used by the server.
|
||||||
VALIDATOR_TIMEOUT=120.0
|
PRECON_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks
|
||||||
|
PRECON_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks
|
||||||
|
USER_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\user-decks
|
||||||
|
FORGE_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\user-decks
|
||||||
+15
@@ -32,9 +32,24 @@ embeddings/
|
|||||||
checkpoints/
|
checkpoints/
|
||||||
imported-decks/
|
imported-decks/
|
||||||
|
|
||||||
|
# User-created decks (generated at runtime, not committed)
|
||||||
|
user-decks/*.dck
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Runtime artifacts / caches
|
||||||
|
static/
|
||||||
|
api/
|
||||||
|
image_cache/
|
||||||
|
data/rag_chroma/
|
||||||
|
.claude/
|
||||||
|
dependency-reduced-pom.xml
|
||||||
|
|
||||||
|
# Precon decks (generated / imported at runtime)
|
||||||
|
precon-decks/
|
||||||
@@ -1,381 +1,434 @@
|
|||||||
# Commander AI Lab
|
# Commander AI Lab
|
||||||
|
|
||||||
Headless batch simulation environment for 3-AI MTG Commander pods.
|
A full-stack Magic: The Gathering Commander research platform. Run thousands of simulated games, train neural networks on the outcomes, build decks with AI that learns from simulation data, get LLM-powered coaching, and play live 4-player battles — all from a single unified system.
|
||||||
Run 100+ games, analyze win rates, and stress-test your decks — all from a web UI or CLI.
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Commander AI Lab connects a rules-complete Forge engine, a fast Monte Carlo simulator, an LLM deck builder, an ML training pipeline, and multiple game clients (web, LAN, Unity) into one closed-loop platform. Simulation results feed back into deck building and AI decision-making, so the system improves the more it plays.
|
||||||
|
|
||||||
|
### Key Capabilities
|
||||||
|
|
||||||
|
- **Batch Simulation** — Run hundreds or thousands of Commander games unattended using Forge (rules-complete) or a headless Python Monte Carlo engine
|
||||||
|
- **ML Training Pipeline** — Reinforcement learning (PPO) trains a neural net on game decision logs; outcome-weighted scoring nudges card weights automatically
|
||||||
|
- **AI Deck Builder** — 7-step LLM pipeline (EDHrec → Scryfall → Ollama) with simulation-learned insights injected into prompts
|
||||||
|
- **LLM Coach** — Streaming coaching sessions powered by Anthropic Claude or local Ollama models, enriched with RAG-retrieved card knowledge
|
||||||
|
- **RAG Card Knowledge** — ChromaDB vector store over 30,000+ Scryfall cards with semantic search and auto-staleness monitoring
|
||||||
|
- **LAN Battlefield** — Browser-based 4-player Commander game client with ML-powered AI opponents and precon deck support
|
||||||
|
- **Unity Client** — Full Unity application with battleground scene, lobby system, WebSocket game state sync, and cross-platform builds
|
||||||
|
- **Card Scanner** — OCR pipeline for physical card recognition via Ximilar API
|
||||||
|
- **163 Precon Decks** — Complete preconstructed deck library in `.dck` format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
│ Web Browser (ai-lab.js + ai-lab.css) │
|
│ Clients │
|
||||||
│ Select decks → Start batch → Poll progress → View stats │
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────────┐ │
|
||||||
└─────────────────────┬───────────────────────────────────────┘
|
│ │ Web UI │ │ LAN Client │ │ Unity Client │ │
|
||||||
│ HTTP (REST)
|
│ │ (React/TS) │ │ (HTML/JS) │ │ (C# / URP) │ │
|
||||||
┌─────────────────────┴───────────────────────────────────────┐
|
│ │ /ui │ │ /lan │ │ /unity-client │ │
|
||||||
│ Python FastAPI Server (lab_api.py, port 8080) │
|
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────────────────────┘ │
|
||||||
│ /api/lab/start — spawn Java batch runner subprocess │
|
│ └────────────────┼────────────────┘ │
|
||||||
│ /api/lab/status — poll progress (parses stdout) │
|
│ │ HTTP/REST + WebSocket │
|
||||||
│ /api/lab/result — return completed JSON results │
|
│ ┌───────────────────────┴─────────────────────────────────────────┐│
|
||||||
│ /api/lab/decks — list decks from Forge user data │
|
│ │ FastAPI Backend (lab_api.py — port 8080) ││
|
||||||
│ /api/lab/history— browse past batch runs │
|
│ │ ││
|
||||||
│ /api/lab/log — stream live console output │
|
│ │ /api/lab/* Batch simulation (Forge + Monte Carlo) ││
|
||||||
└─────────────────────┬───────────────────────────────────────┘
|
│ │ /api/deck-builder/* 7-step LLM deck construction ││
|
||||||
│ subprocess (java -jar)
|
│ │ /api/coach/* Streaming LLM coaching sessions ││
|
||||||
┌─────────────────────┴───────────────────────────────────────┐
|
│ │ /api/ml/* Neural net training + policy server ││
|
||||||
│ Java CLI (LabCli.java + BatchRunner + MultiThreadRunner) │
|
│ │ /api/rag/* ChromaDB vector search ││
|
||||||
│ Parses Forge sim output → GameResult objects → JSON │
|
│ │ /api/game/* Live game sessions (Forge IPC) ││
|
||||||
└─────────────────────┬───────────────────────────────────────┘
|
│ │ /api/policy/* ML policy decisions for AI players ││
|
||||||
│ subprocess per game
|
│ │ /ws/game/{id} WebSocket game state streaming ││
|
||||||
┌─────────────────────┴───────────────────────────────────────┐
|
│ └───────┬──────────────┬──────────────┬──────────────┬────────────┘│
|
||||||
│ Card Forge Engine (forge-gui-desktop sim mode) │
|
│ │ │ │ │ │
|
||||||
│ java -jar forge.jar sim -d "deck1" "deck2" "deck3" │
|
│ ┌──────┴──────┐ ┌────┴─────┐ ┌──────┴──────┐ ┌────┴──────┐ │
|
||||||
│ -f commander -n 1 -q │
|
│ │ Forge │ │ Monte │ │ Ollama │ │ ChromaDB │ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
│ │ Engine │ │ Carlo │ │ (local LLM) │ │ (vector │ │
|
||||||
|
│ │ (Java/JVM) │ │ Engine │ │ qwen2.5:7b │ │ store) │ │
|
||||||
|
│ └─────────────┘ │ (Python) │ └─────────────┘ └───────────┘ │
|
||||||
|
│ └──────────┘ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────────┐│
|
||||||
|
│ │ ML Pipeline ││
|
||||||
|
│ │ decisions_*.jsonl → update_weights.py → learned_weights.json ││
|
||||||
|
│ │ → /api/ml/train → neural net checkpoints ││
|
||||||
|
│ └──────────────────────────────────────────────────────────────────┘│
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start (Windows)
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Simulation Engines
|
||||||
|
|
||||||
|
| Engine | Description | Speed |
|
||||||
|
|---|---|---|
|
||||||
|
| **Forge** | Rules-complete Java engine — handles the stack, instants, triggered abilities, and all Commander rules. Each game spawns a JVM subprocess. | ~0.04 sims/sec (single-threaded) |
|
||||||
|
| **Monte Carlo** | Headless Python engine — models combat keywords (flying, trample, deathtouch, lifelink, menace, reach, haste), summoning sickness, tapped state, commander zone, multiplayer targeting, and board wipes. No JVM required. | Thousands of games in minutes |
|
||||||
|
|
||||||
|
Both engines feed structured results into the same ML pipeline.
|
||||||
|
|
||||||
|
### Deck Builder
|
||||||
|
|
||||||
|
The `POST /api/deck-builder/build` endpoint runs a 7-step pipeline:
|
||||||
|
|
||||||
|
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 — **simulation insights injected here**
|
||||||
|
5. Filter by color identity, collection, and ban list
|
||||||
|
6. Ollama enforces ratios (trim/expand to 99) — **simulation insights injected here**
|
||||||
|
7. Assemble final deck with smart substitution (guarantees 99-card output)
|
||||||
|
|
||||||
|
### AI Coach
|
||||||
|
|
||||||
|
LLM-powered coaching with two modes:
|
||||||
|
|
||||||
|
- **Full Session** — Deep analysis including removal coverage, ramp quality, draw engine profiling, win conditions, anti-synergy detection, pod meta evaluation, and tempo assessment
|
||||||
|
- **Quick Digest** — Fast ~5-second summary for rapid feedback
|
||||||
|
|
||||||
|
Coaching prompts are enriched with semantically relevant cards from the ChromaDB RAG store (15 cards for full sessions, 8 for quick digests). Supports both Anthropic Claude and local Ollama models with streaming responses.
|
||||||
|
|
||||||
|
### RAG Card Knowledge
|
||||||
|
|
||||||
|
- **SQLite FTS5** bulk card database from Scryfall data
|
||||||
|
- **ChromaDB** vector index with natural-language card embeddings
|
||||||
|
- Automatic staleness monitoring (checks every 6 hours, auto-rebuilds if >14 days old)
|
||||||
|
- Graceful fallback — RAG failures never block coaching or deck building
|
||||||
|
|
||||||
|
### ML Training Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Overnight sims → decisions_*.jsonl
|
||||||
|
↓
|
||||||
|
update_weights.py → learned_weights.json (feeds deck builder)
|
||||||
|
↓
|
||||||
|
/api/ml/train → neural net training on decision dataset
|
||||||
|
↓
|
||||||
|
/api/policy/decide → ML policy serves AI decisions in live games
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Outcome-weighted RL** nudges card scoring weights from game results
|
||||||
|
- **Neural net** trains on the full decision log dataset
|
||||||
|
- **Distillation loop** — self-play → collect data → retrain → repeat
|
||||||
|
- **ELO tracking** and model tournaments for comparing AI versions
|
||||||
|
- Weights clamped to `[-20.0, 20.0]`, stored in `learned_weights.json`
|
||||||
|
|
||||||
|
### Win Condition Tracking
|
||||||
|
|
||||||
|
Games are classified by win type — not just "Combat" vs "Other":
|
||||||
|
|
||||||
|
- **Combat** — standard creature damage wins
|
||||||
|
- **Combo** — Approach of the Second Sun, Felidar Sovereign, Happily Ever After, and 30+ known alternate-win cards
|
||||||
|
- **Drain** — life drain effects
|
||||||
|
- **Mill** — library depletion
|
||||||
|
- **Poison** — infect/poison counter wins
|
||||||
|
|
||||||
|
Draw games (timeout) are analyzed separately with per-player life-at-timeout snapshots and likely cause identification.
|
||||||
|
|
||||||
|
### LAN Battlefield Client
|
||||||
|
|
||||||
|
Browser-based 4-player Commander game at `/lan`:
|
||||||
|
|
||||||
|
- **AI Bridge** — REST + WebSocket connectivity with ML policy decisions as Priority 1 (falls back to local heuristic AI)
|
||||||
|
- **Deck System** — 3-tab selector: Precons (163 decks, grouped by set), EDHREC search, and custom deck import
|
||||||
|
- **Player Panels** — Life counters, commander damage bars, poison/energy/experience/rad counters, emblem management
|
||||||
|
- **Backend Detection** — Auto-detects the Commander AI Lab backend; works offline with local precon data
|
||||||
|
|
||||||
|
### Unity Client
|
||||||
|
|
||||||
|
Full Unity application (URP, .NET Standard 2.1) with:
|
||||||
|
|
||||||
|
- **Battleground Scene** — 4-seat layout with rotated perspectives, life/poison/commander damage widgets, phase tracker, turn order bar
|
||||||
|
- **Card System** — Hand, battlefield, graveyard, exile zones with card art, tapped rotation, face-down state, summoning sickness indicators
|
||||||
|
- **Stack & Mana** — LIFO stack display and 6-pip mana pool
|
||||||
|
- **Lobby System** — Configure 4 seats (human/AI), POST to backend, WebSocket game state sync
|
||||||
|
- **Elimination** — Grayscale shader overlay on eliminated players
|
||||||
|
- **Token System** — Create, tap, add counters, delete tokens
|
||||||
|
- **Cross-Platform** — Windows, WebGL, Android, iOS build targets
|
||||||
|
- **Scenes** — MainMenu, Collection, DeckBuilder, DeckGenerator, Simulator, Coach, Scanner, Training, Precon, Battleground
|
||||||
|
|
||||||
|
### Card Scanner
|
||||||
|
|
||||||
|
OCR pipeline for physical Magic cards using the Ximilar API. Scan cards from photos and match them to the Scryfall database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### 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`
|
|
||||||
|
|
||||||
### 1. Build Forge (one-time)
|
| Tool | Version |
|
||||||
|
|---|---|
|
||||||
|
| Python | 3.10+ |
|
||||||
|
| Java | 17+ |
|
||||||
|
| Maven | 3.8+ |
|
||||||
|
| Node.js | 18+ (for LAN client scripts) |
|
||||||
|
| Ollama | Latest (for local LLM inference) |
|
||||||
|
| [Forge](https://github.com/KoalaTrapLord/Forge-Repo) | Built at `D:\ForgeCommander\Forge-Repo` |
|
||||||
|
|
||||||
|
### Setup (Windows)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd D:\ForgeCommander\forge-repo
|
# 1. Build Forge (one-time)
|
||||||
|
cd D:\ForgeCommander\Forge-Repo
|
||||||
mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Build Commander AI Lab
|
# 2. Build Commander AI Lab
|
||||||
```powershell
|
|
||||||
cd D:\ForgeCommander\commander-ai-lab
|
cd D:\ForgeCommander\commander-ai-lab
|
||||||
mvn package "-DskipTests"
|
mvn package "-DskipTests"
|
||||||
```
|
|
||||||
This produces `target/commander-ai-lab-1.0.0-SNAPSHOT.jar` (shaded fat JAR).
|
|
||||||
|
|
||||||
### 3. Install Python dependencies
|
# 3. Install Python dependencies
|
||||||
```powershell
|
pip install -e ".[rag,dev]"
|
||||||
pip install fastapi uvicorn
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Launch the Web UI
|
# 4. Pull an Ollama model for the deck builder
|
||||||
**Option A — One-click launcher:**
|
ollama pull qwen2.5:7b
|
||||||
```powershell
|
|
||||||
|
# 5. Launch
|
||||||
.\start-lab.bat
|
.\start-lab.bat
|
||||||
|
# → Web UI: http://localhost:8080
|
||||||
|
# → LAN Client: http://localhost:8080/lan/
|
||||||
```
|
```
|
||||||
Edit the paths at the top of `start-lab.bat` if needed.
|
|
||||||
|
|
||||||
**Option B — Manual launch:**
|
Or use the automated installer:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python lab_api.py `
|
.\install-commander-ai-lab.ps1
|
||||||
--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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:8080` — select 3 decks, set game count, click **Run AI Sims**.
|
---
|
||||||
|
|
||||||
|
## Overnight AFK Runner
|
||||||
|
|
||||||
|
Run batch simulations unattended, then automatically update card weights and train the neural net:
|
||||||
|
|
||||||
### 5. CLI-only usage (no web server)
|
|
||||||
```powershell
|
```powershell
|
||||||
cd D:\ForgeCommander\forge-repo\forge-gui
|
python overnight-run.py # default: 11.5h DeepSeek sims
|
||||||
|
python overnight-run.py --batches 20 --games 50 # fixed batch count
|
||||||
java -jar "D:\ForgeCommander\commander-ai-lab\target\commander-ai-lab-1.0.0-SNAPSHOT.jar" `
|
python overnight-run.py --no-update-weights # skip weight update
|
||||||
--forge-jar "..\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
|
python overnight-run.py --no-train # skip neural net training
|
||||||
--forge-dir "." `
|
|
||||||
--deck1 "Edgar Markov" `
|
|
||||||
--deck2 "Grimgrin" `
|
|
||||||
--deck3 "Xyris" `
|
|
||||||
--games 100 `
|
|
||||||
--threads 4 `
|
|
||||||
--output results/batch-100.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Scaling Tests
|
**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
|
||||||
|
|
||||||
### Single-threaded baseline (10 games)
|
---
|
||||||
```powershell
|
|
||||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
## Simulation → Deck Builder Feedback Loop
|
||||||
--forge-jar "..." --forge-dir "..." `
|
|
||||||
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
```
|
||||||
--games 10 --threads 1 --output results/baseline-10.json
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multi-threaded (10 games, 2 threads)
|
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. The loop is a no-op until real sim data exists.
|
||||||
```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.
|
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
|
### Simulation
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/api/lab/decks` | GET | List available Commander decks |
|
| `/api/lab/decks` | GET | List available Commander decks |
|
||||||
| `/api/lab/start` | POST | Start a batch run (returns batchId) |
|
| `/api/lab/start` | POST | Start Forge batch (returns batchId) |
|
||||||
| `/api/lab/status?batchId=X` | GET | Poll progress for a running batch |
|
| `/api/lab/start-deepseek` | POST | Start Python/DeepSeek batch |
|
||||||
| `/api/lab/result?batchId=X` | GET | Get completed batch result JSON |
|
| `/api/lab/status?batchId=X` | GET | Poll batch progress |
|
||||||
| `/api/lab/log?batchId=X` | GET | Get live log lines from running batch |
|
| `/api/lab/result?batchId=X` | GET | Get completed batch JSON |
|
||||||
| `/api/lab/history` | GET | List past batch result files |
|
| `/api/lab/history` | GET | List past batch runs |
|
||||||
|
| `/api/lab/precons/deck` | GET | Fetch precon `.dck` file content |
|
||||||
|
|
||||||
### POST /api/lab/start
|
### Deck Builder & Coach
|
||||||
```json
|
|
||||||
{
|
|
||||||
"decks": ["Edgar Markov", "Grimgrin", "Xyris"],
|
|
||||||
"numGames": 100,
|
|
||||||
"threads": 4,
|
|
||||||
"seed": null,
|
|
||||||
"clock": 120
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /api/lab/status response
|
| Endpoint | Method | Description |
|
||||||
```json
|
|---|---|---|
|
||||||
{
|
| `/api/deck-builder/build` | POST | Build a 99-card Commander deck |
|
||||||
"batchId": "abc123",
|
| `/api/coach/chat` | POST | Full coaching session (streaming) |
|
||||||
"running": true,
|
| `/api/coach/quick-digest` | POST | Fast ~5s coaching summary |
|
||||||
"completed": 42,
|
|
||||||
"total": 100,
|
|
||||||
"threads": 4,
|
|
||||||
"elapsedMs": 156000,
|
|
||||||
"error": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Python Simulator (Monte Carlo Engine)
|
### ML & Policy
|
||||||
|
|
||||||
A pure-Python deck simulator for rapid Monte Carlo analysis — no Forge JVM required.
|
| Endpoint | Method | Description |
|
||||||
Ported from the [mtg-commander-lan](https://github.com/) Node.js simulator.
|
|---|---|---|
|
||||||
|
| `/api/ml/train` | POST | Start neural net training |
|
||||||
|
| `/api/ml/train/status` | GET | Poll training progress |
|
||||||
|
| `/api/policy/decide` | POST | Get ML policy decision for game state |
|
||||||
|
|
||||||
### Location
|
### RAG
|
||||||
|
|
||||||
```
|
| Endpoint | Method | Description |
|
||||||
src/commander_ai_lab/
|
|---|---|---|
|
||||||
├── __init__.py
|
| `/api/rag/build` | POST | Build/rebuild ChromaDB index |
|
||||||
├── sim/ # Core simulation engine
|
| `/api/rag/search` | POST | Semantic card search |
|
||||||
│ ├── __init__.py # Public exports
|
| `/api/rag/status` | GET | Index health and staleness |
|
||||||
│ ├── 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
|
### Live Game
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/game/start` | POST | Create a live game session |
|
||||||
|
| `/api/game/action` | POST | Submit a player action |
|
||||||
|
| `/api/game/{id}/actions` | GET | Poll Forge IPC actions |
|
||||||
|
| `/ws/game/{game_id}` | WS | Real-time game state streaming |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ML Scripts
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd D:\ForgeCommander\commander-ai-lab
|
# Update card weights from decision logs
|
||||||
pip install -e .
|
python -m ml.scripts.update_weights --min-games 10 --lr 0.05 --dry-run
|
||||||
|
|
||||||
|
# Reset weights to defaults
|
||||||
|
python -m ml.scripts.update_weights --reset
|
||||||
|
|
||||||
|
# Generate synthetic training data
|
||||||
|
python ml/scripts/generate_synthetic_data.py
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs `commander_ai_lab` as an importable package and registers the `commander-sim` CLI command.
|
---
|
||||||
|
|
||||||
### Quick smoke test
|
## Python Simulator API
|
||||||
|
|
||||||
```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
|
```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
|
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"] * 36)
|
||||||
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")
|
stats = run_simulation(deck, num_games=100, name_a="My Deck")
|
||||||
print(f"Win rate: {stats['win_rate']:.1%}")
|
print(f"Win rate: {stats['win_rate']:.1%}, Avg turns: {stats['avg_turns']:.1f}")
|
||||||
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.
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
commander-ai-lab/
|
commander-ai-lab/
|
||||||
├── lab_api.py # Python FastAPI server (web UI backend)
|
├── lab_api.py # FastAPI server entry point
|
||||||
|
├── overnight-run.py # AFK batch runner
|
||||||
├── start-lab.bat # Windows one-click launcher
|
├── start-lab.bat # Windows one-click launcher
|
||||||
├── pom.xml # Maven build (Java 17, shaded JAR)
|
├── install-commander-ai-lab.ps1 # Automated setup script
|
||||||
├── pyproject.toml # Python package config (src/ layout)
|
├── pom.xml # Maven build (Java/Forge components)
|
||||||
├── ui/
|
├── pyproject.toml # Python package config
|
||||||
│ ├── index.html # Web UI entry point
|
│
|
||||||
│ ├── ai-lab.js # UI controller (deck selection, progress, results)
|
├── routes/ # FastAPI route modules
|
||||||
│ ├── ai-lab.css # Styles (dark theme, responsive)
|
│ ├── lab.py # Batch simulation
|
||||||
│ ├── collection.html # Card collection manager
|
│ ├── deckbuilder.py # Deck builder pipeline
|
||||||
│ ├── collection.js # Collection controller
|
│ ├── coach.py # LLM coaching
|
||||||
│ ├── collection.css # Collection styles
|
│ ├── ml.py # ML training
|
||||||
│ ├── deckbuilder.html # Deck builder + EDH advisor
|
│ ├── rag.py # ChromaDB vector search
|
||||||
│ ├── deckbuilder.js # Deck builder controller
|
│ ├── game.py # Live game sessions
|
||||||
│ └── deckbuilder.css # Deck builder styles
|
│ ├── policy.py # ML policy server
|
||||||
├── src/commander_ai_lab/ # Python simulator package
|
│ ├── precon.py # Precon deck serving
|
||||||
│ ├── __init__.py
|
│ ├── scanner.py # Card OCR
|
||||||
│ ├── sim/ # Core engine (models, engine, rules)
|
│ └── ws_game.py # WebSocket game state
|
||||||
│ └── lab/ # Experiment helpers
|
│
|
||||||
├── src/main/java/commanderailab/ # Java batch runner (Forge integration)
|
├── services/ # Core service layer
|
||||||
│ ├── cli/LabCli.java # CLI entry point (picocli)
|
│ ├── forge_runner.py # Forge subprocess management
|
||||||
│ ├── batch/
|
│ ├── draw_game_analyzer.py # Draw/timeout analysis
|
||||||
│ │ ├── BatchRunner.java # Single-thread runner (parses Forge output)
|
│ ├── rag_store.py # ChromaDB vector store
|
||||||
│ │ └── MultiThreadBatchRunner.java # Multi-thread runner (ExecutorService)
|
│ └── card_text.py # Shared card text builder
|
||||||
│ ├── bridge/WebApiBridge.java # Java-side web API bridge
|
│
|
||||||
│ ├── schema/
|
├── src/commander_ai_lab/
|
||||||
│ │ ├── BatchResult.java # Data model POJOs
|
│ ├── sim/ # Monte Carlo engine
|
||||||
│ │ └── JsonExporter.java # JSON serialization + validation
|
│ │ ├── engine.py # Game loop
|
||||||
│ ├── stats/StatsAggregator.java # Win rates, turn counts, breakdowns
|
│ │ ├── models.py # GameResult, Card, Player
|
||||||
│ └── ai/
|
│ │ ├── rules.py # Combat, keywords, commander rules
|
||||||
│ ├── AiPolicy.java # Policy interface
|
│ │ ├── win_condition_parser.py # Alternate win detection
|
||||||
│ └── ForgeBuiltinPolicy.java # v1 Forge AI wrapper
|
│ │ └── learned_weights.json # ML-learned card weights
|
||||||
├── scanner/ # Card scanner (OCR pipeline)
|
│ ├── deck_builder/ # 7-step LLM pipeline
|
||||||
│ ├── __init__.py
|
│ │ ├── api/ # Scryfall, EDHrec, Ollama, sim_insights
|
||||||
│ ├── ocr.py # Tesseract OCR wrapper
|
│ │ ├── core/ # Models, rules engine, assembler
|
||||||
│ ├── pipeline.py # Image preprocessing pipeline
|
│ │ └── pipeline/ # build_deck.py orchestrator
|
||||||
│ └── preprocess.py # Image preprocessing helpers
|
│ └── coach/ # LLM coaching
|
||||||
├── docs/
|
│ ├── coach_service.py # Session + quick digest
|
||||||
│ ├── PROJECT_VISION.md
|
│ ├── prompt_template.py # System/user prompts
|
||||||
│ ├── batch-result-schema.json
|
│ └── embeddings.py # Card embedding utilities
|
||||||
│ └── game-log-schema.json
|
│
|
||||||
├── sample-decks/ # 3 sample .dck files
|
├── ml/ # ML training pipeline
|
||||||
├── precon-decks/ # Preconstructed decks + index
|
│ ├── scripts/ # update_weights.py, ml_cli.py
|
||||||
├── results/ # Batch output JSON files (gitignored)
|
│ ├── training/ # Neural net trainer
|
||||||
└── src/test/java/ # Unit tests
|
│ ├── encoder/ # Feature encoding
|
||||||
|
│ └── models/ # Saved checkpoints
|
||||||
|
│
|
||||||
|
├── lan-client/ # Browser-based LAN battlefield
|
||||||
|
│ ├── index.html # Entry point
|
||||||
|
│ ├── engine.js # Game engine + ML AI turns
|
||||||
|
│ ├── ai-bridge.js # Backend connector + policy bridge
|
||||||
|
│ ├── player-panels.js # Enhanced player panels
|
||||||
|
│ ├── ui.js # 3-tab deck selector + setup
|
||||||
|
│ └── precons.js # 163 precon deck data
|
||||||
|
│
|
||||||
|
├── unity-client/ # Unity game client
|
||||||
|
│ └── Assets/
|
||||||
|
│ ├── Scenes/ # 11 scenes (MainMenu → Battleground)
|
||||||
|
│ ├── Scripts/ # C# controllers, models, services
|
||||||
|
│ ├── Prefabs/ # Card, deck slot, chat bubble
|
||||||
|
│ └── Materials/ # URP materials + grayscale shader
|
||||||
|
│
|
||||||
|
├── scanner/ # Card OCR pipeline (Ximilar)
|
||||||
|
├── precon-decks/ # 163 preconstructed decks (.dck)
|
||||||
|
├── sample-decks/ # Sample deck files
|
||||||
|
├── data/ # Runtime data (ChromaDB, bulk DB)
|
||||||
|
├── logs/ # Simulation + decision logs
|
||||||
|
├── docs/ # Architecture docs + schemas
|
||||||
|
├── tests/ # Test suite
|
||||||
|
├── scripts/ # Utility scripts
|
||||||
|
├── .github/workflows/ # CI + Unity build automation
|
||||||
|
└── src/main/java/ # Java/Forge batch runner
|
||||||
|
└── commanderailab/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Output Format
|
---
|
||||||
|
|
||||||
Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
|
## Tech Stack
|
||||||
```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
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Python 3.10+, FastAPI, Uvicorn |
|
||||||
|
| Simulation (rules-complete) | Java 17, Maven, Forge |
|
||||||
|
| Simulation (fast) | Python Monte Carlo engine |
|
||||||
|
| LLM Inference | Ollama (qwen2.5:7b), Anthropic Claude |
|
||||||
|
| Vector Store | ChromaDB with all-MiniLM-L6-v2 embeddings |
|
||||||
|
| Card Database | SQLite FTS5 (Scryfall bulk data) |
|
||||||
|
| ML Training | PyTorch, PPO reinforcement learning |
|
||||||
|
| Web Client | HTML/CSS/JS |
|
||||||
|
| Unity Client | Unity 2022 LTS, URP, C# |
|
||||||
|
| CI/CD | GitHub Actions |
|
||||||
|
|
||||||
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
|
## 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
|
- **Monte Carlo engine** — no stack, no instants, no continuous type-changing effects (animated lands don't attack)
|
||||||
- Each game spawns a new JVM process (~2-3s overhead per game)
|
- **Forge engine** — each game spawns a new JVM (~2–3s overhead); ~0.04 sims/sec single-threaded
|
||||||
- Max throughput ~0.04 sims/sec single-threaded (Forge sim is CPU-heavy)
|
- **Forge** — winner life total not captured in quiet mode (only reports losers)
|
||||||
- Multithreading spawns parallel subprocesses — throughput scales linearly with threads up to CPU core count
|
- **Deck builder** — Ollama suggestions depend on the local model quality; `qwen2.5:7b` works well
|
||||||
|
- **LAN client** — EDHREC commander search is a stub (backend-powered, not yet fully wired)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Commander AI Lab is an active research project. Check the [open issues](https://github.com/KoalaTrapLord/commander-ai-lab/issues) for areas where contributions are welcome.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See repository for license details.
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Smoke-tests the Commander AI Lab policy server from PowerShell.
|
||||||
|
Checks: server reachable -> /api/policy/health -> /api/policy/decide.
|
||||||
|
Prints a plain-English pass/fail for every step.
|
||||||
|
|
||||||
|
.USAGE
|
||||||
|
# From repo root (server already running in another window):
|
||||||
|
.\Test-PolicyServer.ps1
|
||||||
|
|
||||||
|
# Custom host/port:
|
||||||
|
.\Test-PolicyServer.ps1 -BaseUrl http://localhost:9000
|
||||||
|
|
||||||
|
# Pretty-print the full decide response:
|
||||||
|
.\Test-PolicyServer.ps1 -Verbose
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$BaseUrl = "http://localhost:8080",
|
||||||
|
[switch]$Verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# ---- helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function Write-Pass { param([string]$msg) Write-Host " [PASS] $msg" -ForegroundColor Green }
|
||||||
|
function Write-Fail { param([string]$msg) Write-Host " [FAIL] $msg" -ForegroundColor Red }
|
||||||
|
function Write-Info { param([string]$msg) Write-Host " [INFO] $msg" -ForegroundColor Cyan }
|
||||||
|
function Write-Warn { param([string]$msg) Write-Host " [WARN] $msg" -ForegroundColor Yellow }
|
||||||
|
function Write-Step { param([string]$msg) Write-Host "`n-- $msg" -ForegroundColor White }
|
||||||
|
|
||||||
|
$allPassed = $true
|
||||||
|
|
||||||
|
function Invoke-Step {
|
||||||
|
param(
|
||||||
|
[string] $Name,
|
||||||
|
[scriptblock] $Block
|
||||||
|
)
|
||||||
|
Write-Step $Name
|
||||||
|
try { & $Block }
|
||||||
|
catch {
|
||||||
|
Write-Fail $_.Exception.Message
|
||||||
|
$script:allPassed = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Step 1: server reachable -----------------------------------------------
|
||||||
|
|
||||||
|
Invoke-Step "Step 1 -- Server reachable at $BaseUrl" {
|
||||||
|
$r1 = $null
|
||||||
|
try { $r1 = Invoke-WebRequest -Uri "$BaseUrl/docs" -UseBasicParsing -TimeoutSec 5 } catch {}
|
||||||
|
if ($r1 -and $r1.StatusCode -eq 200) {
|
||||||
|
Write-Pass "FastAPI server is up (GET /docs -> 200)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$r2 = $null
|
||||||
|
try { $r2 = Invoke-WebRequest -Uri "$BaseUrl/" -UseBasicParsing -TimeoutSec 5 } catch {}
|
||||||
|
if ($r2 -and $r2.StatusCode -lt 500) {
|
||||||
|
Write-Pass "FastAPI server is up (GET / -> $($r2.StatusCode))"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw "Cannot reach $BaseUrl -- is lab_api.py / uvicorn running?"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Step 2: /api/policy/health ---------------------------------------------
|
||||||
|
|
||||||
|
$modelLoaded = $false
|
||||||
|
|
||||||
|
Invoke-Step "Step 2 -- GET $BaseUrl/api/policy/health" {
|
||||||
|
$resp = Invoke-RestMethod -Uri "$BaseUrl/api/policy/health" -Method Get -TimeoutSec 10
|
||||||
|
if ($Verbose) { $resp | ConvertTo-Json -Depth 5 | Write-Host }
|
||||||
|
|
||||||
|
if ($resp.ready -eq $true) {
|
||||||
|
Write-Pass "Model is loaded and ready (ready=true)"
|
||||||
|
$script:modelLoaded = $true
|
||||||
|
} elseif ($resp.status -eq "degraded") {
|
||||||
|
Write-Fail "Health returned status=degraded / ready=false"
|
||||||
|
Write-Warn " -> Policy model is NOT loaded."
|
||||||
|
Write-Warn " -> Start the server with --load-policy, or call POST /api/ml/load_model first."
|
||||||
|
Write-Warn " -> Confirm models/ contains a valid .pt checkpoint."
|
||||||
|
$script:allPassed = $false
|
||||||
|
} else {
|
||||||
|
Write-Warn "Unexpected health response -- dumping:"
|
||||||
|
$resp | ConvertTo-Json -Depth 5 | Write-Host
|
||||||
|
$script:allPassed = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Step 3: /api/policy/decide (synthetic snapshot) ------------------------
|
||||||
|
|
||||||
|
Invoke-Step "Step 3 -- POST $BaseUrl/api/policy/decide (synthetic 4-player snapshot)" {
|
||||||
|
|
||||||
|
if (-not $script:modelLoaded) {
|
||||||
|
Write-Warn "Skipping /decide -- model not loaded (Step 2 failed)."
|
||||||
|
Write-Warn "Fix Step 2 first, then re-run."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = [ordered]@{
|
||||||
|
game_id = "ps1-smoke-test"
|
||||||
|
turn = 3
|
||||||
|
phase = "MAIN1"
|
||||||
|
active_player = 0
|
||||||
|
playstyle = "midrange"
|
||||||
|
temperature = 1.0
|
||||||
|
greedy = $false
|
||||||
|
players = @(
|
||||||
|
[ordered]@{
|
||||||
|
seat = 0
|
||||||
|
name = "TestPlayer0"
|
||||||
|
life = 40
|
||||||
|
manaAvailable = 3
|
||||||
|
landCount = 3
|
||||||
|
creaturesOnField = 1
|
||||||
|
totalPowerOnBoard = 2
|
||||||
|
hand = @("Cultivate", "Solemn Simulacrum", "Sol Ring")
|
||||||
|
battlefield = @("Forest", "Forest", "Forest", "Birds of Paradise")
|
||||||
|
graveyard = @()
|
||||||
|
commandZone = @("Atraxa, Praetors Voice")
|
||||||
|
},
|
||||||
|
[ordered]@{
|
||||||
|
seat = 1
|
||||||
|
name = "TestPlayer1"
|
||||||
|
life = 38
|
||||||
|
manaAvailable = 2
|
||||||
|
landCount = 2
|
||||||
|
creaturesOnField = 0
|
||||||
|
totalPowerOnBoard = 0
|
||||||
|
handCount = 4
|
||||||
|
hand = @()
|
||||||
|
battlefield = @("Island", "Swamp")
|
||||||
|
graveyard = @()
|
||||||
|
commandZone = @("Ur-Dragon")
|
||||||
|
},
|
||||||
|
[ordered]@{
|
||||||
|
seat = 2
|
||||||
|
name = "TestPlayer2"
|
||||||
|
life = 35
|
||||||
|
manaAvailable = 4
|
||||||
|
landCount = 4
|
||||||
|
creaturesOnField = 2
|
||||||
|
totalPowerOnBoard = 5
|
||||||
|
handCount = 3
|
||||||
|
hand = @()
|
||||||
|
battlefield = @("Mountain", "Mountain", "Mountain", "Mountain", "Goblin Guide", "Monastery Swiftspear")
|
||||||
|
graveyard = @()
|
||||||
|
commandZone = @("Krenko, Mob Boss")
|
||||||
|
},
|
||||||
|
[ordered]@{
|
||||||
|
seat = 3
|
||||||
|
name = "TestPlayer3"
|
||||||
|
life = 40
|
||||||
|
manaAvailable = 0
|
||||||
|
landCount = 0
|
||||||
|
creaturesOnField = 0
|
||||||
|
totalPowerOnBoard = 0
|
||||||
|
handCount = 7
|
||||||
|
hand = @()
|
||||||
|
battlefield = @()
|
||||||
|
graveyard = @()
|
||||||
|
commandZone = @("Prossh, Skyraider of Kher")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} | ConvertTo-Json -Depth 10
|
||||||
|
|
||||||
|
$resp = Invoke-RestMethod `
|
||||||
|
-Uri "$BaseUrl/api/policy/decide" `
|
||||||
|
-Method Post `
|
||||||
|
-Body $body `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-TimeoutSec 30
|
||||||
|
|
||||||
|
if ($Verbose) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Full /decide response:" -ForegroundColor White
|
||||||
|
$resp | ConvertTo-Json -Depth 6 | Write-Host
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $true
|
||||||
|
if ([string]::IsNullOrEmpty($resp.action)) {
|
||||||
|
Write-Fail "response.action is empty"
|
||||||
|
$ok = $false
|
||||||
|
}
|
||||||
|
if ($null -eq $resp.confidence) {
|
||||||
|
Write-Fail "response.confidence is missing"
|
||||||
|
$ok = $false
|
||||||
|
}
|
||||||
|
if ($resp.PSObject.Properties["error"] -and -not [string]::IsNullOrEmpty($resp.error)) {
|
||||||
|
Write-Fail "Server returned error: $($resp.error)"
|
||||||
|
$ok = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ok) {
|
||||||
|
$confPct = [math]::Round($resp.confidence * 100, 1)
|
||||||
|
Write-Pass "Decision received -> action='$($resp.action)' conf=$($confPct)% latency=$($resp.inference_ms)ms src=$($resp.vector_source)"
|
||||||
|
|
||||||
|
if ($resp.probabilities) {
|
||||||
|
$top3 = $resp.probabilities.PSObject.Properties |
|
||||||
|
Sort-Object { $_.Value } -Descending |
|
||||||
|
Select-Object -First 3
|
||||||
|
Write-Info "Top-3 action probabilities:"
|
||||||
|
foreach ($p in $top3) {
|
||||||
|
$pct = [math]::Round($p.Value * 100, 1)
|
||||||
|
Write-Info (" " + $p.Name.PadRight(22) + "$pct%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$script:allPassed = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Step 4: log line guidance ----------------------------------------------
|
||||||
|
|
||||||
|
Invoke-Step "Step 4 -- Confirm [routes.policy.decide] log line in server console" {
|
||||||
|
Write-Info "After Step 3 you should see in the uvicorn window:"
|
||||||
|
Write-Info " [decide] turn=3 phase=main_1 -> cast_ramp (conf=0.810) | top5=[...] | src=encoder"
|
||||||
|
Write-Info ""
|
||||||
|
Write-Info "If that line is MISSING:"
|
||||||
|
Write-Info " 1. Confirm uvicorn started with --log-level info (the default)"
|
||||||
|
Write-Info " 2. Confirm lab_api.py calls register_policy_routes(app, policy_svc)"
|
||||||
|
Write-Info " 3. Confirm routes/policy.py is included via routes/__init__.py"
|
||||||
|
Write-Info " 4. Logger name is [routes.policy.decide] -- check no filter silences it"
|
||||||
|
Write-Pass "Guidance printed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Step 5: why Forge ignores the policy server ----------------------------
|
||||||
|
|
||||||
|
Invoke-Step "Step 5 -- Forge IPC status (informational)" {
|
||||||
|
Write-Warn "Forge does NOT call /api/policy/decide on its own."
|
||||||
|
Write-Warn "routes/game.py POST /api/game/start has a TODO: wire Forge engine launch."
|
||||||
|
Write-Warn ""
|
||||||
|
Write-Warn "Until wired, /decide is only hit by:"
|
||||||
|
Write-Warn " - This script"
|
||||||
|
Write-Warn " - overnight-run.py (batch sim path)"
|
||||||
|
Write-Warn " - Manual Invoke-RestMethod / curl calls"
|
||||||
|
Write-Warn ""
|
||||||
|
Write-Warn "Forge launches its built-in AI and plays lands because it has"
|
||||||
|
Write-Warn "no HTTP client pointed at localhost:8080."
|
||||||
|
Write-Warn ""
|
||||||
|
Write-Warn "Next step: wire GameSession inside POST /api/game/start,"
|
||||||
|
Write-Warn "or add a PolicyClient hook in Forge AiController.java."
|
||||||
|
Write-Pass "Status explained"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Summary ----------------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
if ($allPassed) {
|
||||||
|
Write-Host "==========================================" -ForegroundColor Green
|
||||||
|
Write-Host " ALL STEPS PASSED -- policy server live" -ForegroundColor Green
|
||||||
|
Write-Host "==========================================" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "==========================================" -ForegroundColor Red
|
||||||
|
Write-Host " ONE OR MORE STEPS FAILED -- see above" -ForegroundColor Red
|
||||||
|
Write-Host "==========================================" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
+215
-39
@@ -5,7 +5,6 @@ Main orchestrator: loads deck reports, identifies underperformers,
|
|||||||
finds replacement candidates via embeddings, builds prompts,
|
finds replacement candidates via embeddings, builds prompts,
|
||||||
calls the LLM, parses responses, and persists coaching sessions.
|
calls the LLM, parses responses, and persists coaching sessions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
@@ -24,6 +23,10 @@ from .config import (
|
|||||||
from .models import (
|
from .models import (
|
||||||
DeckReport, CoachGoals, CoachSession,
|
DeckReport, CoachGoals, CoachSession,
|
||||||
SuggestedCut, SuggestedAdd, CoachStatus,
|
SuggestedCut, SuggestedAdd, CoachStatus,
|
||||||
|
UpgradePriorityItem, CommanderDependency, MulliganAnalysis,
|
||||||
|
RemovalCoverage, RampQuality, DrawEngineProfile,
|
||||||
|
WinCondition, AntiSynergyFlag,
|
||||||
|
PodPresence, TempoAssessment, MetaMatchup,
|
||||||
)
|
)
|
||||||
from .llm_client import LLMClient
|
from .llm_client import LLMClient
|
||||||
from .embeddings import MTGEmbeddingIndex
|
from .embeddings import MTGEmbeddingIndex
|
||||||
@@ -61,8 +64,7 @@ class CoachService:
|
|||||||
self.embeddings = embeddings
|
self.embeddings = embeddings
|
||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
|
|
||||||
# ── Deck Report I/O ────────────────────────────────────────
|
# ── Deck Report I/O ──────────────────────
|
||||||
|
|
||||||
def load_deck_report(self, deck_id: str) -> Optional[DeckReport]:
|
def load_deck_report(self, deck_id: str) -> Optional[DeckReport]:
|
||||||
"""Load a deck report from disk."""
|
"""Load a deck report from disk."""
|
||||||
report_path = DECK_REPORTS_DIR / f"{deck_id}.json"
|
report_path = DECK_REPORTS_DIR / f"{deck_id}.json"
|
||||||
@@ -71,13 +73,11 @@ class CoachService:
|
|||||||
if re.sub(r'[^a-z0-9]', '', f.stem.lower()) == re.sub(r'[^a-z0-9]', '', deck_id.lower()):
|
if re.sub(r'[^a-z0-9]', '', f.stem.lower()) == re.sub(r'[^a-z0-9]', '', deck_id.lower()):
|
||||||
report_path = f
|
report_path = f
|
||||||
break
|
break
|
||||||
|
|
||||||
if not report_path.exists():
|
if not report_path.exists():
|
||||||
logger.warning("Deck report not found: %s", deck_id)
|
logger.warning("Deck report not found: %s", deck_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(report_path, "r") as f:
|
with open(report_path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return DeckReport(**data)
|
return DeckReport(**data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -90,13 +90,12 @@ class CoachService:
|
|||||||
return []
|
return []
|
||||||
return [f.stem for f in DECK_REPORTS_DIR.glob("*.json")]
|
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:
|
def save_session(self, session: CoachSession) -> Path:
|
||||||
"""Persist a coaching session to disk and SQLite."""
|
"""Persist a coaching session to disk and SQLite."""
|
||||||
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
|
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
|
||||||
with open(path, "w") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
f.write(session.model_dump_json(indent=2))
|
f.write(session.model_dump_json(indent=2))
|
||||||
if _DB_AVAILABLE:
|
if _DB_AVAILABLE:
|
||||||
try:
|
try:
|
||||||
@@ -148,7 +147,7 @@ class CoachService:
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with open(path, "r") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
return CoachSession(**data)
|
return CoachSession(**data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -169,7 +168,7 @@ class CoachService:
|
|||||||
sessions = []
|
sessions = []
|
||||||
for f in sorted(COACH_SESSIONS_DIR.glob("*.json"), reverse=True):
|
for f in sorted(COACH_SESSIONS_DIR.glob("*.json"), reverse=True):
|
||||||
try:
|
try:
|
||||||
with open(f, "r") as fh:
|
with open(f, "r", encoding="utf-8") as fh:
|
||||||
data = json.load(fh)
|
data = json.load(fh)
|
||||||
if deck_id and data.get("deckId", "") != deck_id:
|
if deck_id and data.get("deckId", "") != deck_id:
|
||||||
continue
|
continue
|
||||||
@@ -185,8 +184,7 @@ class CoachService:
|
|||||||
continue
|
continue
|
||||||
return sessions
|
return sessions
|
||||||
|
|
||||||
# ── Main Coaching Pipeline ─────────────────────────────────
|
# ── Main Coaching Pipeline ───────────────────────
|
||||||
|
|
||||||
async def run_coaching_session(
|
async def run_coaching_session(
|
||||||
self,
|
self,
|
||||||
deck_id: str,
|
deck_id: str,
|
||||||
@@ -203,7 +201,6 @@ class CoachService:
|
|||||||
6. Parse response
|
6. Parse response
|
||||||
7. Persist session
|
7. Persist session
|
||||||
"""
|
"""
|
||||||
# 0. Auto-load embeddings if not loaded
|
|
||||||
if not self.embeddings.loaded:
|
if not self.embeddings.loaded:
|
||||||
logger.info("Embeddings not loaded — attempting auto-load...")
|
logger.info("Embeddings not loaded — attempting auto-load...")
|
||||||
try:
|
try:
|
||||||
@@ -215,7 +212,6 @@ class CoachService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Embeddings auto-load failed: %s", e)
|
logger.warning("Embeddings auto-load failed: %s", e)
|
||||||
|
|
||||||
# 1. Load deck report
|
|
||||||
report = self.load_deck_report(deck_id)
|
report = self.load_deck_report(deck_id)
|
||||||
if report is None and fallback_report is not None:
|
if report is None and fallback_report is not None:
|
||||||
report = fallback_report
|
report = fallback_report
|
||||||
@@ -223,10 +219,7 @@ class CoachService:
|
|||||||
if report is None:
|
if report is None:
|
||||||
raise ValueError(f"Deck report not found for: {deck_id}")
|
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]
|
deck_card_names = [c.name for c in report.cards]
|
||||||
|
|
||||||
# 3. Find replacement candidates for underperformers
|
|
||||||
candidates: Dict[str, List[dict]] = {}
|
candidates: Dict[str, List[dict]] = {}
|
||||||
underperformers = report.underperformers[:MAX_UNDERPERFORMERS]
|
underperformers = report.underperformers[:MAX_UNDERPERFORMERS]
|
||||||
if not underperformers:
|
if not underperformers:
|
||||||
@@ -255,19 +248,47 @@ class CoachService:
|
|||||||
else:
|
else:
|
||||||
logger.warning("Embeddings not loaded — skipping candidate search")
|
logger.warning("Embeddings not loaded — skipping candidate search")
|
||||||
|
|
||||||
# 4. Build prompts
|
# Phase 4a: behavior-aware RAG — primary + replacement-effect secondary query
|
||||||
system_prompt = build_system_prompt(report, goals)
|
rag_cards: List[dict] = []
|
||||||
user_prompt = build_user_prompt(report, candidates)
|
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)
|
||||||
|
|
||||||
# 5. Call LLM
|
# 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, rag_cards=rag_cards, rules_context=rules_context)
|
||||||
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
|
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
|
||||||
if COACH_PROVIDER == "anthropic":
|
if COACH_PROVIDER == "anthropic":
|
||||||
import anthropic as _anthropic
|
import anthropic as _anthropic
|
||||||
anthropic_key = ANTHROPIC_API_KEY
|
anthropic_key = ANTHROPIC_API_KEY
|
||||||
if not anthropic_key:
|
if not anthropic_key:
|
||||||
raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.")
|
raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.")
|
||||||
|
|
||||||
# Use streaming to avoid the Anthropic SDK 10-minute non-streaming timeout.
|
|
||||||
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
|
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
|
||||||
async with aclient.messages.stream(
|
async with aclient.messages.stream(
|
||||||
model=ANTHROPIC_MODEL,
|
model=ANTHROPIC_MODEL,
|
||||||
@@ -278,10 +299,7 @@ class CoachService:
|
|||||||
) as stream:
|
) as stream:
|
||||||
content = await stream.get_final_text()
|
content = await stream.get_final_text()
|
||||||
final_msg = await stream.get_final_message()
|
final_msg = await stream.get_final_message()
|
||||||
|
usage = getattr(final_msg, "usage", None)
|
||||||
usage = getattr(final_msg, "usage", None)
|
|
||||||
|
|
||||||
# Build an LLMResponse-compatible object
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
llm_response = SimpleNamespace(
|
llm_response = SimpleNamespace(
|
||||||
content=content,
|
content=content,
|
||||||
@@ -304,7 +322,6 @@ class CoachService:
|
|||||||
else:
|
else:
|
||||||
llm_response = await self.llm.chat(system_prompt, user_prompt)
|
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_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
||||||
session = CoachSession(
|
session = CoachSession(
|
||||||
sessionId=session_id,
|
sessionId=session_id,
|
||||||
@@ -315,24 +332,106 @@ class CoachService:
|
|||||||
completionTokens=llm_response.completion_tokens,
|
completionTokens=llm_response.completion_tokens,
|
||||||
goals=goals,
|
goals=goals,
|
||||||
)
|
)
|
||||||
|
|
||||||
if llm_response.parsed_json:
|
if llm_response.parsed_json:
|
||||||
self._populate_session_from_json(session, llm_response.parsed_json)
|
self._populate_session_from_json(session, llm_response.parsed_json)
|
||||||
else:
|
else:
|
||||||
session.rawTextExplanation = llm_response.content
|
session.rawTextExplanation = llm_response.content
|
||||||
session.summary = "LLM returned non-JSON response. See raw explanation."
|
session.summary = "LLM returned non-JSON response. See raw explanation."
|
||||||
|
|
||||||
# 7. Persist
|
|
||||||
self.save_session(session)
|
self.save_session(session)
|
||||||
logger.info("Coaching session saved: %s", session_id)
|
logger.info("Coaching session saved: %s", session_id)
|
||||||
return session
|
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):
|
def _populate_session_from_json(self, session: CoachSession, data: dict):
|
||||||
"""Parse LLM JSON response into CoachSession fields."""
|
"""Parse LLM JSON response into CoachSession fields."""
|
||||||
session.summary = data.get("summary", "")
|
session.summary = data.get("summary", "")
|
||||||
session.rawTextExplanation = data.get("rawTextExplanation", "")
|
session.rawTextExplanation = data.get("rawTextExplanation", "")
|
||||||
session.manaBaseAdvice = data.get("manaBaseAdvice")
|
session.manaBaseAdvice = data.get("manaBaseAdvice")
|
||||||
|
|
||||||
hints = data.get("heuristicHints", [])
|
hints = data.get("heuristicHints", [])
|
||||||
session.heuristicHints = hints if isinstance(hints, list) else [str(hints)]
|
session.heuristicHints = hints if isinstance(hints, list) else [str(hints)]
|
||||||
|
|
||||||
@@ -343,7 +442,6 @@ class CoachService:
|
|||||||
reason=cut_data.get("reason", ""),
|
reason=cut_data.get("reason", ""),
|
||||||
replacementOptions=cut_data.get("replacementOptions", []),
|
replacementOptions=cut_data.get("replacementOptions", []),
|
||||||
))
|
))
|
||||||
|
|
||||||
for add_data in data.get("suggestedAdds", []):
|
for add_data in data.get("suggestedAdds", []):
|
||||||
if isinstance(add_data, dict):
|
if isinstance(add_data, dict):
|
||||||
session.suggestedAdds.append(SuggestedAdd(
|
session.suggestedAdds.append(SuggestedAdd(
|
||||||
@@ -352,13 +450,93 @@ class CoachService:
|
|||||||
reason=add_data.get("reason", ""),
|
reason=add_data.get("reason", ""),
|
||||||
synergyWith=add_data.get("synergyWith", []),
|
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:
|
def get_status(self) -> CoachStatus:
|
||||||
"""Check the health of all coach subsystems."""
|
"""Check the health of all coach subsystems."""
|
||||||
status = CoachStatus()
|
status = CoachStatus()
|
||||||
|
|
||||||
if COACH_PROVIDER == "anthropic":
|
if COACH_PROVIDER == "anthropic":
|
||||||
anthropic_key = ANTHROPIC_API_KEY
|
anthropic_key = ANTHROPIC_API_KEY
|
||||||
if anthropic_key:
|
if anthropic_key:
|
||||||
@@ -377,9 +555,7 @@ class CoachService:
|
|||||||
status.llmModels = llm_status.get("models", [])
|
status.llmModels = llm_status.get("models", [])
|
||||||
if not status.llmConnected:
|
if not status.llmConnected:
|
||||||
status.error = llm_status.get("error", "Local LLM not reachable")
|
status.error = llm_status.get("error", "Local LLM not reachable")
|
||||||
|
|
||||||
status.embeddingsLoaded = self.embeddings.loaded
|
status.embeddingsLoaded = self.embeddings.loaded
|
||||||
status.embeddingCards = self.embeddings.card_count
|
status.embeddingCards = self.embeddings.card_count
|
||||||
status.deckReportsAvailable = len(self.list_deck_reports())
|
status.deckReportsAvailable = len(self.list_deck_reports())
|
||||||
|
|
||||||
return status
|
return status
|
||||||
+1
-1
@@ -13,7 +13,7 @@ LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.paren
|
|||||||
|
|
||||||
# ── Ollama (OpenAI-compatible API) ────────────────────────────
|
# ── Ollama (OpenAI-compatible API) ────────────────────────────
|
||||||
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
|
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
|
||||||
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
|
LLM_MODEL = os.environ.get("LLM_MODEL", "claude-opus-4-20250514")
|
||||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
|
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
|
||||||
LLM_MAX_RETRIES = 3
|
LLM_MAX_RETRIES = 3
|
||||||
|
|
||||||
|
|||||||
+3
-9
@@ -28,6 +28,7 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from services.card_text import build_card_text
|
||||||
from .config import EMBEDDINGS_DIR, EMBEDDINGS_NPZ, EMBEDDINGS_PARQUET
|
from .config import EMBEDDINGS_DIR, EMBEDDINGS_NPZ, EMBEDDINGS_PARQUET
|
||||||
|
|
||||||
logger = logging.getLogger("coach.embeddings")
|
logger = logging.getLogger("coach.embeddings")
|
||||||
@@ -114,15 +115,8 @@ def _fetch_scryfall_card(card_name: str) -> Optional[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def _build_card_text(card: dict) -> str:
|
def _build_card_text(card: dict) -> str:
|
||||||
"""Build the text string to embed, matching minimaxir/mtg-embeddings format."""
|
"""Delegate to shared build_card_text() -- see services/card_text.py."""
|
||||||
parts = [card["name"]]
|
return build_card_text(card)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class CardMatch:
|
class CardMatch:
|
||||||
|
|||||||
+140
-16
@@ -4,11 +4,9 @@ Commander AI Lab — Coach Data Models
|
|||||||
Pydantic v2 models for deck reports, coach requests/responses,
|
Pydantic v2 models for deck reports, coach requests/responses,
|
||||||
and coaching session persistence.
|
and coaching session persistence.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
# Deck Report Models (mirrors Java DeckReport)
|
# Deck Report Models (mirrors Java DeckReport)
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
@@ -16,16 +14,16 @@ from pydantic import BaseModel, Field
|
|||||||
class CardPerformance(BaseModel):
|
class CardPerformance(BaseModel):
|
||||||
"""Per-card aggregated stats from simulation data."""
|
"""Per-card aggregated stats from simulation data."""
|
||||||
name: str
|
name: str
|
||||||
drawnRate: float = 0.0 # Fraction of games where card was drawn
|
drawnRate: float = 0.0
|
||||||
castRate: float = 0.0 # Fraction of games where drawn AND cast
|
castRate: float = 0.0
|
||||||
keptInOpeningHandRate: float = 0.0
|
keptInOpeningHandRate: float = 0.0
|
||||||
deadCardRate: float = 0.0 # Drawn but never cast/used
|
deadCardRate: float = 0.0
|
||||||
impactScore: float = 0.0 # (winRateWhenCast - overallWinRate) * castRate
|
impactScore: float = 0.0
|
||||||
synergyScore: float = 0.0 # Co-occurrence uplift with other deck cards
|
synergyScore: float = 0.0
|
||||||
clunkinessScore: float = 0.0 # deadCardRate * (1 - castRate)
|
clunkinessScore: float = 0.0
|
||||||
avgTurnCast: Optional[float] = None
|
avgTurnCast: Optional[float] = None
|
||||||
avgDamageDealt: float = 0.0
|
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):
|
class MatchupRecord(BaseModel):
|
||||||
@@ -47,7 +45,7 @@ class DeckMeta(BaseModel):
|
|||||||
class DeckStructure(BaseModel):
|
class DeckStructure(BaseModel):
|
||||||
"""Deck composition breakdown."""
|
"""Deck composition breakdown."""
|
||||||
landCount: int = 0
|
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)
|
cardTypeCounts: Dict[str, int] = Field(default_factory=dict)
|
||||||
functionalCounts: Dict[str, int] = Field(default_factory=dict)
|
functionalCounts: Dict[str, int] = Field(default_factory=dict)
|
||||||
|
|
||||||
@@ -56,7 +54,7 @@ class ComboRecord(BaseModel):
|
|||||||
"""Known combo with performance data."""
|
"""Known combo with performance data."""
|
||||||
cardNames: List[str]
|
cardNames: List[str]
|
||||||
winRateWhenAssembled: float = 0.0
|
winRateWhenAssembled: float = 0.0
|
||||||
assemblyRate: float = 0.0 # Fraction of games where all pieces drawn
|
assemblyRate: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class DeckReport(BaseModel):
|
class DeckReport(BaseModel):
|
||||||
@@ -83,10 +81,10 @@ class DeckReport(BaseModel):
|
|||||||
|
|
||||||
class CoachGoals(BaseModel):
|
class CoachGoals(BaseModel):
|
||||||
"""User-specified coaching goals."""
|
"""User-specified coaching goals."""
|
||||||
targetPowerLevel: Optional[int] = None # 1-10
|
targetPowerLevel: Optional[int] = None
|
||||||
metaFocus: Optional[str] = None # aggro, control, combo, midrange, stax
|
metaFocus: Optional[str] = None
|
||||||
budget: Optional[str] = None # budget, medium, no-limit
|
budget: Optional[str] = None
|
||||||
focusAreas: List[str] = Field(default_factory=list) # e.g., ["ramp", "card draw"]
|
focusAreas: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CoachRequest(BaseModel):
|
class CoachRequest(BaseModel):
|
||||||
@@ -106,12 +104,120 @@ class SuggestedCut(BaseModel):
|
|||||||
class SuggestedAdd(BaseModel):
|
class SuggestedAdd(BaseModel):
|
||||||
"""A card the coach suggests adding."""
|
"""A card the coach suggests adding."""
|
||||||
cardName: str
|
cardName: str
|
||||||
role: str = "" # ramp, draw, removal, finisher, etc.
|
role: str = ""
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
synergyWith: List[str] = Field(default_factory=list)
|
synergyWith: List[str] = Field(default_factory=list)
|
||||||
estimatedManaValue: Optional[float] = None
|
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):
|
class CoachSession(BaseModel):
|
||||||
"""
|
"""
|
||||||
Complete coaching session result.
|
Complete coaching session result.
|
||||||
@@ -130,6 +236,24 @@ class CoachSession(BaseModel):
|
|||||||
promptTokens: int = 0
|
promptTokens: int = 0
|
||||||
completionTokens: int = 0
|
completionTokens: int = 0
|
||||||
goals: Optional[CoachGoals] = None
|
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):
|
class ApplyRequest(BaseModel):
|
||||||
|
|||||||
+299
-26
@@ -4,6 +4,13 @@ Commander AI Lab — Coach Prompt Template
|
|||||||
Constructs system and user prompts for the LLM coach.
|
Constructs system and user prompts for the LLM coach.
|
||||||
Injects deck report data, underperformer analysis, and
|
Injects deck report data, underperformer analysis, and
|
||||||
candidate replacement cards into a structured template.
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
@@ -12,9 +19,9 @@ from .models import DeckReport, CoachGoals, CardPerformance
|
|||||||
from .config import MAX_CANDIDATES_PER_UNDERPERFORMER, MAX_UNDERPERFORMERS
|
from .config import MAX_CANDIDATES_PER_UNDERPERFORMER, MAX_UNDERPERFORMERS
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# System Prompt
|
# System Prompt
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
SYSTEM_PROMPT_TEMPLATE = """You are an expert Magic: The Gathering Commander deck coach.
|
SYSTEM_PROMPT_TEMPLATE = """You are an expert Magic: The Gathering Commander deck coach.
|
||||||
You analyze deck performance reports from simulation data and provide specific,
|
You analyze deck performance reports from simulation data and provide specific,
|
||||||
@@ -36,7 +43,23 @@ RULES:
|
|||||||
- When reasoning about cuts, also consider keptInOpeningHandRate (low = bad in opening hands) and avgTurnCast (high = too slow for the curve).
|
- 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.
|
- 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.
|
- 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.
|
- 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}
|
{goals_section}
|
||||||
|
|
||||||
You MUST respond ONLY with valid JSON matching this exact structure:
|
You MUST respond ONLY with valid JSON matching this exact structure:
|
||||||
@@ -61,6 +84,25 @@ You MUST respond ONLY with valid JSON matching this exact structure:
|
|||||||
"strategicRationale": "How this card fits into the overall game plan and addresses a specific gap"
|
"strategicRationale": "How this card fits into the overall game plan and addresses a specific gap"
|
||||||
}}
|
}}
|
||||||
],
|
],
|
||||||
|
"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"
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"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": {{
|
"manaCurveAnalysis": {{
|
||||||
"currentAssessment": "Detailed analysis of the current mana curve distribution and its implications",
|
"currentAssessment": "Detailed analysis of the current mana curve distribution and its implications",
|
||||||
"recommendations": "Specific suggestions for curve adjustments with reasoning",
|
"recommendations": "Specific suggestions for curve adjustments with reasoning",
|
||||||
@@ -80,15 +122,108 @@ You MUST respond ONLY with valid JSON matching this exact structure:
|
|||||||
"Detailed strategic tip explaining the reasoning and data behind the suggestion"
|
"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",
|
"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 (5) prioritized list of improvements ranked by expected impact on win rate"
|
"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."""
|
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# Quick Digest Prompt
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
QUICK_DIGEST_SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander deck coach.
|
||||||
# User Prompt Builder
|
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}
|
USER_PROMPT_TEMPLATE = """## Deck Report: {deck_name}
|
||||||
Commander: {commander}
|
Commander: {commander}
|
||||||
@@ -103,6 +238,9 @@ Avg Game Length: {avg_game_length:.1f} turns
|
|||||||
- Card Types: {type_summary}
|
- Card Types: {type_summary}
|
||||||
- Functional Roles: {role_summary}
|
- Functional Roles: {role_summary}
|
||||||
|
|
||||||
|
### Sim Insights (pre-computed deck-level signals)
|
||||||
|
{sim_insights_section}
|
||||||
|
|
||||||
### Matchup Data
|
### Matchup Data
|
||||||
{matchup_section}
|
{matchup_section}
|
||||||
|
|
||||||
@@ -115,6 +253,9 @@ Avg Game Length: {avg_game_length:.1f} turns
|
|||||||
### Underperforming Cards (candidates for cuts)
|
### Underperforming Cards (candidates for cuts)
|
||||||
{underperformer_section}
|
{underperformer_section}
|
||||||
|
|
||||||
|
|
||||||
|
### RAG-Retrieved Similar Cards (from ChromaDB)
|
||||||
|
{rag_section}
|
||||||
### Candidate Replacement Cards (from embeddings search)
|
### Candidate Replacement Cards (from embeddings search)
|
||||||
{candidate_section}
|
{candidate_section}
|
||||||
|
|
||||||
@@ -127,7 +268,6 @@ def _format_goals_section(goals: Optional[CoachGoals]) -> str:
|
|||||||
"""Format goals into system prompt rules."""
|
"""Format goals into system prompt rules."""
|
||||||
if not goals:
|
if not goals:
|
||||||
return "- Aim for a balanced, well-rounded deck."
|
return "- Aim for a balanced, well-rounded deck."
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
if goals.targetPowerLevel is not None:
|
if goals.targetPowerLevel is not None:
|
||||||
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
|
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
|
||||||
@@ -135,18 +275,14 @@ def _format_goals_section(goals: Optional[CoachGoals]) -> str:
|
|||||||
lines.append("- Keep suggestions casual and fun-focused.")
|
lines.append("- Keep suggestions casual and fun-focused.")
|
||||||
elif goals.targetPowerLevel >= 8:
|
elif goals.targetPowerLevel >= 8:
|
||||||
lines.append("- Prioritize efficiency and competitive staples.")
|
lines.append("- Prioritize efficiency and competitive staples.")
|
||||||
|
|
||||||
if goals.metaFocus:
|
if goals.metaFocus:
|
||||||
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
|
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
|
||||||
|
|
||||||
if goals.budget:
|
if goals.budget:
|
||||||
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
|
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
|
||||||
"no-limit": "no budget restrictions"}
|
"no-limit": "no budget restrictions"}
|
||||||
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
|
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
|
||||||
|
|
||||||
if goals.focusAreas:
|
if goals.focusAreas:
|
||||||
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
|
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
|
||||||
|
|
||||||
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
|
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
|
||||||
|
|
||||||
|
|
||||||
@@ -158,7 +294,11 @@ def _format_curve(buckets: List[int]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
|
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
|
||||||
"""Format the best performing cards with extended stats."""
|
"""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)
|
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
|
||||||
lines = []
|
lines = []
|
||||||
for c in sorted_cards[:top_n]:
|
for c in sorted_cards[:top_n]:
|
||||||
@@ -167,13 +307,15 @@ def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
|
|||||||
lines.append(
|
lines.append(
|
||||||
f"- {c.name}: impact={c.impactScore:.3f}, "
|
f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||||
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
|
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
|
||||||
f"synergy={c.synergyScore:.3f}{turn_str}{tags}"
|
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."
|
return "\n".join(lines) if lines else "No card performance data available."
|
||||||
|
|
||||||
|
|
||||||
def _format_underperformers(cards: List[CardPerformance],
|
def _format_underperformers(cards: List[CardPerformance],
|
||||||
underperformer_names: List[str]) -> str:
|
underperformer_names: List[str]) -> str:
|
||||||
"""Format underperforming cards with full available stats."""
|
"""Format underperforming cards with full available stats."""
|
||||||
card_map = {c.name: c for c in cards}
|
card_map = {c.name: c for c in cards}
|
||||||
lines = []
|
lines = []
|
||||||
@@ -181,8 +323,16 @@ def _format_underperformers(cards: List[CardPerformance],
|
|||||||
c = card_map.get(name)
|
c = card_map.get(name)
|
||||||
if c:
|
if c:
|
||||||
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
|
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(
|
lines.append(
|
||||||
f"- {c.name}: impact={c.impactScore:.3f}, "
|
f"- {c.name}{flag_str}: impact={c.impactScore:.3f}, "
|
||||||
f"deadCardRate={c.deadCardRate:.1%}, "
|
f"deadCardRate={c.deadCardRate:.1%}, "
|
||||||
f"castRate={c.castRate:.1%}, "
|
f"castRate={c.castRate:.1%}, "
|
||||||
f"clunkiness={c.clunkinessScore:.3f}, "
|
f"clunkiness={c.clunkinessScore:.3f}, "
|
||||||
@@ -195,6 +345,87 @@ def _format_underperformers(cards: List[CardPerformance],
|
|||||||
return "\n".join(lines) if lines else "No clear underperformers identified."
|
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:
|
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
||||||
"""Format replacement candidates grouped by the card they'd replace."""
|
"""Format replacement candidates grouped by the card they'd replace."""
|
||||||
if not candidates:
|
if not candidates:
|
||||||
@@ -205,7 +436,6 @@ def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
|||||||
"Suggest at least 3 different cards across different roles "
|
"Suggest at least 3 different cards across different roles "
|
||||||
"(ramp, draw, removal, threats, utility)."
|
"(ramp, draw, removal, threats, utility)."
|
||||||
)
|
)
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
|
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
|
||||||
lines.append(f"\nReplacements for '{underperformer}':")
|
lines.append(f"\nReplacements for '{underperformer}':")
|
||||||
@@ -218,12 +448,29 @@ def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
|||||||
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
|
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
|
||||||
if text_preview:
|
if text_preview:
|
||||||
lines.append(f" Oracle: {text_preview}")
|
lines.append(f" Oracle: {text_preview}")
|
||||||
|
|
||||||
lines.append("\nChoose from these candidates when possible, but also suggest ")
|
lines.append("\nChoose from these candidates when possible, but also suggest ")
|
||||||
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
|
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
|
||||||
return "\n".join(lines)
|
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:
|
def _format_matchups(report: DeckReport) -> str:
|
||||||
"""Format per-opponent matchup data."""
|
"""Format per-opponent matchup data."""
|
||||||
if not report.matchups:
|
if not report.matchups:
|
||||||
@@ -238,13 +485,23 @@ def _format_matchups(report: DeckReport) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _format_archetypes(report: DeckReport) -> str:
|
def _format_archetypes(report: DeckReport) -> str:
|
||||||
"""Format per-archetype win rates from DeckMeta."""
|
"""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
|
rates = report.meta.perArchetypeWinRates
|
||||||
if not rates:
|
if not rates:
|
||||||
return "No archetype breakdown available."
|
return "No archetype breakdown available."
|
||||||
lines = []
|
lines = []
|
||||||
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]):
|
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]):
|
||||||
lines.append(f"- vs {archetype}: {wr:.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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -262,9 +519,9 @@ def _format_combos(report: DeckReport) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# Main Builder Functions
|
# Main Builder Functions
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def build_system_prompt(report: DeckReport,
|
def build_system_prompt(report: DeckReport,
|
||||||
goals: Optional[CoachGoals] = None) -> str:
|
goals: Optional[CoachGoals] = None) -> str:
|
||||||
@@ -277,16 +534,29 @@ def build_system_prompt(report: DeckReport,
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_user_prompt(report: DeckReport,
|
def build_quick_system_prompt(report: DeckReport,
|
||||||
candidates: Dict[str, List[dict]] = None) -> str:
|
goals: Optional[CoachGoals] = None) -> str:
|
||||||
"""Build the user prompt with full deck report data."""
|
"""Build the quick digest system prompt (~400-600 token target)."""
|
||||||
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
|
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()] \
|
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
|
||||||
if report.structure.cardTypeCounts else ["unknown"]
|
if report.structure.cardTypeCounts else ["unknown"]
|
||||||
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
|
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
|
||||||
if report.structure.functionalCounts else ["unknown"]
|
if report.structure.functionalCounts else ["unknown"]
|
||||||
|
|
||||||
return USER_PROMPT_TEMPLATE.format(
|
return USER_PROMPT_TEMPLATE.format(
|
||||||
deck_name=report.deckId,
|
deck_name=report.deckId,
|
||||||
commander=report.commander,
|
commander=report.commander,
|
||||||
@@ -298,11 +568,14 @@ def build_user_prompt(report: DeckReport,
|
|||||||
curve_summary=_format_curve(report.structure.curveBuckets),
|
curve_summary=_format_curve(report.structure.curveBuckets),
|
||||||
type_summary=", ".join(type_parts),
|
type_summary=", ".join(type_parts),
|
||||||
role_summary=", ".join(role_parts),
|
role_summary=", ".join(role_parts),
|
||||||
|
sim_insights_section=_format_sim_insights(
|
||||||
|
report.cards, report.meta.perArchetypeWinRates),
|
||||||
matchup_section=_format_matchups(report),
|
matchup_section=_format_matchups(report),
|
||||||
archetype_section=_format_archetypes(report),
|
archetype_section=_format_archetypes(report),
|
||||||
top_cards=_format_top_cards(report.cards),
|
top_cards=_format_top_cards(report.cards),
|
||||||
underperformer_section=_format_underperformers(
|
underperformer_section=_format_underperformers(
|
||||||
report.cards, report.underperformers),
|
report.cards, report.underperformers),
|
||||||
candidate_section=_format_candidates(candidates or {}),
|
candidate_section=_format_candidates(candidates or {}),
|
||||||
|
rag_section=_format_rag_cards(rag_cards or []),
|
||||||
combo_section=_format_combos(report),
|
combo_section=_format_combos(report),
|
||||||
)
|
)
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Commander AI Lab — Deck Generation System Prompt
|
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.
|
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:
|
RULES:
|
||||||
- Exactly 100 cards total: 1 commander + 99 other cards
|
- 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
|
- No more than 1 copy of any card except basic lands
|
||||||
- All cards must share the commander's color identity
|
- All cards must share the commander's color identity
|
||||||
- All cards must be legal in Commander format
|
- 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
|
- 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
|
- 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 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 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
|
- 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
|
Commander AI Lab — Deck Generation User Prompt Builder
|
||||||
═══════════════════════════════════════════════════════
|
═══════════════════════════════════════════════════════
|
||||||
Constructs the user message for Claude Opus deck generation
|
Constructs the user message for LLM deck generation.
|
||||||
from a DeckGenV3Request.
|
|
||||||
|
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
|
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(
|
def build_user_prompt(
|
||||||
@@ -18,6 +40,7 @@ def build_user_prompt(
|
|||||||
budget_mode: str = "total",
|
budget_mode: str = "total",
|
||||||
omit_cards: List[str] = None,
|
omit_cards: List[str] = None,
|
||||||
collection_summary: str = "",
|
collection_summary: str = "",
|
||||||
|
slot_budget: Optional[Dict[str, int]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Build the user prompt for deck generation.
|
Build the user prompt for deck generation.
|
||||||
@@ -26,12 +49,13 @@ def build_user_prompt(
|
|||||||
commander: Commander card name
|
commander: Commander card name
|
||||||
commander_type: Type line from Scryfall
|
commander_type: Type line from Scryfall
|
||||||
color_identity: List of color letters, e.g. ["U", "B"]
|
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
|
target_bracket: Target bracket level 1-4
|
||||||
budget_usd: Optional budget constraint
|
budget_usd: Optional budget constraint
|
||||||
budget_mode: "total" for total deck cost, "per_card" for per-card max
|
budget_mode: "total" for total deck cost, "per_card" for per-card max
|
||||||
omit_cards: Cards to exclude
|
omit_cards: Cards to exclude
|
||||||
collection_summary: Pre-built collection summary text block
|
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"
|
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
|
||||||
|
|
||||||
@@ -60,16 +84,21 @@ def build_user_prompt(
|
|||||||
lines.append(f"OMIT LIST (do NOT include these cards): {', '.join(omit_cards)}")
|
lines.append(f"OMIT LIST (do NOT include these cards): {', '.join(omit_cards)}")
|
||||||
|
|
||||||
if collection_summary:
|
if collection_summary:
|
||||||
lines.append(f"")
|
lines.append("")
|
||||||
lines.append(collection_summary)
|
lines.append(collection_summary)
|
||||||
lines.append(f"")
|
lines.append("")
|
||||||
lines.append(f"STRONGLY PREFER cards from the collection when they fit the strategy.")
|
lines.append("STRONGLY PREFER cards from the collection when they fit the strategy.")
|
||||||
else:
|
else:
|
||||||
lines.append(f"")
|
lines.append("")
|
||||||
lines.append(f"No collection data available — suggest the best cards regardless of ownership.")
|
lines.append("No collection data available — suggest the best cards regardless of ownership.")
|
||||||
|
|
||||||
lines.append(f"")
|
# Inject slot budget so LLM knows exact target counts
|
||||||
lines.append(f"Build the deck as a structured JSON response. Include the commander in the cards list.")
|
if slot_budget:
|
||||||
lines.append(f"Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
|
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)
|
return "\n".join(lines)
|
||||||
@@ -3,6 +3,10 @@ Commander AI Lab — Substitution Schemas
|
|||||||
════════════════════════════════════════
|
════════════════════════════════════════
|
||||||
Pydantic models and JSON Schema dicts for the
|
Pydantic models and JSON Schema dicts for the
|
||||||
Smart Substitution Engine output.
|
Smart Substitution Engine output.
|
||||||
|
|
||||||
|
Phase 1 changes:
|
||||||
|
- SubstitutionBatchResult gains padded_slots field
|
||||||
|
- DeckCardWithStatus gains original_card field (was missing)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
@@ -25,9 +29,10 @@ class CardAlternative(BaseModel):
|
|||||||
|
|
||||||
class DeckCardWithStatus(BaseModel):
|
class DeckCardWithStatus(BaseModel):
|
||||||
"""A deck card enriched with ownership and substitution data."""
|
"""A deck card enriched with ownership and substitution data."""
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
extra = "ignore"
|
extra = "ignore"
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
count: int = 1
|
count: int = 1
|
||||||
category: str = ""
|
category: str = ""
|
||||||
@@ -41,9 +46,15 @@ class DeckCardWithStatus(BaseModel):
|
|||||||
owned_qty: int = 0
|
owned_qty: int = 0
|
||||||
scryfall_id: str = ""
|
scryfall_id: str = ""
|
||||||
status: str = Field(default="owned", description="owned | substituted | missing")
|
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)
|
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):
|
class SubstitutionRequest(BaseModel):
|
||||||
@@ -61,11 +72,16 @@ class SubstitutionBatchResult(BaseModel):
|
|||||||
owned_count: int = 0
|
owned_count: int = 0
|
||||||
substituted_count: int = 0
|
substituted_count: int = 0
|
||||||
missing_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)
|
cards: List[DeckCardWithStatus] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
# JSON Schema dicts (for Perplexity fallback substitution)
|
# JSON Schema dicts (for LLM fallback substitution)
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
SUBSTITUTION_RESPONSE_SCHEMA = {
|
SUBSTITUTION_RESPONSE_SCHEMA = {
|
||||||
|
|||||||
+391
-107
@@ -4,12 +4,21 @@ Commander AI Lab — V3 Deck Generator Service
|
|||||||
Generates Commander decks using Anthropic Claude
|
Generates Commander decks using Anthropic Claude
|
||||||
structured JSON output, then runs ownership check
|
structured JSON output, then runs ownership check
|
||||||
and smart substitution.
|
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 json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
from typing import List, Optional, Tuple
|
from typing import List, Optional, Tuple
|
||||||
from urllib.request import urlopen, Request
|
from urllib.request import urlopen, Request
|
||||||
|
|
||||||
@@ -32,6 +41,236 @@ from ..config import (
|
|||||||
logger = logging.getLogger("coach.deckgen")
|
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:
|
class DeckGeneratorV3:
|
||||||
"""
|
"""
|
||||||
V3 Deck Generator using Anthropic Claude structured output.
|
V3 Deck Generator using Anthropic Claude structured output.
|
||||||
@@ -42,18 +281,14 @@ class DeckGeneratorV3:
|
|||||||
3. Call Anthropic Claude with structured JSON schema
|
3. Call Anthropic Claude with structured JSON schema
|
||||||
4. Validate card names via Scryfall
|
4. Validate card names via Scryfall
|
||||||
5. Check ownership against collection DB
|
5. Check ownership against collection DB
|
||||||
6. Run Smart Substitution (embedding + Anthropic Claude fallback)
|
6. Run Smart Substitution (non-destructive)
|
||||||
7. Return finalized deck with substitution data
|
7. _pad_to_99 circuit breaker
|
||||||
|
8. Pre-validation count logging
|
||||||
|
9. Return finalized deck — guaranteed 99 cards
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, anthropic_client: AnthropicClient, db_conn_factory,
|
def __init__(self, anthropic_client: AnthropicClient, db_conn_factory,
|
||||||
embedding_index=None):
|
embedding_index=None):
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
anthropic_client: Configured AnthropicClient instance
|
|
||||||
db_conn_factory: Callable that returns sqlite3.Connection
|
|
||||||
embedding_index: Optional MTGEmbeddingIndex for substitution
|
|
||||||
"""
|
|
||||||
self.anthropic = anthropic_client
|
self.anthropic = anthropic_client
|
||||||
self.db_conn_factory = db_conn_factory
|
self.db_conn_factory = db_conn_factory
|
||||||
self.embedding_index = embedding_index
|
self.embedding_index = embedding_index
|
||||||
@@ -77,9 +312,12 @@ class DeckGeneratorV3:
|
|||||||
"mana_cost": data.get("mana_cost", ""),
|
"mana_cost": data.get("mana_cost", ""),
|
||||||
"image_url": data.get("image_uris", {}).get("normal", ""),
|
"image_url": data.get("image_uris", {}).get("normal", ""),
|
||||||
"oracle_text": data.get("oracle_text", ""),
|
"oracle_text": data.get("oracle_text", ""),
|
||||||
|
"scryfall_found": True,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Scryfall lookup failed for '%s': %s", commander_name, 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 {
|
return {
|
||||||
"name": commander_name,
|
"name": commander_name,
|
||||||
"scryfall_id": "",
|
"scryfall_id": "",
|
||||||
@@ -88,6 +326,7 @@ class DeckGeneratorV3:
|
|||||||
"mana_cost": "",
|
"mana_cost": "",
|
||||||
"image_url": "",
|
"image_url": "",
|
||||||
"oracle_text": "",
|
"oracle_text": "",
|
||||||
|
"scryfall_found": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Step 2: Build Collection Summary ─────────────────────
|
# ── Step 2: Build Collection Summary ─────────────────────
|
||||||
@@ -110,16 +349,10 @@ class DeckGeneratorV3:
|
|||||||
if not rows:
|
if not rows:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Filter by color identity
|
|
||||||
ci_set = set(color_identity) if color_identity else None
|
ci_set = set(color_identity) if color_identity else None
|
||||||
filtered = []
|
filtered = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
name = row["name"]
|
|
||||||
type_line = row["type_line"] or ""
|
|
||||||
# Basic lands and colorless are always allowed
|
|
||||||
if ci_set:
|
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()
|
card_colors = set()
|
||||||
oracle = row["oracle_text"] or ""
|
oracle = row["oracle_text"] or ""
|
||||||
mana_cost = ""
|
mana_cost = ""
|
||||||
@@ -137,7 +370,6 @@ class DeckGeneratorV3:
|
|||||||
if not filtered:
|
if not filtered:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Group by card type
|
|
||||||
groups = {}
|
groups = {}
|
||||||
for row in filtered:
|
for row in filtered:
|
||||||
type_line = row["type_line"] or ""
|
type_line = row["type_line"] or ""
|
||||||
@@ -159,14 +391,12 @@ class DeckGeneratorV3:
|
|||||||
group = "Other"
|
group = "Other"
|
||||||
groups.setdefault(group, []).append(row)
|
groups.setdefault(group, []).append(row)
|
||||||
|
|
||||||
# Sort each group by popularity/price and build text
|
|
||||||
lines = [f"COLLECTION SUMMARY ({len(filtered)} cards in deck colors):"]
|
lines = [f"COLLECTION SUMMARY ({len(filtered)} cards in deck colors):"]
|
||||||
for group_name in ["Creatures", "Instants", "Sorceries", "Artifacts",
|
for group_name in ["Creatures", "Instants", "Sorceries", "Artifacts",
|
||||||
"Enchantments", "Planeswalkers", "Lands", "Other"]:
|
"Enchantments", "Planeswalkers", "Lands", "Other"]:
|
||||||
cards = groups.get(group_name, [])
|
cards = groups.get(group_name, [])
|
||||||
if not cards:
|
if not cards:
|
||||||
continue
|
continue
|
||||||
# Sort by price descending (higher value = likely better card)
|
|
||||||
cards.sort(key=lambda r: r["tcg_price"] or 0, reverse=True)
|
cards.sort(key=lambda r: r["tcg_price"] or 0, reverse=True)
|
||||||
card_strs = []
|
card_strs = []
|
||||||
for c in cards[:max_per_group]:
|
for c in cards[:max_per_group]:
|
||||||
@@ -194,23 +424,32 @@ class DeckGeneratorV3:
|
|||||||
Returns dict with:
|
Returns dict with:
|
||||||
- commander: dict with Scryfall data
|
- commander: dict with Scryfall data
|
||||||
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
|
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
|
||||||
- cards: list of DeckCardWithStatus (after ownership check)
|
- cards: list of DeckCardWithStatus (after ownership check + substitution)
|
||||||
- stats: deck statistics
|
- stats: deck statistics
|
||||||
|
- substitution_log: list of SubstitutionRecord dicts
|
||||||
|
- padded_slots: int > 0 means circuit breaker fired
|
||||||
- tokens_used: {prompt, completion}
|
- tokens_used: {prompt, completion}
|
||||||
"""
|
"""
|
||||||
# Step 1: Resolve commander
|
# Step 1: Resolve commander
|
||||||
cmdr = self.resolve_commander(commander_name)
|
cmdr = self.resolve_commander(commander_name)
|
||||||
color_identity = cmdr["color_identity"]
|
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
|
# Step 2: Collection summary
|
||||||
collection_block = ""
|
collection_block = ""
|
||||||
if use_collection:
|
if use_collection and scryfall_found:
|
||||||
collection_block = self.build_collection_summary(color_identity)
|
collection_block = self.build_collection_summary(color_identity)
|
||||||
logger.info("Collection summary: %d chars",
|
logger.info("Collection summary: %d chars",
|
||||||
len(collection_block) if collection_block else 0)
|
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(
|
user_prompt = build_user_prompt(
|
||||||
commander=cmdr["name"],
|
commander=cmdr["name"],
|
||||||
commander_type=cmdr["type_line"],
|
commander_type=cmdr["type_line"],
|
||||||
@@ -221,11 +460,12 @@ class DeckGeneratorV3:
|
|||||||
budget_mode=budget_mode,
|
budget_mode=budget_mode,
|
||||||
omit_cards=omit_cards,
|
omit_cards=omit_cards,
|
||||||
collection_summary=collection_block,
|
collection_summary=collection_block,
|
||||||
|
slot_budget=SLOT_BUDGET,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 4: Call Anthropic Claude with structured output
|
# Step 4: Call LLM with structured output
|
||||||
use_model = model or DECK_GEN_MODEL
|
use_model = model or DECK_GEN_MODEL
|
||||||
logger.info("Calling Anthropic Claude (%s) for deck generation...", use_model)
|
logger.info("Calling LLM (%s) for deck generation...", use_model)
|
||||||
|
|
||||||
response = self.anthropic.chat_structured(
|
response = self.anthropic.chat_structured(
|
||||||
system_prompt=SYSTEM_PROMPT,
|
system_prompt=SYSTEM_PROMPT,
|
||||||
@@ -239,26 +479,29 @@ class DeckGeneratorV3:
|
|||||||
|
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Anthropic Claude returned invalid response: {response.content[:300]}"
|
f"LLM returned invalid response: {response.content[:300]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_deck = response.parsed_json
|
raw_deck = response.parsed_json
|
||||||
card_count = len(raw_deck.get("cards", []))
|
card_count = len(raw_deck.get("cards", []))
|
||||||
truncated = card_count < 90 # Commander decks should have ~100 cards
|
truncated = card_count < 90
|
||||||
logger.info("Deck generated: %d cards, bracket %d%s",
|
logger.info(
|
||||||
card_count,
|
"Deck generated: %d cards, bracket %s%s",
|
||||||
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
|
card_count,
|
||||||
" (TRUNCATED — response may have hit token limit)" if truncated else "")
|
(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", []))
|
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)
|
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
|
||||||
|
|
||||||
# Step 5c: Secondary safety-net deduplication
|
# Step 5c: Secondary deduplication
|
||||||
seen = {}
|
seen: dict[str, int] = {}
|
||||||
deduped = []
|
deduped: List[DeckCardWithStatus] = []
|
||||||
for c in cards_with_status:
|
for c in cards_with_status:
|
||||||
key = c.name.lower()
|
key = c.name.lower()
|
||||||
if key in seen:
|
if key in seen:
|
||||||
@@ -269,7 +512,27 @@ class DeckGeneratorV3:
|
|||||||
deduped.append(c)
|
deduped.append(c)
|
||||||
cards_with_status = deduped
|
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)
|
stats = self._compute_stats(cards_with_status)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -283,6 +546,12 @@ class DeckGeneratorV3:
|
|||||||
"archetype": raw_deck.get("archetype", ""),
|
"archetype": raw_deck.get("archetype", ""),
|
||||||
"reasoning": raw_deck.get("reasoning", {}),
|
"reasoning": raw_deck.get("reasoning", {}),
|
||||||
"estimated_total_usd": raw_deck.get("estimated_total_usd", 0),
|
"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": {
|
"tokens_used": {
|
||||||
"prompt": response.prompt_tokens,
|
"prompt": response.prompt_tokens,
|
||||||
"completion": response.completion_tokens,
|
"completion": response.completion_tokens,
|
||||||
@@ -296,13 +565,11 @@ class DeckGeneratorV3:
|
|||||||
def _check_ownership(self, cards: List[dict]) -> List[DeckCardWithStatus]:
|
def _check_ownership(self, cards: List[dict]) -> List[DeckCardWithStatus]:
|
||||||
"""
|
"""
|
||||||
Cross-reference generated cards against the collection DB.
|
Cross-reference generated cards against the collection DB.
|
||||||
Sets owned/scryfall_id/owned_qty and real price.
|
Deduplicates by name (case-insensitive).
|
||||||
Deduplicates cards by name (case-insensitive) — if the LLM
|
|
||||||
returns the same card twice, the counts are merged.
|
|
||||||
"""
|
"""
|
||||||
conn = self.db_conn_factory()
|
conn = self.db_conn_factory()
|
||||||
enriched = []
|
enriched: List[DeckCardWithStatus] = []
|
||||||
seen_names: dict[str, int] = {} # name_lower → index in enriched
|
seen_names: dict[str, int] = {}
|
||||||
|
|
||||||
for card in cards:
|
for card in cards:
|
||||||
name = card.get("name", "")
|
name = card.get("name", "")
|
||||||
@@ -314,10 +581,9 @@ class DeckGeneratorV3:
|
|||||||
if name_lower in seen_names:
|
if name_lower in seen_names:
|
||||||
idx = seen_names[name_lower]
|
idx = seen_names[name_lower]
|
||||||
enriched[idx].count += card.get("count", 1)
|
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
|
continue
|
||||||
|
|
||||||
# Look up in collection
|
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
|
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
|
||||||
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
|
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
|
||||||
@@ -348,11 +614,8 @@ class DeckGeneratorV3:
|
|||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
# ── Step 5b: Basic Land Fill ──────────────────────────────
|
# ── Step 5b: Basic Land Fill ──────────────────────────────
|
||||||
_COLOR_TO_BASIC = {
|
_COLOR_TO_BASIC = _COLOR_TO_BASIC
|
||||||
'W': 'Plains', 'U': 'Island', 'B': 'Swamp',
|
_BASIC_NAMES = _BASIC_NAMES
|
||||||
'R': 'Mountain', 'G': 'Forest',
|
|
||||||
}
|
|
||||||
_BASIC_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'}
|
|
||||||
|
|
||||||
def _fill_basic_lands(
|
def _fill_basic_lands(
|
||||||
self,
|
self,
|
||||||
@@ -374,7 +637,7 @@ class DeckGeneratorV3:
|
|||||||
ci = [c.upper() for c in color_identity]
|
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]
|
basic_names = [self._COLOR_TO_BASIC[c] for c in ci if c in self._COLOR_TO_BASIC]
|
||||||
if not basic_names:
|
if not basic_names:
|
||||||
basic_names = ['Wastes']
|
basic_names = ["Wastes"]
|
||||||
|
|
||||||
per_color = basics_needed // len(basic_names)
|
per_color = basics_needed // len(basic_names)
|
||||||
remainder = basics_needed % len(basic_names)
|
remainder = basics_needed % len(basic_names)
|
||||||
@@ -385,20 +648,20 @@ class DeckGeneratorV3:
|
|||||||
non_basics.append(DeckCardWithStatus(
|
non_basics.append(DeckCardWithStatus(
|
||||||
name=bname,
|
name=bname,
|
||||||
count=qty,
|
count=qty,
|
||||||
category='Land',
|
category="Land",
|
||||||
role_tags=[],
|
role_tags=[],
|
||||||
reason='Basic land for mana fixing',
|
reason="Basic land for mana fixing",
|
||||||
estimated_price_usd=0.10,
|
estimated_price_usd=0.10,
|
||||||
owned=True,
|
owned=True,
|
||||||
owned_qty=qty,
|
owned_qty=qty,
|
||||||
status='owned',
|
status="owned",
|
||||||
))
|
))
|
||||||
|
|
||||||
logger.info("Basic land fill: %d basics added across %d color(s)",
|
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
|
return non_basics
|
||||||
|
|
||||||
# ── Step 6: Smart Substitution ───────────────────────────
|
# ── Step 6: Smart Substitution (legacy wrapper for route compatibility) ──
|
||||||
def run_substitution(
|
def run_substitution(
|
||||||
self,
|
self,
|
||||||
cards: List[DeckCardWithStatus],
|
cards: List[DeckCardWithStatus],
|
||||||
@@ -408,15 +671,12 @@ class DeckGeneratorV3:
|
|||||||
"""
|
"""
|
||||||
Run the Smart Substitution Engine on missing cards.
|
Run the Smart Substitution Engine on missing cards.
|
||||||
|
|
||||||
Pipeline:
|
Uses the non-destructive apply_substitutions() under the hood.
|
||||||
a) Identify missing cards
|
Falls back to Anthropic Claude for low-confidence matches,
|
||||||
b) For each: try embedding similarity (free, fast)
|
then to basic land as last resort — NEVER drops a card.
|
||||||
c) For low-confidence matches: batch Anthropic Claude fallback
|
|
||||||
d) Auto-select best substitute per card
|
|
||||||
|
|
||||||
Returns SubstitutionBatchResult with all cards updated.
|
|
||||||
"""
|
"""
|
||||||
color_identity = commander.get("color_identity", [])
|
color_identity = commander.get("color_identity", [])
|
||||||
|
scryfall_found = commander.get("scryfall_found", True)
|
||||||
missing = [c for c in cards if not c.owned]
|
missing = [c for c in cards if not c.owned]
|
||||||
owned = [c for c in cards if 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))
|
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()
|
conn = self.db_conn_factory()
|
||||||
owned_pool_rows = conn.execute(
|
owned_pool_rows = conn.execute(
|
||||||
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
|
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
|
||||||
).fetchall()
|
).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
|
# a) Embedding pass for missing cards
|
||||||
deck_names = set(c.name.lower() for c in cards)
|
needs_anthropic_fallback: List[DeckCardWithStatus] = []
|
||||||
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
|
|
||||||
|
|
||||||
# a) Embedding pass
|
|
||||||
needs_anthropic_fallback = []
|
|
||||||
for card in missing:
|
for card in missing:
|
||||||
embedding_alts = self._embedding_substitution(
|
embedding_alts = self._embedding_substitution(
|
||||||
card, color_identity, available_pool
|
card, color_identity, list(available_pool)
|
||||||
)
|
)
|
||||||
card.alternatives = embedding_alts
|
card.alternatives = embedding_alts
|
||||||
|
|
||||||
# Check if best match meets threshold
|
|
||||||
if embedding_alts and embedding_alts[0].similarity_score >= SUBSTITUTION_MIN_SIMILARITY:
|
if embedding_alts and embedding_alts[0].similarity_score >= SUBSTITUTION_MIN_SIMILARITY:
|
||||||
card.selected_substitute = embedding_alts[0].name
|
card.selected_substitute = embedding_alts[0].name
|
||||||
card.status = "substituted"
|
card.status = "substituted"
|
||||||
# Remove selected from pool
|
card.owned = True
|
||||||
if card.selected_substitute.lower() in [n.lower() for n in available_pool]:
|
available_pool.discard(card.selected_substitute.lower())
|
||||||
available_pool = [n for n in available_pool
|
|
||||||
if n.lower() != card.selected_substitute.lower()]
|
|
||||||
else:
|
else:
|
||||||
needs_anthropic_fallback.append(card)
|
needs_anthropic_fallback.append(card)
|
||||||
|
|
||||||
# b) Anthropic Claude fallback for low-confidence matches
|
# b) Anthropic Claude fallback
|
||||||
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
|
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
|
||||||
self._anthropic_substitution(
|
self._anthropic_substitution(
|
||||||
needs_anthropic_fallback, available_pool,
|
needs_anthropic_fallback, list(available_pool),
|
||||||
commander, strategy
|
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")
|
substituted = sum(1 for c in cards if c.status == "substituted")
|
||||||
still_missing = sum(1 for c in cards if c.status == "missing")
|
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(
|
return SubstitutionBatchResult(
|
||||||
total_cards=len(cards),
|
total_cards=len(cards),
|
||||||
owned_count=len(owned),
|
owned_count=len(owned),
|
||||||
@@ -490,33 +766,31 @@ class DeckGeneratorV3:
|
|||||||
if not self.embedding_index or not self.embedding_index.loaded:
|
if not self.embedding_index or not self.embedding_index.loaded:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Use the embeddings search to find similar cards
|
try:
|
||||||
matches = self.embedding_index.search_similar(
|
matches = self.embedding_index.search_similar(
|
||||||
query_card=card.name,
|
query_card=card.name,
|
||||||
color_filter=color_identity if color_identity else None,
|
color_filter=color_identity if color_identity else None,
|
||||||
type_filter=card.category if card.category else None,
|
type_filter=card.category if card.category else None,
|
||||||
exclude_cards=[card.name],
|
exclude_cards=[card.name],
|
||||||
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3, # Over-fetch, then filter to owned
|
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 = {n.lower() for n in available_pool}
|
||||||
pool_lower = set(n.lower() for n in available_pool)
|
|
||||||
alternatives = []
|
alternatives = []
|
||||||
for match in matches:
|
for match in matches:
|
||||||
if match.name.lower() in pool_lower:
|
if match.name.lower() in pool_lower:
|
||||||
# Compute role overlap
|
role_overlap = list(set(card.role_tags) & set(getattr(match, "role_tags", [])))
|
||||||
match_roles = [] # Would need card's role_tags from DB
|
|
||||||
role_overlap = list(set(card.role_tags) & set(match_roles)) if match_roles else []
|
|
||||||
|
|
||||||
alternatives.append(CardAlternative(
|
alternatives.append(CardAlternative(
|
||||||
name=match.name,
|
name=match.name,
|
||||||
similarity_score=match.similarity,
|
similarity_score=match.similarity,
|
||||||
reason=f"Similar to {card.name} (cosine sim: {match.similarity:.3f})",
|
reason=f"Similar to {card.name} (cosine sim: {match.similarity:.3f})",
|
||||||
source="embedding",
|
source="embedding",
|
||||||
role_overlap=role_overlap,
|
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:
|
if len(alternatives) >= SUBSTITUTION_MAX_ALTERNATIVES:
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -531,12 +805,13 @@ class DeckGeneratorV3:
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Batch Anthropic Claude fallback for cards with low embedding confidence.
|
Batch Anthropic Claude fallback for cards with low embedding confidence.
|
||||||
Mutates the cards in-place with substitution data.
|
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:
|
if not available_pool:
|
||||||
|
logger.warning("[anthropic_substitution] No available pool — skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build the request
|
|
||||||
missing_dicts = [
|
missing_dicts = [
|
||||||
{
|
{
|
||||||
"name": c.name,
|
"name": c.name,
|
||||||
@@ -549,7 +824,7 @@ class DeckGeneratorV3:
|
|||||||
|
|
||||||
user_prompt = build_substitution_prompt(
|
user_prompt = build_substitution_prompt(
|
||||||
missing_cards=missing_dicts,
|
missing_cards=missing_dicts,
|
||||||
allowed_cards=available_pool[:200], # Limit to avoid token overflow
|
allowed_cards=available_pool[:200],
|
||||||
commander=commander.get("name", ""),
|
commander=commander.get("name", ""),
|
||||||
color_identity=commander.get("color_identity", []),
|
color_identity=commander.get("color_identity", []),
|
||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
@@ -568,13 +843,16 @@ class DeckGeneratorV3:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
logger.warning("Anthropic Claude 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
|
return
|
||||||
|
|
||||||
subs = response.parsed_json.get("substitutions", [])
|
subs = response.parsed_json.get("substitutions", [])
|
||||||
logger.info("Anthropic Claude 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}
|
card_by_name = {c.name.lower(): c for c in missing_cards}
|
||||||
for sub in subs:
|
for sub in subs:
|
||||||
orig = sub.get("original", "").lower()
|
orig = sub.get("original", "").lower()
|
||||||
@@ -583,32 +861,38 @@ class DeckGeneratorV3:
|
|||||||
|
|
||||||
if orig in card_by_name and substitute:
|
if orig in card_by_name and substitute:
|
||||||
card = card_by_name[orig]
|
card = card_by_name[orig]
|
||||||
# Add as top alternative
|
|
||||||
anthropic_alt = CardAlternative(
|
anthropic_alt = CardAlternative(
|
||||||
name=substitute,
|
name=substitute,
|
||||||
similarity_score=0.80, # Nominal score for Anthropic Claude suggestions
|
similarity_score=0.80,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
source="Anthropic Claude",
|
source="anthropic",
|
||||||
role_overlap=sub.get("role_overlap", []),
|
role_overlap=sub.get("role_overlap", []),
|
||||||
)
|
)
|
||||||
# Insert at front
|
|
||||||
card.alternatives.insert(0, anthropic_alt)
|
card.alternatives.insert(0, anthropic_alt)
|
||||||
card.selected_substitute = substitute
|
card.selected_substitute = substitute
|
||||||
card.status = "substituted"
|
card.status = "substituted"
|
||||||
|
card.owned = True
|
||||||
|
card.original_card = card.name
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Anthropic Claude 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 ────────────────────────────────────────────────
|
# ── Stats ────────────────────────────────────────────────
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _compute_stats(cards: List[DeckCardWithStatus]) -> dict:
|
def _compute_stats(cards: List[DeckCardWithStatus]) -> dict:
|
||||||
"""Compute deck statistics from the enriched card list."""
|
"""Compute deck statistics from the enriched card list."""
|
||||||
by_category = {}
|
by_category: dict[str, int] = {}
|
||||||
by_status = {"owned": 0, "substituted": 0, "missing": 0}
|
by_status: dict[str, int] = {"owned": 0, "substituted": 0, "missing": 0}
|
||||||
total_price = 0.0
|
total_price = 0.0
|
||||||
owned_price = 0.0
|
owned_price = 0.0
|
||||||
missing_price = 0.0
|
missing_price = 0.0
|
||||||
role_counts = {}
|
role_counts: dict[str, int] = {}
|
||||||
|
|
||||||
for card in cards:
|
for card in cards:
|
||||||
cat = card.category or "Other"
|
cat = card.category or "Other"
|
||||||
|
|||||||
@@ -0,0 +1,785 @@
|
|||||||
|
# Commander-AI-Lab — Unity TCGEngine Commander Board: Implementation Plan (v5)
|
||||||
|
|
||||||
|
> **Architecture Principle:** Forge = Rules Authority · FastAPI = Hub · TCGEngine Unity = Renderer · Dual-Brain AI = Decision Engine
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ UNITY (TCGEngine — 2D Board) │
|
||||||
|
│ PlayerArea x4: Hand | Library | Graveyard | Exile | Command │
|
||||||
|
│ Battlefield zones per player · Card art from local cache │
|
||||||
|
│ GameNetworkManager.cs ←── WebSocket ──► FastAPI │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
▲ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FASTAPI (commander-ai-lab) │
|
||||||
|
│ /ws/game — WebSocket relay │
|
||||||
|
│ /game/action — receive player/AI action, forward to Forge │
|
||||||
|
│ ForgeSubprocessManager — stdin/stdout pipe to Forge JVM │
|
||||||
|
│ CardEnricher — injects art_url from local image cache │
|
||||||
|
│ AIOrchestrator — routes decisions to DeepSeek-R1 or GPT-OSS │
|
||||||
|
│ ChromaDB RAG — card knowledge scoped per player deck │
|
||||||
|
│ ai_decisions.jsonl — logs every AI decision (PPO dataset) │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
▲ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FORGE (Java — Headless Rules Engine) │
|
||||||
|
│ Full Commander rules: tax, 21-commander-damage, 40 life │
|
||||||
|
│ 4-player turn queue · Zone redirects · State-based actions │
|
||||||
|
│ Emits game state JSON to stdout · Receives actions via stdin │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dual-Brain AI Decision Flow
|
||||||
|
|
||||||
|
This is the **core feature** of Commander-AI-Lab. Every AI player decision runs through this pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
Forge emits GameStateDTO (JSON)
|
||||||
|
└─► FastAPI detects active player is AI
|
||||||
|
└─► select_brain(phase, stack_size)
|
||||||
|
├─ DeepSeek-R1-14B → combat, stack decisions, removal
|
||||||
|
└─ GPT-OSS-20B → main phase, ramp, card advantage
|
||||||
|
└─► ChromaDB.query(player_deck_cards, top_k=5)
|
||||||
|
└─► build_prompt(state, rag_context, personality)
|
||||||
|
└─► LLM.generate(prompt)
|
||||||
|
└─► parse_action_json(response)
|
||||||
|
├─ validate vs legal_actions[]
|
||||||
|
├─ log to ai_decisions.jsonl
|
||||||
|
└─► Forge stdin ← chosen action
|
||||||
|
```
|
||||||
|
|
||||||
|
### Brain Selector (`ai/brain_selector.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class Brain(Enum):
|
||||||
|
DEEPSEEK = "deepseek-r1:14b"
|
||||||
|
GPT_OSS = "gpt-oss-20b" # adjust to your Ollama model tag
|
||||||
|
|
||||||
|
def select_brain(phase: str, stack_size: int) -> Brain:
|
||||||
|
"""
|
||||||
|
DeepSeek-R1: combat math, instant-speed stack decisions, targeted removal.
|
||||||
|
GPT-OSS: sorcery-speed strategy, ramp sequencing, card selection.
|
||||||
|
"""
|
||||||
|
combat_phases = {"declare_attackers", "declare_blockers", "combat_damage"}
|
||||||
|
if phase in combat_phases or stack_size > 0:
|
||||||
|
return Brain.DEEPSEEK
|
||||||
|
return Brain.GPT_OSS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prompt Builder (`ai/prompt_builder.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from chromadb import Client
|
||||||
|
from .personality import get_personality_prompt
|
||||||
|
|
||||||
|
chroma = Client()
|
||||||
|
card_collection = chroma.get_collection("mtg_cards")
|
||||||
|
|
||||||
|
def build_prompt(state: dict, player_id: str, personality: str) -> str:
|
||||||
|
# Pull card knowledge scoped to THIS player's deck
|
||||||
|
player_cards = [c["name"] for c in state["players"][player_id]["library"]]
|
||||||
|
rag_results = card_collection.query(
|
||||||
|
query_texts=player_cards[:10],
|
||||||
|
n_results=5
|
||||||
|
)
|
||||||
|
card_context = "\n".join(rag_results["documents"][0])
|
||||||
|
|
||||||
|
personality_prompt = get_personality_prompt(personality)
|
||||||
|
|
||||||
|
return f"""You are an AI playing Magic: The Gathering Commander.
|
||||||
|
{personality_prompt}
|
||||||
|
|
||||||
|
## Current Game State
|
||||||
|
- Phase: {state['phase']}
|
||||||
|
- Active Player: {player_id}
|
||||||
|
- Life Totals: {state['life_totals']}
|
||||||
|
- Commander Damage Dealt: {state['commander_damage']}
|
||||||
|
- Stack: {state['stack']}
|
||||||
|
- Your Hand: {[c['name'] for c in state['players'][player_id]['hand']]}
|
||||||
|
- Battlefield: {[c['name'] for c in state['players'][player_id]['battlefield']]}
|
||||||
|
- Available Mana: {state['players'][player_id]['mana_pool']}
|
||||||
|
|
||||||
|
## Legal Actions
|
||||||
|
{state['legal_actions']}
|
||||||
|
|
||||||
|
## Card Knowledge (RAG Context)
|
||||||
|
{card_context}
|
||||||
|
|
||||||
|
Respond with a single JSON object:
|
||||||
|
{{"action": "<action_type>", "card_id": "<id_or_null>", "targets": [], "reasoning": "<brief>"}}
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### Personality System (`ai/personality.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import random
|
||||||
|
|
||||||
|
PERSONALITIES = {
|
||||||
|
"aggro_beatdown": "You play aggressively. Attack every turn. Remove blockers. Win through combat damage as fast as possible.",
|
||||||
|
"control_lockdown": "You play control. Counter key threats. Hold up mana for interaction. Win only when the board is locked.",
|
||||||
|
"combo_enabler": "You dig for your combo pieces. Every decision is evaluated by how it advances your win condition.",
|
||||||
|
"political_diplomat": "You form alliances. Attack the leader. Make deals. Never be the archenemy.",
|
||||||
|
"voltron_commander": "Everything buffs your commander. Protect it at all costs. Win through commander damage.",
|
||||||
|
"stax_prison": "You tax and restrict opponents. Deploy hate pieces. Win through attrition and resource denial.",
|
||||||
|
"tempo_value": "You trade efficiently. Generate card advantage. Win by being ahead on resources.",
|
||||||
|
"chaos_wildcard": "You make unpredictable decisions. Prioritize fun and chaos over optimal play.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def assign_personalities(player_ids: list[str], deck_archetypes: dict) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Assign a personality per AI player at game creation.
|
||||||
|
80% chance: archetype-matched personality.
|
||||||
|
20% chance: random wildcard for unpredictability.
|
||||||
|
"""
|
||||||
|
assignments = {}
|
||||||
|
for pid in player_ids:
|
||||||
|
archetype = deck_archetypes.get(pid, "tempo_value")
|
||||||
|
if random.random() < 0.20:
|
||||||
|
assignments[pid] = random.choice(list(PERSONALITIES.keys()))
|
||||||
|
else:
|
||||||
|
assignments[pid] = archetype
|
||||||
|
return assignments
|
||||||
|
|
||||||
|
def get_personality_prompt(personality: str) -> str:
|
||||||
|
return f"## Your Playstyle\n{PERSONALITIES.get(personality, PERSONALITIES['tempo_value'])}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI Orchestrator (`ai/ai_orchestrator.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json, time, logging
|
||||||
|
from pathlib import Path
|
||||||
|
from .brain_selector import select_brain, Brain
|
||||||
|
from .prompt_builder import build_prompt
|
||||||
|
import ollama
|
||||||
|
|
||||||
|
LOG_PATH = Path("logs/ai_decisions.jsonl")
|
||||||
|
LOG_PATH.parent.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
async def get_ai_action(state: dict, player_id: str, personality: str) -> dict:
|
||||||
|
brain = select_brain(state["phase"], len(state["stack"]))
|
||||||
|
prompt = build_prompt(state, player_id, personality)
|
||||||
|
t_start = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = ollama.chat(
|
||||||
|
model=brain.value,
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
options={"temperature": 0.3, "num_predict": 256}
|
||||||
|
)
|
||||||
|
raw = response["message"]["content"]
|
||||||
|
# Extract JSON from response (model may wrap in markdown)
|
||||||
|
action = json.loads(raw[raw.find("{"):raw.rfind("}")+1])
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"AI parse failed ({brain.value}): {e}. Passing turn.")
|
||||||
|
action = {"action": "pass", "card_id": None, "targets": [], "reasoning": "parse_error"}
|
||||||
|
|
||||||
|
# Validate against Forge legal actions
|
||||||
|
if action.get("action") not in [a["type"] for a in state["legal_actions"]]:
|
||||||
|
logging.warning(f"Illegal action returned by {brain.value}. Falling back to pass.")
|
||||||
|
action = {"action": "pass", "card_id": None, "targets": [], "reasoning": "illegal_action"}
|
||||||
|
|
||||||
|
# Log for PPO training dataset
|
||||||
|
log_entry = {
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"player_id": player_id,
|
||||||
|
"brain": brain.value,
|
||||||
|
"personality": personality,
|
||||||
|
"phase": state["phase"],
|
||||||
|
"action": action,
|
||||||
|
"reasoning": action.get("reasoning", ""),
|
||||||
|
"latency_ms": round((time.time() - t_start) * 1000),
|
||||||
|
}
|
||||||
|
with open(LOG_PATH, "a") as f:
|
||||||
|
f.write(json.dumps(log_entry) + "\n")
|
||||||
|
|
||||||
|
return action
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Local Card Image Cache
|
||||||
|
|
||||||
|
**Build this first.** Every downstream system depends on local art URLs.
|
||||||
|
|
||||||
|
### Step 1 — Bulk Download (`scripts/download_card_images.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests, json, os
|
||||||
|
from pathlib import Path
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
BULK_URL = "https://api.scryfall.com/bulk-data/default-cards"
|
||||||
|
IMAGE_DIR = Path("static/card-images")
|
||||||
|
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def download_images():
|
||||||
|
# Get bulk data URL
|
||||||
|
meta = requests.get(BULK_URL).json()
|
||||||
|
cards = requests.get(meta["download_uri"], stream=True).json()
|
||||||
|
|
||||||
|
for card in tqdm(cards, desc="Downloading card images"):
|
||||||
|
image_uris = card.get("image_uris") or {}
|
||||||
|
url = image_uris.get("normal")
|
||||||
|
if not url:
|
||||||
|
continue # skip DFCs without top-level image_uris
|
||||||
|
|
||||||
|
safe_name = card["name"].replace("/", "_").replace(" ", "_")
|
||||||
|
set_code = card.get("set", "unknown")
|
||||||
|
filename = IMAGE_DIR / set_code / f"{safe_name}_{card['collector_number']}.jpg"
|
||||||
|
filename.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if filename.exists():
|
||||||
|
continue # already cached
|
||||||
|
|
||||||
|
img_data = requests.get(url, timeout=10).content
|
||||||
|
filename.write_bytes(img_data)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
download_images()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Build SQLite Index (`scripts/build_image_index.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
IMAGE_DIR = Path("static/card-images")
|
||||||
|
DB_PATH = Path("data/card_images.db")
|
||||||
|
DB_PATH.parent.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
def build_index():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS card_images (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
for img_path in IMAGE_DIR.rglob("*.jpg"):
|
||||||
|
card_name = img_path.stem.rsplit("_", 1)[0].replace("_", " ")
|
||||||
|
conn.execute("INSERT OR REPLACE INTO card_images VALUES (?, ?)",
|
||||||
|
(card_name, str(img_path)))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
build_index()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 — Card Enricher (`api/card_enricher.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path("data/card_images.db")
|
||||||
|
STATIC_URL = "http://localhost:8000/static/card-images"
|
||||||
|
|
||||||
|
def enrich_card(card: dict) -> dict:
|
||||||
|
"""Inject local art_url into every CardDTO before sending to Unity."""
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.execute("SELECT path FROM card_images WHERE name = ?", (card["name"],))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row:
|
||||||
|
relative = Path(row[0]).relative_to("static/card-images")
|
||||||
|
card["art_url"] = f"{STATIC_URL}/{relative}"
|
||||||
|
else:
|
||||||
|
card["art_url"] = f"{STATIC_URL}/card_back.jpg"
|
||||||
|
return card
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4 — FastAPI Static Mount (`main.py` addition)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
app.mount("/static/card-images", StaticFiles(directory="static/card-images"), name="card-images")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Forge Bridge (Rules Engine)
|
||||||
|
|
||||||
|
### ForgeSubprocessManager (`forge/subprocess_manager.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import subprocess, json, asyncio, threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FORGE_JAR = Path("forge/forge-game.jar")
|
||||||
|
|
||||||
|
class ForgeSubprocessManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.process = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def start(self, deck_paths: list[str], format: str = "commander"):
|
||||||
|
cmd = [
|
||||||
|
"java", "-jar", str(FORGE_JAR),
|
||||||
|
"--headless",
|
||||||
|
"--format", format,
|
||||||
|
"--decks", *deck_paths,
|
||||||
|
"--output", "json"
|
||||||
|
]
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_state(self) -> dict:
|
||||||
|
with self._lock:
|
||||||
|
line = self.process.stdout.readline()
|
||||||
|
return json.loads(line)
|
||||||
|
|
||||||
|
def send_action(self, action: dict):
|
||||||
|
with self._lock:
|
||||||
|
self.process.stdin.write(json.dumps(action) + "\n")
|
||||||
|
self.process.stdin.flush()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self.process:
|
||||||
|
self.process.terminate()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Transfer Objects (`api/dtos.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
class CardDTO(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
power: Optional[str]
|
||||||
|
toughness: Optional[str]
|
||||||
|
mana_cost: str
|
||||||
|
art_url: str = ""
|
||||||
|
tapped: bool = False
|
||||||
|
zone: str = "hand"
|
||||||
|
|
||||||
|
class PlayerDTO(BaseModel):
|
||||||
|
id: str
|
||||||
|
life: int
|
||||||
|
commander_damage: dict[str, int]
|
||||||
|
commander_tax: int
|
||||||
|
hand: list[CardDTO]
|
||||||
|
battlefield: list[CardDTO]
|
||||||
|
graveyard: list[CardDTO]
|
||||||
|
exile: list[CardDTO]
|
||||||
|
command_zone: list[CardDTO]
|
||||||
|
library_count: int
|
||||||
|
mana_pool: dict[str, int]
|
||||||
|
is_ai: bool
|
||||||
|
personality: Optional[str]
|
||||||
|
|
||||||
|
class GameStateDTO(BaseModel):
|
||||||
|
game_id: str
|
||||||
|
phase: str
|
||||||
|
active_player: str
|
||||||
|
priority: str
|
||||||
|
turn_number: int
|
||||||
|
players: list[PlayerDTO]
|
||||||
|
stack: list[dict]
|
||||||
|
legal_actions: list[dict]
|
||||||
|
winner: Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — FastAPI WebSocket Game Server
|
||||||
|
|
||||||
|
### Game Loop (`api/game_loop.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio, uuid
|
||||||
|
from .dtos import GameStateDTO
|
||||||
|
from forge.subprocess_manager import ForgeSubprocessManager
|
||||||
|
from ai.ai_orchestrator import get_ai_action
|
||||||
|
from ai.personality import assign_personalities
|
||||||
|
from api.card_enricher import enrich_card
|
||||||
|
|
||||||
|
active_games: dict[str, dict] = {}
|
||||||
|
|
||||||
|
async def start_game(deck_paths: list[str], ai_players: list[str]) -> str:
|
||||||
|
game_id = str(uuid.uuid4())
|
||||||
|
forge = ForgeSubprocessManager()
|
||||||
|
forge.start(deck_paths)
|
||||||
|
|
||||||
|
# Assign AI personalities at game creation
|
||||||
|
archetypes = detect_archetypes(deck_paths, ai_players) # ChromaDB query
|
||||||
|
personalities = assign_personalities(ai_players, archetypes)
|
||||||
|
|
||||||
|
active_games[game_id] = {
|
||||||
|
"forge": forge,
|
||||||
|
"ai_players": ai_players,
|
||||||
|
"personalities": personalities,
|
||||||
|
"websockets": []
|
||||||
|
}
|
||||||
|
asyncio.create_task(game_loop(game_id))
|
||||||
|
return game_id
|
||||||
|
|
||||||
|
async def game_loop(game_id: str):
|
||||||
|
game = active_games[game_id]
|
||||||
|
forge = game["forge"]
|
||||||
|
|
||||||
|
while True:
|
||||||
|
raw_state = forge.read_state()
|
||||||
|
if raw_state.get("winner"):
|
||||||
|
await broadcast(game_id, raw_state)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Enrich every card with local art_url
|
||||||
|
state = enrich_state(raw_state)
|
||||||
|
await broadcast(game_id, state)
|
||||||
|
|
||||||
|
priority_player = state["priority"]
|
||||||
|
if priority_player in game["ai_players"]:
|
||||||
|
personality = game["personalities"][priority_player]
|
||||||
|
action = await get_ai_action(state, priority_player, personality)
|
||||||
|
forge.send_action(action)
|
||||||
|
# else: wait for Unity client to send action via WebSocket
|
||||||
|
|
||||||
|
async def broadcast(game_id: str, state: dict):
|
||||||
|
game = active_games[game_id]
|
||||||
|
for ws in game["websockets"]:
|
||||||
|
await ws.send_json(state)
|
||||||
|
|
||||||
|
def enrich_state(state: dict) -> dict:
|
||||||
|
for player in state["players"]:
|
||||||
|
for zone in ["hand", "battlefield", "graveyard", "exile", "command_zone"]:
|
||||||
|
player[zone] = [enrich_card(c) for c in player.get(zone, [])]
|
||||||
|
return state
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket Endpoint (`api/routes/game.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from api.game_loop import active_games, start_game
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.websocket("/ws/game/{game_id}")
|
||||||
|
async def game_ws(websocket: WebSocket, game_id: str):
|
||||||
|
await websocket.accept()
|
||||||
|
game = active_games.get(game_id)
|
||||||
|
if not game:
|
||||||
|
await websocket.close(code=4004)
|
||||||
|
return
|
||||||
|
game["websockets"].append(websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_json()
|
||||||
|
# Human player action forwarded directly to Forge
|
||||||
|
game["forge"].send_action(data)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
game["websockets"].remove(websocket)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Unity TCGEngine Board (2D)
|
||||||
|
|
||||||
|
### Project Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
unity-commander-ui/
|
||||||
|
├── Assets/
|
||||||
|
│ ├── TCGEngine/ ← TCGEngine base (unmodified)
|
||||||
|
│ ├── Commander/
|
||||||
|
│ │ ├── Scripts/
|
||||||
|
│ │ │ ├── GameNetworkManager.cs ← WebSocket ↔ FastAPI
|
||||||
|
│ │ │ ├── CommanderBoardUI.cs ← 4-player layout controller
|
||||||
|
│ │ │ ├── PlayerAreaUI.cs ← per-player zone panel
|
||||||
|
│ │ │ ├── CardImageLoader.cs ← local cache texture fetcher
|
||||||
|
│ │ │ ├── ZonePanel.cs ← Hand/Library/Graveyard/Exile/Command
|
||||||
|
│ │ │ └── AIThinkingIndicator.cs ← spinner while AI brain is deciding
|
||||||
|
│ │ └── Prefabs/
|
||||||
|
│ │ ├── CardPrefab.prefab
|
||||||
|
│ │ ├── PlayerAreaPrefab.prefab
|
||||||
|
│ │ └── ZonePanelPrefab.prefab
|
||||||
|
│ └── Scenes/
|
||||||
|
│ └── CommanderBoard.unity
|
||||||
|
```
|
||||||
|
|
||||||
|
### Board Layout (matches screenshot)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [LIBRARY][GRAVEYARD][EXILE] [────── HAND ──────] (Player 3) │
|
||||||
|
│ [COMMAND] │
|
||||||
|
├──────────────────────────────┬───────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ BATTLEFIELD │ BATTLEFIELD │
|
||||||
|
│ (Player 1) │ (Player 2) │
|
||||||
|
│ BATTLEFIELD (center) │
|
||||||
|
│ BATTLEFIELD │ BATTLEFIELD │
|
||||||
|
│ (Player 4) │ (Player 3) │
|
||||||
|
│ │ │
|
||||||
|
├──────────────────────────────┴───────────────────────────────────┤
|
||||||
|
│ [COMMAND] │
|
||||||
|
│ [LIBRARY][GRAVEYARD][EXILE] [────── HAND ──────] (Player 1) │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### GameNetworkManager.cs
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using UnityEngine;
|
||||||
|
using NativeWebSocket;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
public class GameNetworkManager : MonoBehaviour
|
||||||
|
{
|
||||||
|
public static GameNetworkManager Instance { get; private set; }
|
||||||
|
|
||||||
|
[SerializeField] private string serverUrl = "ws://localhost:8000/ws/game/";
|
||||||
|
[SerializeField] private string gameId;
|
||||||
|
|
||||||
|
private WebSocket _socket;
|
||||||
|
|
||||||
|
public delegate void GameStateReceived(GameStateDTO state);
|
||||||
|
public event GameStateReceived OnGameStateReceived;
|
||||||
|
|
||||||
|
async void Awake()
|
||||||
|
{
|
||||||
|
Instance = this;
|
||||||
|
_socket = new WebSocket($"{serverUrl}{gameId}");
|
||||||
|
|
||||||
|
_socket.OnMessage += (bytes) =>
|
||||||
|
{
|
||||||
|
var json = System.Text.Encoding.UTF8.GetString(bytes);
|
||||||
|
var state = JsonConvert.DeserializeObject<GameStateDTO>(json);
|
||||||
|
UnityMainThreadDispatcher.Enqueue(() => OnGameStateReceived?.Invoke(state));
|
||||||
|
};
|
||||||
|
|
||||||
|
await _socket.Connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void SendAction(object action)
|
||||||
|
{
|
||||||
|
var json = JsonConvert.SerializeObject(action);
|
||||||
|
await _socket.SendText(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Update() => _socket?.DispatchMessageQueue();
|
||||||
|
|
||||||
|
async void OnDestroy() => await _socket?.Close();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CardImageLoader.cs
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.Networking;
|
||||||
|
using UnityEngine.UI;
|
||||||
|
|
||||||
|
public class CardImageLoader : MonoBehaviour
|
||||||
|
{
|
||||||
|
private static Dictionary<string, Texture2D> _cache = new();
|
||||||
|
|
||||||
|
public static IEnumerator LoadCardArt(string artUrl, RawImage target)
|
||||||
|
{
|
||||||
|
if (_cache.TryGetValue(artUrl, out var cached))
|
||||||
|
{
|
||||||
|
target.texture = cached;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var req = UnityWebRequestTexture.GetTexture(artUrl);
|
||||||
|
yield return req.SendWebRequest();
|
||||||
|
|
||||||
|
if (req.result == UnityWebRequest.Result.Success)
|
||||||
|
{
|
||||||
|
var tex = DownloadHandlerTexture.GetContent(req);
|
||||||
|
_cache[artUrl] = tex;
|
||||||
|
target.texture = tex;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"Failed to load card art: {artUrl}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AIThinkingIndicator.cs
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UI;
|
||||||
|
|
||||||
|
/// Shows a spinner + brain label while the AI is deciding.
|
||||||
|
public class AIThinkingIndicator : MonoBehaviour
|
||||||
|
{
|
||||||
|
[SerializeField] private GameObject spinnerPanel;
|
||||||
|
[SerializeField] private Text brainLabel;
|
||||||
|
[SerializeField] private Text personalityLabel;
|
||||||
|
|
||||||
|
public void Show(string brain, string personality)
|
||||||
|
{
|
||||||
|
spinnerPanel.SetActive(true);
|
||||||
|
brainLabel.text = $"🧠 {brain}";
|
||||||
|
personalityLabel.text = $"🎭 {personality}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Hide() => spinnerPanel.SetActive(false);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Commander Zone UIs
|
||||||
|
|
||||||
|
### Zone Definitions per Player
|
||||||
|
|
||||||
|
| Zone Panel | Content | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `LIBRARY` | Count only | Click → draw/look at top |
|
||||||
|
| `GRAVEYARD` | Scrollable card list | Full card art on hover |
|
||||||
|
| `EXILE` | Scrollable card list | Includes face-down indicators |
|
||||||
|
| `HAND` | Wide panel, card art | Hidden to opponents |
|
||||||
|
| `COMMAND` | Commander card(s) | Shows commander tax counter |
|
||||||
|
| `BATTLEFIELD` | Open grid | Tapped/untapped state, counters |
|
||||||
|
|
||||||
|
### Commander-Specific UI Elements
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class CommanderZoneUI : MonoBehaviour
|
||||||
|
{
|
||||||
|
[SerializeField] private Text taxCounter; // "Tax: +{2N}"
|
||||||
|
[SerializeField] private RawImage commanderArt;
|
||||||
|
[SerializeField] private Text commanderDamageDealt; // per opponent
|
||||||
|
|
||||||
|
public void Refresh(PlayerDTO player)
|
||||||
|
{
|
||||||
|
int tax = player.CommanderTax;
|
||||||
|
taxCounter.text = tax > 0 ? $"+{tax * 2}" : "";
|
||||||
|
|
||||||
|
if (player.CommandZone.Count > 0)
|
||||||
|
StartCoroutine(CardImageLoader.LoadCardArt(
|
||||||
|
player.CommandZone[0].ArtUrl, commanderArt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Commander Rules Validation Layer
|
||||||
|
|
||||||
|
Forge handles all rules natively. FastAPI validates the AI response is within the legal actions list Forge returns — no custom rules code needed in Python or Unity.
|
||||||
|
|
||||||
|
Key Commander rules confirmed handled by Forge headless:
|
||||||
|
- **Commander Tax** — +2 generic per previous cast from command zone
|
||||||
|
- **21 Commander Damage** — tracked per attacker/defender pair
|
||||||
|
- **40 Starting Life**
|
||||||
|
- **Zone Redirect** — commander goes to command zone instead of graveyard/exile (player choice)
|
||||||
|
- **Color Identity** — deck validation at game start
|
||||||
|
- **Singleton** — deck validation at game start
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — AI Decision Logging & PPO Pipeline
|
||||||
|
|
||||||
|
Every AI decision written to `logs/ai_decisions.jsonl`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": 1743520800.123,
|
||||||
|
"player_id": "player_2",
|
||||||
|
"brain": "deepseek-r1:14b",
|
||||||
|
"personality": "aggro_beatdown",
|
||||||
|
"phase": "declare_attackers",
|
||||||
|
"action": {"action": "attack", "card_id": "c_042", "targets": ["player_1"], "reasoning": "Open attack, no blockers visible"},
|
||||||
|
"latency_ms": 847
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This log becomes the **supervised learning dataset** for the next PPO training iteration. Each (state, action) pair from winning games is a positive training example.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
commander-ai-lab/
|
||||||
|
├── main.py ← FastAPI app, mounts static files
|
||||||
|
├── api/
|
||||||
|
│ ├── dtos.py ← Pydantic DTOs
|
||||||
|
│ ├── card_enricher.py ← Injects art_url into CardDTOs
|
||||||
|
│ ├── game_loop.py ← Main game orchestration loop
|
||||||
|
│ └── routes/
|
||||||
|
│ └── game.py ← WebSocket + REST endpoints
|
||||||
|
├── ai/
|
||||||
|
│ ├── ai_orchestrator.py ← Main AI decision entry point
|
||||||
|
│ ├── brain_selector.py ← DeepSeek-R1 vs GPT-OSS routing
|
||||||
|
│ ├── prompt_builder.py ← Game state + RAG → prompt
|
||||||
|
│ └── personality.py ← 8 personalities + assignment
|
||||||
|
├── forge/
|
||||||
|
│ └── subprocess_manager.py ← Forge JVM stdin/stdout bridge
|
||||||
|
├── scripts/
|
||||||
|
│ ├── download_card_images.py ← One-time Scryfall bulk download
|
||||||
|
│ └── build_image_index.py ← SQLite name→path index
|
||||||
|
├── static/
|
||||||
|
│ └── card-images/ ← ~2GB local image cache
|
||||||
|
│ └── {set_code}/
|
||||||
|
│ └── {CardName}_{num}.jpg
|
||||||
|
├── data/
|
||||||
|
│ └── card_images.db ← SQLite image index
|
||||||
|
├── logs/
|
||||||
|
│ └── ai_decisions.jsonl ← PPO training dataset
|
||||||
|
├── unity-commander-ui/ ← Unity 2D TCGEngine project
|
||||||
|
└── docs/
|
||||||
|
└── implementation_plan.md ← This document
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phased Timeline
|
||||||
|
|
||||||
|
| Phase | Scope | Est. Time |
|
||||||
|
|---|---|---|
|
||||||
|
| **0** | Scryfall image download + SQLite index + FastAPI static mount | 1 day |
|
||||||
|
| **1** | Forge subprocess bridge + DTO definitions | 2–3 days |
|
||||||
|
| **2** | AI Orchestrator + brain selector + prompt builder + personality system | 3–4 days |
|
||||||
|
| **3** | Unity TCGEngine project setup + WebSocket GameNetworkManager | 3–4 days |
|
||||||
|
| **4** | PlayerAreaUI + ZonePanels + CardImageLoader + board layout | 3–4 days |
|
||||||
|
| **5** | Commander zone UI (tax counter, commander damage, zone redirect) | 1–2 days |
|
||||||
|
| **6** | AI decision logging + end-to-end game loop test | 1–2 days |
|
||||||
|
|
||||||
|
**Total estimated: ~3 weeks to first playable Commander game with live AI opponents.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|---|---|
|
||||||
|
| Forge = rules authority | Forge already handles all Commander rules natively. No custom rules code. |
|
||||||
|
| TCGEngine = dumb renderer | All game logic lives in Forge. Unity only renders state it receives. |
|
||||||
|
| FastAPI = hub | Bridges Forge ↔ Unity ↔ AI. Single point of truth. |
|
||||||
|
| Dual-brain routing | DeepSeek-R1 excels at analytical/math decisions; GPT-OSS at strategic sequencing. |
|
||||||
|
| ChromaDB scoped per player | RAG context relevant to the active player's deck, not global card knowledge. |
|
||||||
|
| Personality persists per game | Consistent playstyle per AI across all 4 players per session. |
|
||||||
|
| Local image cache | Eliminates network latency for card art. ~2GB one-time download. |
|
||||||
|
| ai_decisions.jsonl | Every AI decision is a future PPO training sample. |
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
with open('ml/eval/forge_evaluator.py', 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
# Fix 1: absolute JAR path in subprocess call
|
||||||
|
data = data.replace(
|
||||||
|
b'"--forge-jar", self.cfg.forge_jar,',
|
||||||
|
b'"--forge-jar", str(Path(self.cfg.forge_jar).resolve()),'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix 2: resolve forge_work_dir to absolute path too
|
||||||
|
data = data.replace(
|
||||||
|
b'"--forge-dir", self.cfg.forge_work_dir,',
|
||||||
|
b'"--forge-dir", str(Path(self.cfg.forge_work_dir).resolve()),'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix 3: change default forge_work_dir
|
||||||
|
data = data.replace(
|
||||||
|
b'forge_work_dir: str = "forge"',
|
||||||
|
b'forge_work_dir: str = "D:/ForgeCommander/forge-repo/forge-gui"'
|
||||||
|
)
|
||||||
|
|
||||||
|
with open('ml/eval/forge_evaluator.py', 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
|
||||||
|
import py_compile
|
||||||
|
py_compile.compile('ml/eval/forge_evaluator.py', doraise=True)
|
||||||
|
print('forge_evaluator.py - Syntax OK')
|
||||||
|
|
||||||
|
# Fix eval_policy.py too
|
||||||
|
with open('scripts/eval_policy.py', 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
|
||||||
|
data = data.replace(
|
||||||
|
b'"--forge-dir", default="forge"',
|
||||||
|
b'"--forge-dir", default="D:/ForgeCommander/forge-repo/forge-gui"'
|
||||||
|
)
|
||||||
|
|
||||||
|
with open('scripts/eval_policy.py', 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
|
||||||
|
py_compile.compile('scripts/eval_policy.py', doraise=True)
|
||||||
|
print('eval_policy.py - Syntax OK')
|
||||||
+147
-72
@@ -3,9 +3,13 @@
|
|||||||
Commander AI Lab — FastAPI Backend
|
Commander AI Lab — FastAPI Backend
|
||||||
Thin entry point: registers routers and starts uvicorn.
|
Thin entry point: registers routers and starts uvicorn.
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -22,7 +26,7 @@ except ImportError:
|
|||||||
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
|
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
|
||||||
import sys; sys.exit(1)
|
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.database import init_collection_db
|
||||||
from services.logging import setup_logging
|
from services.logging import setup_logging
|
||||||
from services.precon_service import PRECON_INDEX, download_precon_database
|
from services.precon_service import PRECON_INDEX, download_precon_database
|
||||||
@@ -32,21 +36,85 @@ from routes.precon import router as precon_router
|
|||||||
from routes.import_routes import router as import_router
|
from routes.import_routes import router as import_router
|
||||||
from routes.lab import router as lab_router
|
from routes.lab import router as lab_router
|
||||||
from routes.scanner import router as scanner_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.deepseek import router as deepseek_router
|
||||||
from routes.deckgen import router as deckgen_router
|
from routes.deckgen import router as deckgen_router
|
||||||
from routes.coach import router as coach_router, init_coach_service
|
from routes.coach import router as coach_router, init_coach_service
|
||||||
from routes.ml import router as ml_router
|
from routes.ml import router as ml_router
|
||||||
from routes.ws_game import router as ws_game_router
|
from routes.ws_game import router as ws_game_router
|
||||||
|
from routes.game import router as game_router
|
||||||
|
|
||||||
log = logging.getLogger("commander_ai_lab.api")
|
log = logging.getLogger("commander_ai_lab.api")
|
||||||
|
|
||||||
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
|
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
|
||||||
# ── CORS origins (env-configurable) ────────────────────────────
|
|
||||||
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
|
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
|
||||||
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
||||||
|
|
||||||
# ── App ────────────────────────────────────────────────────────
|
# Module-level handle so lifespan can call .load() after routes are registered.
|
||||||
app = FastAPI(title="Commander AI Lab API", version="3.0.0")
|
_POLICY_SVC = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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."""
|
||||||
|
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 model load — routes were already registered at module scope (before
|
||||||
|
# any app.mount() calls) so /api/policy/* is reachable regardless of the
|
||||||
|
# StaticFiles root mount. Only the heavyweight model load happens here.
|
||||||
|
try:
|
||||||
|
if _POLICY_SVC is not None:
|
||||||
|
if _POLICY_SVC.load():
|
||||||
|
log.info("Policy model loaded successfully")
|
||||||
|
else:
|
||||||
|
log.warning("Policy model not loaded — /api/policy/health will report degraded")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Policy model load 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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=allowed_origins,
|
allow_origins=allowed_origins,
|
||||||
@@ -61,41 +129,61 @@ app.include_router(precon_router)
|
|||||||
app.include_router(import_router)
|
app.include_router(import_router)
|
||||||
app.include_router(lab_router)
|
app.include_router(lab_router)
|
||||||
app.include_router(scanner_router)
|
app.include_router(scanner_router)
|
||||||
|
app.include_router(rag_router)
|
||||||
app.include_router(deepseek_router)
|
app.include_router(deepseek_router)
|
||||||
app.include_router(deckgen_router)
|
app.include_router(deckgen_router)
|
||||||
app.include_router(coach_router)
|
app.include_router(coach_router)
|
||||||
app.include_router(ws_game_router)
|
app.include_router(ws_game_router)
|
||||||
app.include_router(ml_router)
|
app.include_router(ml_router)
|
||||||
|
app.include_router(game_router) # live game session routes
|
||||||
|
|
||||||
|
# ── Policy routes — MUST be registered here, before any app.mount() calls ────────────────────
|
||||||
|
# StaticFiles mounted at "/" acts as a sub-application and returns its own 404 for
|
||||||
|
# paths it doesn't serve, rather than falling through to later-registered routes.
|
||||||
|
# Any include_router() called after app.mount("/", ...) will be unreachable.
|
||||||
|
# Registering here (module scope) guarantees the /api/policy/* endpoints exist in
|
||||||
|
# the route list before the root static mount is added below.
|
||||||
|
try:
|
||||||
|
from ml.serving.policy_server import PolicyInferenceService
|
||||||
|
from routes.policy import register_policy_routes
|
||||||
|
_POLICY_SVC = PolicyInferenceService()
|
||||||
|
register_policy_routes(app, _POLICY_SVC)
|
||||||
|
log.info("Policy routes registered (/api/policy/*)")
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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")
|
@app.get("/api/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint for load balancers and Unity client polling."""
|
"""Health check endpoint for load balancers and Unity client polling."""
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
# ── Static UI (MUST come after all @app.get / include_router calls) ──────────────────────────
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_legacy_ui_dir = _Path(__file__).parent / "ui"
|
||||||
|
_lan_client_dir = _Path(__file__).parent / "lan-client"
|
||||||
|
_precon_decks_dir = _Path(__file__).parent / "precon-decks"
|
||||||
|
_user_decks_dir = _Path(__file__).parent / "user-decks"
|
||||||
|
_spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||||
|
|
||||||
@app.on_event("startup")
|
# Mount LAN client at /lan (served before the root catch-all)
|
||||||
async def _on_startup():
|
if _lan_client_dir.exists():
|
||||||
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
|
app.mount("/lan", StaticFiles(directory=str(_lan_client_dir), html=True), name="lan-client")
|
||||||
# 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()
|
|
||||||
|
|
||||||
|
# Mount precon-decks/ so .dck files are fetchable at /precon-decks/{file}.dck
|
||||||
|
if _precon_decks_dir.exists():
|
||||||
|
app.mount("/precon-decks", StaticFiles(directory=str(_precon_decks_dir)), name="precon-decks")
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
# Mount user-decks/ so exported .dck files are fetchable at /user-decks/{file}.dck
|
||||||
async def _on_shutdown():
|
if _user_decks_dir.exists():
|
||||||
"""Close the async SQLite connection pool."""
|
app.mount("/user-decks", StaticFiles(directory=str(_user_decks_dir)), name="user-decks")
|
||||||
from services.async_db import async_close_db
|
|
||||||
await async_close_db()
|
|
||||||
|
|
||||||
# ── Static UI ──────────────────────────────────────────────────
|
|
||||||
_legacy_ui_dir = Path(__file__).parent / "ui"
|
|
||||||
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
|
||||||
|
|
||||||
if _legacy_ui_dir.exists():
|
if _legacy_ui_dir.exists():
|
||||||
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
|
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
|
||||||
@@ -103,6 +191,7 @@ elif _spa_dir.exists():
|
|||||||
_spa_assets = _spa_dir / "assets"
|
_spa_assets = _spa_dir / "assets"
|
||||||
if _spa_assets.exists():
|
if _spa_assets.exists():
|
||||||
app.mount("/assets", StaticFiles(directory=str(_spa_assets)), name="spa-assets")
|
app.mount("/assets", StaticFiles(directory=str(_spa_assets)), name="spa-assets")
|
||||||
|
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
async def _spa_catchall(full_path: str):
|
async def _spa_catchall(full_path: str):
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -111,20 +200,24 @@ elif _spa_dir.exists():
|
|||||||
return FileResponse(str(requested_file))
|
return FileResponse(str(requested_file))
|
||||||
return FileResponse(str(_spa_dir / "index.html"))
|
return FileResponse(str(_spa_dir / "index.html"))
|
||||||
|
|
||||||
# ── Startup ────────────────────────────────────────────────────
|
|
||||||
|
# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────────────
|
||||||
def _parse_args():
|
def _parse_args():
|
||||||
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
|
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
|
||||||
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
|
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
|
||||||
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", ""))
|
# Support both FORGE_DIR and FORGE_WORKING_DIR (alias for backwards compat)
|
||||||
|
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR") or os.environ.get("FORGE_WORKING_DIR", ""))
|
||||||
p.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", ""))
|
p.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", ""))
|
||||||
p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""))
|
p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""))
|
||||||
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
|
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
|
||||||
|
p.add_argument("--results-dir", default=os.environ.get("RESULTS_DIR", ""))
|
||||||
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
|
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("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
|
||||||
p.add_argument("--anthropic-key", default=os.environ.get("ANTHROPIC_API_KEY", ""))
|
p.add_argument("--anthropic-key", default=os.environ.get("ANTHROPIC_API_KEY", ""))
|
||||||
p.add_argument("--verbose", "-v", action="store_true")
|
p.add_argument("--verbose", "-v", action="store_true")
|
||||||
return p.parse_args()
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_forge_decks_dir() -> str:
|
def _resolve_forge_decks_dir() -> str:
|
||||||
import sys
|
import sys
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
@@ -142,52 +235,37 @@ def _resolve_forge_decks_dir() -> str:
|
|||||||
return str(candidate)
|
return str(candidate)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _resolve_forge_dir() -> str:
|
def _resolve_forge_dir() -> str:
|
||||||
"""Auto-detect the Forge installation directory.
|
"""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.
|
|
||||||
"""
|
|
||||||
import sys
|
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):
|
if CFG.forge_jar and os.path.isfile(CFG.forge_jar):
|
||||||
jar_path = Path(CFG.forge_jar)
|
jar_path = Path(CFG.forge_jar)
|
||||||
# target/ -> forge-gui-desktop/ -> <forge-root>
|
|
||||||
candidate = jar_path.parent.parent.parent
|
candidate = jar_path.parent.parent.parent
|
||||||
if candidate.is_dir() and (candidate / "res").is_dir():
|
if candidate.is_dir() and (candidate / "res").is_dir():
|
||||||
return str(candidate)
|
return str(candidate)
|
||||||
# Also try just one level up from target/
|
|
||||||
candidate = jar_path.parent.parent
|
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"
|
forge_gui_sibling = jar_path.parent.parent.parent / "forge-gui"
|
||||||
if forge_gui_sibling.is_dir() and (forge_gui_sibling / "res").is_dir():
|
if forge_gui_sibling.is_dir() and (forge_gui_sibling / "res").is_dir():
|
||||||
return str(forge_gui_sibling)
|
return str(forge_gui_sibling)
|
||||||
if candidate.is_dir() and (candidate / "res").is_dir():
|
if candidate.is_dir() and (candidate / "res").is_dir():
|
||||||
return str(candidate)
|
return str(candidate)
|
||||||
# Fall back to the directory containing the JAR itself
|
|
||||||
candidate = jar_path.parent
|
candidate = jar_path.parent
|
||||||
if candidate.is_dir():
|
if candidate.is_dir():
|
||||||
return str(candidate)
|
return str(candidate)
|
||||||
|
|
||||||
# 2. Check common sibling/child directories relative to project root
|
|
||||||
project_root = Path(__file__).parent
|
project_root = Path(__file__).parent
|
||||||
for candidate in [
|
for candidate in [
|
||||||
project_root / "forge",
|
project_root / "forge",
|
||||||
project_root.parent / "forge-repo",
|
project_root.parent / "Forge-Repo",
|
||||||
project_root.parent / "forge-repo" / "forge-gui",
|
project_root.parent / "Forge-Repo" / "forge-gui",
|
||||||
|
project_root.parent / "forge-repo", # legacy fallback
|
||||||
|
project_root.parent / "forge-repo" / "forge-gui",
|
||||||
project_root.parent / "forge",
|
project_root.parent / "forge",
|
||||||
project_root / "forge-gui-desktop",
|
project_root / "forge-gui-desktop",
|
||||||
]:
|
]:
|
||||||
if candidate.is_dir():
|
if candidate.is_dir():
|
||||||
# Prefer directory that has 'res' subfolder
|
|
||||||
if (candidate / "res").is_dir():
|
if (candidate / "res").is_dir():
|
||||||
return str(candidate)
|
return str(candidate)
|
||||||
|
|
||||||
# 3. Platform-specific user data directories
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
appdata = os.environ.get("APPDATA", "")
|
appdata = os.environ.get("APPDATA", "")
|
||||||
if appdata:
|
if appdata:
|
||||||
@@ -195,32 +273,26 @@ def _resolve_forge_dir() -> str:
|
|||||||
if candidate.is_dir():
|
if candidate.is_dir():
|
||||||
return str(candidate)
|
return str(candidate)
|
||||||
home = Path.home()
|
home = Path.home()
|
||||||
for candidate in [
|
for candidate in [home / ".forge", home / "Forge"]:
|
||||||
home / ".forge",
|
|
||||||
home / "Forge",
|
|
||||||
]:
|
|
||||||
if candidate.is_dir():
|
if candidate.is_dir():
|
||||||
return str(candidate)
|
return str(candidate)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _resolve_forge_jar() -> str:
|
def _resolve_forge_jar() -> str:
|
||||||
"""Auto-detect the Forge GUI desktop JAR in common locations."""
|
"""Auto-detect the Forge GUI desktop JAR in common locations."""
|
||||||
project_root = Path(__file__).parent
|
project_root = Path(__file__).parent
|
||||||
|
|
||||||
# Candidate directories where the Forge JAR might live
|
|
||||||
candidates = [
|
candidates = [
|
||||||
project_root / "forge-gui-desktop" / "target",
|
project_root / "forge-gui-desktop" / "target",
|
||||||
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
|
project_root.parent / "Forge-Repo" / "forge-gui-desktop" / "target",
|
||||||
|
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target", # legacy fallback
|
||||||
project_root.parent / "forge" / "forge-gui-desktop" / "target",
|
project_root.parent / "forge" / "forge-gui-desktop" / "target",
|
||||||
]
|
]
|
||||||
|
forge_dir_env = os.environ.get("FORGE_DIR", "") or os.environ.get("FORGE_WORKING_DIR", "")
|
||||||
# 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:
|
if forge_dir_env:
|
||||||
fd = Path(forge_dir_env)
|
fd = Path(forge_dir_env)
|
||||||
candidates.append(fd.parent / "forge-gui-desktop" / "target")
|
candidates.append(fd.parent / "forge-gui-desktop" / "target")
|
||||||
candidates.append(fd / "target")
|
candidates.append(fd / "target")
|
||||||
|
|
||||||
for target_dir in candidates:
|
for target_dir in candidates:
|
||||||
if target_dir.is_dir():
|
if target_dir.is_dir():
|
||||||
for pattern in [
|
for pattern in [
|
||||||
@@ -234,6 +306,7 @@ def _resolve_forge_jar() -> str:
|
|||||||
return str(jars[0])
|
return str(jars[0])
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _resolve_lab_jar() -> str:
|
def _resolve_lab_jar() -> str:
|
||||||
target_dir = Path(__file__).parent / "target"
|
target_dir = Path(__file__).parent / "target"
|
||||||
if target_dir.exists():
|
if target_dir.exists():
|
||||||
@@ -248,36 +321,25 @@ def _resolve_lab_jar() -> str:
|
|||||||
return str(jars[0])
|
return str(jars[0])
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _resolve_precon_dir() -> str:
|
def _resolve_precon_dir() -> str:
|
||||||
"""Auto-detect the precon-decks directory.
|
"""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.
|
|
||||||
"""
|
|
||||||
project_root = Path(__file__).parent
|
project_root = Path(__file__).parent
|
||||||
|
|
||||||
# 1. Standard location: <project-root>/precon-decks
|
|
||||||
candidate = project_root / "precon-decks"
|
candidate = project_root / "precon-decks"
|
||||||
if candidate.is_dir():
|
if candidate.is_dir():
|
||||||
return str(candidate)
|
return str(candidate)
|
||||||
|
|
||||||
# 2. Check parent directory (in case running from a subdirectory)
|
|
||||||
candidate = project_root.parent / "precon-decks"
|
candidate = project_root.parent / "precon-decks"
|
||||||
if candidate.is_dir():
|
if candidate.is_dir():
|
||||||
return str(candidate)
|
return str(candidate)
|
||||||
|
|
||||||
# 3. Derive from forge_dir if available
|
|
||||||
if CFG.forge_dir:
|
if CFG.forge_dir:
|
||||||
candidate = Path(CFG.forge_dir) / "precon-decks"
|
candidate = Path(CFG.forge_dir) / "precon-decks"
|
||||||
if candidate.is_dir():
|
if candidate.is_dir():
|
||||||
return str(candidate)
|
return str(candidate)
|
||||||
|
|
||||||
# 4. Fall back: create the directory in the project root so downloads work
|
|
||||||
fallback = project_root / "precon-decks"
|
fallback = project_root / "precon-decks"
|
||||||
fallback.mkdir(parents=True, exist_ok=True)
|
fallback.mkdir(parents=True, exist_ok=True)
|
||||||
return str(fallback)
|
return str(fallback)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = _parse_args()
|
args = _parse_args()
|
||||||
setup_logging(logging.DEBUG if args.verbose else logging.INFO)
|
setup_logging(logging.DEBUG if args.verbose else logging.INFO)
|
||||||
@@ -291,6 +353,15 @@ def main():
|
|||||||
CFG.ximilar_api_key = args.ximilar_key
|
CFG.ximilar_api_key = args.ximilar_key
|
||||||
CFG.anthropic_api_key = args.anthropic_key
|
CFG.anthropic_api_key = args.anthropic_key
|
||||||
|
|
||||||
|
# Wire RESULTS_DIR: use explicit env/arg if set, otherwise keep default "results"
|
||||||
|
# but resolve it to an absolute path relative to this file so it works
|
||||||
|
# regardless of what directory the server is launched from.
|
||||||
|
if args.results_dir:
|
||||||
|
CFG.results_dir = args.results_dir
|
||||||
|
else:
|
||||||
|
# Default: results/ next to lab_api.py (always absolute)
|
||||||
|
CFG.results_dir = str(Path(__file__).parent / "results")
|
||||||
|
|
||||||
from services.forge_runner import get_java17
|
from services.forge_runner import get_java17
|
||||||
j17 = get_java17()
|
j17 = get_java17()
|
||||||
|
|
||||||
@@ -311,8 +382,12 @@ def main():
|
|||||||
init_collection_db()
|
init_collection_db()
|
||||||
init_coach_service()
|
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
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
|
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Commander AI Lab — Collection</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="global.css" />
|
||||||
|
<link rel="stylesheet" href="collection.css" />
|
||||||
|
<style>
|
||||||
|
/* No nav bar when embedded in LAN client iframe */
|
||||||
|
:root { --nav-height: 0px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- nav.js removed — collection is embedded via iframe in the LAN client -->
|
||||||
|
<div id="collection-container"></div>
|
||||||
|
<script>
|
||||||
|
window.LAB_API_BASE = window.LAB_API_BASE || '';
|
||||||
|
</script>
|
||||||
|
<script src="collection.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
Collection.init();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<!-- Card Preview Popup (hover tooltip) -->
|
||||||
|
<div id="coll-card-preview" style="display:none; position:fixed; pointer-events:none; z-index:9999;">
|
||||||
|
<img id="coll-card-preview-img" src="" alt="Card preview" style="width:220px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.7);" />
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,495 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Commander AI Lab — Deck Builder</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="global.css" />
|
||||||
|
<link rel="stylesheet" href="deckbuilder.css" />
|
||||||
|
<style>
|
||||||
|
/* No nav bar when embedded in LAN client iframe */
|
||||||
|
:root { --nav-height: 0px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- nav.js removed — deck builder is embedded via iframe in the LAN client -->
|
||||||
|
|
||||||
|
<div id="deck-builder-page">
|
||||||
|
|
||||||
|
<!-- TOP BAR -->
|
||||||
|
<header class="deck-topbar">
|
||||||
|
<div class="deck-topbar-commander">
|
||||||
|
<div class="deck-commander-portrait" id="deck-commander-portrait">
|
||||||
|
<span class="deck-commander-portrait-placeholder">⚔</span>
|
||||||
|
<img class="deck-commander-portrait-img" id="deck-commander-img" src="" alt="" style="display:none" />
|
||||||
|
</div>
|
||||||
|
<div class="deck-name-block">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="deck-name-input"
|
||||||
|
id="deck-name-input"
|
||||||
|
placeholder="Untitled Deck"
|
||||||
|
value=""
|
||||||
|
/>
|
||||||
|
<div class="deck-commander-label" id="deck-commander-label">No commander selected</div>
|
||||||
|
</div>
|
||||||
|
<div class="deck-color-chips" id="deck-color-chips"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="deck-topbar-center">
|
||||||
|
<div class="deck-count-display">
|
||||||
|
<span class="deck-count-num" id="deck-total-count">0</span>
|
||||||
|
<span class="deck-count-sep">/</span>
|
||||||
|
<span class="deck-count-target">100</span>
|
||||||
|
<span class="deck-count-label">cards</span>
|
||||||
|
</div>
|
||||||
|
<button class="deck-btn deck-btn-ghost" id="deck-new-btn">+ New Deck</button>
|
||||||
|
<div class="deck-selector-wrap">
|
||||||
|
<select class="deck-selector" id="deck-selector">
|
||||||
|
<option value="">— Select Deck —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="deck-btn deck-btn-danger" id="deck-delete-btn" title="Delete this deck" style="display:none;">✕ Delete</button>
|
||||||
|
<button class="deck-btn deck-btn-danger-outline" id="deck-clear-all-btn" title="Delete all decks">Clear All</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="deck-topbar-actions">
|
||||||
|
<button class="deck-btn deck-btn-import" id="deck-import-btn" title="Import a decklist">
|
||||||
|
<span>⬆</span> Import
|
||||||
|
</button>
|
||||||
|
<button class="deck-btn deck-btn-ghost" id="deck-export-btn" title="Export as .dck file">
|
||||||
|
<span>⬇</span> Export .dck
|
||||||
|
</button>
|
||||||
|
<button class="deck-btn deck-btn-ghost" id="deck-export-sim-btn" title="Export deck as .dck to Forge sim folder">
|
||||||
|
<span>📤</span> Export to Sim
|
||||||
|
</button>
|
||||||
|
<button class="deck-btn deck-btn-ghost" id="deck-export-game-btn" title="Send deck to LAN game setup" style="border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);">
|
||||||
|
<span>🎮</span> Use in Game
|
||||||
|
</button>
|
||||||
|
<button class="deck-btn deck-btn-sim" id="deck-sim-btn" title="Run quick simulation">
|
||||||
|
<span class="deck-btn-icon">▶</span> Run Simulation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- MAIN 3-COLUMN LAYOUT -->
|
||||||
|
<main class="deck-main">
|
||||||
|
|
||||||
|
<!-- LEFT COLUMN: Collection Search -->
|
||||||
|
<aside class="deck-col-left">
|
||||||
|
<div class="deck-panel deck-panel-search">
|
||||||
|
<div class="deck-panel-header">
|
||||||
|
<span class="deck-panel-title">Collection Search</span>
|
||||||
|
<span class="deck-search-count" id="deck-search-count">0 results</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="deck-search-bar-wrap">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="deck-search-bar"
|
||||||
|
id="deck-search-bar"
|
||||||
|
placeholder="Search cards..."
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<span class="deck-search-icon">🔍</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="deck-filter-row">
|
||||||
|
<select class="deck-filter-select" id="deck-type-filter">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="Land">Land</option>
|
||||||
|
<option value="Creature">Creature</option>
|
||||||
|
<option value="Instant">Instant</option>
|
||||||
|
<option value="Sorcery">Sorcery</option>
|
||||||
|
<option value="Artifact">Artifact</option>
|
||||||
|
<option value="Enchantment">Enchantment</option>
|
||||||
|
<option value="Planeswalker">Planeswalker</option>
|
||||||
|
</select>
|
||||||
|
<div class="deck-cmc-filter">
|
||||||
|
<input type="number" class="deck-cmc-input" id="deck-cmc-min" placeholder="0" min="0" max="20" />
|
||||||
|
<span class="deck-cmc-sep">–</span>
|
||||||
|
<input type="number" class="deck-cmc-input" id="deck-cmc-max" placeholder="∞" min="0" max="20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="deck-color-filter-row" id="deck-color-filter">
|
||||||
|
<button class="deck-color-pip deck-color-W" data-color="W" title="White">W</button>
|
||||||
|
<button class="deck-color-pip deck-color-U" data-color="U" title="Blue">U</button>
|
||||||
|
<button class="deck-color-pip deck-color-B" data-color="B" title="Black">B</button>
|
||||||
|
<button class="deck-color-pip deck-color-R" data-color="R" title="Red">R</button>
|
||||||
|
<button class="deck-color-pip deck-color-G" data-color="G" title="Green">G</button>
|
||||||
|
<button class="deck-color-pip deck-color-C" data-color="C" title="Colorless">C</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results list -->
|
||||||
|
<div class="deck-search-results" id="deck-search-results">
|
||||||
|
<div class="deck-empty-state">
|
||||||
|
<div class="deck-empty-icon">🃏</div>
|
||||||
|
<div class="deck-empty-text">Search your collection to add cards</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="deck-search-footer">
|
||||||
|
<button class="deck-btn deck-btn-success deck-btn-full" id="deck-add-all-owned-btn" style="display:none">
|
||||||
|
Add All Matching (Owned)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- CENTER COLUMN: Deck Card List -->
|
||||||
|
<section class="deck-col-center">
|
||||||
|
<div class="deck-panel deck-panel-cards" id="deck-card-list-panel">
|
||||||
|
<div class="deck-panel-header deck-card-list-header">
|
||||||
|
<span class="deck-panel-title">Deck Contents</span>
|
||||||
|
<div class="deck-card-list-actions">
|
||||||
|
<span class="deck-card-list-meta" id="deck-card-list-meta">Select or create a deck</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card groups by type -->
|
||||||
|
<div class="deck-card-groups" id="deck-card-groups">
|
||||||
|
<div class="deck-empty-state deck-empty-state-lg">
|
||||||
|
<div class="deck-empty-icon">⚔</div>
|
||||||
|
<div class="deck-empty-text">Create or select a deck to start building</div>
|
||||||
|
<button class="deck-btn deck-btn-accent" id="deck-empty-new-btn">+ New Deck</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick add by name -->
|
||||||
|
<div class="deck-quickadd-row" id="deck-quickadd-row" style="display:none">
|
||||||
|
<div class="deck-quickadd-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="deck-quickadd-input"
|
||||||
|
id="deck-quickadd-input"
|
||||||
|
placeholder="Add card by name..."
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<div class="deck-quickadd-dropdown" id="deck-quickadd-dropdown" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<button class="deck-btn deck-btn-accent-sm" id="deck-quickadd-btn">Add</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deck stats footer -->
|
||||||
|
<div class="deck-stats-footer" id="deck-stats-footer" style="display:none">
|
||||||
|
<div class="deck-stat-pill">
|
||||||
|
<span class="deck-stat-pill-num" id="deck-footer-total">0</span>
|
||||||
|
<span class="deck-stat-pill-label">total</span>
|
||||||
|
</div>
|
||||||
|
<div class="deck-stat-pill">
|
||||||
|
<span class="deck-stat-pill-num deck-stat-owned" id="deck-footer-owned">0</span>
|
||||||
|
<span class="deck-stat-pill-label">owned</span>
|
||||||
|
</div>
|
||||||
|
<div class="deck-stat-pill">
|
||||||
|
<span class="deck-stat-pill-num" id="deck-footer-avg-cmc">0.0</span>
|
||||||
|
<span class="deck-stat-pill-label">avg CMC</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: EDH Advisor -->
|
||||||
|
<aside class="deck-col-right">
|
||||||
|
|
||||||
|
<!-- Ratio Dashboard -->
|
||||||
|
<div class="deck-panel deck-panel-ratios" id="deck-ratio-panel">
|
||||||
|
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ratio-body">
|
||||||
|
<span class="deck-panel-title">📊 Ratio Dashboard</span>
|
||||||
|
<span class="deck-collapse-arrow">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="deck-panel-body" id="deck-ratio-body">
|
||||||
|
<div class="deck-ratio-bars" id="deck-ratio-bars">
|
||||||
|
<div class="deck-ratio-placeholder">Load a deck to see ratios</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fix My Ratios -->
|
||||||
|
<div class="deck-panel deck-panel-fix" id="deck-fix-panel">
|
||||||
|
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-fix-body">
|
||||||
|
<span class="deck-panel-title">🔧 Fix My Ratios</span>
|
||||||
|
<span class="deck-collapse-arrow">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="deck-panel-body" id="deck-fix-body">
|
||||||
|
<div class="deck-shortfalls" id="deck-shortfalls">
|
||||||
|
<div class="deck-ratio-placeholder">No analysis available</div>
|
||||||
|
</div>
|
||||||
|
<div class="deck-fix-actions" id="deck-fix-actions" style="display:none">
|
||||||
|
<button class="deck-btn deck-btn-ghost deck-btn-full" id="deck-autosuggest-btn">
|
||||||
|
⚡ Auto-suggest from Collection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="deck-fix-suggestions" id="deck-fix-suggestions" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EDHREC Recommendations -->
|
||||||
|
<div class="deck-panel deck-panel-edhrec" id="deck-edhrec-panel">
|
||||||
|
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-edhrec-body">
|
||||||
|
<span class="deck-panel-title">✨ EDHREC Recs</span>
|
||||||
|
<span class="deck-collapse-arrow">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="deck-panel-body" id="deck-edhrec-body">
|
||||||
|
<div class="deck-edhrec-controls">
|
||||||
|
<label class="deck-toggle-label">
|
||||||
|
<input type="checkbox" class="deck-toggle-check" id="deck-edhrec-owned-only" />
|
||||||
|
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
|
||||||
|
Owned Only
|
||||||
|
</label>
|
||||||
|
<button class="deck-btn deck-btn-ghost deck-btn-sm" id="deck-edhrec-refresh-btn">
|
||||||
|
↺ Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="deck-edhrec-list" id="deck-edhrec-list">
|
||||||
|
<div class="deck-ratio-placeholder">Load a deck to see recommendations</div>
|
||||||
|
</div>
|
||||||
|
<div class="deck-edhrec-bulk" id="deck-edhrec-bulk" style="display:none">
|
||||||
|
<button class="deck-btn deck-btn-success deck-btn-full" id="deck-edhrec-bulk-owned-btn">
|
||||||
|
Add All Owned (respect ratios)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Research (Perplexity) — hidden until pplx status check -->
|
||||||
|
<div class="deck-panel deck-panel-ai" id="deck-ai-research-panel" style="display:none">
|
||||||
|
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ai-research-body">
|
||||||
|
<span class="deck-panel-title">🧠 AI Research</span>
|
||||||
|
<span class="deck-collapse-arrow">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="deck-panel-body" id="deck-ai-research-body">
|
||||||
|
<div class="deck-ai-controls">
|
||||||
|
<div class="deck-form-field deck-form-field-sm">
|
||||||
|
<label class="deck-form-label-sm">Analysis Goal</label>
|
||||||
|
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-research-goal"
|
||||||
|
placeholder="Identify weaknesses and suggest upgrades" />
|
||||||
|
</div>
|
||||||
|
<div class="deck-ai-row">
|
||||||
|
<div class="deck-form-field deck-form-field-sm deck-form-field-half">
|
||||||
|
<label class="deck-form-label-sm">Budget ($)</label>
|
||||||
|
<input type="number" class="deck-form-input deck-form-input-sm" id="ai-research-budget"
|
||||||
|
placeholder="No limit" min="0" step="10" />
|
||||||
|
</div>
|
||||||
|
<label class="deck-toggle-label deck-toggle-label-sm">
|
||||||
|
<input type="checkbox" class="deck-toggle-check" id="ai-research-use-collection" checked />
|
||||||
|
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
|
||||||
|
Use Collection
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="deck-form-field deck-form-field-sm">
|
||||||
|
<label class="deck-form-label-sm">Omit Cards (comma-separated)</label>
|
||||||
|
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-research-omit"
|
||||||
|
placeholder="Dockside Extortionist, Rhystic Study" />
|
||||||
|
</div>
|
||||||
|
<button class="deck-btn deck-btn-ai deck-btn-full" id="ai-research-btn" disabled>
|
||||||
|
🔍 Analyze Deck
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="deck-ai-loading" id="ai-research-loading" style="display:none">
|
||||||
|
<div class="deck-ai-spinner"></div>
|
||||||
|
<span>Analyzing with Claude Opus...</span>
|
||||||
|
</div>
|
||||||
|
<div class="deck-ai-results" id="ai-research-results" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Deck Generate (Perplexity) — hidden until pplx status check -->
|
||||||
|
<div class="deck-panel deck-panel-ai" id="deck-ai-generate-panel" style="display:none">
|
||||||
|
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ai-generate-body">
|
||||||
|
<span class="deck-panel-title">✨ AI Generate Deck</span>
|
||||||
|
<span class="deck-collapse-arrow">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="deck-panel-body" id="deck-ai-generate-body">
|
||||||
|
<div class="deck-ai-controls">
|
||||||
|
<div class="deck-form-field deck-form-field-sm">
|
||||||
|
<label class="deck-form-label-sm">Commander</label>
|
||||||
|
<div class="deck-ai-commander-wrap">
|
||||||
|
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-gen-commander"
|
||||||
|
placeholder="Search for a commander..." autocomplete="off" />
|
||||||
|
<div class="deck-ai-commander-dropdown" id="ai-gen-commander-dropdown" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="deck-ai-row">
|
||||||
|
<div class="deck-form-field deck-form-field-sm deck-form-field-half">
|
||||||
|
<label class="deck-form-label-sm">Budget ($)</label>
|
||||||
|
<input type="number" class="deck-form-input deck-form-input-sm" id="ai-gen-budget"
|
||||||
|
placeholder="No limit" min="0" step="10" />
|
||||||
|
</div>
|
||||||
|
<label class="deck-toggle-label deck-toggle-label-sm">
|
||||||
|
<input type="checkbox" class="deck-toggle-check" id="ai-gen-use-collection" checked />
|
||||||
|
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
|
||||||
|
Use Collection
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="deck-form-field deck-form-field-sm">
|
||||||
|
<label class="deck-form-label-sm">Omit Cards (comma-separated)</label>
|
||||||
|
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-gen-omit"
|
||||||
|
placeholder="Rhystic Study, Cyclonic Rift" />
|
||||||
|
</div>
|
||||||
|
<button class="deck-btn deck-btn-ai deck-btn-full" id="ai-gen-btn">
|
||||||
|
✨ Generate Full Deck
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="deck-ai-loading" id="ai-gen-loading" style="display:none">
|
||||||
|
<div class="deck-ai-spinner"></div>
|
||||||
|
<span>Generating deck with Claude Opus...</span>
|
||||||
|
</div>
|
||||||
|
<div class="deck-ai-results" id="ai-gen-results" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- CARD PREVIEW TOOLTIP -->
|
||||||
|
<div class="deck-card-preview" id="deck-card-preview" style="display:none">
|
||||||
|
<img class="deck-card-preview-img" id="deck-card-preview-img" src="" alt="" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOAST CONTAINER -->
|
||||||
|
<div class="deck-toast-container" id="deck-toast-container"></div>
|
||||||
|
|
||||||
|
<!-- CONFIRM MODAL -->
|
||||||
|
<div class="deck-modal-overlay" id="deck-confirm-modal" style="display:none">
|
||||||
|
<div class="deck-modal">
|
||||||
|
<div class="deck-modal-header">
|
||||||
|
<h3 class="deck-modal-title" id="deck-confirm-title">Confirm Action</h3>
|
||||||
|
<button class="deck-modal-close-btn" id="deck-confirm-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="deck-modal-body">
|
||||||
|
<p class="deck-confirm-message" id="deck-confirm-message"></p>
|
||||||
|
<div class="deck-confirm-changes" id="deck-confirm-changes"></div>
|
||||||
|
</div>
|
||||||
|
<div class="deck-modal-footer">
|
||||||
|
<button class="deck-btn deck-btn-ghost" id="deck-confirm-cancel">Cancel</button>
|
||||||
|
<button class="deck-btn deck-btn-accent" id="deck-confirm-ok">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NEW DECK MODAL -->
|
||||||
|
<div class="deck-modal-overlay" id="deck-new-modal" style="display:none">
|
||||||
|
<div class="deck-modal">
|
||||||
|
<div class="deck-modal-header">
|
||||||
|
<h3 class="deck-modal-title">Create New Deck</h3>
|
||||||
|
<button class="deck-modal-close-btn" id="deck-new-modal-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="deck-modal-body">
|
||||||
|
<div class="deck-form-field">
|
||||||
|
<label class="deck-form-label">Deck Name</label>
|
||||||
|
<input type="text" class="deck-form-input" id="deck-new-name" placeholder="My Commander Deck" />
|
||||||
|
</div>
|
||||||
|
<div class="deck-form-field">
|
||||||
|
<label class="deck-form-label">Commander (optional)</label>
|
||||||
|
<div class="deck-commander-search-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="deck-form-input"
|
||||||
|
id="deck-new-commander-search"
|
||||||
|
placeholder="Search Scryfall for commander..."
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<div class="deck-commander-dropdown" id="deck-new-commander-dropdown" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="deck-new-commander-selected" id="deck-new-commander-selected" style="display:none">
|
||||||
|
<span class="deck-selected-commander-name" id="deck-new-commander-name"></span>
|
||||||
|
<button class="deck-clear-commander-btn" id="deck-clear-commander-btn">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="deck-modal-footer">
|
||||||
|
<button class="deck-btn deck-btn-ghost" id="deck-new-cancel">Cancel</button>
|
||||||
|
<button class="deck-btn deck-btn-accent" id="deck-new-create">Create Deck</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IMPORT MODAL -->
|
||||||
|
<div class="deck-modal-overlay" id="deck-import-modal" style="display:none">
|
||||||
|
<div class="deck-modal deck-modal-wide">
|
||||||
|
<div class="deck-modal-header">
|
||||||
|
<h3 class="deck-modal-title">Import Decklist</h3>
|
||||||
|
<button class="deck-modal-close-btn" id="deck-import-modal-close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="deck-modal-body">
|
||||||
|
<div class="deck-import-tabs">
|
||||||
|
<button class="deck-import-tab active" data-tab="paste">Paste Text</button>
|
||||||
|
<button class="deck-import-tab" data-tab="file">Upload File</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="deck-import-paste-panel" class="deck-import-panel">
|
||||||
|
<textarea id="deck-import-textarea" rows="14" placeholder="Paste your decklist here...
|
||||||
|
|
||||||
|
Supported formats:
|
||||||
|
[Commander]
|
||||||
|
1 Korvold, Fae-Cursed King
|
||||||
|
|
||||||
|
[Deck]
|
||||||
|
1 Sol Ring
|
||||||
|
1 Cultivate
|
||||||
|
37 Forest
|
||||||
|
|
||||||
|
Or plain list:
|
||||||
|
Sol Ring
|
||||||
|
Cultivate
|
||||||
|
Forest"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="deck-import-file-panel" class="deck-import-panel" style="display:none">
|
||||||
|
<div class="deck-import-dropzone" id="deck-import-dropzone">
|
||||||
|
<div class="deck-import-dropzone-label">Drop a .dck or .txt file here, or click to browse</div>
|
||||||
|
<input type="file" id="deck-import-file" accept=".dck,.txt,.dec,.mwDeck" style="display:none" />
|
||||||
|
</div>
|
||||||
|
<div class="deck-import-file-info" id="deck-import-file-name"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="deck-import-options">
|
||||||
|
<label class="deck-import-option-label">
|
||||||
|
<input type="radio" name="import-mode" value="new" checked /> Create new deck
|
||||||
|
</label>
|
||||||
|
<label class="deck-import-option-label">
|
||||||
|
<input type="radio" name="import-mode" value="add" /> Add to current deck
|
||||||
|
</label>
|
||||||
|
<label class="deck-import-option-label">
|
||||||
|
<input type="radio" name="import-mode" value="replace" /> Replace current deck cards
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="deck-import-new-name-row" class="deck-import-name-row">
|
||||||
|
<label>Deck name (optional):</label>
|
||||||
|
<input type="text" id="deck-import-name" placeholder="Auto-detected from commander" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="deck-import-progress-wrap" id="deck-import-progress" style="display:none">
|
||||||
|
<div class="deck-import-progress-label">Importing cards (Scryfall lookup)...</div>
|
||||||
|
<div class="deck-import-progress-track">
|
||||||
|
<div class="deck-import-progress-fill" id="deck-import-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="deck-import-result" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="deck-modal-footer">
|
||||||
|
<button class="deck-btn deck-btn-ghost" id="deck-import-cancel">Cancel</button>
|
||||||
|
<button class="deck-btn deck-btn-accent" id="deck-import-go">Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /#deck-builder-page -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set API base URL — when served from a different host, override this value
|
||||||
|
window.LAB_API_BASE = window.LAB_API_BASE || '';
|
||||||
|
</script>
|
||||||
|
<script src="deckbuilder.js"></script>
|
||||||
|
<!-- Card Preview Popup (hover tooltip) -->
|
||||||
|
<div id="deck-card-preview" style="display:none; position:fixed; pointer-events:none; z-index:60;">
|
||||||
|
<img id="deck-card-preview-img" src="" alt="Card preview" style="width:220px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.7);" />
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,245 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Commander AI Lab — Auto Deck Generator V3</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="global.css" />
|
||||||
|
<link rel="stylesheet" href="deckgenerator.css" />
|
||||||
|
<style>
|
||||||
|
/* No nav bar when embedded in LAN client iframe */
|
||||||
|
:root { --nav-height: 0px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- nav.js removed — deck generator is embedded via iframe in the LAN client -->
|
||||||
|
|
||||||
|
<div id="deckgen-page">
|
||||||
|
|
||||||
|
<!-- MAIN LAYOUT -->
|
||||||
|
<main class="dg-main">
|
||||||
|
|
||||||
|
<!-- LEFT: Configuration -->
|
||||||
|
<section class="dg-col-config">
|
||||||
|
|
||||||
|
<!-- Commander Search -->
|
||||||
|
<div class="dg-panel dg-panel-commander">
|
||||||
|
<div class="dg-panel-header">
|
||||||
|
<span class="dg-panel-title">Commander</span>
|
||||||
|
</div>
|
||||||
|
<div class="dg-panel-body">
|
||||||
|
<div class="dg-commander-search-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="dg-input"
|
||||||
|
id="dg-commander-input"
|
||||||
|
placeholder="Search for a legendary creature..."
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<div class="dg-commander-dropdown" id="dg-commander-dropdown"></div>
|
||||||
|
</div>
|
||||||
|
<div class="dg-commander-selected" id="dg-commander-selected" style="display:none">
|
||||||
|
<div class="dg-commander-card">
|
||||||
|
<button class="dg-commander-clear" id="dg-commander-clear" title="Clear commander">×</button>
|
||||||
|
<img class="dg-commander-img" id="dg-commander-img" src="" alt="" />
|
||||||
|
<div class="dg-commander-info">
|
||||||
|
<div class="dg-commander-name" id="dg-commander-name"></div>
|
||||||
|
<div class="dg-commander-type" id="dg-commander-type"></div>
|
||||||
|
<div class="dg-commander-colors" id="dg-commander-colors"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Strategy & Bracket -->
|
||||||
|
<div class="dg-panel">
|
||||||
|
<div class="dg-panel-header">
|
||||||
|
<span class="dg-panel-title">Strategy & Power</span>
|
||||||
|
</div>
|
||||||
|
<div class="dg-panel-body">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="dg-input"
|
||||||
|
id="dg-strategy"
|
||||||
|
placeholder="e.g. zombie tokens, voltron, spellslinger..."
|
||||||
|
title="Describe your deck strategy — the AI uses this to pick thematically aligned cards"
|
||||||
|
/>
|
||||||
|
<div class="dg-bracket-row">
|
||||||
|
<label class="dg-label">
|
||||||
|
Target Bracket
|
||||||
|
<span class="tip-icon" data-tip="B1 = precon-level casual. B2 = upgraded with synergies. B3 = high power with strong combos. B4 = cEDH with maximum optimization, fast mana, and instant-speed interaction.">?</span>
|
||||||
|
</label>
|
||||||
|
<div class="dg-bracket-btns" id="dg-bracket-btns">
|
||||||
|
<button class="dg-bracket-btn" data-bracket="1" title="Casual — precon level">B1</button>
|
||||||
|
<button class="dg-bracket-btn" data-bracket="2" title="Upgraded Casual">B2</button>
|
||||||
|
<button class="dg-bracket-btn dg-bracket-active" data-bracket="3" title="High Power">B3</button>
|
||||||
|
<button class="dg-bracket-btn" data-bracket="4" title="cEDH — maximum power">B4</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deck Name -->
|
||||||
|
<div class="dg-panel">
|
||||||
|
<div class="dg-panel-header">
|
||||||
|
<span class="dg-panel-title">Deck Name</span>
|
||||||
|
</div>
|
||||||
|
<div class="dg-panel-body">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="dg-input"
|
||||||
|
id="dg-deck-name"
|
||||||
|
placeholder="Auto-generated from commander name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Budget -->
|
||||||
|
<div class="dg-panel">
|
||||||
|
<div class="dg-panel-header">
|
||||||
|
<span class="dg-panel-title">Budget</span>
|
||||||
|
</div>
|
||||||
|
<div class="dg-panel-body">
|
||||||
|
<div class="dg-budget-row">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="dg-input dg-input-narrow"
|
||||||
|
id="dg-budget"
|
||||||
|
placeholder="No limit"
|
||||||
|
min="0"
|
||||||
|
step="25"
|
||||||
|
/>
|
||||||
|
<select class="dg-select" id="dg-budget-mode" title="Total Deck: caps the combined price of all 100 cards. Per Card Max: caps each individual card price.">
|
||||||
|
<option value="total">Total Deck $</option>
|
||||||
|
<option value="per_card">Per Card Max $</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Options -->
|
||||||
|
<div class="dg-panel">
|
||||||
|
<div class="dg-panel-header">
|
||||||
|
<span class="dg-panel-title">Options</span>
|
||||||
|
</div>
|
||||||
|
<div class="dg-panel-body">
|
||||||
|
<label class="dg-toggle-row">
|
||||||
|
<span class="dg-toggle-label">
|
||||||
|
Use my collection
|
||||||
|
<span class="tip-icon" data-tip="Prioritizes cards you already own before pulling from the full card pool. Saves money and ensures the deck is ready to play. Requires an imported collection.">?</span>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" class="dg-toggle-check" id="dg-use-collection" checked />
|
||||||
|
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
<label class="dg-toggle-row">
|
||||||
|
<span class="dg-toggle-label">
|
||||||
|
Smart Substitution
|
||||||
|
<span class="tip-icon" data-tip="When a suggested card isn't in your collection, automatically finds the closest functional replacement you do own. Keeps the deck playable without buying extra cards.">?</span>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" class="dg-toggle-check" id="dg-substitution" checked />
|
||||||
|
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
<div class="dg-model-row">
|
||||||
|
<label class="dg-label">
|
||||||
|
AI Model
|
||||||
|
<span class="tip-icon" data-tip="Deck generation is powered by Claude Opus 4, Anthropic's most capable model.">?</span>
|
||||||
|
</label>
|
||||||
|
<input type="hidden" id="dg-model" value="claude-opus-4-20250514" />
|
||||||
|
<span style="font-size:0.8rem; color:var(--text-muted, #888);">claude-opus-4-20250514</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Omit Cards (chips input) -->
|
||||||
|
<div class="dg-panel">
|
||||||
|
<div class="dg-panel-header">
|
||||||
|
<span class="dg-panel-title">Omit Cards</span>
|
||||||
|
</div>
|
||||||
|
<div class="dg-panel-body">
|
||||||
|
<div class="dg-omit-wrap" id="dg-omit-wrap">
|
||||||
|
<div class="dg-omit-chips" id="dg-omit-chips"></div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="dg-input dg-omit-input"
|
||||||
|
id="dg-omit-input"
|
||||||
|
placeholder="Type a card name and press Enter…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small class="dg-hint">Cards listed here will be excluded from the generated deck.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="dg-actions">
|
||||||
|
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
|
||||||
|
<span class="dg-btn-icon">⚡</span> Generate Deck
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conversation Log -->
|
||||||
|
<div id="dg-conversation-log" class="dg-conversation-log">
|
||||||
|
<div class="dg-log-entry dg-log-info"><span class="dg-log-ts">[Ready]</span> Waiting for deck generation...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- RIGHT: Preview Results -->
|
||||||
|
<section class="dg-col-results">
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div class="dg-empty-state" id="dg-empty-state">
|
||||||
|
<div class="dg-empty-icon">⚡</div>
|
||||||
|
<div class="dg-empty-title">V3 AI Deck Generator</div>
|
||||||
|
<div class="dg-empty-text">Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Claude Opus with structured output.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div class="dg-loading" id="dg-loading" style="display:none">
|
||||||
|
<div class="dg-spinner"></div>
|
||||||
|
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Claude Opus 4...</div>
|
||||||
|
</div>
|
||||||
|
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
|
||||||
|
<div class="dg-progress-bar">
|
||||||
|
<div class="dg-progress-fill" id="dg-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="dg-progress-meta">
|
||||||
|
<span class="dg-progress-phase" id="dg-progress-phase"></span>
|
||||||
|
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="dg-results" id="dg-results" style="display:none">
|
||||||
|
<div class="dg-results-header">
|
||||||
|
<div id="dg-strategy-bar"></div>
|
||||||
|
<div id="dg-stats-bar" class="dg-stats-bar"></div>
|
||||||
|
<div id="dg-sub-summary" style="display:none"></div>
|
||||||
|
<div class="dg-result-actions">
|
||||||
|
<button class="dg-btn dg-btn-secondary" id="dg-regenerate-btn">↻ Regenerate</button>
|
||||||
|
<button class="dg-btn dg-btn-primary" id="dg-save-btn">💾 Save to Deck Builder</button>
|
||||||
|
<button class="dg-btn dg-btn-ghost" id="dg-export-csv">CSV</button>
|
||||||
|
<button class="dg-btn dg-btn-ghost" id="dg-export-dck">Forge .dck</button>
|
||||||
|
<button class="dg-btn dg-btn-ghost" id="dg-export-moxfield">Moxfield</button>
|
||||||
|
<button class="dg-btn dg-btn-ghost" id="dg-export-shopping">🛒 Shopping</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="dg-card-groups" class="dg-card-groups"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- TOAST CONTAINER -->
|
||||||
|
<div class="dg-toast-container" id="dg-toast-container"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.LAB_API_BASE = window.LAB_API_BASE || '';
|
||||||
|
</script>
|
||||||
|
<script src="deckgenerator.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,795 @@
|
|||||||
|
/**
|
||||||
|
* Commander AI Lab — Auto Deck Generator V3 UI
|
||||||
|
* ═══════════════════════════════════════════════
|
||||||
|
*
|
||||||
|
* V3 Endpoints:
|
||||||
|
* GET /api/deck/v3/status — check V3 generator status
|
||||||
|
* GET /api/deck-generator/commander-search — autocomplete commanders
|
||||||
|
* POST /api/deck/v3/generate — generate deck (local AI structured output)
|
||||||
|
* POST /api/deck/v3/commit — generate + save to Deck Builder
|
||||||
|
* POST /api/deck/v3/export/csv — export as CSV
|
||||||
|
* POST /api/deck/v3/export/dck — export as Forge .dck
|
||||||
|
* POST /api/deck/v3/export/moxfield — export in Moxfield paste format
|
||||||
|
* POST /api/deck/v3/export/shopping — shopping list of missing cards
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DeckGenerator = (() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const API_BASE = window.LAB_API_BASE || window.location.origin;
|
||||||
|
const DEBOUNCE_MS = 350;
|
||||||
|
|
||||||
|
const COLOR_MAP = {
|
||||||
|
W: { label: 'W', cls: 'dg-pip-W' },
|
||||||
|
U: { label: 'U', cls: 'dg-pip-U' },
|
||||||
|
B: { label: 'B', cls: 'dg-pip-B' },
|
||||||
|
R: { label: 'R', cls: 'dg-pip-R' },
|
||||||
|
G: { label: 'G', cls: 'dg-pip-G' },
|
||||||
|
C: { label: 'C', cls: 'dg-pip-C' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_ORDER = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Battle', 'Other'];
|
||||||
|
const TYPE_ICONS = {
|
||||||
|
'Creature': '🐉', 'Instant': '⚡', 'Sorcery': '🌀', 'Artifact': '🔧',
|
||||||
|
'Enchantment': '✨', 'Planeswalker': '🌟', 'Land': '🏔', 'Battle': '⚔',
|
||||||
|
'Other': '🃏',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGES = {
|
||||||
|
owned: { label: 'Owned', cls: 'dg-badge-owned' },
|
||||||
|
substituted: { label: 'Substituted', cls: 'dg-badge-substituted' },
|
||||||
|
missing: { label: 'Missing', cls: 'dg-badge-missing' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────
|
||||||
|
let state = {
|
||||||
|
commander: null,
|
||||||
|
targetBracket: 3,
|
||||||
|
previewResult: null,
|
||||||
|
lastRequestBody: null,
|
||||||
|
isLoading: false,
|
||||||
|
v3Ready: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let searchTimeout = null;
|
||||||
|
|
||||||
|
// ── Progress bar state ──────────────────────────────
|
||||||
|
const PROGRESS_PHASES = [
|
||||||
|
{ at: 0, pct: 5, msg: 'Connecting to AI model...' },
|
||||||
|
{ at: 3, pct: 10, msg: 'Resolving commander on Scryfall...' },
|
||||||
|
{ at: 6, pct: 18, msg: 'Building collection summary...' },
|
||||||
|
{ at: 10, pct: 25, msg: 'Sending prompt to LLM...' },
|
||||||
|
{ at: 20, pct: 40, msg: 'AI is building the 99...' },
|
||||||
|
{ at: 35, pct: 55, msg: 'Selecting cards and filling slots...' },
|
||||||
|
{ at: 50, pct: 65, msg: 'Running smart substitution...' },
|
||||||
|
{ at: 70, pct: 78, msg: 'Cross-referencing with collection...' },
|
||||||
|
{ at: 90, pct: 88, msg: 'Almost there — finalizing deck...' },
|
||||||
|
{ at: 110, pct: 93, msg: 'Still working — large model can take a moment...' },
|
||||||
|
];
|
||||||
|
let _progressInterval = null;
|
||||||
|
let _progressStart = 0;
|
||||||
|
|
||||||
|
function startProgress() {
|
||||||
|
_progressStart = Date.now();
|
||||||
|
const wrap = $('dg-progress-wrap');
|
||||||
|
const fill = $('dg-progress-fill');
|
||||||
|
const phase = $('dg-progress-phase');
|
||||||
|
const elapsed = $('dg-progress-elapsed');
|
||||||
|
if (!wrap) return;
|
||||||
|
wrap.style.display = 'block';
|
||||||
|
fill.style.width = '0%';
|
||||||
|
phase.textContent = '';
|
||||||
|
elapsed.textContent = '0s elapsed';
|
||||||
|
let lastPhaseIdx = -1;
|
||||||
|
_progressInterval = setInterval(() => {
|
||||||
|
const secs = Math.floor((Date.now() - _progressStart) / 1000);
|
||||||
|
elapsed.textContent = secs + 's elapsed';
|
||||||
|
for (let i = PROGRESS_PHASES.length - 1; i >= 0; i--) {
|
||||||
|
if (secs >= PROGRESS_PHASES[i].at) {
|
||||||
|
fill.style.width = PROGRESS_PHASES[i].pct + '%';
|
||||||
|
if (i !== lastPhaseIdx) {
|
||||||
|
phase.textContent = PROGRESS_PHASES[i].msg;
|
||||||
|
logMessage(PROGRESS_PHASES[i].msg, 'info');
|
||||||
|
lastPhaseIdx = i;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopProgress(success) {
|
||||||
|
if (_progressInterval) {
|
||||||
|
clearInterval(_progressInterval);
|
||||||
|
_progressInterval = null;
|
||||||
|
}
|
||||||
|
const fill = $('dg-progress-fill');
|
||||||
|
const phase = $('dg-progress-phase');
|
||||||
|
const elapsed = $('dg-progress-elapsed');
|
||||||
|
if (fill) fill.style.width = success ? '100%' : '0%';
|
||||||
|
if (phase) phase.textContent = success ? 'Done!' : 'Failed';
|
||||||
|
const secs = Math.floor((Date.now() - _progressStart) / 1000);
|
||||||
|
if (elapsed) elapsed.textContent = secs + 's total';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Omit Cards (chips) ─────────────────────────────
|
||||||
|
let omitCards = [];
|
||||||
|
|
||||||
|
function addOmitCard(name) {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (omitCards.some(c => c.toLowerCase() === trimmed.toLowerCase())) return;
|
||||||
|
omitCards.push(trimmed);
|
||||||
|
renderOmitChips();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOmitCard(idx) {
|
||||||
|
omitCards.splice(idx, 1);
|
||||||
|
renderOmitChips();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOmitChips() {
|
||||||
|
const container = $('dg-omit-chips');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = omitCards.map((name, i) =>
|
||||||
|
'<span class="dg-chip">' +
|
||||||
|
escHtml(name) +
|
||||||
|
'<span class="dg-chip-x" data-idx="' + i + '">×</span>' +
|
||||||
|
'</span>'
|
||||||
|
).join('');
|
||||||
|
container.querySelectorAll('.dg-chip-x').forEach(el => {
|
||||||
|
el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
// ── Init ────────────────────────────────────────────────
|
||||||
|
async function init() {
|
||||||
|
await checkV3Status();
|
||||||
|
bindEvents();
|
||||||
|
checkQueryParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkV3Status() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/deck/v3/status');
|
||||||
|
const data = await resp.json();
|
||||||
|
state.v3Ready = data.initialized;
|
||||||
|
if (!state.v3Ready) {
|
||||||
|
// Warn but do not block — local/Ollama mode may still work
|
||||||
|
console.warn('V3 status: not initialized. Error:', data.error || 'unknown');
|
||||||
|
toast('V3 generator not ready — check that the Anthropic API key is configured', 'warning');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('V3 status check failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
// Commander search
|
||||||
|
const input = $('dg-commander-input');
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (q.length < 2) { hideDropdown(); return; }
|
||||||
|
searchTimeout = setTimeout(() => searchCommander(q), DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('focus', () => {
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (q.length >= 2) searchCommander(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const wrap = document.querySelector('.dg-commander-search-wrap');
|
||||||
|
if (wrap && !wrap.contains(e.target)) hideDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('dg-commander-clear').addEventListener('click', clearCommander);
|
||||||
|
|
||||||
|
// Bracket buttons
|
||||||
|
document.querySelectorAll('.dg-bracket-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.dg-bracket-btn').forEach(b => b.classList.remove('dg-bracket-active'));
|
||||||
|
btn.classList.add('dg-bracket-active');
|
||||||
|
state.targetBracket = parseInt(btn.dataset.bracket, 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate & save
|
||||||
|
$('dg-preview-btn').addEventListener('click', generateDeck);
|
||||||
|
$('dg-save-btn').addEventListener('click', commitDeck);
|
||||||
|
$('dg-regenerate-btn').addEventListener('click', generateDeck);
|
||||||
|
|
||||||
|
// Export buttons
|
||||||
|
$('dg-export-csv').addEventListener('click', () => exportDeck('csv'));
|
||||||
|
$('dg-export-dck').addEventListener('click', () => exportDeck('dck'));
|
||||||
|
$('dg-export-moxfield').addEventListener('click', () => exportMoxfield());
|
||||||
|
$('dg-export-shopping').addEventListener('click', () => exportShopping());
|
||||||
|
|
||||||
|
// Omit cards chips input
|
||||||
|
const omitInput = $('dg-omit-input');
|
||||||
|
if (omitInput) {
|
||||||
|
omitInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addOmitCard(omitInput.value);
|
||||||
|
omitInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const omitWrap = $('dg-omit-wrap');
|
||||||
|
if (omitWrap) {
|
||||||
|
omitWrap.addEventListener('click', () => omitInput && omitInput.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkQueryParams() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const name = params.get('commander');
|
||||||
|
if (name) {
|
||||||
|
$('dg-commander-input').value = name;
|
||||||
|
searchCommander(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build Request Body ──────────────────────────────────
|
||||||
|
function buildRequestBody() {
|
||||||
|
const budget = parseFloat($('dg-budget').value) || null;
|
||||||
|
return {
|
||||||
|
commander_name: state.commander.name,
|
||||||
|
strategy: $('dg-strategy').value.trim(),
|
||||||
|
target_bracket: state.targetBracket,
|
||||||
|
budget_usd: budget,
|
||||||
|
budget_mode: $('dg-budget-mode').value,
|
||||||
|
omit_cards: omitCards.slice(),
|
||||||
|
use_collection: $('dg-use-collection').checked,
|
||||||
|
run_substitution: $('dg-substitution').checked,
|
||||||
|
model: $('dg-model').value,
|
||||||
|
deck_name: $('dg-deck-name').value.trim() || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commander Search ────────────────────────────────────
|
||||||
|
function positionDropdown() {
|
||||||
|
const input = $('dg-commander-input');
|
||||||
|
const dropdown = $('dg-commander-dropdown');
|
||||||
|
const rect = input.getBoundingClientRect();
|
||||||
|
dropdown.style.top = (rect.bottom + 4) + 'px';
|
||||||
|
dropdown.style.left = rect.left + 'px';
|
||||||
|
dropdown.style.width = rect.width + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchCommander(q) {
|
||||||
|
const dropdown = $('dg-commander-dropdown');
|
||||||
|
dropdown.innerHTML = '<div class="dg-dropdown-loading">Searching...</div>';
|
||||||
|
dropdown.classList.add('open');
|
||||||
|
positionDropdown();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/deck-generator/commander-search?q=' + encodeURIComponent(q));
|
||||||
|
const data = await resp.json();
|
||||||
|
renderDropdown(data.results || []);
|
||||||
|
} catch (e) {
|
||||||
|
dropdown.innerHTML = '<div class="dg-dropdown-empty">Search failed</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDropdown(results) {
|
||||||
|
const dropdown = $('dg-commander-dropdown');
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
dropdown.innerHTML = '<div class="dg-dropdown-empty">No legendary creatures found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown.innerHTML = results.map((r, idx) => {
|
||||||
|
const colors = (r.color_identity || [])
|
||||||
|
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const badge = r.in_collection
|
||||||
|
? '<span class="dg-dropdown-item-badge dg-badge-owned">Owned</span>'
|
||||||
|
: '<span class="dg-dropdown-item-badge dg-badge-scryfall">Scryfall</span>';
|
||||||
|
|
||||||
|
const imgSrc = r.image_url ? r.image_url.replace('version=normal', 'version=small') : '';
|
||||||
|
const imgTag = imgSrc
|
||||||
|
? '<img class="dg-dropdown-item-img" src="' + imgSrc + '" alt="" loading="lazy" />'
|
||||||
|
: '<div class="dg-dropdown-item-img" style="background:var(--lab-surface)"></div>';
|
||||||
|
|
||||||
|
return '<div class="dg-dropdown-item" data-idx="' + idx + '">'
|
||||||
|
+ imgTag
|
||||||
|
+ '<div class="dg-dropdown-item-info">'
|
||||||
|
+ ' <div class="dg-dropdown-item-name">' + escHtml(r.name) + '</div>'
|
||||||
|
+ ' <div class="dg-dropdown-item-type">' + escHtml(r.type_line || '') + '</div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div style="display:flex;gap:3px;align-items:center">' + colors + '</div>'
|
||||||
|
+ badge
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
dropdown.querySelectorAll('.dg-dropdown-item').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
selectCommander(results[parseInt(el.dataset.idx, 10)]);
|
||||||
|
hideDropdown();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCommander(cmdr) {
|
||||||
|
state.commander = cmdr;
|
||||||
|
$('dg-commander-input').style.display = 'none';
|
||||||
|
$('dg-commander-selected').style.display = 'block';
|
||||||
|
$('dg-commander-name').textContent = cmdr.name;
|
||||||
|
$('dg-commander-type').textContent = cmdr.type_line || '';
|
||||||
|
|
||||||
|
$('dg-commander-colors').innerHTML = (cmdr.color_identity || [])
|
||||||
|
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const img = $('dg-commander-img');
|
||||||
|
if (cmdr.image_url) {
|
||||||
|
var imgUrl = cmdr.image_url;
|
||||||
|
if (imgUrl.indexOf('version=small') !== -1) {
|
||||||
|
imgUrl = imgUrl.replace('version=small', 'version=normal');
|
||||||
|
} else if (imgUrl.indexOf('version=') === -1 && imgUrl.indexOf('?') !== -1) {
|
||||||
|
imgUrl += '&version=normal';
|
||||||
|
} else if (imgUrl.indexOf('version=') === -1) {
|
||||||
|
imgUrl += '?version=normal';
|
||||||
|
}
|
||||||
|
img.src = imgUrl;
|
||||||
|
img.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
img.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
$('dg-preview-btn').disabled = false;
|
||||||
|
|
||||||
|
// Auto-fill deck name if empty
|
||||||
|
const deckNameInput = $('dg-deck-name');
|
||||||
|
if (deckNameInput && !deckNameInput.value.trim()) {
|
||||||
|
deckNameInput.value = 'Auto - ' + cmdr.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCommander() {
|
||||||
|
state.commander = null;
|
||||||
|
$('dg-commander-input').style.display = '';
|
||||||
|
$('dg-commander-input').value = '';
|
||||||
|
$('dg-commander-selected').style.display = 'none';
|
||||||
|
$('dg-preview-btn').disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDropdown() {
|
||||||
|
$('dg-commander-dropdown').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Conversation Log Helper --
|
||||||
|
function logMessage(msg, type = 'info') {
|
||||||
|
const log = $('dg-conversation-log');
|
||||||
|
if (!log) return;
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = 'dg-log-entry dg-log-' + type;
|
||||||
|
const ts = new Date().toLocaleTimeString();
|
||||||
|
entry.innerHTML = '<span class="dg-log-ts">[' + ts + ']</span> ' + msg;
|
||||||
|
log.appendChild(entry);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
const log = $('dg-conversation-log');
|
||||||
|
if (!log) return;
|
||||||
|
log.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generate Deck ───────────────────────────────────────
|
||||||
|
async function generateDeck() {
|
||||||
|
clearLog();
|
||||||
|
logMessage('Connecting to Claude AI...', 'info');
|
||||||
|
if (!state.commander) { toast('Select a commander first', 'error'); return; }
|
||||||
|
// Non-blocking warning only — local Ollama mode does not require v3Ready
|
||||||
|
if (!state.v3Ready) {
|
||||||
|
logMessage('Warning: V3 status check failed. Attempting generation anyway via local AI...', 'warning');
|
||||||
|
}
|
||||||
|
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
|
||||||
|
|
||||||
|
setLoading(true, 'Generating deck via Local AI...');
|
||||||
|
startProgress();
|
||||||
|
|
||||||
|
const body = buildRequestBody();
|
||||||
|
logMessage('Sending request to AI model... this may take a moment.', 'info');
|
||||||
|
state.lastRequestBody = body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/deck/v3/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || err.detail || 'Generation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
state.previewResult = await resp.json();
|
||||||
|
logMessage('AI response received! Processing deck data...', 'success');
|
||||||
|
|
||||||
|
const stats = state.previewResult.stats || {};
|
||||||
|
const cardCount = (stats.total_cards || 0);
|
||||||
|
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
|
||||||
|
stopProgress(true);
|
||||||
|
renderResults(state.previewResult);
|
||||||
|
|
||||||
|
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
|
||||||
|
} catch (e) {
|
||||||
|
stopProgress(false);
|
||||||
|
toast(e.message || 'Generation failed', 'error');
|
||||||
|
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(loading, text) {
|
||||||
|
state.isLoading = loading;
|
||||||
|
$('dg-empty-state').style.display = 'none';
|
||||||
|
$('dg-loading').style.display = loading ? 'flex' : 'none';
|
||||||
|
$('dg-results').style.display = loading ? 'none' : 'block';
|
||||||
|
$('dg-preview-btn').disabled = loading || !state.commander;
|
||||||
|
if (text) $('dg-loading-text').textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render Results ──────────────────────────────────────
|
||||||
|
function renderResults(result) {
|
||||||
|
$('dg-loading').style.display = 'none';
|
||||||
|
$('dg-results').style.display = 'block';
|
||||||
|
$('dg-preview-btn').disabled = false;
|
||||||
|
|
||||||
|
renderStrategyBar(result);
|
||||||
|
renderStatsBar(result);
|
||||||
|
renderSubstitutionSummary(result);
|
||||||
|
renderCardGroups(result);
|
||||||
|
renderUseInGameButton(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUseInGameButton(result) {
|
||||||
|
// Only show when running inside the LAN client iframe
|
||||||
|
if (window.parent === window) return;
|
||||||
|
|
||||||
|
var existing = document.getElementById('dg-export-game-btn');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.id = 'dg-export-game-btn';
|
||||||
|
btn.className = 'dg-btn dg-btn-preview';
|
||||||
|
btn.style.cssText = 'margin:12px auto;display:block;border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);';
|
||||||
|
btn.innerHTML = '<span class="dg-btn-icon">🎮</span> Use in Game';
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var cards = (result.cards || []).map(function(c) {
|
||||||
|
return { name: c.name, quantity: c.count || 1, is_commander: false };
|
||||||
|
});
|
||||||
|
if (result.commander) {
|
||||||
|
cards.unshift({ name: result.commander.name, quantity: 1, is_commander: true });
|
||||||
|
}
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'DECK_BUILDER_EXPORT',
|
||||||
|
deck: cards,
|
||||||
|
deckName: result.commander ? result.commander.name + ' Deck' : 'Generated Deck',
|
||||||
|
commander: result.commander ? result.commander.name : '',
|
||||||
|
}, '*');
|
||||||
|
toast('Deck sent to game setup!', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('dg-results').appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStrategyBar(result) {
|
||||||
|
const bar = $('dg-strategy-bar');
|
||||||
|
if (!bar) return;
|
||||||
|
const bracket = result.bracket || {};
|
||||||
|
const archetype = result.archetype || '';
|
||||||
|
const strategy = result.strategy_summary || '';
|
||||||
|
|
||||||
|
bar.innerHTML = '<div class="dg-strategy-info">'
|
||||||
|
+ '<span class="dg-bracket-badge dg-bracket-' + (bracket.level || 0) + '">B' + (bracket.level || '?') + '</span>'
|
||||||
|
+ '<span class="dg-archetype-badge">' + escHtml(archetype) + '</span>'
|
||||||
|
+ '<span class="dg-strategy-text">' + escHtml(strategy) + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ (bracket.reasoning ? '<div class="dg-bracket-reasoning">' + escHtml(bracket.reasoning) + '</div>' : '')
|
||||||
|
+ (bracket.game_changers && bracket.game_changers.length
|
||||||
|
? '<div class="dg-game-changers">Game Changers: ' + bracket.game_changers.map(c => '<span class="dg-gc-chip">' + escHtml(c) + '</span>').join('') + '</div>'
|
||||||
|
: '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatsBar(result) {
|
||||||
|
const stats = result.stats || {};
|
||||||
|
const container = $('dg-stats-bar');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const chips = [
|
||||||
|
chipHtml('Total', stats.total_cards || 0, ''),
|
||||||
|
chipHtml('Owned', (stats.by_status || {}).owned || 0, 'dg-stat-owned'),
|
||||||
|
chipHtml('Subbed', (stats.by_status || {}).substituted || 0, 'dg-stat-substituted'),
|
||||||
|
chipHtml('Missing', (stats.by_status || {}).missing || 0, 'dg-stat-missing'),
|
||||||
|
chipHtml('Lands', stats.land_count || 0, ''),
|
||||||
|
chipHtml('$' + (stats.total_price_usd || 0).toFixed(0), 'Total', 'dg-stat-price'),
|
||||||
|
];
|
||||||
|
|
||||||
|
container.innerHTML = chips.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipHtml(label, num, cls) {
|
||||||
|
return '<div class="dg-stat-chip ' + cls + '">'
|
||||||
|
+ '<span class="dg-stat-num">' + num + '</span>'
|
||||||
|
+ '<span class="dg-stat-label">' + label + '</span>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubstitutionSummary(result) {
|
||||||
|
const el = $('dg-sub-summary');
|
||||||
|
const sub = result.substitution_stats;
|
||||||
|
if (!sub) { el.style.display = 'none'; return; }
|
||||||
|
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.innerHTML = '<div class="dg-sub-bar">'
|
||||||
|
+ '<span class="dg-sub-stat dg-sub-owned">' + sub.owned + ' owned</span>'
|
||||||
|
+ '<span class="dg-sub-stat dg-sub-substituted">' + sub.substituted + ' substituted</span>'
|
||||||
|
+ '<span class="dg-sub-stat dg-sub-missing">' + sub.missing + ' still missing</span>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCardGroups(result) {
|
||||||
|
const container = $('dg-card-groups');
|
||||||
|
if (!container) return;
|
||||||
|
const cards = result.cards || [];
|
||||||
|
const commander = result.commander;
|
||||||
|
|
||||||
|
const groups = {};
|
||||||
|
for (const card of cards) {
|
||||||
|
const cat = card.category || 'Other';
|
||||||
|
if (!groups[cat]) groups[cat] = [];
|
||||||
|
groups[cat].push(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTypes = TYPE_ORDER.filter(t => groups[t] && groups[t].length > 0);
|
||||||
|
for (const t of Object.keys(groups)) {
|
||||||
|
if (!sortedTypes.includes(t)) sortedTypes.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (commander) {
|
||||||
|
html += '<div class="dg-card-group">'
|
||||||
|
+ '<div class="dg-group-header">'
|
||||||
|
+ '<span class="dg-group-title">⚔ Commander</span>'
|
||||||
|
+ '<span class="dg-group-count">1</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="dg-group-body">'
|
||||||
|
+ '<div class="dg-card-row dg-card-row-commander">'
|
||||||
|
+ '<span class="dg-card-row-name" style="font-weight:600;color:#fff">' + escHtml(commander.name) + '</span>'
|
||||||
|
+ '<span class="dg-card-row-source dg-src-collection">Commander</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const type of sortedTypes) {
|
||||||
|
const groupCards = groups[type];
|
||||||
|
if (!groupCards || groupCards.length === 0) continue;
|
||||||
|
|
||||||
|
groupCards.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||||
|
|
||||||
|
const icon = TYPE_ICONS[type] || '🃏';
|
||||||
|
html += '<div class="dg-card-group">'
|
||||||
|
+ '<div class="dg-group-header" data-type="' + type + '">'
|
||||||
|
+ '<span class="dg-group-title">' + icon + ' ' + escHtml(type) + '</span>'
|
||||||
|
+ '<span class="dg-group-count">' + groupCards.reduce(function(s,c){ return s + (c.count || 1); }, 0) + '</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="dg-group-body" data-group="' + type + '">';
|
||||||
|
|
||||||
|
for (const card of groupCards) {
|
||||||
|
html += renderCardRow(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
container.querySelectorAll('.dg-group-header').forEach(header => {
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
const type = header.dataset.type;
|
||||||
|
if (!type) return;
|
||||||
|
const body = container.querySelector('.dg-group-body[data-group="' + type + '"]');
|
||||||
|
if (body) body.classList.toggle('collapsed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCardRow(card) {
|
||||||
|
const statusInfo = STATUS_BADGES[card.status] || STATUS_BADGES.missing;
|
||||||
|
const statusBadge = '<span class="dg-card-status ' + statusInfo.cls + '">' + statusInfo.label + '</span>';
|
||||||
|
|
||||||
|
const roleTags = (card.role_tags || []).slice(0, 3)
|
||||||
|
.map(r => '<span class="dg-role-chip">' + escHtml(r) + '</span>')
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
let subHtml = '';
|
||||||
|
if (card.status === 'substituted' && card.selected_substitute) {
|
||||||
|
subHtml = '<div class="dg-card-sub-line">'
|
||||||
|
+ '<span class="dg-sub-arrow">→</span> '
|
||||||
|
+ '<span class="dg-sub-name">' + escHtml(card.selected_substitute) + '</span>'
|
||||||
|
+ '</div>';
|
||||||
|
} else if (card.status === 'missing' && card.alternatives && card.alternatives.length > 0) {
|
||||||
|
subHtml = '<div class="dg-card-sub-line dg-sub-suggestions">'
|
||||||
|
+ '<span class="dg-sub-label">Alternatives:</span> '
|
||||||
|
+ card.alternatives.slice(0, 3).map(a =>
|
||||||
|
'<span class="dg-alt-chip" title="' + escAttr(a.reason || '') + '">'
|
||||||
|
+ escHtml(a.name) + ' (' + (a.similarity_score * 100).toFixed(0) + '%)'
|
||||||
|
+ '</span>'
|
||||||
|
).join('')
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = card.estimated_price_usd
|
||||||
|
? '<span class="dg-card-price">$' + card.estimated_price_usd.toFixed(2) + '</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return '<div class="dg-card-row dg-card-status-' + card.status + '">'
|
||||||
|
+ '<div class="dg-card-row-main">'
|
||||||
|
+ '<span class="dg-card-row-name">' + ((card.count || 1) > 1 ? '<span class="dg-card-count">' + card.count + 'x</span> ' : '') + escHtml(card.name) + '</span>'
|
||||||
|
+ '<span class="dg-card-row-roles">' + roleTags + '</span>'
|
||||||
|
+ statusBadge
|
||||||
|
+ price
|
||||||
|
+ '</div>'
|
||||||
|
+ (card.reason ? '<div class="dg-card-reason">' + escHtml(card.reason) + '</div>' : '')
|
||||||
|
+ subHtml
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commit Deck ─────────────────────────────────────────
|
||||||
|
async function commitDeck() {
|
||||||
|
if (!state.commander) { toast('No commander selected', 'error'); return; }
|
||||||
|
|
||||||
|
$('dg-save-btn').disabled = true;
|
||||||
|
$('dg-save-btn').textContent = 'Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = state.lastRequestBody || buildRequestBody();
|
||||||
|
const resp = await fetch(API_BASE + '/api/deck/v3/commit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || err.detail || 'Save failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resp.json();
|
||||||
|
toast('Deck saved: ' + (result.deck_name || 'Unknown'), 'success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = 'deckbuilder.html?deck_id=' + result.deck_id;
|
||||||
|
}, 1200);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || 'Save failed', 'error');
|
||||||
|
} finally {
|
||||||
|
$('dg-save-btn').disabled = false;
|
||||||
|
$('dg-save-btn').innerHTML = '<span class="dg-btn-icon">💾</span> Save to Deck Builder';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Exports ─────────────────────────────────────────────
|
||||||
|
async function exportDeck(format) {
|
||||||
|
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
|
||||||
|
|
||||||
|
const body = state.lastRequestBody || buildRequestBody();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/deck/v3/export/' + format, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) throw new Error('Export failed');
|
||||||
|
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = (state.commander.name || 'deck').replace(/\s+/g, '_') + '.' + (format === 'csv' ? 'csv' : 'dck');
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast('Exported ' + format.toUpperCase(), 'success');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || 'Export failed', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportMoxfield() {
|
||||||
|
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
|
||||||
|
|
||||||
|
const body = state.lastRequestBody || buildRequestBody();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/deck/v3/export/moxfield', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.content) {
|
||||||
|
await navigator.clipboard.writeText(data.content);
|
||||||
|
toast('Moxfield deck copied to clipboard', 'success');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast('Moxfield export failed', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportShopping() {
|
||||||
|
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
|
||||||
|
|
||||||
|
const body = state.lastRequestBody || buildRequestBody();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(API_BASE + '/api/deck/v3/export/shopping', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
const list = data.shopping_list || [];
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
toast('All cards are owned — nothing to buy', 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = ['Shopping List — ' + (data.commander || 'Deck'), ''];
|
||||||
|
for (const item of list) {
|
||||||
|
lines.push(item.count + 'x ' + item.name + ' ($' + (item.estimated_price_usd || 0).toFixed(2) + ') — ' + item.category);
|
||||||
|
}
|
||||||
|
lines.push('', 'Total: ' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(2));
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(lines.join('\n'));
|
||||||
|
toast('Shopping list copied (' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(0) + ')', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
toast('Shopping list failed', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toast ───────────────────────────────────────────────
|
||||||
|
function toast(msg, type) {
|
||||||
|
type = type || 'info';
|
||||||
|
const container = $('dg-toast-container');
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'dg-toast dg-toast-' + type;
|
||||||
|
el.textContent = msg;
|
||||||
|
container.appendChild(el);
|
||||||
|
setTimeout(() => el.remove(), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────
|
||||||
|
function escHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escAttr(str) {
|
||||||
|
return str.replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init };
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
DeckGenerator.init();
|
||||||
|
});
|
||||||
+25735
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,955 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════════
|
||||||
|
Commander AI Lab — Global Design System
|
||||||
|
Dark & Polished · Premium Dashboard Aesthetic
|
||||||
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── Reset ──────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
/* ── Design Tokens ──────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
/* Surfaces — layered dark with subtle warmth */
|
||||||
|
--bg: #0c0e14;
|
||||||
|
--surface-0: #12141c;
|
||||||
|
--surface-1: #171a24;
|
||||||
|
--surface-2: #1c1f2b;
|
||||||
|
--surface-3: #222533;
|
||||||
|
--surface-hover: #252839;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border: #2a2d3a;
|
||||||
|
--border-subtle: #1e2130;
|
||||||
|
--border-focus: #4a6fa5;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #e1e4eb;
|
||||||
|
--text-secondary:#8b90a0;
|
||||||
|
--text-tertiary: #5c6070;
|
||||||
|
--text-inverse: #0c0e14;
|
||||||
|
|
||||||
|
/* Accent — cool blue with subtle depth */
|
||||||
|
--accent: #5b9ef0;
|
||||||
|
--accent-hover: #7ab4ff;
|
||||||
|
--accent-dim: rgba(91, 158, 240, 0.12);
|
||||||
|
--accent-glow: rgba(91, 158, 240, 0.25);
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--success: #4ade80;
|
||||||
|
--success-dim: rgba(74, 222, 128, 0.12);
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-dim: rgba(251, 191, 36, 0.12);
|
||||||
|
--error: #f87171;
|
||||||
|
--error-dim: rgba(248, 113, 113, 0.12);
|
||||||
|
--purple: #a78bfa;
|
||||||
|
--purple-dim: rgba(167, 139, 250, 0.12);
|
||||||
|
--gold: #fcd34d;
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 20px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0,0,0,0.35);
|
||||||
|
--shadow-lg: 0 12px 40px rgba(0,0,0,0.45);
|
||||||
|
--shadow-glow: 0 0 20px var(--accent-dim);
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--duration: 180ms;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
|
||||||
|
/* Spacing scale */
|
||||||
|
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
|
||||||
|
--sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
|
||||||
|
--sp-8: 32px; --sp-10: 40px; --sp-12: 48px;
|
||||||
|
--sp-16: 64px;
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--content-max: 1280px;
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
--nav-height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base ───────────────────────────────────────────────── */
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 24px; }
|
||||||
|
h2 { font-size: 18px; }
|
||||||
|
h3 { font-size: 16px; }
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; transition: color var(--duration) var(--ease); }
|
||||||
|
a:hover { color: var(--accent-hover); }
|
||||||
|
|
||||||
|
img, svg { display: block; max-width: 100%; }
|
||||||
|
button { cursor: pointer; font: inherit; }
|
||||||
|
input, select, textarea { font: inherit; color: inherit; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ──────────────────────────────────────────── */
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
|
||||||
|
|
||||||
|
/* ── Navigation ─────────────────────────────────────────── */
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--nav-height);
|
||||||
|
padding: 0 var(--sp-6);
|
||||||
|
background: rgba(12, 14, 20, 0.85);
|
||||||
|
backdrop-filter: blur(16px) saturate(1.5);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(1.5);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-right: var(--sp-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all var(--duration) var(--ease);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a.active {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a .nav-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a.active .nav-icon,
|
||||||
|
.nav-links a:hover .nav-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page Layout ────────────────────────────────────────── */
|
||||||
|
.page {
|
||||||
|
max-width: var(--content-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--sp-8) var(--sp-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--sp-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cards / Panels ─────────────────────────────────────── */
|
||||||
|
.panel {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--sp-6);
|
||||||
|
margin-bottom: var(--sp-6);
|
||||||
|
transition: border-color var(--duration) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel:hover {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--sp-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header .badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stat Cards ─────────────────────────────────────────── */
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: var(--sp-4);
|
||||||
|
margin-bottom: var(--sp-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--sp-5);
|
||||||
|
transition: all var(--duration) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons ────────────────────────────────────────────── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration) var(--ease);
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
box-shadow: 0 4px 14px var(--accent-glow);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success { background: var(--success); color: var(--text-inverse); }
|
||||||
|
.btn-success:hover { box-shadow: 0 4px 14px var(--success-dim); }
|
||||||
|
|
||||||
|
.btn-purple { background: var(--purple); color: var(--text-inverse); }
|
||||||
|
.btn-purple:hover { box-shadow: 0 4px 14px var(--purple-dim); }
|
||||||
|
|
||||||
|
.btn-danger { background: var(--error); color: #fff; }
|
||||||
|
.btn-danger:hover { box-shadow: 0 4px 14px var(--error-dim); }
|
||||||
|
|
||||||
|
.btn-sm { padding: 5px 12px; font-size: 12px; }
|
||||||
|
.btn-lg { padding: 12px 24px; font-size: 15px; }
|
||||||
|
.btn-icon-only { padding: 8px; width: 36px; height: 36px; }
|
||||||
|
|
||||||
|
/* ── Forms ──────────────────────────────────────────────── */
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-select,
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-0);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all var(--duration) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-select:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder,
|
||||||
|
.form-textarea::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235c6070' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
padding-right: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress Bar ───────────────────────────────────────── */
|
||||||
|
.progress-container {
|
||||||
|
margin-top: var(--sp-4);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
background: var(--surface-0);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-bottom: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-detail {
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Phase Badge ────────────────────────────────────────── */
|
||||||
|
.badge-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-status::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-idle { background: var(--surface-3); color: var(--text-secondary); }
|
||||||
|
.badge-idle::before { background: var(--text-tertiary); }
|
||||||
|
|
||||||
|
.badge-running { background: var(--accent-dim); color: var(--accent); }
|
||||||
|
.badge-running::before { background: var(--accent); animation: pulse-dot 1.5s ease-in-out infinite; }
|
||||||
|
|
||||||
|
.badge-success { background: var(--success-dim); color: var(--success); }
|
||||||
|
.badge-success::before { background: var(--success); }
|
||||||
|
|
||||||
|
.badge-error { background: var(--error-dim); color: var(--error); }
|
||||||
|
.badge-error::before { background: var(--error); }
|
||||||
|
|
||||||
|
.badge-warning { background: var(--warning-dim); color: var(--warning); }
|
||||||
|
.badge-warning::before { background: var(--warning); }
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty State ────────────────────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-12) var(--sp-6);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tables ─────────────────────────────────────────────── */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead th {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody td {
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tags ───────────────────────────────────────────────── */
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-blue { background: var(--accent-dim); color: var(--accent); }
|
||||||
|
.tag-green { background: var(--success-dim); color: var(--success); }
|
||||||
|
.tag-yellow { background: var(--warning-dim); color: var(--warning); }
|
||||||
|
.tag-red { background: var(--error-dim); color: var(--error); }
|
||||||
|
.tag-purple { background: var(--purple-dim); color: var(--purple); }
|
||||||
|
|
||||||
|
/* ── Toast / Notification ───────────────────────────────── */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--sp-6);
|
||||||
|
right: var(--sp-6);
|
||||||
|
padding: var(--sp-3) var(--sp-5);
|
||||||
|
background: var(--surface-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
z-index: 1000;
|
||||||
|
animation: toast-in 0.3s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success { border-left: 3px solid var(--success); }
|
||||||
|
.toast-error { border-left: 3px solid var(--error); }
|
||||||
|
.toast-warning { border-left: 3px solid var(--warning); }
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(16px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading Spinner ────────────────────────────────────── */
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Divider ────────────────────────────────────────────── */
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: var(--sp-5) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Utility Classes ────────────────────────────────────── */
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.text-accent { color: var(--accent); }
|
||||||
|
.text-success { color: var(--success); }
|
||||||
|
.text-warning { color: var(--warning); }
|
||||||
|
.text-error { color: var(--error); }
|
||||||
|
.text-muted { color: var(--text-secondary); }
|
||||||
|
.text-faint { color: var(--text-tertiary); }
|
||||||
|
.text-mono { font-family: var(--font-mono); }
|
||||||
|
.text-sm { font-size: 12px; }
|
||||||
|
.text-xs { font-size: 11px; }
|
||||||
|
.font-bold { font-weight: 700; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.mt-2 { margin-top: var(--sp-2); }
|
||||||
|
.mt-4 { margin-top: var(--sp-4); }
|
||||||
|
.mt-6 { margin-top: var(--sp-6); }
|
||||||
|
.mb-2 { margin-bottom: var(--sp-2); }
|
||||||
|
.mb-4 { margin-bottom: var(--sp-4); }
|
||||||
|
.mb-6 { margin-bottom: var(--sp-6); }
|
||||||
|
.gap-2 { gap: var(--sp-2); }
|
||||||
|
.gap-4 { gap: var(--sp-4); }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.flex-col { flex-direction: column; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.flex-1 { flex: 1; }
|
||||||
|
.grid { display: grid; }
|
||||||
|
.sr-only {
|
||||||
|
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||||
|
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ─────────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page {
|
||||||
|
padding: var(--sp-5) var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
padding: 0 var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
margin-right: var(--sp-4);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.stat-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════
|
||||||
|
PREMIUM UPGRADE LAYER
|
||||||
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── Ambient background glow (body) ─────────────────────── */
|
||||||
|
body {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(91,158,240,0.07) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 90% 110%, rgba(167,139,250,0.05) 0%, transparent 60%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page title: subtle gradient text ───────────────────── */
|
||||||
|
.page-header h1 {
|
||||||
|
background: linear-gradient(135deg, #fff 60%, rgba(91,158,240,0.85) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nav: stronger glass + accent underline ─────────────── */
|
||||||
|
.nav {
|
||||||
|
background: rgba(9,11,16,0.80);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.8);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.8);
|
||||||
|
box-shadow: 0 1px 0 rgba(91,158,240,0.08), 0 4px 24px rgba(0,0,0,0.3);
|
||||||
|
border-bottom-color: rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.nav-brand svg { filter: drop-shadow(0 0 8px rgba(91,158,240,0.4)); }
|
||||||
|
.nav-links a.active { box-shadow: inset 0 0 0 1px rgba(91,158,240,0.2); }
|
||||||
|
|
||||||
|
/* ── Panels: gradient border on hover ───────────────────── */
|
||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
transition: border-color var(--duration) var(--ease),
|
||||||
|
box-shadow var(--duration) var(--ease);
|
||||||
|
}
|
||||||
|
.panel::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(135deg, rgba(91,158,240,0.18), transparent 50%, rgba(167,139,250,0.1));
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--duration) var(--ease);
|
||||||
|
}
|
||||||
|
.panel:hover::before { opacity: 1; }
|
||||||
|
.panel:hover {
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(91,158,240,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stat cards: gradient surface + shimmer top edge ─────── */
|
||||||
|
.stat-card {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(145deg, var(--surface-2), var(--surface-1));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stat-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.stat-card:hover {
|
||||||
|
box-shadow: var(--shadow-md), 0 0 20px rgba(91,158,240,0.08);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons: gradient fills + inner highlight ───────────── */
|
||||||
|
.btn { position: relative; overflow: hidden; }
|
||||||
|
.btn::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 50%;
|
||||||
|
background: linear-gradient(to bottom, rgba(255,255,255,0.07), transparent);
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent), #4a8de0);
|
||||||
|
box-shadow: 0 2px 8px rgba(91,158,240,0.25);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, var(--accent-hover), var(--accent));
|
||||||
|
box-shadow: 0 4px 20px rgba(91,158,240,0.4);
|
||||||
|
}
|
||||||
|
.btn-success {
|
||||||
|
background: linear-gradient(135deg, var(--success), #38c46c);
|
||||||
|
box-shadow: 0 2px 8px rgba(74,222,128,0.2);
|
||||||
|
}
|
||||||
|
.btn-success:hover { box-shadow: 0 4px 20px rgba(74,222,128,0.4); transform: translateY(-1px); }
|
||||||
|
.btn-purple {
|
||||||
|
background: linear-gradient(135deg, var(--purple), #9171f0);
|
||||||
|
box-shadow: 0 2px 8px rgba(167,139,250,0.2);
|
||||||
|
}
|
||||||
|
.btn-purple:hover { box-shadow: 0 4px 20px rgba(167,139,250,0.4); transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* ── Tags: subtle colored border ────────────────────────── */
|
||||||
|
.tag-blue { border: 1px solid rgba(91,158,240,0.25); }
|
||||||
|
.tag-green { border: 1px solid rgba(74,222,128,0.25); }
|
||||||
|
.tag-yellow { border: 1px solid rgba(251,191,36,0.25); }
|
||||||
|
.tag-red { border: 1px solid rgba(248,113,113,0.25); }
|
||||||
|
.tag-purple { border: 1px solid rgba(167,139,250,0.25); }
|
||||||
|
|
||||||
|
/* ── Progress fill: glow ─────────────────────────────────── */
|
||||||
|
.progress-fill { box-shadow: 0 0 8px rgba(91,158,240,0.5); }
|
||||||
|
|
||||||
|
/* ── Badge running: glow dot ─────────────────────────────── */
|
||||||
|
.badge-running { box-shadow: 0 0 12px rgba(91,158,240,0.15); }
|
||||||
|
.badge-running::before { box-shadow: 0 0 6px var(--accent); }
|
||||||
|
|
||||||
|
/* ── Toast: glassmorphism + colored glow ─────────────────── */
|
||||||
|
.toast { backdrop-filter: blur(12px); }
|
||||||
|
.toast-success { box-shadow: var(--shadow-lg), 0 0 20px rgba(74,222,128,0.1); }
|
||||||
|
.toast-error { box-shadow: var(--shadow-lg), 0 0 20px rgba(248,113,113,0.1); }
|
||||||
|
|
||||||
|
/* ── Divider: fade-out gradient ──────────────────────────── */
|
||||||
|
.divider {
|
||||||
|
background: linear-gradient(90deg, transparent, var(--border), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inputs: inset shadow for depth ─────────────────────── */
|
||||||
|
.form-input, .form-select, .form-textarea {
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
Tooltip System (.tip-icon + data-tip)
|
||||||
|
Shared across all pages — do not duplicate in page-specific CSS
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.tip-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
cursor: default;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-icon:hover {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-icon::after {
|
||||||
|
content: attr(data-tip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--surface-0, #1a1a2e);
|
||||||
|
color: var(--text, #e0e0e0);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: var(--radius-md, 6px);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: normal;
|
||||||
|
width: 240px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-icon::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 3px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-top-color: var(--border, #333);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-icon:hover::after,
|
||||||
|
.tip-icon:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-icon.tip-below::after {
|
||||||
|
bottom: auto;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-icon.tip-below::before {
|
||||||
|
bottom: auto;
|
||||||
|
top: calc(100% + 3px);
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-bottom-color: var(--border, #333);
|
||||||
|
}
|
||||||
@@ -0,0 +1,944 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MTG Commander Battlefield</title>
|
||||||
|
<link rel="stylesheet" href="style.css?v=20260403_0300">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
SETUP SCREEN
|
||||||
|
==================================================== -->
|
||||||
|
<div id="setup-screen">
|
||||||
|
<!-- Populated by ui.js initSetupScreen() -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
TABLE SURFACE — 4-Quadrant Battlefield
|
||||||
|
==================================================== -->
|
||||||
|
<div class="table-surface" id="game-battlefield" style="display:none;">
|
||||||
|
|
||||||
|
<!-- TOP BAR: Phase tracker + controls -->
|
||||||
|
<div id="top-bar">
|
||||||
|
<span class="top-bar-logo">Commander</span>
|
||||||
|
|
||||||
|
<div class="turn-counter-badge" id="turn-counter">Turn <span>1</span></div>
|
||||||
|
|
||||||
|
<div class="active-player-indicator" id="active-player-indicator">
|
||||||
|
<span class="active-player-dot" id="active-dot" style="background:#c0392b;"></span>
|
||||||
|
Player 1's Turn
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phase track -->
|
||||||
|
<div class="phase-track-wrapper">
|
||||||
|
<div class="phase-track" id="phase-track"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phase/turn action buttons -->
|
||||||
|
<div class="top-bar-actions">
|
||||||
|
<button class="btn-phase" onclick="nextPhase()" title="Advance to next phase (N)">Next Phase</button>
|
||||||
|
<button class="btn-phase btn-next-turn" onclick="nextTurn()" title="End turn, advance to next player (T)">Next Turn</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Utility buttons -->
|
||||||
|
<div class="top-bar-utils">
|
||||||
|
<button class="btn-util" id="btn-undo" onclick="undo()" title="Undo (Ctrl+Z)" disabled>↩</button>
|
||||||
|
<button class="btn-util" id="btn-redo" onclick="redo()" title="Redo (Ctrl+Y)" disabled>↪</button>
|
||||||
|
<button class="btn-util" id="btn-triggers" onclick="toggleTriggerStack()" title="Trigger Stack">❇</button>
|
||||||
|
<button class="btn-util" onclick="openDiceModal()" title="Dice & Coins (G)">🎲</button>
|
||||||
|
<button class="btn-util" onclick="checkStateBasedActions()" title="Check State-Based Actions">SBA</button>
|
||||||
|
<button class="btn-util" onclick="toggleSettingsPanel()" title="Settings">⚙</button>
|
||||||
|
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">🔍</button>
|
||||||
|
<button class="btn-util" id="btn-deckbuilder-toggle" onclick="toggleDeckBuilderPanel()" title="Deck Builder">🃏</button>
|
||||||
|
<button class="btn-util btn-util-exit" onclick="location.reload()" title="Return to Start Screen">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storm counter -->
|
||||||
|
<span id="storm-badge" style="display:none;font-size:10px;color:var(--mtg-gold);background:rgba(200,168,67,0.15);border:1px solid rgba(200,168,67,0.3);border-radius:4px;padding:2px 7px;white-space:nowrap;">Storm: 0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DIVIDER LINES -->
|
||||||
|
<div class="divider-h"></div>
|
||||||
|
<div class="divider-v"></div>
|
||||||
|
|
||||||
|
<!-- CORNER ACCENTS -->
|
||||||
|
<div class="corner-accent tl"></div>
|
||||||
|
<div class="corner-accent tr"></div>
|
||||||
|
<div class="corner-accent bl"></div>
|
||||||
|
<div class="corner-accent br"></div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ================================================
|
||||||
|
4-QUADRANT BATTLEFIELD
|
||||||
|
================================================ -->
|
||||||
|
<div class="battlefield">
|
||||||
|
|
||||||
|
<!-- PLAYER 1: Top-Left (Crimson) -->
|
||||||
|
<div class="player-area p1" id="player-area-1">
|
||||||
|
<span class="player-label" id="player-label-1">Player 1</span>
|
||||||
|
|
||||||
|
<!-- Zone Row: Library, Exile, Graveyard, Hand -->
|
||||||
|
<div class="zone-row" id="zone-row-1">
|
||||||
|
<div class="zone library" id="lib-zone-1" data-player="1" data-zone="library"
|
||||||
|
onclick="openZoneModal(0,'library')" data-tooltip="Library">
|
||||||
|
<span class="card-count" id="lib-count-1">0</span>
|
||||||
|
<span class="zone-icon">📚</span>
|
||||||
|
<span class="zone-name">Library</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone exile" id="exile-zone-1" data-player="1" data-zone="exile"
|
||||||
|
onclick="openZoneModal(0,'exile')" data-tooltip="Exile">
|
||||||
|
<span class="card-count" id="exile-count-1" style="display:none;">0</span>
|
||||||
|
<span class="zone-icon">⬡</span>
|
||||||
|
<span class="zone-name">Exile</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone graveyard" id="gv-zone-1" data-player="1" data-zone="graveyard"
|
||||||
|
onclick="openZoneModal(0,'graveyard')" data-tooltip="Graveyard">
|
||||||
|
<span class="card-count" id="gv-count-1" style="display:none;">0</span>
|
||||||
|
<span class="zone-icon">☠</span>
|
||||||
|
<span class="zone-name">Grave</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone hand" id="hand-zone-1" data-player="1" data-zone="hand"
|
||||||
|
onclick="openZoneModal(0,'hand')" data-tooltip="Hand">
|
||||||
|
<span class="zone-icon">🃏</span>
|
||||||
|
<span class="zone-name">Hand (<span id="hand-count-1">0</span>)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Commander Row -->
|
||||||
|
<div class="commander-row" id="cmd-row-1">
|
||||||
|
<div class="zone commander" id="cmd-zone-1" data-player="1" data-zone="command"
|
||||||
|
onclick="openZoneModal(0,'command')" data-tooltip="Command Zone">
|
||||||
|
<span class="zone-icon">👑</span>
|
||||||
|
<span class="zone-name">Commander</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Battle Zone -->
|
||||||
|
<div class="battle-zone" id="battle-zone-1">
|
||||||
|
<div class="battle-zone-inner"></div>
|
||||||
|
<span class="battle-zone-label">Battlefield</span>
|
||||||
|
<!-- Battlefield cards injected here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PLAYER 2: Top-Right (Sapphire) -->
|
||||||
|
<div class="player-area p2" id="player-area-2">
|
||||||
|
<span class="player-label" id="player-label-2">Player 2</span>
|
||||||
|
|
||||||
|
<div class="zone-row" id="zone-row-2">
|
||||||
|
<div class="zone library" id="lib-zone-2" data-player="2" data-zone="library"
|
||||||
|
onclick="openZoneModal(1,'library')" data-tooltip="Library">
|
||||||
|
<span class="card-count" id="lib-count-2">0</span>
|
||||||
|
<span class="zone-icon">📚</span>
|
||||||
|
<span class="zone-name">Library</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone exile" id="exile-zone-2" data-player="2" data-zone="exile"
|
||||||
|
onclick="openZoneModal(1,'exile')" data-tooltip="Exile">
|
||||||
|
<span class="card-count" id="exile-count-2" style="display:none;">0</span>
|
||||||
|
<span class="zone-icon">⬡</span>
|
||||||
|
<span class="zone-name">Exile</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone graveyard" id="gv-zone-2" data-player="2" data-zone="graveyard"
|
||||||
|
onclick="openZoneModal(1,'graveyard')" data-tooltip="Graveyard">
|
||||||
|
<span class="card-count" id="gv-count-2" style="display:none;">0</span>
|
||||||
|
<span class="zone-icon">☠</span>
|
||||||
|
<span class="zone-name">Grave</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone hand" id="hand-zone-2" data-player="2" data-zone="hand"
|
||||||
|
onclick="openZoneModal(1,'hand')" data-tooltip="Hand">
|
||||||
|
<span class="zone-icon">🃏</span>
|
||||||
|
<span class="zone-name">Hand (<span id="hand-count-2">0</span>)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="commander-row" id="cmd-row-2">
|
||||||
|
<div class="zone commander" id="cmd-zone-2" data-player="2" data-zone="command"
|
||||||
|
onclick="openZoneModal(1,'command')" data-tooltip="Command Zone">
|
||||||
|
<span class="zone-icon">👑</span>
|
||||||
|
<span class="zone-name">Commander</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="battle-zone" id="battle-zone-2">
|
||||||
|
<div class="battle-zone-inner"></div>
|
||||||
|
<span class="battle-zone-label">Battlefield</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PLAYER 3: Bottom-Left (Emerald) -->
|
||||||
|
<div class="player-area p3" id="player-area-3">
|
||||||
|
<!-- Battle zone on top for bottom players -->
|
||||||
|
<div class="battle-zone" id="battle-zone-3">
|
||||||
|
<div class="battle-zone-inner"></div>
|
||||||
|
<span class="battle-zone-label">Battlefield</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="commander-row" id="cmd-row-3">
|
||||||
|
<div class="zone commander" id="cmd-zone-3" data-player="3" data-zone="command"
|
||||||
|
onclick="openZoneModal(2,'command')" data-tooltip="Command Zone">
|
||||||
|
<span class="zone-icon">👑</span>
|
||||||
|
<span class="zone-name">Commander</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="zone-row" id="zone-row-3">
|
||||||
|
<div class="zone library" id="lib-zone-3" data-player="3" data-zone="library"
|
||||||
|
onclick="openZoneModal(2,'library')" data-tooltip="Library">
|
||||||
|
<span class="card-count" id="lib-count-3">0</span>
|
||||||
|
<span class="zone-icon">📚</span>
|
||||||
|
<span class="zone-name">Library</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone exile" id="exile-zone-3" data-player="3" data-zone="exile"
|
||||||
|
onclick="openZoneModal(2,'exile')" data-tooltip="Exile">
|
||||||
|
<span class="card-count" id="exile-count-3" style="display:none;">0</span>
|
||||||
|
<span class="zone-icon">⬡</span>
|
||||||
|
<span class="zone-name">Exile</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone graveyard" id="gv-zone-3" data-player="3" data-zone="graveyard"
|
||||||
|
onclick="openZoneModal(2,'graveyard')" data-tooltip="Graveyard">
|
||||||
|
<span class="card-count" id="gv-count-3" style="display:none;">0</span>
|
||||||
|
<span class="zone-icon">☠</span>
|
||||||
|
<span class="zone-name">Grave</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone hand" id="hand-zone-3" data-player="3" data-zone="hand"
|
||||||
|
onclick="openZoneModal(2,'hand')" data-tooltip="Hand">
|
||||||
|
<span class="zone-icon">🃏</span>
|
||||||
|
<span class="zone-name">Hand (<span id="hand-count-3">0</span>)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="player-label" id="player-label-3">Player 3</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PLAYER 4: Bottom-Right (Gold) -->
|
||||||
|
<div class="player-area p4" id="player-area-4">
|
||||||
|
<div class="battle-zone" id="battle-zone-4">
|
||||||
|
<div class="battle-zone-inner"></div>
|
||||||
|
<span class="battle-zone-label">Battlefield</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="commander-row" id="cmd-row-4">
|
||||||
|
<div class="zone commander" id="cmd-zone-4" data-player="4" data-zone="command"
|
||||||
|
onclick="openZoneModal(3,'command')" data-tooltip="Command Zone">
|
||||||
|
<span class="zone-icon">👑</span>
|
||||||
|
<span class="zone-name">Commander</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="zone-row" id="zone-row-4">
|
||||||
|
<div class="zone library" id="lib-zone-4" data-player="4" data-zone="library"
|
||||||
|
onclick="openZoneModal(3,'library')" data-tooltip="Library">
|
||||||
|
<span class="card-count" id="lib-count-4">0</span>
|
||||||
|
<span class="zone-icon">📚</span>
|
||||||
|
<span class="zone-name">Library</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone exile" id="exile-zone-4" data-player="4" data-zone="exile"
|
||||||
|
onclick="openZoneModal(3,'exile')" data-tooltip="Exile">
|
||||||
|
<span class="card-count" id="exile-count-4" style="display:none;">0</span>
|
||||||
|
<span class="zone-icon">⬡</span>
|
||||||
|
<span class="zone-name">Exile</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone graveyard" id="gv-zone-4" data-player="4" data-zone="graveyard"
|
||||||
|
onclick="openZoneModal(3,'graveyard')" data-tooltip="Graveyard">
|
||||||
|
<span class="card-count" id="gv-count-4" style="display:none;">0</span>
|
||||||
|
<span class="zone-icon">☠</span>
|
||||||
|
<span class="zone-name">Grave</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone hand" id="hand-zone-4" data-player="4" data-zone="hand"
|
||||||
|
onclick="openZoneModal(3,'hand')" data-tooltip="Hand">
|
||||||
|
<span class="zone-icon">🃏</span>
|
||||||
|
<span class="zone-name">Hand (<span id="hand-count-4">0</span>)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="player-label" id="player-label-4">Player 4</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /.battlefield -->
|
||||||
|
|
||||||
|
<!-- LIFE PANELS OVERLAY (top-most layer) -->
|
||||||
|
<div id="life-panels-overlay">
|
||||||
|
<div class="life-panel lp-p1" id="life-panel-1">
|
||||||
|
<div class="life-panel-box life-box">
|
||||||
|
<span class="life-panel-label">Life</span>
|
||||||
|
<div class="life-counter" id="life-counter-1">
|
||||||
|
<button onclick="adjustLife(0,-1)" aria-label="Decrease P1 life">−</button>
|
||||||
|
<span class="life-counter-value" id="life-0" onclick="showSetLifeDialog(0)">40</span>
|
||||||
|
<button onclick="adjustLife(0,1)" aria-label="Increase P1 life">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-0" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
|
||||||
|
<span class="life-panel-label">Cmd Dmg</span>
|
||||||
|
<div class="cmd-dmg-inline-row">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="life-panel-box counters-box">
|
||||||
|
<span class="life-panel-label">Counters</span>
|
||||||
|
<div class="poison-badge" id="poison-0" onclick="adjustPoison(0,1)" title="Poison counters (click +1, right-click -1)" oncontextmenu="event.preventDefault();adjustPoison(0,-1)">0 poison</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="life-panel lp-p2" id="life-panel-2">
|
||||||
|
<div class="life-panel-box life-box">
|
||||||
|
<span class="life-panel-label">Life</span>
|
||||||
|
<div class="life-counter" id="life-counter-2">
|
||||||
|
<button onclick="adjustLife(1,-1)" aria-label="Decrease P2 life">−</button>
|
||||||
|
<span class="life-counter-value" id="life-1" onclick="showSetLifeDialog(1)">40</span>
|
||||||
|
<button onclick="adjustLife(1,1)" aria-label="Increase P2 life">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-1" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
|
||||||
|
<span class="life-panel-label">Cmd Dmg</span>
|
||||||
|
<div class="cmd-dmg-inline-row">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="life-panel-box counters-box">
|
||||||
|
<span class="life-panel-label">Counters</span>
|
||||||
|
<div class="poison-badge" id="poison-1" onclick="adjustPoison(1,1)" oncontextmenu="event.preventDefault();adjustPoison(1,-1)">0 poison</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="life-panel lp-p3" id="life-panel-3">
|
||||||
|
<div class="life-panel-box life-box">
|
||||||
|
<span class="life-panel-label">Life</span>
|
||||||
|
<div class="life-counter" id="life-counter-3">
|
||||||
|
<button onclick="adjustLife(2,-1)" aria-label="Decrease P3 life">−</button>
|
||||||
|
<span class="life-counter-value" id="life-2" onclick="showSetLifeDialog(2)">40</span>
|
||||||
|
<button onclick="adjustLife(2,1)" aria-label="Increase P3 life">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-2" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
|
||||||
|
<span class="life-panel-label">Cmd Dmg</span>
|
||||||
|
<div class="cmd-dmg-inline-row">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="life-panel-box counters-box">
|
||||||
|
<span class="life-panel-label">Counters</span>
|
||||||
|
<div class="poison-badge" id="poison-2" onclick="adjustPoison(2,1)" oncontextmenu="event.preventDefault();adjustPoison(2,-1)">0 poison</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="life-panel lp-p4" id="life-panel-4">
|
||||||
|
<div class="life-panel-box life-box">
|
||||||
|
<span class="life-panel-label">Life</span>
|
||||||
|
<div class="life-counter" id="life-counter-4">
|
||||||
|
<button onclick="adjustLife(3,-1)" aria-label="Decrease P4 life">−</button>
|
||||||
|
<span class="life-counter-value" id="life-3" onclick="showSetLifeDialog(3)">40</span>
|
||||||
|
<button onclick="adjustLife(3,1)" aria-label="Increase P4 life">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-3" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
|
||||||
|
<span class="life-panel-label">Cmd Dmg</span>
|
||||||
|
<div class="cmd-dmg-inline-row">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="life-panel-box counters-box">
|
||||||
|
<span class="life-panel-label">Counters</span>
|
||||||
|
<div class="poison-badge" id="poison-3" onclick="adjustPoison(3,1)" oncontextmenu="event.preventDefault();adjustPoison(3,-1)">0 poison</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- COMBAT SVG OVERLAY (arrows) -->
|
||||||
|
<svg id="combat-overlay" xmlns="http://www.w3.org/2000/svg" style="position:absolute;inset:44px 0 0 0;pointer-events:none;z-index:30;">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||||
|
<polygon points="0 0, 10 3.5, 0 7" fill="rgba(211,32,42,0.7)"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<g id="combat-arrows-svg"></g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- FLOATING LOG -->
|
||||||
|
<div id="floating-log" class="floating-log"></div>
|
||||||
|
|
||||||
|
<!-- SHORTCUT HINT -->
|
||||||
|
<div class="shortcut-hint"></div>
|
||||||
|
|
||||||
|
<!-- CARD SEARCH SIDEBAR (right side, collapsible) -->
|
||||||
|
<div id="card-search-sidebar" class="collapsed">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<span class="sidebar-title">Card Search</span>
|
||||||
|
<button class="sidebar-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Close search">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-input-wrap">
|
||||||
|
<input class="search-input" id="card-search-input" type="text" placeholder="Search Scryfall..." autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
<div class="search-status" id="search-status">Type to search cards...</div>
|
||||||
|
<div class="card-results" id="card-results"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar edge tab -->
|
||||||
|
<div id="sidebar-edge-tab" class="sidebar-edge-tab" title="Toggle card search">‹</div>
|
||||||
|
|
||||||
|
<!-- LEFT ACTION PANEL (triggers + quick actions) -->
|
||||||
|
<div id="left-action-panel" class="collapsed">
|
||||||
|
<div class="left-panel-header">
|
||||||
|
<span class="left-panel-title">Triggers <span id="left-panel-trigger-count" class="left-panel-count">0</span></span>
|
||||||
|
<button class="sidebar-toggle" onclick="toggleLeftPanel()" title="Close panel">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="left-panel-actions">
|
||||||
|
<button class="left-panel-btn" onclick="openTokenModal((typeof gameState !== 'undefined' && gameState) ? (gameState.currentPlayerIndex || 0) : 0)" title="Create token">✨ Create Token</button>
|
||||||
|
<button class="left-panel-btn" onclick="autoArrangeBattlefield()" title="Auto-arrange battlefield">⇄ Auto-Arrange</button>
|
||||||
|
</div>
|
||||||
|
<div class="left-panel-body" id="left-panel-triggers">
|
||||||
|
<div class="left-panel-empty">No pending triggers.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Left panel edge tab -->
|
||||||
|
<div id="left-panel-edge-tab" class="left-panel-edge-tab" onclick="toggleLeftPanel()" title="Toggle quick actions">›</div>
|
||||||
|
|
||||||
|
</div><!-- /.table-surface / #game-battlefield -->
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
CONTEXT MENU
|
||||||
|
==================================================== -->
|
||||||
|
<div id="context-menu"></div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
FLOATING CARD PREVIEW
|
||||||
|
==================================================== -->
|
||||||
|
<div id="card-preview">
|
||||||
|
<img src="" alt="Card Preview"/>
|
||||||
|
<div class="card-preview-pt-overlay" id="card-preview-pt-overlay"></div>
|
||||||
|
<div class="card-preview-info">
|
||||||
|
<div class="card-preview-name"></div>
|
||||||
|
<div class="card-preview-type"></div>
|
||||||
|
<div class="card-preview-oracle"></div>
|
||||||
|
<div class="card-preview-pt"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
ZONE MODAL
|
||||||
|
==================================================== -->
|
||||||
|
<div id="zone-modal">
|
||||||
|
<div class="zone-modal-card">
|
||||||
|
<div class="zone-modal-header">
|
||||||
|
<span class="zone-modal-title" id="zone-modal-title">Player Zones</span>
|
||||||
|
<button class="btn-close" onclick="closeZoneModal()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="zone-modal-tabs" id="zone-modal-tabs">
|
||||||
|
<!-- Populated by renderZoneModal() -->
|
||||||
|
</div>
|
||||||
|
<div class="zone-modal-body" id="zone-modal-body">
|
||||||
|
<!-- Populated by renderZoneModal() -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
DICE MODAL
|
||||||
|
==================================================== -->
|
||||||
|
<div id="dice-modal">
|
||||||
|
<div class="dice-modal-card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||||
|
<span style="font-family:var(--font-display);font-size:14px;font-weight:700;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.08em;">Dice & Coins</span>
|
||||||
|
<button class="btn-close" onclick="closeDiceModal()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="dice-result-display" id="dice-result-display">—</div>
|
||||||
|
<div class="dice-label" id="dice-label">Choose a die</div>
|
||||||
|
<div class="dice-buttons">
|
||||||
|
<button class="btn-dice" onclick="rollDice(4)">d4</button>
|
||||||
|
<button class="btn-dice" onclick="rollDice(6)">d6</button>
|
||||||
|
<button class="btn-dice" onclick="rollDice(8)">d8</button>
|
||||||
|
<button class="btn-dice" onclick="rollDice(10)">d10</button>
|
||||||
|
<button class="btn-dice" onclick="rollDice(12)">d12</button>
|
||||||
|
<button class="btn-dice" onclick="rollDice(20)">d20</button>
|
||||||
|
<button class="btn-dice" onclick="rollDice(100)">d100</button>
|
||||||
|
<button class="btn-dice coin" onclick="flipCoin()">Coin Flip</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
TOKEN MODAL
|
||||||
|
==================================================== -->
|
||||||
|
<div id="token-modal">
|
||||||
|
<div class="token-modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">Create Token</span>
|
||||||
|
<button class="btn-close" onclick="closeTokenModal()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="token-modal-body">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Token Name</label>
|
||||||
|
<input class="game-input" id="token-name" type="text" placeholder="e.g. 1/1 White Soldier"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Type Line</label>
|
||||||
|
<input class="game-input" id="token-type" type="text" placeholder="Creature — Soldier"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Power/Toughness</label>
|
||||||
|
<input class="game-input" id="token-pt" type="text" placeholder="1/1"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Count</label>
|
||||||
|
<input class="game-input" id="token-count" type="number" value="1" min="1" max="20"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Keywords (comma-separated)</label>
|
||||||
|
<input class="game-input" id="token-keywords" type="text" placeholder="Flying, Vigilance"/>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Player</label>
|
||||||
|
<select class="game-input" id="token-player-select" style="height:28px;">
|
||||||
|
<option value="0">Player 1</option>
|
||||||
|
<option value="1">Player 2</option>
|
||||||
|
<option value="2">Player 3</option>
|
||||||
|
<option value="3">Player 4</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<button class="btn btn-primary" onclick="createTokenFromModal()">Create Token(s)</button>
|
||||||
|
<button class="btn btn-cancel" onclick="closeTokenModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
DECK MODAL
|
||||||
|
==================================================== -->
|
||||||
|
<div id="deck-modal">
|
||||||
|
<div class="deck-modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">Deck Manager</span>
|
||||||
|
<button class="btn-close" onclick="closeDeckModal()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="deck-modal-body">
|
||||||
|
<!-- Populated by renderDeckModalContent() -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
COMMANDER DAMAGE MATRIX MODAL
|
||||||
|
==================================================== -->
|
||||||
|
<div id="cmd-dmg-matrix-modal">
|
||||||
|
<!-- Populated by renderCmdDmgMatrix() -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
TRIGGER STACK PANEL
|
||||||
|
==================================================== -->
|
||||||
|
<div id="trigger-stack-panel">
|
||||||
|
<div class="trigger-stack-card">
|
||||||
|
<div class="trigger-stack-header">
|
||||||
|
<span class="trigger-stack-title">Trigger Stack</span>
|
||||||
|
<button class="btn-close" style="font-size:14px;width:20px;height:20px;" onclick="closeTriggerStack()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="trigger-stack-body" id="trigger-stack-body">
|
||||||
|
<div class="trigger-stack-empty">No pending triggers.</div>
|
||||||
|
</div>
|
||||||
|
<div class="trigger-stack-actions">
|
||||||
|
<button class="btn-resolve" onclick="resolveNextTrigger()">Resolve Next</button>
|
||||||
|
<button class="btn-resolve-all" onclick="resolveAllTriggers()">Resolve All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
SETTINGS PANEL
|
||||||
|
==================================================== -->
|
||||||
|
<div id="settings-panel">
|
||||||
|
<div class="modal-header" style="flex-shrink:0;">
|
||||||
|
<span class="modal-title">Settings</span>
|
||||||
|
<button class="btn-close" onclick="toggleSettingsPanel()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-body" id="settings-body" style="flex:1;overflow-y:auto;padding:12px;">
|
||||||
|
<!-- Populated by renderSettingsPanel() -->
|
||||||
|
</div>
|
||||||
|
<div style="padding:10px 14px;border-top:1px solid var(--color-border);display:flex;gap:8px;flex-wrap:wrap;flex-shrink:0;">
|
||||||
|
<button class="btn btn-primary" onclick="openDiceModal()">🎲 Dice</button>
|
||||||
|
<button class="btn btn-gold" onclick="openCmdDmgMatrix()">CMD Damage Matrix</button>
|
||||||
|
<button class="btn btn-cancel" onclick="checkStateBasedActions()">Check SBA</button>
|
||||||
|
<button class="btn btn-cancel" onclick="autoArrangeBattlefield()">Auto-Arrange</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
PRIORITY BANNER
|
||||||
|
==================================================== -->
|
||||||
|
<div id="priority-banner">
|
||||||
|
<span class="priority-text" id="priority-text">Waiting for priority...</span>
|
||||||
|
<button class="btn-priority-pass" onclick="passPriority()">Pass Priority</button>
|
||||||
|
<button class="btn-close" style="font-size:13px;" onclick="closePriorityWindow()">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
LEGAL ACTIONS BAR
|
||||||
|
==================================================== -->
|
||||||
|
<div id="legal-actions-bar">
|
||||||
|
<!-- Populated by renderLegalActionsBar() -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
UNDO HISTORY PANEL
|
||||||
|
==================================================== -->
|
||||||
|
<div id="undo-history-panel">
|
||||||
|
<!-- Populated by renderUndoHistory() -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
MANA COLOR PICKER (dynamically appended) -->
|
||||||
|
<!-- Zone card context menu (dynamically appended) -->
|
||||||
|
<!-- SBA alert panel (dynamically appended) -->
|
||||||
|
<!-- Set-life dialog (dynamically appended) -->
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
SCRIPTS
|
||||||
|
Load order matters:
|
||||||
|
1. precons.js — static PRECON_DATA fallback
|
||||||
|
2. engine.js — game state & core logic
|
||||||
|
3. ai-bridge.js — backend connector & DeckSourceClient
|
||||||
|
4. precon-loader.js — startGameWithDecks() + loadPreconDeckFromDck()
|
||||||
|
5. player-panels.js — player panel rendering
|
||||||
|
6. ui.js — setup screen, deck picker, UI wiring
|
||||||
|
==================================================== -->
|
||||||
|
<script src="precons.js?v=20260403_0300"></script>
|
||||||
|
<script src="engine.js?v=20260403_0300"></script>
|
||||||
|
<script src="ai-bridge.js?v=20260403_0300"></script>
|
||||||
|
<script src="precon-loader.js?v=20260403_0300"></script>
|
||||||
|
<script src="player-panels.js?v=20260403_0300"></script>
|
||||||
|
<script src="ui.js?v=20260403_0300"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ============================================================
|
||||||
|
// STARTUP & BRIDGE FUNCTIONS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Token creation from modal
|
||||||
|
function createTokenFromModal() {
|
||||||
|
if (!gameState) return;
|
||||||
|
const name = document.getElementById('token-name').value.trim() || '1/1 Token';
|
||||||
|
const typeLine = document.getElementById('token-type').value.trim() || 'Creature';
|
||||||
|
const pt = document.getElementById('token-pt').value.trim() || '1/1';
|
||||||
|
const count = parseInt(document.getElementById('token-count').value) || 1;
|
||||||
|
const keywords = document.getElementById('token-keywords').value.trim();
|
||||||
|
const playerIdx = parseInt(document.getElementById('token-player-select').value) || 0;
|
||||||
|
|
||||||
|
const parts = pt.split('/');
|
||||||
|
const power = parts[0] ? parts[0].trim() : '1';
|
||||||
|
const toughness = parts[1] ? parts[1].trim() : '1';
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
addCardToBattlefield(name, '', typeLine, playerIdx, undefined, undefined,
|
||||||
|
keywords ? keywords.replace(/,/g, ' ') : '',
|
||||||
|
{ power, toughness, pt, isToken: true });
|
||||||
|
}
|
||||||
|
addLogEntry('Created ' + count + 'x <strong>' + name + '</strong> token(s) for Player ' + (playerIdx + 1));
|
||||||
|
closeTokenModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass priority
|
||||||
|
function passPriority() {
|
||||||
|
addLogEntry('Priority passed.');
|
||||||
|
closePriorityWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open precon browser (stub - opens deck modal for current player)
|
||||||
|
function openPreconBrowser() {
|
||||||
|
if (gameState) openDeckModal(gameState.currentPlayerIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open collection modal (stub)
|
||||||
|
function openCollectionModal() { showToast('Collection tracker available in full version.'); }
|
||||||
|
|
||||||
|
// Scry dialog stub
|
||||||
|
function showScryDialog(playerId) {
|
||||||
|
if (!gameState) return;
|
||||||
|
const player = gameState.players[playerId];
|
||||||
|
if (!player || player.zones.library.length === 0) {
|
||||||
|
addLogEntry('!! ' + (player ? player.name : 'Player') + ' has no cards to scry.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = Math.min(parseInt(prompt('Scry how many? (1-7)', '1') || '1'), 7, player.zones.library.length);
|
||||||
|
if (isNaN(count) || count < 1) return;
|
||||||
|
const topCards = player.zones.library.slice(0, count);
|
||||||
|
const names = topCards.map(c => c.name || '?').join(', ');
|
||||||
|
addLogEntry('<strong>' + player.name + '</strong> scrys ' + count + ': [' + names + ']');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mill dialog stub
|
||||||
|
function showMillDialog(playerId) {
|
||||||
|
if (!gameState) return;
|
||||||
|
const count = parseInt(prompt('Mill how many cards?', '1') || '1');
|
||||||
|
if (isNaN(count) || count < 1) return;
|
||||||
|
millCards(playerId, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Become monarch
|
||||||
|
function becomeMonarch(playerId) {
|
||||||
|
if (!gameState) return;
|
||||||
|
gameState.monarchPlayerIndex = playerId;
|
||||||
|
const player = gameState.players[playerId];
|
||||||
|
addLogEntry('👑 <strong>' + (player ? player.name : 'Player') + '</strong> becomes the Monarch!');
|
||||||
|
updateMonarchBadges();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empower the life counter right-click on the quadrant labels
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Wire right-click on life values to set-life dialog
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
(function(idx) {
|
||||||
|
var lifeEl = document.getElementById('life-' + idx);
|
||||||
|
if (lifeEl) {
|
||||||
|
lifeEl.addEventListener('contextmenu', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
showSetLifeDialog(idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Life counter right-click on quadrant buttons too
|
||||||
|
// (already handled via inline onclick in HTML)
|
||||||
|
|
||||||
|
// Auto-detect Commander-AI-Lab backend on page load
|
||||||
|
if (typeof AIBridge !== 'undefined') {
|
||||||
|
AIBridge.init().then(function(status) {
|
||||||
|
var indicator = document.getElementById('backend-status-indicator');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.textContent = status.connected
|
||||||
|
? (status.modelLoaded ? '\u{1F9E0} ML Backend Connected' : '\u26a1 Backend Connected (no ML model)')
|
||||||
|
: '\u{1F4E1} Local Mode (no backend)';
|
||||||
|
indicator.className = 'backend-status ' +
|
||||||
|
(status.connected ? (status.modelLoaded ? 'connected-ml' : 'connected') : 'disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up WebSocket push events from backend
|
||||||
|
AIBridge.connector.on('ai:decision', function(msg) {
|
||||||
|
if (!gameState || !msg.action) return;
|
||||||
|
var playerIdx = gameState.currentPlayerIndex;
|
||||||
|
var player = gameState.players[playerIdx];
|
||||||
|
if (player && player.isAI) {
|
||||||
|
AIBridge.MacroActionMapper.execute(msg.action, playerIdx, gameState, msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AIBridge.connector.on('state:snapshot', function(msg) {
|
||||||
|
if (typeof AI_BRIDGE_CONFIG !== 'undefined' && AI_BRIDGE_CONFIG.verbose) {
|
||||||
|
console.log('[WS] State snapshot received from backend');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
DECK BUILDER PANEL (iframe)
|
||||||
|
==================================================== -->
|
||||||
|
<div id="panel-deckbuilder" class="fullscreen-panel hidden">
|
||||||
|
<div class="fullscreen-panel-header">
|
||||||
|
<span>Deck Builder</span>
|
||||||
|
<button class="btn-util" onclick="toggleDeckBuilderPanel()" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<iframe src="deckbuilder.html"
|
||||||
|
style="width:100%;height:calc(100vh - 36px);border:none;"
|
||||||
|
id="deckbuilder-iframe">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-collection" class="fullscreen-panel hidden">
|
||||||
|
<div class="fullscreen-panel-header">
|
||||||
|
<span>Collection</span>
|
||||||
|
<button class="btn-util" onclick="toggleCollectionPanel()" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<iframe src="collection.html"
|
||||||
|
style="width:100%;height:calc(100vh - 36px);border:none;"
|
||||||
|
id="collection-iframe">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-deckgenerator" class="fullscreen-panel hidden">
|
||||||
|
<div class="fullscreen-panel-header">
|
||||||
|
<span>Auto Deck Generator</span>
|
||||||
|
<button class="btn-util" onclick="toggleDeckGenPanel()" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<iframe src="deckgenerator.html"
|
||||||
|
style="width:100%;height:calc(100vh - 36px);border:none;"
|
||||||
|
id="deckgenerator-iframe">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleDeckBuilderPanel() {
|
||||||
|
var panel = document.getElementById('panel-deckbuilder');
|
||||||
|
if (panel) panel.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDeckGenPanel() {
|
||||||
|
var panel = document.getElementById('panel-deckgenerator');
|
||||||
|
if (panel) panel.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCollectionPanel() {
|
||||||
|
var panel = document.getElementById('panel-collection');
|
||||||
|
if (panel) panel.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLeftPanel() {
|
||||||
|
var panel = document.getElementById('left-action-panel');
|
||||||
|
if (panel) {
|
||||||
|
panel.classList.toggle('collapsed');
|
||||||
|
if (!panel.classList.contains('collapsed') && typeof refreshLeftPanelTriggers === 'function') {
|
||||||
|
refreshLeftPanelTriggers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the triggers list inside the left action panel.
|
||||||
|
// Mirrors #trigger-stack-body rendering but stays embedded in the panel.
|
||||||
|
function refreshLeftPanelTriggers() {
|
||||||
|
var body = document.getElementById('left-panel-triggers');
|
||||||
|
var countEl = document.getElementById('left-panel-trigger-count');
|
||||||
|
if (!body) return;
|
||||||
|
if (typeof gameState === 'undefined' || !gameState) {
|
||||||
|
if (countEl) countEl.textContent = '0';
|
||||||
|
body.innerHTML = '<div class="left-panel-empty">No pending triggers.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var pending = (gameState.triggerStack || []).filter(function(t){ return !t.resolved; });
|
||||||
|
if (countEl) countEl.textContent = String(pending.length);
|
||||||
|
if (pending.length === 0) {
|
||||||
|
body.innerHTML = '<div class="left-panel-empty">No pending triggers.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var icons = (typeof TRIGGER_TYPE_ICONS !== 'undefined') ? TRIGGER_TYPE_ICONS : {};
|
||||||
|
var labels = (typeof TRIGGER_TYPE_LABELS !== 'undefined') ? TRIGGER_TYPE_LABELS : {};
|
||||||
|
var html = '';
|
||||||
|
pending.forEach(function(t) {
|
||||||
|
var icon = icons[t.type] || '\u2747';
|
||||||
|
var label = labels[t.type] || t.type || '';
|
||||||
|
var owner = (gameState.players && gameState.players[t.ownerIndex]) || {};
|
||||||
|
var color = owner.color || '#888';
|
||||||
|
var name = (t.cardName || 'Unknown').replace(/</g,'<');
|
||||||
|
var snip = (t.oracleSnippet || '').replace(/</g,'<');
|
||||||
|
html +=
|
||||||
|
'<div class="lpt-item">' +
|
||||||
|
'<div class="lpt-icon">' + icon + '</div>' +
|
||||||
|
'<div class="lpt-body">' +
|
||||||
|
'<div class="lpt-title">' + name + '</div>' +
|
||||||
|
'<div class="lpt-type">' + label + '</div>' +
|
||||||
|
'<div class="lpt-text">' + snip + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="lpt-dot" style="background:' + color + '"></div>' +
|
||||||
|
'<button class="lpt-dismiss" title="Dismiss" onclick="if(typeof dismissTrigger===\'function\')dismissTrigger(' + t.id + ');refreshLeftPanelTriggers();">✕</button>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
body.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate each seat's Cmd Dmg score-area box with per-opponent badges.
|
||||||
|
// Skips the seat's own player number from the display.
|
||||||
|
function renderCmdDmgInline() {
|
||||||
|
if (typeof gameState === 'undefined' || !gameState || !gameState.players) return;
|
||||||
|
var players = gameState.players;
|
||||||
|
for (var i = 0; i < players.length; i++) {
|
||||||
|
var box = document.getElementById('cmd-dmg-inline-' + i);
|
||||||
|
if (!box) continue;
|
||||||
|
var row = box.querySelector('.cmd-dmg-inline-row');
|
||||||
|
if (!row) continue;
|
||||||
|
var me = players[i];
|
||||||
|
if (!me) continue;
|
||||||
|
var html = '';
|
||||||
|
for (var j = 0; j < players.length; j++) {
|
||||||
|
if (j === i) continue; // leave own player number out
|
||||||
|
var op = players[j];
|
||||||
|
if (!op) continue;
|
||||||
|
var dmg = (me.commanderDamage && me.commanderDamage[op.id]) || 0;
|
||||||
|
var danger = dmg >= 21 ? ' danger' : dmg >= 15 ? ' crit' : dmg >= 8 ? ' warn' : '';
|
||||||
|
html +=
|
||||||
|
'<span class="cdi-badge' + danger + '" title="Commander damage from ' + (op.name||'P'+(j+1)) + '">' +
|
||||||
|
'<span class="cdi-dot" style="background:' + (op.color||'#888') + '"></span>' +
|
||||||
|
'P' + (j + 1) + ' <b>' + dmg + '</b>' +
|
||||||
|
'</span>';
|
||||||
|
}
|
||||||
|
row.innerHTML = html || '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook into the trigger-stack badge updater so the left panel stays in sync.
|
||||||
|
(function() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
var tryHook = function() {
|
||||||
|
if (typeof window.updateTriggerBadge === 'function' && !window.updateTriggerBadge._leftPanelHooked) {
|
||||||
|
var orig = window.updateTriggerBadge;
|
||||||
|
window.updateTriggerBadge = function() {
|
||||||
|
var r = orig.apply(this, arguments);
|
||||||
|
try { refreshLeftPanelTriggers(); } catch(e) {}
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
window.updateTriggerBadge._leftPanelHooked = true;
|
||||||
|
}
|
||||||
|
// Also wrap adjustCommanderDamage so badges refresh after matrix clicks.
|
||||||
|
if (typeof window.adjustCommanderDamage === 'function' && !window.adjustCommanderDamage._cdmInlineHooked) {
|
||||||
|
var origAdj = window.adjustCommanderDamage;
|
||||||
|
window.adjustCommanderDamage = function() {
|
||||||
|
var r = origAdj.apply(this, arguments);
|
||||||
|
try { renderCmdDmgInline(); } catch(e) {}
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
window.adjustCommanderDamage._cdmInlineHooked = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', tryHook);
|
||||||
|
} else {
|
||||||
|
tryHook();
|
||||||
|
}
|
||||||
|
// Also attempt the hook again a tick later (engine.js loads after this script in some orderings)
|
||||||
|
setTimeout(tryHook, 0);
|
||||||
|
setTimeout(tryHook, 500);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Listen for deck exports from the deck builder iframe
|
||||||
|
window.addEventListener('message', function(e) {
|
||||||
|
if (!e.data || e.data.type !== 'DECK_BUILDER_EXPORT') return;
|
||||||
|
|
||||||
|
var deckData = e.data;
|
||||||
|
// Build a decklist text string compatible with importDeckForPlayer()
|
||||||
|
var lines = [];
|
||||||
|
var commanders = deckData.deck.filter(function(c) { return c.is_commander; });
|
||||||
|
var nonCmd = deckData.deck.filter(function(c) { return !c.is_commander; });
|
||||||
|
|
||||||
|
if (commanders.length > 0) {
|
||||||
|
lines.push('[Commander]');
|
||||||
|
commanders.forEach(function(c) { lines.push('1 ' + c.name); });
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
lines.push('[Deck]');
|
||||||
|
nonCmd.forEach(function(c) { lines.push((c.quantity || 1) + ' ' + c.name); });
|
||||||
|
|
||||||
|
// Store on window for the setup screen to pick up
|
||||||
|
window._pendingDeckImport = {
|
||||||
|
text: lines.join('\n'),
|
||||||
|
deckName: deckData.deckName || 'Imported Deck',
|
||||||
|
commander: deckData.commander || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// If a game is in progress, import into current player's slot
|
||||||
|
if (typeof gameState !== 'undefined' && gameState && typeof importDeckForPlayer === 'function') {
|
||||||
|
var playerIdx = gameState.currentPlayerIndex || 0;
|
||||||
|
importDeckForPlayer(playerIdx, window._pendingDeckImport.text).then(function() {
|
||||||
|
if (typeof renderHandArea === 'function') renderHandArea();
|
||||||
|
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
|
||||||
|
if (typeof addLogEntry === 'function') {
|
||||||
|
addLogEntry('Imported deck "' + window._pendingDeckImport.deckName + '" from Deck Builder');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (typeof addLogEntry === 'function') {
|
||||||
|
addLogEntry('Deck "' + window._pendingDeckImport.deckName + '" ready — start a game to use it.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the deck builder panel
|
||||||
|
toggleDeckBuilderPanel();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// player-panels.js — Enhanced Player Panel Component
|
||||||
|
// Provides collapsible panels with life, commander damage, counters, and emblems.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an enhanced player panel widget for a quadrant.
|
||||||
|
* Replaces the basic life counter with a full tracking panel.
|
||||||
|
*
|
||||||
|
* @param {object} player - Player object from gameState
|
||||||
|
* @param {number} quadrant - 1-4 (matches CSS class p1-p4)
|
||||||
|
* @param {boolean} isActive - Whether this is the active player
|
||||||
|
*/
|
||||||
|
function createEnhancedPlayerPanel(player, quadrant, isActive) {
|
||||||
|
var panel = document.createElement('div');
|
||||||
|
panel.className = 'enhanced-player-panel' +
|
||||||
|
(isActive ? ' active-turn' : '') +
|
||||||
|
(player.eliminated ? ' eliminated' : '');
|
||||||
|
panel.id = 'enhanced-panel-' + player.id;
|
||||||
|
panel.setAttribute('data-player-id', player.id);
|
||||||
|
|
||||||
|
// Track collapse state
|
||||||
|
var collapseKey = 'panel-collapsed-' + player.id;
|
||||||
|
var isCollapsed = localStorage.getItem(collapseKey) === 'true';
|
||||||
|
|
||||||
|
// ── Header Row ──────────────────────────────────────────
|
||||||
|
var headerHtml =
|
||||||
|
'<div class="epp-header" onclick="togglePlayerPanel(' + player.id + ')">' +
|
||||||
|
'<span class="epp-collapse-arrow">' + (isCollapsed ? '\u25b8' : '\u25be') + '</span>' +
|
||||||
|
'<div class="epp-name-dot" style="background:' + player.color + '"></div>' +
|
||||||
|
'<span class="epp-name">' + player.name + '</span>' +
|
||||||
|
(player.isAI ? '<span class="epp-ai-badge">AI</span>' : '') +
|
||||||
|
'<span class="epp-life-badge">' +
|
||||||
|
'\u2665 ' + player.life +
|
||||||
|
(player.life < player.startingLife
|
||||||
|
? ' / ' + player.startingLife
|
||||||
|
: '') +
|
||||||
|
'</span>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// ── Life Counter Row ────────────────────────────────────
|
||||||
|
var lifeHtml =
|
||||||
|
'<div class="epp-life-row">' +
|
||||||
|
'<button class="epp-life-btn minus" onclick="adjustLife(' + player.id + ',-1)" ' +
|
||||||
|
'oncontextmenu="event.preventDefault();adjustLife(' + player.id + ',-5)" ' +
|
||||||
|
'title="Left: -1 | Right: -5">\u2212</button>' +
|
||||||
|
'<div class="epp-life-value" id="epp-life-' + player.id + '" ' +
|
||||||
|
'onclick="showSetLifeDialog(' + player.id + ')">' +
|
||||||
|
player.life +
|
||||||
|
'</div>' +
|
||||||
|
'<button class="epp-life-btn plus" onclick="adjustLife(' + player.id + ',1)" ' +
|
||||||
|
'oncontextmenu="event.preventDefault();adjustLife(' + player.id + ',5)" ' +
|
||||||
|
'title="Left: +1 | Right: +5">+</button>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// ── Commander Damage Section ────────────────────────────
|
||||||
|
var otherPlayers = gameState.players.filter(function(p) {
|
||||||
|
return p.id !== player.id && !p.eliminated;
|
||||||
|
});
|
||||||
|
|
||||||
|
var cmdDmgHtml =
|
||||||
|
'<div class="epp-section">' +
|
||||||
|
'<div class="epp-section-label">Commander Damage</div>' +
|
||||||
|
'<div class="epp-cmd-dmg-grid">';
|
||||||
|
|
||||||
|
otherPlayers.forEach(function(op) {
|
||||||
|
var dmg = player.commanderDamage[op.id] || 0;
|
||||||
|
var pct = Math.round((dmg / 21) * 100);
|
||||||
|
var dangerClass = dmg >= 18 ? 'critical' : dmg >= 10 ? 'warning' : '';
|
||||||
|
|
||||||
|
cmdDmgHtml +=
|
||||||
|
'<div class="epp-cmd-dmg-item ' + dangerClass + '">' +
|
||||||
|
'<div class="epp-cmd-dmg-bar" style="width:' + pct + '%"></div>' +
|
||||||
|
'<div class="epp-cmd-dmg-dot" style="background:' + op.color + '"></div>' +
|
||||||
|
'<span class="epp-cmd-dmg-name">' + op.name + '</span>' +
|
||||||
|
'<span class="epp-cmd-dmg-value">' + dmg + '/21</span>' +
|
||||||
|
'<button class="epp-counter-btn" ' +
|
||||||
|
'onclick="event.stopPropagation();adjustCommanderDamage(' + player.id + ',' + op.id + ',1)" ' +
|
||||||
|
'title="Add commander damage">+</button>' +
|
||||||
|
'<button class="epp-counter-btn" ' +
|
||||||
|
'onclick="event.stopPropagation();adjustCommanderDamage(' + player.id + ',' + op.id + ',-1)" ' +
|
||||||
|
'title="Remove commander damage">\u2212</button>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
cmdDmgHtml += '</div></div>';
|
||||||
|
|
||||||
|
// ── Counters Section ────────────────────────────────────
|
||||||
|
var poison = player.poison || 0;
|
||||||
|
var energy = player.energy || 0;
|
||||||
|
var experience = player.experience || 0;
|
||||||
|
var rad = player.rad || 0;
|
||||||
|
|
||||||
|
var countersHtml =
|
||||||
|
'<div class="epp-section">' +
|
||||||
|
'<div class="epp-section-label">Counters</div>' +
|
||||||
|
'<div class="epp-counters-grid">' +
|
||||||
|
_buildCounterWidget('Poison', '\u2620', poison, 10,
|
||||||
|
'adjustPoison(' + player.id + ',1)',
|
||||||
|
'adjustPoison(' + player.id + ',-1)',
|
||||||
|
poison >= 8 ? 'critical' : poison >= 5 ? 'warning' : '') +
|
||||||
|
_buildCounterWidget('Energy', '\u269b', energy, 0,
|
||||||
|
'adjustEnergy(' + player.id + ',1)',
|
||||||
|
'adjustEnergy(' + player.id + ',-1)', '') +
|
||||||
|
_buildCounterWidget('Experience', '\u2605', experience, 0,
|
||||||
|
'adjustExperience(' + player.id + ',1)',
|
||||||
|
'adjustExperience(' + player.id + ',-1)', '') +
|
||||||
|
_buildCounterWidget('Rad', '\u2622', rad, 0,
|
||||||
|
'adjustRad(' + player.id + ',1)',
|
||||||
|
'adjustRad(' + player.id + ',-1)', '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// ── Emblems Section ─────────────────────────────────────
|
||||||
|
var emblems = player.emblems || [];
|
||||||
|
var emblemsHtml =
|
||||||
|
'<div class="epp-section">' +
|
||||||
|
'<div class="epp-section-label">Emblems</div>' +
|
||||||
|
'<div class="epp-emblems">';
|
||||||
|
|
||||||
|
if (emblems.length === 0) {
|
||||||
|
emblemsHtml += '<span class="epp-no-emblems">None</span>';
|
||||||
|
} else {
|
||||||
|
emblems.forEach(function(emb, idx) {
|
||||||
|
emblemsHtml +=
|
||||||
|
'<div class="epp-emblem-chip" title="' + emb.name + '">' +
|
||||||
|
'<span class="epp-emblem-name">' + emb.name + '</span>' +
|
||||||
|
'<button class="epp-emblem-remove" ' +
|
||||||
|
'onclick="event.stopPropagation();removeEmblem(' + player.id + ',' + idx + ')">\u00d7</button>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
emblemsHtml +=
|
||||||
|
'<button class="epp-add-emblem-btn" ' +
|
||||||
|
'onclick="event.stopPropagation();showAddEmblemDialog(' + player.id + ')" ' +
|
||||||
|
'title="Add an emblem">+ Emblem</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// ── Assemble Panel ──────────────────────────────────────
|
||||||
|
var bodyHtml =
|
||||||
|
'<div class="epp-body" id="epp-body-' + player.id + '" ' +
|
||||||
|
'style="' + (isCollapsed ? 'display:none;' : '') + '">' +
|
||||||
|
lifeHtml + cmdDmgHtml + countersHtml + emblemsHtml +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
panel.innerHTML = headerHtml + bodyHtml;
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildCounterWidget(label, icon, value, threshold, onInc, onDec, dangerClass) {
|
||||||
|
return '<div class="epp-counter ' + dangerClass + '">' +
|
||||||
|
'<div class="epp-counter-icon">' + icon + '</div>' +
|
||||||
|
'<div class="epp-counter-info">' +
|
||||||
|
'<span class="epp-counter-value">' + value + '</span>' +
|
||||||
|
'<span class="epp-counter-label">' + label + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="epp-counter-btns">' +
|
||||||
|
'<button class="epp-counter-btn" onclick="event.stopPropagation();' + onInc + '">+</button>' +
|
||||||
|
'<button class="epp-counter-btn" onclick="event.stopPropagation();' + onDec + '">\u2212</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlayerPanel(playerId) {
|
||||||
|
var body = document.getElementById('epp-body-' + playerId);
|
||||||
|
var panel = document.getElementById('enhanced-panel-' + playerId);
|
||||||
|
if (!body || !panel) return;
|
||||||
|
|
||||||
|
var isCollapsed = body.style.display === 'none';
|
||||||
|
body.style.display = isCollapsed ? '' : 'none';
|
||||||
|
|
||||||
|
var arrow = panel.querySelector('.epp-collapse-arrow');
|
||||||
|
if (arrow) arrow.textContent = isCollapsed ? '\u25be' : '\u25b8';
|
||||||
|
|
||||||
|
localStorage.setItem('panel-collapsed-' + playerId, !isCollapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counter adjustment functions
|
||||||
|
function adjustEnergy(playerId, amount) {
|
||||||
|
if (!gameState) return;
|
||||||
|
var player = gameState.players[playerId];
|
||||||
|
if (!player) return;
|
||||||
|
player.energy = Math.max(0, (player.energy || 0) + amount);
|
||||||
|
addLogEntry('<strong>' + player.name + '</strong> energy: ' + player.energy);
|
||||||
|
renderEnhancedPanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustExperience(playerId, amount) {
|
||||||
|
if (!gameState) return;
|
||||||
|
var player = gameState.players[playerId];
|
||||||
|
if (!player) return;
|
||||||
|
player.experience = Math.max(0, (player.experience || 0) + amount);
|
||||||
|
addLogEntry('<strong>' + player.name + '</strong> experience: ' + player.experience);
|
||||||
|
renderEnhancedPanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustRad(playerId, amount) {
|
||||||
|
if (!gameState) return;
|
||||||
|
var player = gameState.players[playerId];
|
||||||
|
if (!player) return;
|
||||||
|
player.rad = Math.max(0, (player.rad || 0) + amount);
|
||||||
|
addLogEntry('<strong>' + player.name + '</strong> rad counters: ' + player.rad);
|
||||||
|
renderEnhancedPanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddEmblemDialog(playerId) {
|
||||||
|
var name = prompt('Enter emblem name (e.g., "Sorin, Vengeful Bloodlord"):');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
if (!gameState) return;
|
||||||
|
var player = gameState.players[playerId];
|
||||||
|
if (!player) return;
|
||||||
|
if (!player.emblems) player.emblems = [];
|
||||||
|
player.emblems.push({ name: name.trim() });
|
||||||
|
addLogEntry('<strong>' + player.name + '</strong> gained emblem: ' + name.trim());
|
||||||
|
renderEnhancedPanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEmblem(playerId, idx) {
|
||||||
|
if (!gameState) return;
|
||||||
|
var player = gameState.players[playerId];
|
||||||
|
if (!player || !player.emblems) return;
|
||||||
|
var removed = player.emblems.splice(idx, 1);
|
||||||
|
if (removed.length > 0) {
|
||||||
|
addLogEntry('<strong>' + player.name + '</strong> lost emblem: ' + removed[0].name);
|
||||||
|
}
|
||||||
|
renderEnhancedPanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
// index: 0 1 2 3 → area: 1 2 4 3
|
||||||
|
// players[2] → bottom-RIGHT (area-4), players[3] → bottom-LEFT (area-3)
|
||||||
|
// Keeps turn order P1→P2→P3→P4 visually clockwise.
|
||||||
|
var PLAYER_INDEX_TO_QUADRANT = [1, 2, 4, 3];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-render all enhanced player panels in their quadrant positions.
|
||||||
|
*/
|
||||||
|
function renderEnhancedPanels() {
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
for (var i = 0; i < gameState.players.length; i++) {
|
||||||
|
var player = gameState.players[i];
|
||||||
|
var quadrant = PLAYER_INDEX_TO_QUADRANT[i] || (i + 1);
|
||||||
|
var areaEl = document.getElementById('player-area-' + quadrant);
|
||||||
|
if (!areaEl) continue;
|
||||||
|
|
||||||
|
// Find or create the enhanced panel container
|
||||||
|
var container = areaEl.querySelector('.epp-container');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'epp-container';
|
||||||
|
// Insert before the zone row
|
||||||
|
var zoneRow = areaEl.querySelector('.zone-row');
|
||||||
|
if (zoneRow) {
|
||||||
|
areaEl.insertBefore(container, zoneRow);
|
||||||
|
} else {
|
||||||
|
areaEl.appendChild(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isActive = gameState.currentPlayerIndex === i;
|
||||||
|
var panel = createEnhancedPlayerPanel(player, quadrant, isActive);
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(panel);
|
||||||
|
|
||||||
|
// Hide the old life counter (we have our own now)
|
||||||
|
var oldLifeCounter = areaEl.querySelector('.life-counter');
|
||||||
|
if (oldLifeCounter) oldLifeCounter.style.display = 'none';
|
||||||
|
|
||||||
|
// Hide old poison badge (we have counters section)
|
||||||
|
var oldPoison = areaEl.querySelector('.poison-badge');
|
||||||
|
if (oldPoison) oldPoison.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
/**
|
||||||
|
* precon-loader.js
|
||||||
|
* Handles loading precon .dck files from the backend into the lan-client simulator.
|
||||||
|
* Depends on: engine.js (gameState, initGame, addLogEntry, renderPlayerPanels)
|
||||||
|
* ai-bridge.js (AIBridge.deckSource.fetchPreconDeck)
|
||||||
|
* ui.js (setupDeckChoices, setupPlayerCount, setupStartingLife)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Scryfall collection endpoint — accepts up to 75 card identifiers per call
|
||||||
|
const SCRYFALL_COLLECTION_URL = 'https://api.scryfall.com/cards/collection';
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Scryfall image cache: cardName -> { imageUrl, typeLine, ... }
|
||||||
|
// Persisted to localStorage so repeat games don't re-hit Scryfall.
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
const _SCRYFALL_CACHE_KEY = 'commanderLab.scryfallCache.v1';
|
||||||
|
const _SCRYFALL_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
const _SCRYFALL_CACHE_MAX_ENTRIES = 20000;
|
||||||
|
|
||||||
|
let _scryfallCache = {};
|
||||||
|
(function _loadScryfallCacheFromLocalStorage() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(_SCRYFALL_CACHE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object') return;
|
||||||
|
const now = Date.now();
|
||||||
|
const stamp = parsed.__stamp || 0;
|
||||||
|
if (now - stamp > _SCRYFALL_CACHE_TTL_MS) {
|
||||||
|
console.log('[scryfall-cache] expired (> 30 days), clearing');
|
||||||
|
localStorage.removeItem(_SCRYFALL_CACHE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete parsed.__stamp;
|
||||||
|
_scryfallCache = parsed;
|
||||||
|
const n = Object.keys(_scryfallCache).length;
|
||||||
|
console.log('[scryfall-cache] loaded ' + n + ' entries from localStorage');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[scryfall-cache] load failed:', e);
|
||||||
|
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
|
||||||
|
_scryfallCache = {};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
let _scryfallCacheSaveTimer = null;
|
||||||
|
function _persistScryfallCache() {
|
||||||
|
// Debounced — coalesce rapid writes during batch enrichment
|
||||||
|
if (_scryfallCacheSaveTimer) return;
|
||||||
|
_scryfallCacheSaveTimer = setTimeout(function () {
|
||||||
|
_scryfallCacheSaveTimer = null;
|
||||||
|
try {
|
||||||
|
// Trim to MAX_ENTRIES if over (drop entries with no imageUrl first)
|
||||||
|
const keys = Object.keys(_scryfallCache);
|
||||||
|
if (keys.length > _SCRYFALL_CACHE_MAX_ENTRIES) {
|
||||||
|
const trimmed = {};
|
||||||
|
const sorted = keys.sort((a, b) =>
|
||||||
|
(_scryfallCache[b].imageUrl ? 1 : 0) - (_scryfallCache[a].imageUrl ? 1 : 0)
|
||||||
|
).slice(0, _SCRYFALL_CACHE_MAX_ENTRIES);
|
||||||
|
for (const k of sorted) trimmed[k] = _scryfallCache[k];
|
||||||
|
_scryfallCache = trimmed;
|
||||||
|
}
|
||||||
|
const payload = Object.assign({ __stamp: Date.now() }, _scryfallCache);
|
||||||
|
localStorage.setItem(_SCRYFALL_CACHE_KEY, JSON.stringify(payload));
|
||||||
|
} catch (e) {
|
||||||
|
// Quota exceeded — clear and skip. Cache will rebuild next session.
|
||||||
|
console.warn('[scryfall-cache] persist failed (quota?):', e);
|
||||||
|
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
|
||||||
|
}
|
||||||
|
}, 750);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScryfallCache() {
|
||||||
|
_scryfallCache = {};
|
||||||
|
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
|
||||||
|
console.log('[scryfall-cache] cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// startGameWithDecks
|
||||||
|
// Called by ui.js startGame() when players have selected precon decks.
|
||||||
|
// ============================================================
|
||||||
|
async function startGameWithDecks() {
|
||||||
|
const playerNames = [];
|
||||||
|
for (let i = 0; i < (setupPlayerCount || 4); i++) {
|
||||||
|
const inp = document.getElementById('player-name-' + i);
|
||||||
|
playerNames.push(inp && inp.value.trim() ? inp.value.trim() : 'Player ' + (i + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
initGame(setupPlayerCount || 4, playerNames, setupStartingLife || 40);
|
||||||
|
|
||||||
|
const choices = typeof setupDeckChoices !== 'undefined' ? setupDeckChoices : {};
|
||||||
|
const loadPromises = Object.entries(choices).map(async ([idxStr, choice]) => {
|
||||||
|
const playerIdx = parseInt(idxStr, 10);
|
||||||
|
if (choice.type !== 'precon' || !choice.preconId) return;
|
||||||
|
try {
|
||||||
|
const fileName = choice.preconId.endsWith('.dck') ? choice.preconId : choice.preconId + '.dck';
|
||||||
|
const data = await AIBridge.deckSource.fetchPreconDeck(fileName);
|
||||||
|
if (!data || !data.decklist) {
|
||||||
|
addLogEntry('\u26A0 Could not load deck for Player ' + (playerIdx + 1) + ' (no decklist returned)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadPreconDeckFromDck(playerIdx, data.decklist, choice.preconId);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[precon-loader] Failed to load deck for player', playerIdx, e);
|
||||||
|
addLogEntry('\u26A0 Deck load failed for Player ' + (playerIdx + 1) + ': ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(loadPromises);
|
||||||
|
|
||||||
|
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
|
||||||
|
if (typeof updateActivePlayerHighlight === 'function') updateActivePlayerHighlight();
|
||||||
|
|
||||||
|
// Block on Scryfall enrichment BEFORE the game becomes interactive. This is
|
||||||
|
// critical: the opening 7 cards are dealt via player.zones.hand.push(lib.shift())
|
||||||
|
// directly (bypassing drawCard()), so they don't get _enrichCardFromCache'd at
|
||||||
|
// draw time. Without awaiting here, the AI's first turn fires with empty
|
||||||
|
// typeLine/cmc/oracleText/isRamp/isRemoval flags and can only play lands.
|
||||||
|
addLogEntry('\u23F3 Loading card data from Scryfall...');
|
||||||
|
try {
|
||||||
|
await enrichAllZonesWithScryfall();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[precon-loader] Enrichment failed, continuing anyway:', e);
|
||||||
|
}
|
||||||
|
addLogEntry('\u2705 Card data loaded. Game ready.');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// loadPreconDeckFromDck
|
||||||
|
// Parses a Forge-format .dck file string and populates a player's zones.
|
||||||
|
// ============================================================
|
||||||
|
function loadPreconDeckFromDck(playerIdx, dckContent, deckLabel) {
|
||||||
|
if (!gameState) return;
|
||||||
|
const player = gameState.players[playerIdx];
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
let commander = '';
|
||||||
|
let section = '';
|
||||||
|
const mainCards = [];
|
||||||
|
|
||||||
|
for (const rawLine of dckContent.split('\n')) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('//')) continue;
|
||||||
|
|
||||||
|
if (line.startsWith('[') && line.endsWith(']')) {
|
||||||
|
section = line.slice(1, -1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.includes('=') && !line.match(/^\d/)) continue;
|
||||||
|
|
||||||
|
const m = line.match(/^(\d+)\s+(.+?)(?:\|.*)?$/);
|
||||||
|
if (!m) continue;
|
||||||
|
|
||||||
|
const qty = parseInt(m[1], 10);
|
||||||
|
const name = m[2].trim();
|
||||||
|
|
||||||
|
if (section === 'Commander') {
|
||||||
|
commander = name;
|
||||||
|
} else if (section === 'Main') {
|
||||||
|
for (let q = 0; q < qty; q++) mainCards.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
player.zones.command = [];
|
||||||
|
player.zones.library = [];
|
||||||
|
player.zones.hand = [];
|
||||||
|
player.zones.graveyard = [];
|
||||||
|
player.zones.exile = [];
|
||||||
|
|
||||||
|
if (commander) {
|
||||||
|
player.zones.command.push({
|
||||||
|
id: 'cmd-' + playerIdx + '-' + Date.now(),
|
||||||
|
name: commander,
|
||||||
|
imageUrl: '',
|
||||||
|
typeLine: 'Legendary Creature',
|
||||||
|
oracleText: '',
|
||||||
|
manaCost: '',
|
||||||
|
});
|
||||||
|
player.commanderCardName = commander;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cardName of mainCards) {
|
||||||
|
player.zones.library.push({
|
||||||
|
id: 'lib-' + playerIdx + '-' + cardName + '-' + Math.random().toString(36).slice(2, 6),
|
||||||
|
name: cardName,
|
||||||
|
imageUrl: '',
|
||||||
|
typeLine: '',
|
||||||
|
oracleText: '',
|
||||||
|
manaCost: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fisher-Yates shuffle
|
||||||
|
const lib = player.zones.library;
|
||||||
|
for (let i = lib.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[lib[i], lib[j]] = [lib[j], lib[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw opening hand
|
||||||
|
for (let d = 0; d < 7 && lib.length > 0; d++) {
|
||||||
|
player.zones.hand.push(lib.shift());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof updateZoneCountsForPlayer === 'function') updateZoneCountsForPlayer(playerIdx);
|
||||||
|
if (typeof renderCommanderZone === 'function') renderCommanderZone(playerIdx);
|
||||||
|
if (typeof renderHandZone === 'function') renderHandZone(playerIdx);
|
||||||
|
|
||||||
|
const label = deckLabel ? deckLabel.replace('.dck', '') : 'Unknown Deck';
|
||||||
|
addLogEntry(
|
||||||
|
'\uD83C\uDCCF <strong>' + player.name + '</strong> loaded: <em>' + label + '</em>' +
|
||||||
|
(commander ? ' \u2014 Commander: <strong>' + commander + '</strong>' : '') +
|
||||||
|
' (' + lib.length + ' in library, 7 in hand)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SCRYFALL IMAGE ENRICHMENT
|
||||||
|
// After zones are populated with card names, batch-fetch card
|
||||||
|
// data from Scryfall and backfill imageUrl on every card object.
|
||||||
|
// Uses the /cards/collection endpoint (75 cards per request).
|
||||||
|
// Results are cached in _scryfallCache so re-renders are instant.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function enrichAllZonesWithScryfall() {
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
// Gather all unique card names across all players and all zones
|
||||||
|
const nameSet = new Set();
|
||||||
|
let totalCards = 0;
|
||||||
|
let cacheHits = 0;
|
||||||
|
for (const player of gameState.players) {
|
||||||
|
for (const zone of Object.values(player.zones)) {
|
||||||
|
for (const card of zone) {
|
||||||
|
if (!card.name) continue;
|
||||||
|
totalCards++;
|
||||||
|
if (_scryfallCache[card.name]) {
|
||||||
|
cacheHits++;
|
||||||
|
} else {
|
||||||
|
nameSet.add(card.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameSet.size === 0) {
|
||||||
|
// Everything was in cache — just apply and bail, no network
|
||||||
|
addLogEntry('\u2705 Card images loaded from cache (' + cacheHits + ' cards).');
|
||||||
|
_applyScryfallDataToAllCards();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = Array.from(nameSet);
|
||||||
|
const hitRatio = totalCards > 0 ? Math.round((cacheHits / totalCards) * 100) : 0;
|
||||||
|
addLogEntry('\uD83C\uDF0D Fetching ' + names.length + ' new cards from Scryfall (' + hitRatio + '% cache hit rate)...');
|
||||||
|
|
||||||
|
// Split into batches of 75 (Scryfall max)
|
||||||
|
const BATCH = 75;
|
||||||
|
for (let i = 0; i < names.length; i += BATCH) {
|
||||||
|
const batch = names.slice(i, i + BATCH);
|
||||||
|
try {
|
||||||
|
await _fetchScryfallBatch(batch);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[precon-loader] Scryfall batch failed', e);
|
||||||
|
}
|
||||||
|
// Polite delay between batches to respect Scryfall rate limit (50-100ms)
|
||||||
|
if (i + BATCH < names.length) await _sleep(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply cached data to every card object in every zone
|
||||||
|
_applyScryfallDataToAllCards();
|
||||||
|
|
||||||
|
addLogEntry('\u2705 Card images loaded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchScryfallBatch(names) {
|
||||||
|
const identifiers = names.map(name => ({ name }));
|
||||||
|
const resp = await fetch(SCRYFALL_COLLECTION_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ identifiers }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Scryfall HTTP ' + resp.status);
|
||||||
|
const json = await resp.json();
|
||||||
|
|
||||||
|
for (const card of (json.data || [])) {
|
||||||
|
const name = card.name;
|
||||||
|
// Pick the best image: normal > large > small, handle double-faced
|
||||||
|
let imageUrl = '';
|
||||||
|
if (card.image_uris) {
|
||||||
|
imageUrl = card.image_uris.normal || card.image_uris.large || card.image_uris.small || '';
|
||||||
|
} else if (card.card_faces && card.card_faces[0] && card.card_faces[0].image_uris) {
|
||||||
|
imageUrl = card.card_faces[0].image_uris.normal || card.card_faces[0].image_uris.large || '';
|
||||||
|
}
|
||||||
|
_scryfallCache[name] = {
|
||||||
|
imageUrl,
|
||||||
|
typeLine: card.type_line || '',
|
||||||
|
oracleText: card.oracle_text || (card.card_faces && card.card_faces[0] ? card.card_faces[0].oracle_text : '') || '',
|
||||||
|
manaCost: card.mana_cost || (card.card_faces && card.card_faces[0] ? card.card_faces[0].mana_cost : '') || '',
|
||||||
|
cmc: card.cmc || 0,
|
||||||
|
power: card.power || '',
|
||||||
|
toughness: card.toughness || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark not-found cards so we don't re-fetch them on next enrichment
|
||||||
|
for (const name of names) {
|
||||||
|
if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', cmc: 0, power: '', toughness: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistScryfallCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _applyScryfallDataToAllCards() {
|
||||||
|
if (!gameState) return;
|
||||||
|
for (const player of gameState.players) {
|
||||||
|
for (const zone of Object.values(player.zones)) {
|
||||||
|
for (const card of zone) {
|
||||||
|
const data = _scryfallCache[card.name];
|
||||||
|
if (data) {
|
||||||
|
card.imageUrl = data.imageUrl || card.imageUrl;
|
||||||
|
card.typeLine = data.typeLine || card.typeLine;
|
||||||
|
card.oracleText = data.oracleText || card.oracleText;
|
||||||
|
card.manaCost = data.manaCost || card.manaCost;
|
||||||
|
card.cmc = data.cmc || card.cmc || 0;
|
||||||
|
if (data.power) card.power = data.power;
|
||||||
|
if (data.toughness) card.toughness = data.toughness;
|
||||||
|
|
||||||
|
// Tag roles from oracle text so aiCategorizeHand works
|
||||||
|
const ot = (card.oracleText || '').toLowerCase();
|
||||||
|
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
|
||||||
|
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
|
||||||
|
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also enrich battlefield cards owned by this player
|
||||||
|
if (gameState.battlefieldCards) {
|
||||||
|
for (const card of gameState.battlefieldCards) {
|
||||||
|
if (card.ownerIndex !== player.id) continue;
|
||||||
|
const data = _scryfallCache[card.name];
|
||||||
|
if (data) {
|
||||||
|
if (!card.imageUrl) card.imageUrl = data.imageUrl || '';
|
||||||
|
card.typeLine = data.typeLine || card.typeLine;
|
||||||
|
card.oracleText = data.oracleText || card.oracleText;
|
||||||
|
card.manaCost = data.manaCost || card.manaCost;
|
||||||
|
card.cmc = data.cmc || card.cmc || 0;
|
||||||
|
|
||||||
|
const ot = (card.oracleText || '').toLowerCase();
|
||||||
|
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
|
||||||
|
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
|
||||||
|
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger UI refresh so images appear without a full reload
|
||||||
|
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
|
||||||
|
if (typeof renderCommanderZone === 'function') {
|
||||||
|
for (let i = 0; i < gameState.players.length; i++) renderCommanderZone(i);
|
||||||
|
}
|
||||||
|
if (typeof renderHandZone === 'function') {
|
||||||
|
for (let i = 0; i < gameState.players.length; i++) renderHandZone(i);
|
||||||
|
}
|
||||||
|
if (typeof updateZoneCounts === 'function') updateZoneCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously enrich a single card from the Scryfall cache.
|
||||||
|
* Called at draw time so cards have typeLine/cmc/oracle data
|
||||||
|
* before the AI's first turn, even if async enrichment hasn't completed.
|
||||||
|
*/
|
||||||
|
function _enrichCardFromCache(card) {
|
||||||
|
if (!card || !card.name) return;
|
||||||
|
const data = _scryfallCache[card.name];
|
||||||
|
if (!data) return;
|
||||||
|
card.imageUrl = data.imageUrl || card.imageUrl || '';
|
||||||
|
card.typeLine = data.typeLine || card.typeLine || '';
|
||||||
|
card.oracleText = data.oracleText || card.oracleText || '';
|
||||||
|
card.manaCost = data.manaCost || card.manaCost || '';
|
||||||
|
card.cmc = data.cmc || card.cmc || 0;
|
||||||
|
if (data.power) card.power = data.power;
|
||||||
|
if (data.toughness) card.toughness = data.toughness;
|
||||||
|
|
||||||
|
// Tag oracle roles
|
||||||
|
const ot = (card.oracleText || '').toLowerCase();
|
||||||
|
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
|
||||||
|
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
|
||||||
|
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich a single card object on-demand (useful for cards drawn or played after initial load).
|
||||||
|
* Returns a Promise that resolves when the card's imageUrl is set.
|
||||||
|
*/
|
||||||
|
async function enrichCardWithScryfall(card) {
|
||||||
|
if (!card || !card.name) return;
|
||||||
|
if (card.imageUrl) return; // already has image
|
||||||
|
|
||||||
|
const cached = _scryfallCache[card.name];
|
||||||
|
if (cached) {
|
||||||
|
card.imageUrl = cached.imageUrl || '';
|
||||||
|
card.typeLine = cached.typeLine || card.typeLine;
|
||||||
|
card.oracleText = cached.oracleText || card.oracleText;
|
||||||
|
card.manaCost = cached.manaCost || card.manaCost;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _fetchScryfallBatch([card.name]);
|
||||||
|
const data = _scryfallCache[card.name];
|
||||||
|
if (data) {
|
||||||
|
card.imageUrl = data.imageUrl || '';
|
||||||
|
card.typeLine = data.typeLine || card.typeLine;
|
||||||
|
card.oracleText = data.oracleText || card.oracleText;
|
||||||
|
card.manaCost = data.manaCost || card.manaCost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Auto-generated from precon-decks/precon-index.json
|
||||||
|
// Run: node scripts/generate-precons-js.js to regenerate
|
||||||
|
var PRECON_DATA = [
|
||||||
|
{"id":"Counterpunch","name":"Counterpunch","commander":"Ghave, Guru of Spores","set":"Commander 2011","fileName":"Counterpunch.dck","colors":[],"year":2011},
|
||||||
|
{"id":"Devour_for_Power_CMD","name":"Devour for Power","commander":"The Mimeoplasm","set":"Commander 2011","fileName":"Devour_for_Power_CMD.dck","colors":[],"year":2011},
|
||||||
|
{"id":"Heavenly_Inferno_CMD","name":"Heavenly Inferno","commander":"Kaalia of the Vast","set":"Commander 2011","fileName":"Heavenly_Inferno_CMD.dck","colors":[],"year":2011},
|
||||||
|
{"id":"Mirror_Mastery","name":"Mirror Mastery","commander":"Riku of Two Reflections","set":"Commander 2011","fileName":"Mirror_Mastery.dck","colors":[],"year":2011},
|
||||||
|
{"id":"Political_Puppets","name":"Political Puppets","commander":"Zedruu the Greathearted","set":"Commander 2011","fileName":"Political_Puppets.dck","colors":[],"year":2011},
|
||||||
|
{"id":"Eternal_Bargain","name":"Eternal Bargain","commander":"Oloro, Ageless Ascetic","set":"Commander 2013","fileName":"Eternal_Bargain.dck","colors":[],"year":2013},
|
||||||
|
{"id":"Evasive_Maneuvers_C13","name":"Evasive Maneuvers","commander":"Derevi, Empyrial Tactician","set":"Commander 2013","fileName":"Evasive_Maneuvers_C13.dck","colors":[],"year":2013},
|
||||||
|
{"id":"Mind_Seize","name":"Mind Seize","commander":"Jeleva, Nephalia's Scourge","set":"Commander 2013","fileName":"Mind_Seize.dck","colors":[],"year":2013},
|
||||||
|
{"id":"Nature_of_the_Beast","name":"Nature of the Beast","commander":"Marath, Will of the Wild","set":"Commander 2013","fileName":"Nature_of_the_Beast.dck","colors":[],"year":2013},
|
||||||
|
{"id":"Power_Hungry","name":"Power Hungry","commander":"Prossh, Skyraider of Kher","set":"Commander 2013","fileName":"Power_Hungry.dck","colors":[],"year":2013},
|
||||||
|
{"id":"Built_From_Scratch_C14","name":"Built From Scratch","commander":"Daretti, Scrap Savant","set":"Commander 2014","fileName":"Built_From_Scratch_C14.dck","colors":[],"year":2014},
|
||||||
|
{"id":"Forged_In_Stone","name":"Forged In Stone","commander":"Nahiri, the Lithomancer","set":"Commander 2014","fileName":"Forged_In_Stone.dck","colors":[],"year":2014},
|
||||||
|
{"id":"Guided_By_Nature","name":"Guided By Nature","commander":"Freyalise, Llanowar's Fury","set":"Commander 2014","fileName":"Guided_By_Nature.dck","colors":[],"year":2014},
|
||||||
|
{"id":"Peer_Through_Time","name":"Peer Through Time","commander":"Teferi, Temporal Archmage","set":"Commander 2014","fileName":"Peer_Through_Time.dck","colors":[],"year":2014},
|
||||||
|
{"id":"Sworn_To_Darkness","name":"Sworn To Darkness","commander":"Ob Nixilis of the Black Oath","set":"Commander 2014","fileName":"Sworn_To_Darkness.dck","colors":[],"year":2014},
|
||||||
|
{"id":"Call_the_Spirits","name":"Call the Spirits","commander":"Daxos the Returned","set":"Commander 2015","fileName":"Call_the_Spirits.dck","colors":[],"year":2015},
|
||||||
|
{"id":"Plunder_the_Graves_C15","name":"Plunder the Graves","commander":"Meren of Clan Nel Toth","set":"Commander 2015","fileName":"Plunder_the_Graves_C15.dck","colors":[],"year":2015},
|
||||||
|
{"id":"Seize_Control","name":"Seize Control","commander":"Mizzix of the Izmagnus","set":"Commander 2015","fileName":"Seize_Control.dck","colors":[],"year":2015},
|
||||||
|
{"id":"Swell_the_Host","name":"Swell the Host","commander":"Ezuri, Claw of Progress","set":"Commander 2015","fileName":"Swell_the_Host.dck","colors":[],"year":2015},
|
||||||
|
{"id":"Wade_into_Battle_C15","name":"Wade into Battle","commander":"Kalemne, Disciple of Iroas","set":"Commander 2015","fileName":"Wade_into_Battle_C15.dck","colors":[],"year":2015},
|
||||||
|
{"id":"Breed_Lethality_C16","name":"Breed Lethality","commander":"Atraxa, Praetors' Voice","set":"Commander 2016","fileName":"Breed_Lethality_C16.dck","colors":[],"year":2016},
|
||||||
|
{"id":"Entropic_Uprising","name":"Entropic Uprising","commander":"Yidris, Maelstrom Wielder","set":"Commander 2016","fileName":"Entropic_Uprising.dck","colors":[],"year":2016},
|
||||||
|
{"id":"Invent_Superiority","name":"Invent Superiority","commander":"Breya, Etherium Shaper","set":"Commander 2016","fileName":"Invent_Superiority.dck","colors":[],"year":2016},
|
||||||
|
{"id":"Open_Hostility","name":"Open Hostility","commander":"Saskia the Unyielding","set":"Commander 2016","fileName":"Open_Hostility.dck","colors":[],"year":2016},
|
||||||
|
{"id":"Stalwart_Unity","name":"Stalwart Unity","commander":"Kynaios and Tiro of Meletis","set":"Commander 2016","fileName":"Stalwart_Unity.dck","colors":[],"year":2016},
|
||||||
|
{"id":"Evasive_Maneuvers_CMA","name":"Evasive Maneuvers","commander":"Derevi, Empyrial Tactician","set":"Commander Anthology","fileName":"Evasive_Maneuvers_CMA.dck","colors":[],"year":2017},
|
||||||
|
{"id":"Guided_by_Nature","name":"Guided by Nature","commander":"Freyalise, Llanowar's Fury","set":"Commander Anthology","fileName":"Guided_by_Nature.dck","colors":[],"year":2017},
|
||||||
|
{"id":"Heavenly_Inferno_CMA","name":"Heavenly Inferno","commander":"Kaalia of the Vast","set":"Commander Anthology","fileName":"Heavenly_Inferno_CMA.dck","colors":[],"year":2017},
|
||||||
|
{"id":"Plunder_the_Graves_CMA","name":"Plunder the Graves","commander":"Meren of Clan Nel Toth","set":"Commander Anthology","fileName":"Plunder_the_Graves_CMA.dck","colors":[],"year":2017},
|
||||||
|
{"id":"Arcane_Wizardry","name":"Arcane Wizardry","commander":"Inalla, Archmage Ritualist","set":"Commander 2017","fileName":"Arcane_Wizardry.dck","colors":[],"year":2017},
|
||||||
|
{"id":"Draconic_Domination","name":"Draconic Domination","commander":"The Ur-Dragon","set":"Commander 2017","fileName":"Draconic_Domination.dck","colors":[],"year":2017},
|
||||||
|
{"id":"Feline_Ferocity","name":"Feline Ferocity","commander":"Arahbo, Roar of the World","set":"Commander 2017","fileName":"Feline_Ferocity.dck","colors":[],"year":2017},
|
||||||
|
{"id":"Vampiric_Bloodlust","name":"Vampiric Bloodlust","commander":"Edgar Markov","set":"Commander 2017","fileName":"Vampiric_Bloodlust.dck","colors":[],"year":2017},
|
||||||
|
{"id":"Breed_Lethality_CM2","name":"Breed Lethality","commander":"Atraxa, Praetors' Voice","set":"Commander Anthology Volume II","fileName":"Breed_Lethality_CM2.dck","colors":[],"year":2018},
|
||||||
|
{"id":"Built_From_Scratch_CM2","name":"Built From Scratch","commander":"Daretti, Scrap Savant","set":"Commander Anthology Volume II","fileName":"Built_From_Scratch_CM2.dck","colors":[],"year":2018},
|
||||||
|
{"id":"Devour_for_Power_CM2","name":"Devour for Power","commander":"The Mimeoplasm","set":"Commander Anthology Volume II","fileName":"Devour_for_Power_CM2.dck","colors":[],"year":2018},
|
||||||
|
{"id":"Wade_into_Battle_CM2","name":"Wade into Battle","commander":"Kalemne, Disciple of Iroas","set":"Commander Anthology Volume II","fileName":"Wade_into_Battle_CM2.dck","colors":[],"year":2018},
|
||||||
|
{"id":"Adaptive_Enchantment","name":"Adaptive Enchantment","commander":"Estrid, the Masked","set":"Commander 2018","fileName":"Adaptive_Enchantment.dck","colors":[],"year":2018},
|
||||||
|
{"id":"Exquisite_Invention","name":"Exquisite Invention","commander":"Saheeli, the Gifted","set":"Commander 2018","fileName":"Exquisite_Invention.dck","colors":[],"year":2018},
|
||||||
|
{"id":"Natures_Vengeance","name":"Nature's Vengeance","commander":"Lord Windgrace","set":"Commander 2018","fileName":"Natures_Vengeance.dck","colors":[],"year":2018},
|
||||||
|
{"id":"Subjective_Reality","name":"Subjective Reality","commander":"Aminatou, the Fateshifter","set":"Commander 2018","fileName":"Subjective_Reality.dck","colors":[],"year":2018},
|
||||||
|
{"id":"Faceless_Menace","name":"Faceless Menace","commander":"Kadena, Slinking Sorcerer","set":"Commander 2019","fileName":"Faceless_Menace.dck","colors":[],"year":2019},
|
||||||
|
{"id":"Merciless_Rage","name":"Merciless Rage","commander":"Anje Falkenrath","set":"Commander 2019","fileName":"Merciless_Rage.dck","colors":[],"year":2019},
|
||||||
|
{"id":"Mystic_Intellect","name":"Mystic Intellect","commander":"Sevinne, the Chronoclasm","set":"Commander 2019","fileName":"Mystic_Intellect.dck","colors":[],"year":2019},
|
||||||
|
{"id":"Primal_Genesis","name":"Primal Genesis","commander":"Ghired, Conclave Exile","set":"Commander 2019","fileName":"Primal_Genesis.dck","colors":[],"year":2019},
|
||||||
|
{"id":"Arcane_Maelstrom","name":"Arcane Maelstrom","commander":"Kalamax, the Stormsire","set":"Commander 2020","fileName":"Arcane_Maelstrom.dck","colors":[],"year":2020},
|
||||||
|
{"id":"Enhanced_Evolution","name":"Enhanced Evolution","commander":"Otrimi, the Ever-Playful","set":"Commander 2020","fileName":"Enhanced_Evolution.dck","colors":[],"year":2020},
|
||||||
|
{"id":"Ruthless_Regiment","name":"Ruthless Regiment","commander":"Jirina Kudro","set":"Commander 2020","fileName":"Ruthless_Regiment.dck","colors":[],"year":2020},
|
||||||
|
{"id":"Symbiotic_Swarm","name":"Symbiotic Swarm","commander":"Kathril, Aspect Warper","set":"Commander 2020","fileName":"Symbiotic_Swarm.dck","colors":[],"year":2020},
|
||||||
|
{"id":"Timeless_Wisdom","name":"Timeless Wisdom","commander":"Gavi, Nest Warden","set":"Commander 2020","fileName":"Timeless_Wisdom.dck","colors":[],"year":2020},
|
||||||
|
{"id":"Lands_Wrath","name":"Land's Wrath","commander":"Obuun, Mul Daya Ancestor","set":"Zendikar Rising Commander","fileName":"Lands_Wrath.dck","colors":[],"year":2020},
|
||||||
|
{"id":"Sneak_Attack","name":"Sneak Attack","commander":"Anowon, the Ruin Thief","set":"Zendikar Rising Commander","fileName":"Sneak_Attack.dck","colors":[],"year":2020},
|
||||||
|
{"id":"Arm_for_Battle","name":"Arm for Battle","commander":"Wyleth, Soul of Steel","set":"Commander Legends","fileName":"Arm_for_Battle.dck","colors":[],"year":2020},
|
||||||
|
{"id":"Reap_the_Tides","name":"Reap the Tides","commander":"Aesi, Tyrant of Gyre Strait","set":"Commander Legends","fileName":"Reap_the_Tides.dck","colors":[],"year":2020},
|
||||||
|
{"id":"Elven_Empire","name":"Elven Empire","commander":"Lathril, Blade of the Elves","set":"Kaldheim Commander","fileName":"Elven_Empire.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Phantom_Premonition","name":"Phantom Premonition","commander":"Ranar the Ever-Watchful","set":"Kaldheim Commander","fileName":"Phantom_Premonition.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Lorehold_Legacies","name":"Lorehold Legacies","commander":"Osgir, the Reconstructor","set":"Commander 2021","fileName":"Lorehold_Legacies.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Prismari_Performance","name":"Prismari Performance","commander":"Zaffai, Thunder Conductor","set":"Commander 2021","fileName":"Prismari_Performance.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Quantum_Quandrix","name":"Quantum Quandrix","commander":"Adrix and Nev, Twincasters","set":"Commander 2021","fileName":"Quantum_Quandrix.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Silverquill_Statement","name":"Silverquill Statement","commander":"Breena, the Demagogue","set":"Commander 2021","fileName":"Silverquill_Statement.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Witherbloom_Witchcraft","name":"Witherbloom Witchcraft","commander":"Willowdusk, Essence Seer","set":"Commander 2021","fileName":"Witherbloom_Witchcraft.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Aura_of_Courage","name":"Aura of Courage","commander":"Galea, Kindler of Hope","set":"Forgotten Realms Commander","fileName":"Aura_of_Courage.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Draconic_Rage","name":"Draconic Rage","commander":"Vrondiss, Rage of Ancients","set":"Forgotten Realms Commander","fileName":"Draconic_Rage.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Dungeons_of_Death","name":"Dungeons of Death","commander":"Sefris of the Hidden Ways","set":"Forgotten Realms Commander","fileName":"Dungeons_of_Death.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Planar_Portal","name":"Planar Portal","commander":"Prosper, Tome-Bound","set":"Forgotten Realms Commander","fileName":"Planar_Portal.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Coven_Counters","name":"Coven Counters","commander":"Leinore, Autumn Sovereign","set":"Midnight Hunt Commander","fileName":"Coven_Counters.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Undead_Unleashed","name":"Undead Unleashed","commander":"Wilhelt, the Rotcleaver","set":"Midnight Hunt Commander","fileName":"Undead_Unleashed.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Spirit_Squadron","name":"Spirit Squadron","commander":"Millicent, Restless Revenant","set":"Crimson Vow Commander","fileName":"Spirit_Squadron.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Vampiric_Bloodline","name":"Vampiric Bloodline","commander":"Strefan, Maurer Progenitor","set":"Crimson Vow Commander","fileName":"Vampiric_Bloodline.dck","colors":[],"year":2021},
|
||||||
|
{"id":"Buckle_Up","name":"Buckle Up","commander":"Kotori, Pilot Prodigy","set":"Neon Dynasty Commander","fileName":"Buckle_Up.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Upgrades_Unleashed","name":"Upgrades Unleashed","commander":"Chishiro, the Shattered Blade","set":"Neon Dynasty Commander","fileName":"Upgrades_Unleashed.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Heads_I_Win,_Tails_You_Lose","name":"Heads I Win, Tails You Lose","commander":"Zndrsplt, Eye of Wisdom","set":"Secret Lair Drop","fileName":"Heads_I_Win,_Tails_You_Lose.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Bedecked_Brokers","name":"Bedecked Brokers","commander":"Perrie, the Pulverizer","set":"New Capenna Commander","fileName":"Bedecked_Brokers.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Cabaretti_Cacophony","name":"Cabaretti Cacophony","commander":"Kitt Kanto, Mayhem Diva","set":"New Capenna Commander","fileName":"Cabaretti_Cacophony.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Maestros_Massacre","name":"Maestros Massacre","commander":"Anhelo, the Painter","set":"New Capenna Commander","fileName":"Maestros_Massacre.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Obscura_Operation","name":"Obscura Operation","commander":"Kamiz, Obscura Oculus","set":"New Capenna Commander","fileName":"Obscura_Operation.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Riveteers_Rampage","name":"Riveteers Rampage","commander":"Henzie \"Toolbox\" Torre","set":"New Capenna Commander","fileName":"Riveteers_Rampage.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Draconic_Dissent","name":"Draconic Dissent","commander":"Firkraag, Cunning Instigator","set":"Commander Legends: Battle for Baldur's Gate","fileName":"Draconic_Dissent.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Exit_from_Exile","name":"Exit from Exile","commander":"Faldorn, Dread Wolf Herald","set":"Commander Legends: Battle for Baldur's Gate","fileName":"Exit_from_Exile.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Mind_Flayarrrs","name":"Mind Flayarrrs","commander":"Captain N'ghathrod","set":"Commander Legends: Battle for Baldur's Gate","fileName":"Mind_Flayarrrs.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Party_Time","name":"Party Time","commander":"Nalia de'Arnise","set":"Commander Legends: Battle for Baldur's Gate","fileName":"Party_Time.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Legends_Legacy","name":"Legends' Legacy","commander":"Dihada, Binder of Wills","set":"Dominaria United Commander","fileName":"Legends_Legacy.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Painbow","name":"Painbow","commander":"Jared Carthalion","set":"Dominaria United Commander","fileName":"Painbow.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Forces_of_the_Imperium","name":"Forces of the Imperium","commander":"Inquisitor Greyfax","set":"Warhammer 40,000 Commander","fileName":"Forces_of_the_Imperium.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Forces_of_the_Imperium_Collectors_Edition","name":"Forces of the Imperium Collector's Edition","commander":"Inquisitor Greyfax","set":"Warhammer 40,000 Commander","fileName":"Forces_of_the_Imperium_Collectors_Edition.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Necron_Dynasties","name":"Necron Dynasties","commander":"Szarekh, the Silent King","set":"Warhammer 40,000 Commander","fileName":"Necron_Dynasties.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Necron_Dynasties_Collectors_Edition","name":"Necron Dynasties Collector's Edition","commander":"Szarekh, the Silent King","set":"Warhammer 40,000 Commander","fileName":"Necron_Dynasties_Collectors_Edition.dck","colors":[],"year":2022},
|
||||||
|
{"id":"The_Ruinous_Powers","name":"The Ruinous Powers","commander":"Abaddon the Despoiler","set":"Warhammer 40,000 Commander","fileName":"The_Ruinous_Powers.dck","colors":[],"year":2022},
|
||||||
|
{"id":"The_Ruinous_Powers_Collectors_Edition","name":"The Ruinous Powers Collector's Edition","commander":"Abaddon the Despoiler","set":"Warhammer 40,000 Commander","fileName":"The_Ruinous_Powers_Collectors_Edition.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Tyranid_Swarm","name":"Tyranid Swarm","commander":"The Swarmlord","set":"Warhammer 40,000 Commander","fileName":"Tyranid_Swarm.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Tyranid_Swarm_Collectors_Edition","name":"Tyranid Swarm Collector's Edition","commander":"The Swarmlord","set":"Warhammer 40,000 Commander","fileName":"Tyranid_Swarm_Collectors_Edition.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Mishras_Burnished_Banner","name":"Mishra's Burnished Banner","commander":"Mishra, Eminent One","set":"The Brothers' War Commander","fileName":"Mishras_Burnished_Banner.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Urzas_Iron_Alliance","name":"Urza's Iron Alliance","commander":"Urza, Chief Artificer","set":"The Brothers' War Commander","fileName":"Urzas_Iron_Alliance.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Chaos_Incarnate","name":"Chaos Incarnate","commander":"Kardur, Doomscourge","set":"Starter Commander Decks","fileName":"Chaos_Incarnate.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Draconic_Destruction","name":"Draconic Destruction","commander":"Atarka, World Render","set":"Starter Commander Decks","fileName":"Draconic_Destruction.dck","colors":[],"year":2022},
|
||||||
|
{"id":"First_Flight","name":"First Flight","commander":"Isperia, Supreme Judge","set":"Starter Commander Decks","fileName":"First_Flight.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Grave_Danger","name":"Grave Danger","commander":"Gisa and Geralf","set":"Starter Commander Decks","fileName":"Grave_Danger.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Token_Triumph","name":"Token Triumph","commander":"Emmara, Soul of the Accord","set":"Starter Commander Decks","fileName":"Token_Triumph.dck","colors":[],"year":2022},
|
||||||
|
{"id":"Corrupting_Influence","name":"Corrupting Influence","commander":"Ixhel, Scion of Atraxa","set":"Phyrexia: All Will Be One Commander","fileName":"Corrupting_Influence.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Rebellion_Rising","name":"Rebellion Rising","commander":"Neyali, Suns' Vanguard","set":"Phyrexia: All Will Be One Commander","fileName":"Rebellion_Rising.dck","colors":[],"year":2023},
|
||||||
|
{"id":"From_Cute_to_Brute","name":"From Cute to Brute","commander":"Esika, God of the Tree","set":"Secret Lair Drop","fileName":"From_Cute_to_Brute.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Elven_Council","name":"Elven Council","commander":"Galadriel, Elven-Queen","set":"Tales of Middle-earth Commander","fileName":"Elven_Council.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Food_and_Fellowship","name":"Food and Fellowship","commander":"Frodo, Adventurous Hobbit","set":"Tales of Middle-earth Commander","fileName":"Food_and_Fellowship.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Riders_of_Rohan","name":"Riders of Rohan","commander":"Éowyn, Shieldmaiden","set":"Tales of Middle-earth Commander","fileName":"Riders_of_Rohan.dck","colors":[],"year":2023},
|
||||||
|
{"id":"The_Hosts_of_Mordor","name":"The Hosts of Mordor","commander":"Sauron, Lord of the Rings","set":"Tales of Middle-earth Commander","fileName":"The_Hosts_of_Mordor.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Eldrazi_Unbound","name":"Eldrazi Unbound","commander":"Zhulodok, Void Gorger","set":"Commander Masters","fileName":"Eldrazi_Unbound.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Enduring_Enchantments","name":"Enduring Enchantments","commander":"Anikthea, Hand of Erebos","set":"Commander Masters","fileName":"Enduring_Enchantments.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Planeswalker_Party","name":"Planeswalker Party","commander":"Commodore Guff","set":"Commander Masters","fileName":"Planeswalker_Party.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Sliver_Swarm","name":"Sliver Swarm","commander":"Sliver Gravemother","set":"Commander Masters","fileName":"Sliver_Swarm.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Angels_Theyre_Just_Like_Us_but_Cooler_and_with_Wings","name":"Angels: They're Just Like Us but Cooler and with Wings","commander":"Gisela, the Broken Blade","set":"Secret Lair Drop","fileName":"Angels_Theyre_Just_Like_Us_but_Cooler_and_with_Wings.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Fae_Dominion","name":"Fae Dominion","commander":"Tegwyll, Duke of Splendor","set":"Wilds of Eldraine Commander","fileName":"Fae_Dominion.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Virtue_and_Valor","name":"Virtue and Valor","commander":"Ellivere of the Wild Court","set":"Wilds of Eldraine Commander","fileName":"Virtue_and_Valor.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Ahoy_Mateys","name":"Ahoy Mateys","commander":"Admiral Brass, Unsinkable","set":"The Lost Caverns of Ixalan Commander","fileName":"Ahoy_Mateys.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Blood_Rites","name":"Blood Rites","commander":"Clavileño, First of the Blessed","set":"The Lost Caverns of Ixalan Commander","fileName":"Blood_Rites.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Explorers_of_the_Deep","name":"Explorers of the Deep","commander":"Hakbal of the Surging Soul","set":"The Lost Caverns of Ixalan Commander","fileName":"Explorers_of_the_Deep.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Veloci-Ramp-Tor","name":"Veloci-Ramp-Tor","commander":"Pantlaza, Sun-Favored","set":"The Lost Caverns of Ixalan Commander","fileName":"Veloci-Ramp-Tor.dck","colors":[],"year":2023},
|
||||||
|
{"id":"Raining_Cats_and_Dogs","name":"Raining Cats and Dogs","commander":"Rin and Seri, Inseparable","set":"Secret Lair Drop","fileName":"Raining_Cats_and_Dogs.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Blame_Game","name":"Blame Game","commander":"Nelly Borca, Impulsive Accuser","set":"Murders at Karlov Manor Commander","fileName":"Blame_Game.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Deadly_Disguise","name":"Deadly Disguise","commander":"Kaust, Eyes of the Glade","set":"Murders at Karlov Manor Commander","fileName":"Deadly_Disguise.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Deep_Clue_Sea","name":"Deep Clue Sea","commander":"Morska, Undersea Sleuth","set":"Murders at Karlov Manor Commander","fileName":"Deep_Clue_Sea.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Revenant_Recon","name":"Revenant Recon","commander":"Mirko, Obsessive Theorist","set":"Murders at Karlov Manor Commander","fileName":"Revenant_Recon.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Hail,_Caesar","name":"Hail, Caesar","commander":"Caesar, Legion's Emperor","set":"Fallout","fileName":"Hail,_Caesar.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Mutant_Menace","name":"Mutant Menace","commander":"The Wise Mothman","set":"Fallout","fileName":"Mutant_Menace.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Science","name":"Science!","commander":"Dr. Madison Li","set":"Fallout","fileName":"Science.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Scrappy_Survivors","name":"Scrappy Survivors","commander":"Dogmeat, Ever Loyal","set":"Fallout","fileName":"Scrappy_Survivors.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Desert_Bloom","name":"Desert Bloom","commander":"Yuma, Proud Protector","set":"Outlaws of Thunder Junction Commander","fileName":"Desert_Bloom.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Grand_Larceny","name":"Grand Larceny","commander":"Gonti, Canny Acquisitor","set":"Outlaws of Thunder Junction Commander","fileName":"Grand_Larceny.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Most_Wanted","name":"Most Wanted","commander":"Olivia, Opulent Outlaw","set":"Outlaws of Thunder Junction Commander","fileName":"Most_Wanted.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Quick_Draw","name":"Quick Draw","commander":"Stella Lee, Wild Card","set":"Outlaws of Thunder Junction Commander","fileName":"Quick_Draw.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Creative_Energy","name":"Creative Energy","commander":"Satya, Aetherflux Genius","set":"Modern Horizons 3 Commander","fileName":"Creative_Energy.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Creative_Energy_Collectors_Edition","name":"Creative Energy Collector's Edition","commander":"Satya, Aetherflux Genius","set":"Modern Horizons 3 Commander","fileName":"Creative_Energy_Collectors_Edition.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Eldrazi_Incursion","name":"Eldrazi Incursion","commander":"Ulalek, Fused Atrocity","set":"Modern Horizons 3 Commander","fileName":"Eldrazi_Incursion.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Eldrazi_Incursion_Collectors_Edition","name":"Eldrazi Incursion Collector's Edition","commander":"Ulalek, Fused Atrocity","set":"Modern Horizons 3 Commander","fileName":"Eldrazi_Incursion_Collectors_Edition.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Graveyard_Overdrive","name":"Graveyard Overdrive","commander":"Disa the Restless","set":"Modern Horizons 3 Commander","fileName":"Graveyard_Overdrive.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Graveyard_Overdrive_Collectors_Edition","name":"Graveyard Overdrive Collector's Edition","commander":"Disa the Restless","set":"Modern Horizons 3 Commander","fileName":"Graveyard_Overdrive_Collectors_Edition.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Tricky_Terrain","name":"Tricky Terrain","commander":"Omo, Queen of Vesuva","set":"Modern Horizons 3 Commander","fileName":"Tricky_Terrain.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Tricky_Terrain_Collectors_Edition","name":"Tricky Terrain Collector's Edition","commander":"Omo, Queen of Vesuva","set":"Modern Horizons 3 Commander","fileName":"Tricky_Terrain_Collectors_Edition.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Animated_Army","name":"Animated Army","commander":"Bello, Bard of the Brambles","set":"Bloomburrow Commander","fileName":"Animated_Army.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Family_Matters","name":"Family Matters","commander":"Zinnia, Valley's Voice","set":"Bloomburrow Commander","fileName":"Family_Matters.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Peace_Offering","name":"Peace Offering","commander":"Ms. Bumbleflower","set":"Bloomburrow Commander","fileName":"Peace_Offering.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Squirreled_Away","name":"Squirreled Away","commander":"Hazel of the Rootbloom","set":"Bloomburrow Commander","fileName":"Squirreled_Away.dck","colors":[],"year":2024},
|
||||||
|
{"id":"20_Ways_to_Win","name":"20 Ways to Win","commander":"Go-Shintai of Life's Origin","set":"Secret Lair Drop","fileName":"20_Ways_to_Win.dck","colors":[],"year":2024},
|
||||||
|
{"id":"Eternal_Might","name":"Eternal Might","commander":"Temmet, Naktamun's Will","set":"Aetherdrift Commander","fileName":"Eternal_Might.dck","colors":[],"year":2025},
|
||||||
|
{"id":"Living_Energy","name":"Living Energy","commander":"Saheeli, Radiant Creator","set":"Aetherdrift Commander","fileName":"Living_Energy.dck","colors":[],"year":2025},
|
||||||
|
{"id":"Abzan_Armor","name":"Abzan Armor","commander":"Felothar the Steadfast","set":"Tarkir: Dragonstorm Commander","fileName":"Abzan_Armor.dck","colors":[],"year":2025},
|
||||||
|
{"id":"Jeskai_Striker","name":"Jeskai Striker","commander":"Shiko and Narset, Unified","set":"Tarkir: Dragonstorm Commander","fileName":"Jeskai_Striker.dck","colors":[],"year":2025},
|
||||||
|
{"id":"Mardu_Surge","name":"Mardu Surge","commander":"Zurgo Stormrender","set":"Tarkir: Dragonstorm Commander","fileName":"Mardu_Surge.dck","colors":[],"year":2025},
|
||||||
|
{"id":"Sultai_Arisen","name":"Sultai Arisen","commander":"Teval, the Balanced Scale","set":"Tarkir: Dragonstorm Commander","fileName":"Sultai_Arisen.dck","colors":[],"year":2025},
|
||||||
|
{"id":"Temur_Roar","name":"Temur Roar","commander":"Eshki, Temur's Roar","set":"Tarkir: Dragonstorm Commander","fileName":"Temur_Roar.dck","colors":[],"year":2025},
|
||||||
|
{"id":"Everyones_Invited","name":"Everyone's Invited!","commander":"Morophon, the Boundless","set":"Secret Lair Drop","fileName":"Everyones_Invited.dck","colors":[],"year":2025},
|
||||||
|
{"id":"Counter_Blitz_(FINAL_FANTASY_X)","name":"Counter Blitz (FINAL FANTASY X)","commander":"Tidus, Yuna's Guardian","set":"Final Fantasy Commander","fileName":"Counter_Blitz_(FINAL_FANTASY_X).dck","colors":[],"year":2025},
|
||||||
|
{"id":"Counter_Blitz_Collectors_Edition_(FINAL_FANTASY_X)","name":"Counter Blitz Collector's Edition (FINAL FANTASY X)","commander":"Tidus, Yuna's Guardian","set":"Final Fantasy Commander","fileName":"Counter_Blitz_Collectors_Edition_(FINAL_FANTASY_X).dck","colors":[],"year":2025},
|
||||||
|
{"id":"Limit_Break_(FINAL_FANTASY_VII)","name":"Limit Break (FINAL FANTASY VII)","commander":"Cloud, Ex-SOLDIER","set":"Final Fantasy Commander","fileName":"Limit_Break_(FINAL_FANTASY_VII).dck","colors":[],"year":2025},
|
||||||
|
{"id":"Limit_Break_Collectors_Edition_(FINAL_FANTASY_VII)","name":"Limit Break Collector's Edition (FINAL FANTASY VII)","commander":"Cloud, Ex-SOLDIER","set":"Final Fantasy Commander","fileName":"Limit_Break_Collectors_Edition_(FINAL_FANTASY_VII).dck","colors":[],"year":2025},
|
||||||
|
{"id":"Revival_Trance_(FINAL_FANTASY_VI)","name":"Revival Trance (FINAL FANTASY VI)","commander":"Terra, Herald of Hope","set":"Final Fantasy Commander","fileName":"Revival_Trance_(FINAL_FANTASY_VI).dck","colors":[],"year":2025},
|
||||||
|
{"id":"Revival_Trance_Collectors_Edition_(FINAL_FANTASY_VI)","name":"Revival Trance Collector's Edition (FINAL FANTASY VI)","commander":"Terra, Herald of Hope","set":"Final Fantasy Commander","fileName":"Revival_Trance_Collectors_Edition_(FINAL_FANTASY_VI).dck","colors":[],"year":2025},
|
||||||
|
{"id":"Scions_&_Spellcraft_(FINAL_FANTASY_XIV)","name":"Scions & Spellcraft (FINAL FANTASY XIV)","commander":"Y'shtola, Night's Blessed","set":"Final Fantasy Commander","fileName":"Scions_&_Spellcraft_(FINAL_FANTASY_XIV).dck","colors":[],"year":2025},
|
||||||
|
{"id":"Scions_&_Spellcraft_Collectors_Edition_(FINAL_FANTASY_XIV)","name":"Scions & Spellcraft Collector's Edition (FINAL FANTASY XIV)","commander":"Y'shtola, Night's Blessed","set":"Final Fantasy Commander","fileName":"Scions_&_Spellcraft_Collectors_Edition_(FINAL_FANTASY_XIV).dck","colors":[],"year":2025},
|
||||||
|
{"id":"Counter_Intelligence","name":"Counter Intelligence","commander":"Inspirit, Flagship Vessel","set":"Edge of Eternities Commander","fileName":"Counter_Intelligence.dck","colors":[],"year":2025},
|
||||||
|
{"id":"World_Shaper","name":"World Shaper","commander":"Hearthhull, the Worldseed","set":"Edge of Eternities Commander","fileName":"World_Shaper.dck","colors":[],"year":2025},
|
||||||
|
{"id":"Blight_Curse","name":"Blight Curse","commander":"Auntie Ool, Cursewretch","set":"Lorwyn Eclipsed Commander","fileName":"Blight_Curse.dck","colors":[],"year":2026},
|
||||||
|
{"id":"Dance_of_the_Elements","name":"Dance of the Elements","commander":"Ashling, the Limitless","set":"Lorwyn Eclipsed Commander","fileName":"Dance_of_the_Elements.dck","colors":[],"year":2026},
|
||||||
|
{"id":"Turtle_Power","name":"Turtle Power!","commander":"Heroes in a Half Shell","set":"Teenage Mutant Ninja Turtles Eternal","fileName":"Turtle_Power.dck","colors":[],"year":2026}
|
||||||
|
];
|
||||||
File diff suppressed because it is too large.
Load diff
+2515
File diff suppressed because it is too large.
Load diff
+204
-83
@@ -4,10 +4,15 @@ Commander AI Lab — Action Labeler
|
|||||||
Maps raw Forge action records (from DecisionSnapshot JSONL)
|
Maps raw Forge action records (from DecisionSnapshot JSONL)
|
||||||
to one of 8 macro-action labels for supervised policy learning.
|
to one of 8 macro-action labels for supervised policy learning.
|
||||||
|
|
||||||
Classification logic:
|
Classification logic (updated):
|
||||||
1. Check action.type (cast, attack, land, cast_commander, pass)
|
1. Check action.type for unambiguous types (attack, land, pass, hold,
|
||||||
2. For "cast" actions, classify by card oracle text / card type keywords
|
cast_commander)
|
||||||
3. For ambiguous cases, use the raw log line and card name heuristics
|
2. For "cast" actions:
|
||||||
|
a. If action.card is present, match directly against name databases
|
||||||
|
b. If action.card is absent (Forge omits it), scan the active
|
||||||
|
player's hand against name databases to infer the cast type.
|
||||||
|
Priority: removal > ramp > draw > commander > creature (default)
|
||||||
|
3. Fallback: PASS
|
||||||
|
|
||||||
Macro-actions (from ml.config.scope):
|
Macro-actions (from ml.config.scope):
|
||||||
0: CAST_CREATURE — Play a creature
|
0: CAST_CREATURE — Play a creature
|
||||||
@@ -41,11 +46,6 @@ logger = logging.getLogger("ml.labeler")
|
|||||||
# Card Type Keyword Databases
|
# Card Type Keyword Databases
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# Known removal spells (partial list — expanded from common Commander staples)
|
|
||||||
REMOVAL_KEYWORDS = {
|
|
||||||
"destroy", "exile", "sacrifice", "counter", "bounce", "return.*to.*hand",
|
|
||||||
"-\\d+/-\\d+", "damage to", "fight", "deathtouch",
|
|
||||||
}
|
|
||||||
REMOVAL_CARD_NAMES = {
|
REMOVAL_CARD_NAMES = {
|
||||||
"swords to plowshares", "path to exile", "beast within", "chaos warp",
|
"swords to plowshares", "path to exile", "beast within", "chaos warp",
|
||||||
"counterspell", "swan song", "negate", "arcane denial", "doomblade",
|
"counterspell", "swan song", "negate", "arcane denial", "doomblade",
|
||||||
@@ -62,45 +62,52 @@ REMOVAL_CARD_NAMES = {
|
|||||||
"decree of pain", "in garruk's wake", "plague wind",
|
"decree of pain", "in garruk's wake", "plague wind",
|
||||||
"fire covenant", "imprisoned in the moon", "darksteel mutation",
|
"fire covenant", "imprisoned in the moon", "darksteel mutation",
|
||||||
"kenrith's transformation", "song of the dryads",
|
"kenrith's transformation", "song of the dryads",
|
||||||
|
"abrade", "disenchant", "naturalize", "wear", "tear",
|
||||||
|
"shattering spree", "shatter", "smash to smithereens",
|
||||||
|
"lightning bolt", "lightning helix", "condemn", "oust",
|
||||||
|
"pongify", "reality shift", "turn to frog", "polymorph",
|
||||||
|
"boros charm", "utter end", "anguished unmaking", "mortify",
|
||||||
|
"putrefy", "maelstrom pulse", "void rend", "render silent",
|
||||||
|
"overwhelming denial", "test of talents", "dispel", "spell pierce",
|
||||||
|
"flusterstorm", "mental misstep", "pact of negation",
|
||||||
|
"opposition agent", "hullbreacher", "narset parter of veils",
|
||||||
|
"grafdigger's cage", "rest in peace", "leyline of the void",
|
||||||
|
"tormod's crypt", "relic of progenitus",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Known draw spells
|
|
||||||
DRAW_KEYWORDS = {
|
|
||||||
"draw", "scry", "look at the top", "reveal.*library",
|
|
||||||
"search your library.*hand", "impulse", "brainstorm",
|
|
||||||
}
|
|
||||||
DRAW_CARD_NAMES = {
|
DRAW_CARD_NAMES = {
|
||||||
"rhystic study", "mystic remora", "phyrexian arena", "sylvan library",
|
"rhystic study", "mystic remora", "phyrexian arena", "sylvan library",
|
||||||
"harmonize", "blue sun's zenith", "pull from tomorrow",
|
"harmonize", "blue sun's zenith", "pull from tomorrow",
|
||||||
"windfall", "wheel of fortune", "timetwister", "brainstorm",
|
"windfall", "wheel of fortune", "timetwister", "brainstorm",
|
||||||
"ponder", "preordain", "opt", "consider", "treasure cruise",
|
"ponder", "preordain", "opt", "consider", "treasure cruise",
|
||||||
"dig through time", "fact or fiction", "nights whisper",
|
"dig through time", "fact or fiction", "night's whisper",
|
||||||
"nights whisper", "sign in blood", "read the bones",
|
"sign in blood", "read the bones", "painful truths",
|
||||||
"painful truths", "ambition's cost", "promise of power",
|
"ambition's cost", "promise of power", "necropotence",
|
||||||
"necropotence", "dark confidant", "toski bearer of secrets",
|
"dark confidant", "toski bearer of secrets", "beast whisperer",
|
||||||
"beast whisperer", "guardian project", "the great henge",
|
"guardian project", "the great henge", "skullclamp",
|
||||||
"skullclamp", "esper sentinel", "archivist of oghma",
|
"esper sentinel", "archivist of oghma", "mind's eye",
|
||||||
"mind's eye", "consecrated sphinx", "ancient craving",
|
"consecrated sphinx", "ancient craving", "faithless looting",
|
||||||
"faithless looting", "cathartic reunion", "thrill of possibility",
|
"cathartic reunion", "thrill of possibility", "jeska's will",
|
||||||
"jeska's will", "valakut awakening", "stinging study",
|
"valakut awakening", "stinging study", "notion thief",
|
||||||
"night's whisper", "notion thief", "tymna the weaver",
|
"tymna the weaver", "shamanic revelation", "rishkar's expertise",
|
||||||
"shamanic revelation", "rishkar's expertise", "return of the wildspeaker",
|
"return of the wildspeaker", "garruk's uprising", "elemental bond",
|
||||||
"garruk's uprising", "elemental bond", "kindred discovery",
|
"kindred discovery", "coastal piracy", "reconnaissance mission",
|
||||||
"coastal piracy", "reconnaissance mission", "bident of thassa",
|
"bident of thassa", "distant melody", "slate of ancestry",
|
||||||
|
"wheel of fate", "reforge the soul", "magus of the wheel",
|
||||||
|
"river's rebuke", "prosperity", "intellectual offering",
|
||||||
|
"peer into the abyss", "mind spring", "stroke of genius",
|
||||||
|
"jace's ingenuity", "tidings", "concentrate", "divination",
|
||||||
|
"inspiration", "counsel of the soratami", "accumulated knowledge",
|
||||||
|
"predict", "pressure point", "quicken", "serum visions",
|
||||||
|
"gitaxian probe", "mental note", "thought scour",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Known ramp cards
|
|
||||||
RAMP_KEYWORDS = {
|
|
||||||
"add {", "add one mana", "mana of any color",
|
|
||||||
"land.*onto the battlefield", "search.*basic land.*battlefield",
|
|
||||||
}
|
|
||||||
RAMP_CARD_NAMES = {
|
RAMP_CARD_NAMES = {
|
||||||
"sol ring", "mana crypt", "mana vault", "arcane signet",
|
"sol ring", "mana crypt", "mana vault", "arcane signet",
|
||||||
"commander's sphere", "chromatic lantern", "fellwar stone",
|
"commander's sphere", "chromatic lantern", "fellwar stone",
|
||||||
"mind stone", "thought vessel", "thran dynamo", "worn powerstone",
|
"mind stone", "thought vessel", "thran dynamo", "worn powerstone",
|
||||||
"gilded lotus", "hedron archive", "everflowing chalice",
|
"gilded lotus", "hedron archive", "everflowing chalice",
|
||||||
"coalition relic", "prismatic lens", "signets", # catch-all partial
|
"coalition relic", "prismatic lens",
|
||||||
"talisman", # catch-all for talisman cycle
|
|
||||||
"cultivate", "kodama's reach", "rampant growth", "farseek",
|
"cultivate", "kodama's reach", "rampant growth", "farseek",
|
||||||
"nature's lore", "three visits", "skyshroud claim",
|
"nature's lore", "three visits", "skyshroud claim",
|
||||||
"explosive vegetation", "migration path", "circuitous route",
|
"explosive vegetation", "migration path", "circuitous route",
|
||||||
@@ -112,13 +119,144 @@ RAMP_CARD_NAMES = {
|
|||||||
"birds of paradise", "llanowar elves", "elvish mystic",
|
"birds of paradise", "llanowar elves", "elvish mystic",
|
||||||
"fyndhorn elves", "avacyn's pilgrim", "elves of deep shadow",
|
"fyndhorn elves", "avacyn's pilgrim", "elves of deep shadow",
|
||||||
"bloom tender", "priest of titania", "selvala heart of the wilds",
|
"bloom tender", "priest of titania", "selvala heart of the wilds",
|
||||||
|
"boreal druid", "green sun's zenith", "eternal witness",
|
||||||
|
"reclamation sage", "mystic snake", "fierce empath",
|
||||||
|
"signal the clans", "crop rotation", "harrow", "kodama's reach",
|
||||||
|
"traverse the ulvenwald", "search for tomorrow", "primal growth",
|
||||||
|
"land grant", "untamed wilds", "rangers path", "rangers' path",
|
||||||
|
"nissa's pilgrimage", "forest grove", "grove of the burnwillows",
|
||||||
|
"cabal stronghold", "urborg tomb of yawgmoth", "urborg, tomb of yawgmoth",
|
||||||
|
"bojuka bog", "ghost quarter", "field of ruin", "strip mine",
|
||||||
|
"wasteland", "tectonic edge", "dust bowl",
|
||||||
|
"worn powerstone", "basalt monolith", "grim monolith",
|
||||||
|
"mana geode", "darksteel ingot", "manalith", "opaline unicorn",
|
||||||
|
"vessel of endless rest", "corrupted grafstone", "dreamstone hedron",
|
||||||
|
"sisay's ring", "ur-golem's eye", "blinkmoth urn",
|
||||||
|
"astral cornucopia", "druids' repository", "black market",
|
||||||
|
"cryptolith rite", "earthcraft", "citanul hierophants",
|
||||||
|
"zendikar resurgent", "mirari's wake", "mana reflection",
|
||||||
|
"vorinclex voice of hunger", "vorinclex, voice of hunger",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Known creature-type indicators
|
# Partial-match prefixes (for cycles like Signets, Talismans)
|
||||||
CREATURE_KEYWORDS = {
|
RAMP_PARTIAL_PREFIXES = {
|
||||||
"creature", "token", "enters the battlefield",
|
"talisman", "signet", "locket", "banner", "obelisk",
|
||||||
|
"keyrune", "cluestone", "monolith", "medallion",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DRAW_PARTIAL_PREFIXES = {
|
||||||
|
"scroll", "tome", "journal",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
# Core classification helpers
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _classify_card_name(name: str) -> Optional[MacroAction]:
|
||||||
|
"""
|
||||||
|
Classify a single card name into a MacroAction, or None if unknown.
|
||||||
|
|
||||||
|
Checks exact matches first, then partial prefix matches.
|
||||||
|
Returns None for unrecognised cards (caller handles the default).
|
||||||
|
"""
|
||||||
|
n = name.lower().strip()
|
||||||
|
if not n:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if n in REMOVAL_CARD_NAMES:
|
||||||
|
return MacroAction.CAST_REMOVAL
|
||||||
|
if n in DRAW_CARD_NAMES:
|
||||||
|
return MacroAction.CAST_DRAW
|
||||||
|
if n in RAMP_CARD_NAMES:
|
||||||
|
return MacroAction.CAST_RAMP
|
||||||
|
|
||||||
|
# Partial prefix match for cycles (Talismans, Signets, etc.)
|
||||||
|
for prefix in RAMP_PARTIAL_PREFIXES:
|
||||||
|
if n.startswith(prefix):
|
||||||
|
return MacroAction.CAST_RAMP
|
||||||
|
for prefix in DRAW_PARTIAL_PREFIXES:
|
||||||
|
if n.startswith(prefix):
|
||||||
|
return MacroAction.CAST_DRAW
|
||||||
|
|
||||||
|
# Substring match (catches "Doom Blade" inside longer names etc.)
|
||||||
|
for known in REMOVAL_CARD_NAMES:
|
||||||
|
if known in n or n in known:
|
||||||
|
return MacroAction.CAST_REMOVAL
|
||||||
|
for known in DRAW_CARD_NAMES:
|
||||||
|
if known in n or n in known:
|
||||||
|
return MacroAction.CAST_DRAW
|
||||||
|
for known in RAMP_CARD_NAMES:
|
||||||
|
if known in n or n in known:
|
||||||
|
return MacroAction.CAST_RAMP
|
||||||
|
|
||||||
|
return None # Unknown → caller defaults to CAST_CREATURE
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_from_hand(
|
||||||
|
players: list,
|
||||||
|
active_seat: int,
|
||||||
|
commander_names: Optional[set] = None,
|
||||||
|
) -> MacroAction:
|
||||||
|
"""
|
||||||
|
Infer the cast action type by scanning the active player's hand.
|
||||||
|
|
||||||
|
When action.card is absent (Forge omits it), the hand snapshot
|
||||||
|
is the best available signal. We scan all hand cards and return
|
||||||
|
the highest-priority match:
|
||||||
|
|
||||||
|
removal > ramp > draw > commander > creature (default)
|
||||||
|
|
||||||
|
Priority rationale: Forge's AI plays the highest-impact available
|
||||||
|
action, so if any removal/ramp/draw spell is in hand during a cast
|
||||||
|
decision, it's the most likely card played.
|
||||||
|
"""
|
||||||
|
if active_seat >= len(players):
|
||||||
|
return MacroAction.CAST_CREATURE
|
||||||
|
|
||||||
|
hand: List[str] = players[active_seat].get("hand", [])
|
||||||
|
command_zone: List[str] = players[active_seat].get("command_zone", [])
|
||||||
|
|
||||||
|
# Build commander name set from command_zone if not provided
|
||||||
|
if commander_names is None:
|
||||||
|
commander_names = {c.lower().strip() for c in command_zone if c}
|
||||||
|
|
||||||
|
found_removal = False
|
||||||
|
found_ramp = False
|
||||||
|
found_draw = False
|
||||||
|
found_commander = False
|
||||||
|
|
||||||
|
for card in hand:
|
||||||
|
card_lower = card.lower().strip()
|
||||||
|
|
||||||
|
# Commander check: hand card matches a command zone card
|
||||||
|
if card_lower in commander_names:
|
||||||
|
found_commander = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = _classify_card_name(card_lower)
|
||||||
|
if result == MacroAction.CAST_REMOVAL:
|
||||||
|
found_removal = True
|
||||||
|
elif result == MacroAction.CAST_RAMP:
|
||||||
|
found_ramp = True
|
||||||
|
elif result == MacroAction.CAST_DRAW:
|
||||||
|
found_draw = True
|
||||||
|
|
||||||
|
# Return highest-priority found
|
||||||
|
if found_removal:
|
||||||
|
return MacroAction.CAST_REMOVAL
|
||||||
|
if found_ramp:
|
||||||
|
return MacroAction.CAST_RAMP
|
||||||
|
if found_draw:
|
||||||
|
return MacroAction.CAST_DRAW
|
||||||
|
if found_commander:
|
||||||
|
return MacroAction.CAST_COMMANDER
|
||||||
|
return MacroAction.CAST_CREATURE
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
# Main label_action entry point
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def label_action(decision: dict) -> int:
|
def label_action(decision: dict) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -133,74 +271,52 @@ def label_action(decision: dict) -> int:
|
|||||||
action = decision.get("action", {})
|
action = decision.get("action", {})
|
||||||
action_type = action.get("type", "").lower()
|
action_type = action.get("type", "").lower()
|
||||||
card_name = (action.get("card", "") or "").lower().strip()
|
card_name = (action.get("card", "") or "").lower().strip()
|
||||||
raw_line = (action.get("raw", "") or "").lower()
|
|
||||||
|
|
||||||
# ── Direct type matching ────────────────────────────
|
players = decision.get("players", [])
|
||||||
|
active_seat = decision.get("active_seat", 0)
|
||||||
|
|
||||||
|
# ── Unambiguous direct-type matches ──────────────────────
|
||||||
|
|
||||||
# Commander cast is unambiguous
|
|
||||||
if action_type == "cast_commander":
|
if action_type == "cast_commander":
|
||||||
return ACTION_TO_IDX[MacroAction.CAST_COMMANDER]
|
return ACTION_TO_IDX[MacroAction.CAST_COMMANDER]
|
||||||
|
|
||||||
# Attack is unambiguous
|
|
||||||
if action_type == "attack":
|
if action_type == "attack":
|
||||||
return ACTION_TO_IDX[MacroAction.ATTACK_OPPONENT]
|
return ACTION_TO_IDX[MacroAction.ATTACK_OPPONENT]
|
||||||
|
|
||||||
# Land play → classify as CAST_RAMP (developing mana is ramp)
|
# Land play → developing mana base = ramp
|
||||||
if action_type == "land":
|
if action_type == "land":
|
||||||
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
|
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
|
||||||
|
|
||||||
# Pass / hold mana
|
# Pass / hold mana — check available mana to distinguish
|
||||||
if action_type in ("pass", "hold"):
|
if action_type in ("pass", "hold"):
|
||||||
# Check if player had mana open (from state)
|
|
||||||
active_seat = decision.get("active_seat", 0)
|
|
||||||
players = decision.get("players", [])
|
|
||||||
if active_seat < len(players):
|
if active_seat < len(players):
|
||||||
mana = players[active_seat].get("mana", 0)
|
mana = players[active_seat].get("mana", 0)
|
||||||
if mana >= 2:
|
if mana >= 2:
|
||||||
return ACTION_TO_IDX[MacroAction.HOLD_MANA]
|
return ACTION_TO_IDX[MacroAction.HOLD_MANA]
|
||||||
return ACTION_TO_IDX[MacroAction.PASS]
|
return ACTION_TO_IDX[MacroAction.PASS]
|
||||||
|
|
||||||
# ── Cast actions: classify by card identity ─────────
|
# ── Cast actions ──────────────────────────────────────
|
||||||
|
|
||||||
if action_type == "cast":
|
if action_type == "cast":
|
||||||
# 1. Check known card name databases
|
# Path A: action.card is present — classify it directly
|
||||||
if card_name in REMOVAL_CARD_NAMES or _partial_match(card_name, REMOVAL_CARD_NAMES):
|
if card_name:
|
||||||
return ACTION_TO_IDX[MacroAction.CAST_REMOVAL]
|
result = _classify_card_name(card_name)
|
||||||
|
if result is not None:
|
||||||
|
return ACTION_TO_IDX[result]
|
||||||
|
# Unrecognised named card → default creature
|
||||||
|
return ACTION_TO_IDX[MacroAction.CAST_CREATURE]
|
||||||
|
|
||||||
if card_name in DRAW_CARD_NAMES or _partial_match(card_name, DRAW_CARD_NAMES):
|
# Path B: action.card absent (Forge omits it) — infer from hand
|
||||||
return ACTION_TO_IDX[MacroAction.CAST_DRAW]
|
inferred = _classify_from_hand(players, active_seat)
|
||||||
|
return ACTION_TO_IDX[inferred]
|
||||||
|
|
||||||
if card_name in RAMP_CARD_NAMES or _partial_match(card_name, RAMP_CARD_NAMES):
|
# ── Fallback ──────────────────────────────────────────
|
||||||
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
|
|
||||||
|
|
||||||
# 2. Check raw log line for keyword patterns
|
|
||||||
for keyword in REMOVAL_KEYWORDS:
|
|
||||||
if re.search(keyword, raw_line):
|
|
||||||
return ACTION_TO_IDX[MacroAction.CAST_REMOVAL]
|
|
||||||
|
|
||||||
for keyword in DRAW_KEYWORDS:
|
|
||||||
if re.search(keyword, raw_line):
|
|
||||||
return ACTION_TO_IDX[MacroAction.CAST_DRAW]
|
|
||||||
|
|
||||||
for keyword in RAMP_KEYWORDS:
|
|
||||||
if re.search(keyword, raw_line):
|
|
||||||
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
|
|
||||||
|
|
||||||
# 3. Default: if it's a spell cast and not recognized, assume creature
|
|
||||||
# (most casts in Commander are creatures or creature-adjacent)
|
|
||||||
return ACTION_TO_IDX[MacroAction.CAST_CREATURE]
|
|
||||||
|
|
||||||
# ── Fallback ────────────────────────────────────────
|
|
||||||
return ACTION_TO_IDX[MacroAction.PASS]
|
return ACTION_TO_IDX[MacroAction.PASS]
|
||||||
|
|
||||||
|
|
||||||
def _partial_match(name: str, name_set: set) -> bool:
|
# ══════════════════════════════════════════════════════════
|
||||||
"""Check if card name partially matches any name in the set."""
|
# File-level helpers
|
||||||
for known in name_set:
|
# ══════════════════════════════════════════════════════════
|
||||||
if known in name or name in known:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
|
def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
|
||||||
"""
|
"""
|
||||||
@@ -214,7 +330,7 @@ def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
|
|||||||
labels = []
|
labels = []
|
||||||
raw = []
|
raw = []
|
||||||
|
|
||||||
with open(jsonl_path, "r") as f:
|
with open(jsonl_path, "r", encoding="utf-8") as f:
|
||||||
for i, line in enumerate(f):
|
for i, line in enumerate(f):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
@@ -232,7 +348,11 @@ def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
|
|||||||
|
|
||||||
|
|
||||||
def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
|
def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
|
||||||
"""Print and return the distribution of action labels."""
|
"""Print and return the distribution of action labels.
|
||||||
|
|
||||||
|
Iterates MacroAction enum in index order so the display always
|
||||||
|
matches ACTION_TO_IDX regardless of dict insertion order.
|
||||||
|
"""
|
||||||
dist = {}
|
dist = {}
|
||||||
for action in MacroAction:
|
for action in MacroAction:
|
||||||
idx = ACTION_TO_IDX[action]
|
idx = ACTION_TO_IDX[action]
|
||||||
@@ -242,10 +362,11 @@ def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
|
|||||||
total = len(labels)
|
total = len(labels)
|
||||||
logger.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
|
logger.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
|
||||||
logger.info("─" * 43)
|
logger.info("─" * 43)
|
||||||
|
# Sort descending by count for readability
|
||||||
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
|
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
|
||||||
pct = (count / total * 100) if total > 0 else 0
|
pct = (count / total * 100) if total > 0 else 0
|
||||||
logger.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
|
logger.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
|
||||||
logger.info(f"{'─' * 43}")
|
logger.info("─" * 43)
|
||||||
logger.info(f" {'TOTAL':<23} {total:>8}")
|
logger.info(f" {'TOTAL':<23} {total:>8}")
|
||||||
|
|
||||||
return dist
|
return dist
|
||||||
+9
-1
@@ -292,7 +292,15 @@ class DistillationDefaults:
|
|||||||
ppo_episodes_per_iter: int = 64
|
ppo_episodes_per_iter: int = 64
|
||||||
ppo_lr: float = 3e-4
|
ppo_lr: float = 3e-4
|
||||||
ppo_clip_epsilon: float = 0.2
|
ppo_clip_epsilon: float = 0.2
|
||||||
ppo_entropy_coeff: float = 0.01
|
# Increased from 0.01 to 0.04 to prevent premature convergence on the
|
||||||
|
# "pass"/"land" macro-actions. The policy-collapse bug (see
|
||||||
|
# commander-ai-lab-land-only-bug-analysis.md) is exacerbated by a low
|
||||||
|
# entropy bonus: PPO settled on argmax very early in training, so the
|
||||||
|
# model never learned to cast creatures / removal / draw spells even
|
||||||
|
# when they were affordable. A higher entropy bonus keeps the action
|
||||||
|
# distribution flat enough during self-play for those alternatives to
|
||||||
|
# accumulate reward signal.
|
||||||
|
ppo_entropy_coeff: float = 0.04
|
||||||
ppo_eval_episodes: int = 100
|
ppo_eval_episodes: int = 100
|
||||||
|
|
||||||
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
|
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
|
||||||
|
|||||||
+33
-18
@@ -6,8 +6,9 @@ Reads ML decision JSONL files, encodes states, labels actions,
|
|||||||
and produces a training-ready dataset saved as NPZ.
|
and produces a training-ready dataset saved as NPZ.
|
||||||
|
|
||||||
Phase 2: Source Awareness (Issue #66)
|
Phase 2: Source Awareness (Issue #66)
|
||||||
- Scans both Forge sim files (ml-decisions-sim-*.jsonl) and
|
- Scans Forge sim files (ml-decisions-sim-*.jsonl) → source "forge"
|
||||||
PPO self-play files (ml-decisions-ppo-*.jsonl)
|
- Scans PPO self-play (ml-decisions-ppo-*.jsonl) → source "ppo"
|
||||||
|
- Scans DeepSeek files (ml-decisions-ds-*.jsonl) → source "ds"
|
||||||
- Configurable source_weights for sampling ratios
|
- Configurable source_weights for sampling ratios
|
||||||
- min_reward_threshold filter for PPO data quality
|
- min_reward_threshold filter for PPO data quality
|
||||||
- Each sample tagged with source for downstream analysis
|
- Each sample tagged with source for downstream analysis
|
||||||
@@ -21,7 +22,7 @@ Output NPZ structure:
|
|||||||
game_ids: object (N,) — game ID for each sample
|
game_ids: object (N,) — game ID for each sample
|
||||||
outcomes: object (N,) — game outcome per sample
|
outcomes: object (N,) — game outcome per sample
|
||||||
playstyles: object (N,) — deck archetype per sample
|
playstyles: object (N,) — deck archetype per sample
|
||||||
sources: object (N,) — data source ("forge" or "ppo")
|
sources: object (N,) — data source ("forge", "ppo", or "ds")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -62,11 +63,11 @@ class DatasetConfig:
|
|||||||
embeddings_dir: Optional[str] = None
|
embeddings_dir: Optional[str] = None
|
||||||
max_samples: Optional[int] = None
|
max_samples: Optional[int] = None
|
||||||
|
|
||||||
# Source weights control sampling ratios between Forge and PPO data.
|
# Source weights control sampling ratios between Forge, PPO, and DS data.
|
||||||
# Default: Forge-only (ppo=0.0). Use MIXED_MODE_PRESETS from
|
# ds=4.0 oversamples the 8 DeepSeek files from ~5.8% → ~20% of effective
|
||||||
# ml.config.scope for blended configurations (e.g. 90/10, 80/20).
|
# training tokens, matching their higher signal quality relative to sim data.
|
||||||
source_weights: Dict[str, float] = field(
|
source_weights: Dict[str, float] = field(
|
||||||
default_factory=lambda: {"forge": 1.0, "ppo": 0.0}
|
default_factory=lambda: {"forge": 1.0, "ppo": 0.0, "ds": 4.0}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Minimum reward threshold for PPO data — only include decisions
|
# Minimum reward threshold for PPO data — only include decisions
|
||||||
@@ -81,12 +82,13 @@ class DatasetConfig:
|
|||||||
|
|
||||||
def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
|
def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
|
||||||
"""
|
"""
|
||||||
Scan results directory for both Forge sim and PPO decision files.
|
Scan results directory for Forge sim, PPO, and DeepSeek decision files.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping source name to list of JSONL file paths.
|
Dict mapping source name to list of JSONL file paths.
|
||||||
Keys: "forge" for ml-decisions-sim-*.jsonl
|
Keys: "forge" for ml-decisions-sim-*.jsonl
|
||||||
"ppo" for ml-decisions-ppo-*.jsonl
|
"ppo" for ml-decisions-ppo-*.jsonl
|
||||||
|
"ds" for ml-decisions-ds-*.jsonl
|
||||||
"""
|
"""
|
||||||
results_path = Path(results_dir)
|
results_path = Path(results_dir)
|
||||||
sources = {}
|
sources = {}
|
||||||
@@ -101,10 +103,16 @@ def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
|
|||||||
if ppo_files:
|
if ppo_files:
|
||||||
sources["ppo"] = ppo_files
|
sources["ppo"] = ppo_files
|
||||||
|
|
||||||
# Also pick up legacy files that don't have sim/ppo prefix
|
# DeepSeek high-quality decisions (upweighted by default)
|
||||||
|
ds_files = sorted(results_path.glob("ml-decisions-ds-*.jsonl"))
|
||||||
|
if ds_files:
|
||||||
|
sources["ds"] = ds_files
|
||||||
|
|
||||||
|
# Legacy files that don't match any known prefix — treat as forge
|
||||||
|
known = set(forge_files + ppo_files + ds_files)
|
||||||
legacy_files = sorted(
|
legacy_files = sorted(
|
||||||
f for f in results_path.glob("ml-decisions-*.jsonl")
|
f for f in results_path.glob("ml-decisions-*.jsonl")
|
||||||
if f not in set(forge_files + ppo_files)
|
if f not in known
|
||||||
)
|
)
|
||||||
if legacy_files:
|
if legacy_files:
|
||||||
sources.setdefault("forge", []).extend(legacy_files)
|
sources.setdefault("forge", []).extend(legacy_files)
|
||||||
@@ -123,7 +131,7 @@ def _passes_reward_filter(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Filter PPO decisions by reward threshold.
|
Filter PPO decisions by reward threshold.
|
||||||
Forge data always passes. PPO data must meet the threshold.
|
Forge and DS data always passes. PPO data must meet the threshold.
|
||||||
"""
|
"""
|
||||||
if source != "ppo":
|
if source != "ppo":
|
||||||
return True
|
return True
|
||||||
@@ -155,7 +163,7 @@ def _apply_source_weights(
|
|||||||
if weight <= 0:
|
if weight <= 0:
|
||||||
logger.info(" Skipping %d %s samples (weight=0)", len(samples), source)
|
logger.info(" Skipping %d %s samples (weight=0)", len(samples), source)
|
||||||
continue
|
continue
|
||||||
if weight >= 1.0 and weight == 1.0:
|
if weight == 1.0:
|
||||||
combined.extend(samples)
|
combined.extend(samples)
|
||||||
elif weight < 1.0:
|
elif weight < 1.0:
|
||||||
# Subsample
|
# Subsample
|
||||||
@@ -193,8 +201,9 @@ def build_dataset(
|
|||||||
Build complete training dataset from all ML decision files.
|
Build complete training dataset from all ML decision files.
|
||||||
|
|
||||||
Phase 2 additions:
|
Phase 2 additions:
|
||||||
- Scans both Forge and PPO JSONL files
|
- Scans Forge, PPO, and DeepSeek (ds) JSONL files
|
||||||
- Applies source_weights for configurable mixing
|
- Applies source_weights for configurable mixing
|
||||||
|
(default: ds=4.0 to oversample high-quality DeepSeek decisions)
|
||||||
- Filters PPO data by min_reward_threshold
|
- Filters PPO data by min_reward_threshold
|
||||||
- Tags each sample with its source
|
- Tags each sample with its source
|
||||||
|
|
||||||
@@ -202,14 +211,15 @@ def build_dataset(
|
|||||||
results_dir: Path to results/ directory containing JSONL files
|
results_dir: Path to results/ directory containing JSONL files
|
||||||
embeddings_dir: Path to embeddings/ directory (default: auto-detect)
|
embeddings_dir: Path to embeddings/ directory (default: auto-detect)
|
||||||
max_samples: Cap total samples (None = unlimited)
|
max_samples: Cap total samples (None = unlimited)
|
||||||
source_weights: Dict of source → sampling weight (default: forge=1.0, ppo=0.5)
|
source_weights: Dict of source → sampling weight
|
||||||
|
(default: forge=1.0, ppo=0.0, ds=4.0)
|
||||||
min_reward_threshold: Min episode_return for PPO data (default: 0.0)
|
min_reward_threshold: Min episode_return for PPO data (default: 0.0)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with keys: states, labels, game_ids, outcomes, playstyles, sources
|
Dict with keys: states, labels, game_ids, outcomes, playstyles, sources
|
||||||
"""
|
"""
|
||||||
if source_weights is None:
|
if source_weights is None:
|
||||||
source_weights = {"forge": 1.0, "ppo": 0.0}
|
source_weights = {"forge": 1.0, "ppo": 0.0, "ds": 4.0}
|
||||||
|
|
||||||
# Load card embeddings
|
# Load card embeddings
|
||||||
card_index = CardEmbeddingIndex(embeddings_dir)
|
card_index = CardEmbeddingIndex(embeddings_dir)
|
||||||
@@ -225,7 +235,7 @@ def build_dataset(
|
|||||||
|
|
||||||
encoder = StateEncoder(card_index)
|
encoder = StateEncoder(card_index)
|
||||||
|
|
||||||
# --- Phase 2 Task 1: Discover files from both sources ---
|
# --- Phase 2 Task 1: Discover files from all sources ---
|
||||||
source_files = _discover_jsonl_files(results_dir)
|
source_files = _discover_jsonl_files(results_dir)
|
||||||
if not source_files:
|
if not source_files:
|
||||||
logger.error("No ML decision files found in %s", results_dir)
|
logger.error("No ML decision files found in %s", results_dir)
|
||||||
@@ -256,7 +266,7 @@ def build_dataset(
|
|||||||
logger.info("Processing %s [%s] ...", jsonl_file.name, source)
|
logger.info("Processing %s [%s] ...", jsonl_file.name, source)
|
||||||
file_count = 0
|
file_count = 0
|
||||||
|
|
||||||
with open(jsonl_file, "r") as f:
|
with open(jsonl_file, "r", encoding="utf-8") as f:
|
||||||
for line_num, line in enumerate(f):
|
for line_num, line in enumerate(f):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
@@ -503,7 +513,11 @@ def main():
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--ppo-weight", type=float, default=0.0,
|
"--ppo-weight", type=float, default=0.0,
|
||||||
help="Sampling weight for PPO self-play data (default: 0.0, Forge-only)",
|
help="Sampling weight for PPO self-play data (default: 0.0, disabled)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ds-weight", type=float, default=4.0,
|
||||||
|
help="Sampling weight for DeepSeek decision data (default: 4.0, ~20%% effective share)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--min-reward", type=float, default=0.0,
|
"--min-reward", type=float, default=0.0,
|
||||||
@@ -515,6 +529,7 @@ def main():
|
|||||||
source_weights = {
|
source_weights = {
|
||||||
"forge": args.forge_weight,
|
"forge": args.forge_weight,
|
||||||
"ppo": args.ppo_weight,
|
"ppo": args.ppo_weight,
|
||||||
|
"ds": args.ds_weight,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build dataset
|
# Build dataset
|
||||||
|
|||||||
@@ -523,7 +523,7 @@ class QualityGate:
|
|||||||
report_path = os.path.join(
|
report_path = os.path.join(
|
||||||
self.config.reports_dir, f"quality_{timestamp}.json"
|
self.config.reports_dir, f"quality_{timestamp}.json"
|
||||||
)
|
)
|
||||||
with open(report_path, "w") as f:
|
with open(report_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(metrics, f, indent=2)
|
json.dump(metrics, f, indent=2)
|
||||||
logger.info("Quality report saved: %s", report_path)
|
logger.info("Quality report saved: %s", report_path)
|
||||||
|
|
||||||
@@ -582,6 +582,6 @@ class QualityGate:
|
|||||||
path = os.path.join(self.config.reports_dir, "verdict_history.json")
|
path = os.path.join(self.config.reports_dir, "verdict_history.json")
|
||||||
|
|
||||||
history = [v.to_dict() for v in self.verdicts]
|
history = [v.to_dict() for v in self.verdicts]
|
||||||
with open(path, "w") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(history, f, indent=2)
|
json.dump(history, f, indent=2)
|
||||||
logger.info("Verdict history saved: %s (%d entries)", path, len(history))
|
logger.info("Verdict history saved: %s (%d entries)", path, len(history))
|
||||||
+106
-38
@@ -141,8 +141,17 @@ class CardEmbeddingIndex:
|
|||||||
logger.error("Auto-download failed: %s", e)
|
logger.error("Auto-download failed: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_embedding(self, card_name: str) -> np.ndarray:
|
def get_embedding(self, card_name) -> np.ndarray:
|
||||||
"""Get embedding vector for a card name. Returns zero vector if not found."""
|
"""Get embedding vector for a card name. Returns zero vector if not found.
|
||||||
|
|
||||||
|
Accepts either a plain string or a dict with a 'name' key (defensive
|
||||||
|
against the LAN client sending structured battlefield objects).
|
||||||
|
"""
|
||||||
|
# Handle dict objects from the LAN client's battlefield_detail
|
||||||
|
if isinstance(card_name, dict):
|
||||||
|
card_name = card_name.get('name', '')
|
||||||
|
if not card_name or not isinstance(card_name, str):
|
||||||
|
return self._zero_vec
|
||||||
if not self._loaded:
|
if not self._loaded:
|
||||||
return self._zero_vec
|
return self._zero_vec
|
||||||
idx = self._name_to_idx.get(card_name.lower())
|
idx = self._name_to_idx.get(card_name.lower())
|
||||||
@@ -155,8 +164,11 @@ class CardEmbeddingIndex:
|
|||||||
return self._embeddings[idx]
|
return self._embeddings[idx]
|
||||||
return self._zero_vec
|
return self._zero_vec
|
||||||
|
|
||||||
def mean_pool_zone(self, card_names: List[str]) -> np.ndarray:
|
def mean_pool_zone(self, card_names: list) -> np.ndarray:
|
||||||
"""Mean-pool embeddings for a list of card names (a game zone)."""
|
"""Mean-pool embeddings for a list of card names (a game zone).
|
||||||
|
|
||||||
|
Each entry may be a plain string or a dict with a 'name' key.
|
||||||
|
"""
|
||||||
if not card_names:
|
if not card_names:
|
||||||
return self._zero_vec
|
return self._zero_vec
|
||||||
vectors = [self.get_embedding(name) for name in card_names]
|
vectors = [self.get_embedding(name) for name in card_names]
|
||||||
@@ -167,6 +179,24 @@ class CardEmbeddingIndex:
|
|||||||
return self._loaded
|
return self._loaded
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cmdr_dmg(raw) -> int:
|
||||||
|
"""Collapse cmdr_dmg to a scalar integer regardless of source format.
|
||||||
|
|
||||||
|
Forge sends cmdr_dmg as a dict keyed by opponent seat string, e.g.
|
||||||
|
{"0": 14, "2": 7}. _build_encoder_snapshot already sums this before
|
||||||
|
passing to predict(), but the live Forge IPC path (and legacy JSONL)
|
||||||
|
may send the raw dict. Summing all values is correct for Commander:
|
||||||
|
the total commander damage received from all opponents is what matters
|
||||||
|
for the life-loss threshold (21).
|
||||||
|
"""
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return sum(int(v) for v in raw.values())
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
class StateEncoder:
|
class StateEncoder:
|
||||||
"""
|
"""
|
||||||
Encodes a decision snapshot JSON dict into a fixed-size numpy vector.
|
Encodes a decision snapshot JSON dict into a fixed-size numpy vector.
|
||||||
@@ -178,6 +208,23 @@ class StateEncoder:
|
|||||||
[0:29] - Global features (life, mana, turn, phase, etc.)
|
[0:29] - Global features (life, mana, turn, phase, etc.)
|
||||||
[29:6173] - Zone embeddings (4 zones × 2 players × 768)
|
[29:6173] - Zone embeddings (4 zones × 2 players × 768)
|
||||||
[6173:6177] - Playstyle one-hot (4 dims)
|
[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):
|
def __init__(self, card_index: CardEmbeddingIndex):
|
||||||
@@ -197,7 +244,6 @@ class StateEncoder:
|
|||||||
"""
|
"""
|
||||||
players = decision.get("players", [])
|
players = decision.get("players", [])
|
||||||
if len(players) < 2:
|
if len(players) < 2:
|
||||||
# Pad to 2 players for 1v1
|
|
||||||
while len(players) < 2:
|
while len(players) < 2:
|
||||||
players.append(self._empty_player(len(players)))
|
players.append(self._empty_player(len(players)))
|
||||||
|
|
||||||
@@ -218,55 +264,76 @@ class StateEncoder:
|
|||||||
return state.astype(np.float32)
|
return state.astype(np.float32)
|
||||||
|
|
||||||
def _encode_global(self, decision: dict, players: list) -> np.ndarray:
|
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
|
||||||
|
|
||||||
|
cmdr_dmg is resolved via _resolve_cmdr_dmg() which handles both the
|
||||||
|
live Forge format (dict keyed by opponent seat) and the pre-collapsed
|
||||||
|
int from _build_encoder_snapshot / JSONL training snapshots.
|
||||||
|
"""
|
||||||
features = []
|
features = []
|
||||||
|
|
||||||
for p in players[:2]: # Exactly 2 players for 1v1
|
for p in players[:2]:
|
||||||
# Per-player scalars (10 each)
|
# idx 0: life
|
||||||
life = p.get("life", 40)
|
life = p.get("life", 40)
|
||||||
features.append(life / 40.0) # Normalized life
|
features.append(life / 40.0)
|
||||||
|
|
||||||
cmdr_dmg = p.get("cmdr_dmg", 0)
|
# idx 1: commander damage taken
|
||||||
features.append(cmdr_dmg / 21.0) # Normalized commander damage
|
# _resolve_cmdr_dmg handles dict (live Forge IPC) and int (JSONL/pre-collapsed)
|
||||||
|
cmdr_dmg = _resolve_cmdr_dmg(p.get("cmdr_dmg", 0))
|
||||||
|
features.append(cmdr_dmg / 21.0)
|
||||||
|
|
||||||
|
# idx 2: mana available
|
||||||
mana = p.get("mana", 0)
|
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)
|
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", []))
|
hand_size = len(p.get("hand", []))
|
||||||
features.append(min(hand_size / 15.0, 1.0))
|
features.append(min(hand_size / 15.0, 1.0))
|
||||||
|
|
||||||
|
# idx 5: graveyard size
|
||||||
grave_size = len(p.get("graveyard", []))
|
grave_size = len(p.get("graveyard", []))
|
||||||
features.append(min(grave_size / 100.0, 1.0))
|
features.append(min(grave_size / 100.0, 1.0))
|
||||||
|
|
||||||
|
# idx 6: creatures on battlefield
|
||||||
creatures = p.get("creatures", 0)
|
creatures = p.get("creatures", 0)
|
||||||
features.append(min(creatures / 30.0, 1.0))
|
features.append(min(creatures / 30.0, 1.0))
|
||||||
|
|
||||||
# Total power on board (not yet tracked accurately; use creatures as proxy)
|
# idx 7: total power on board
|
||||||
total_power = creatures * 3 # rough heuristic: avg 3 power
|
# 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))
|
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
|
is_active = 1.0 if p.get("seat") == decision.get("active_seat") else 0.0
|
||||||
features.append(is_active)
|
features.append(is_active)
|
||||||
|
|
||||||
# Land count (extra feature — helps distinguish ramp strategies)
|
# idx 9-12: phase one-hot (4 dims)
|
||||||
lands = p.get("lands", 0)
|
|
||||||
features.append(min(lands / 15.0, 1.0))
|
|
||||||
|
|
||||||
# Phase one-hot (4 dims per player)
|
|
||||||
phase_str = decision.get("phase", "main_1")
|
phase_str = decision.get("phase", "main_1")
|
||||||
phase_oh = [0.0] * 4
|
phase_oh = [0.0] * 4
|
||||||
try:
|
try:
|
||||||
phase_enum = GamePhase(phase_str)
|
phase_enum = GamePhase(phase_str)
|
||||||
phase_oh[PHASE_TO_IDX[phase_enum]] = 1.0
|
phase_oh[PHASE_TO_IDX[phase_enum]] = 1.0
|
||||||
except (ValueError, KeyError):
|
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)
|
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)
|
turn = decision.get("turn", 1)
|
||||||
features.append(min(turn / GAME_SCOPE.max_turns, 1.0))
|
features.append(min(turn / GAME_SCOPE.max_turns, 1.0))
|
||||||
|
|
||||||
@@ -290,8 +357,6 @@ class StateEncoder:
|
|||||||
for p in players[:2]:
|
for p in players[:2]:
|
||||||
for zone_key in zone_keys:
|
for zone_key in zone_keys:
|
||||||
cards = p.get(zone_key, [])
|
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)
|
pooled = self.card_index.mean_pool_zone(cards)
|
||||||
vectors.append(pooled)
|
vectors.append(pooled)
|
||||||
|
|
||||||
@@ -304,24 +369,27 @@ class StateEncoder:
|
|||||||
style = Playstyle(playstyle_str.lower())
|
style = Playstyle(playstyle_str.lower())
|
||||||
vec[PLAYSTYLE_TO_IDX[style]] = 1.0
|
vec[PLAYSTYLE_TO_IDX[style]] = 1.0
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
# Default: midrange
|
|
||||||
vec[PLAYSTYLE_TO_IDX[Playstyle.MIDRANGE]] = 1.0
|
vec[PLAYSTYLE_TO_IDX[Playstyle.MIDRANGE]] = 1.0
|
||||||
return vec
|
return vec
|
||||||
|
|
||||||
def _empty_player(self, seat: int) -> dict:
|
def _empty_player(self, seat: int) -> dict:
|
||||||
"""Create an empty player dict for padding."""
|
"""Create an empty player dict for padding."""
|
||||||
return {
|
return {
|
||||||
"seat": seat,
|
"seat": seat,
|
||||||
"life": 40,
|
"life": 40,
|
||||||
"cmdr_dmg": 0,
|
"cmdr_dmg": 0,
|
||||||
"mana": 0,
|
"mana": 0,
|
||||||
"cmdr_tax": 0,
|
"cmdr_tax": 0,
|
||||||
"creatures": 0,
|
"creatures": 0,
|
||||||
"lands": 0,
|
"total_power": 0,
|
||||||
"hand": [],
|
"total_toughness": 0,
|
||||||
"battlefield": [],
|
"artifacts": 0,
|
||||||
"graveyard": [],
|
"enchantments": 0,
|
||||||
"command_zone": [],
|
"lands": 0,
|
||||||
|
"hand": [],
|
||||||
|
"battlefield": [],
|
||||||
|
"graveyard": [],
|
||||||
|
"command_zone": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -347,7 +415,7 @@ def encode_decisions_file(
|
|||||||
states = []
|
states = []
|
||||||
raw = []
|
raw = []
|
||||||
|
|
||||||
with open(jsonl_path, "r") as f:
|
with open(jsonl_path, "r", encoding="utf-8") as f:
|
||||||
for i, line in enumerate(f):
|
for i, line in enumerate(f):
|
||||||
if max_samples and i >= max_samples:
|
if max_samples and i >= max_samples:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ class EloHistory:
|
|||||||
def _load(self):
|
def _load(self):
|
||||||
if os.path.exists(self.path):
|
if os.path.exists(self.path):
|
||||||
try:
|
try:
|
||||||
with open(self.path, "r") as f:
|
with open(self.path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
self.entries = data.get("entries", [])
|
self.entries = data.get("entries", [])
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -138,7 +138,7 @@ class EloHistory:
|
|||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
||||||
with open(self.path, "w") as f:
|
with open(self.path, "w", encoding="utf-8") as f:
|
||||||
json.dump({"entries": self.entries}, f, indent=2)
|
json.dump({"entries": self.entries}, f, indent=2)
|
||||||
|
|
||||||
def append(self, generation: int, ratings: Dict[str, float]):
|
def append(self, generation: int, ratings: Dict[str, float]):
|
||||||
|
|||||||
@@ -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, encoding="utf-8") 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", encoding="utf-8") as f:
|
||||||
|
json.dump(payload, f, indent=2)
|
||||||
|
logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path)
|
||||||
@@ -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, encoding="utf-8") 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", encoding="utf-8") as f:
|
||||||
|
json.dump(payload, f, indent=2)
|
||||||
|
logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path)
|
||||||
@@ -417,7 +417,7 @@ def main():
|
|||||||
|
|
||||||
# Save results
|
# Save results
|
||||||
output_path = args.output or os.path.join(ckpt_dir, "tournament_results.json")
|
output_path = args.output or os.path.join(ckpt_dir, "tournament_results.json")
|
||||||
with open(output_path, "w") as f:
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(result.to_dict(), f, indent=2)
|
json.dump(result.to_dict(), f, indent=2)
|
||||||
logger.info("Results saved: %s", output_path)
|
logger.info("Results saved: %s", output_path)
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def load_precon_cards(precon_dir: str) -> dict:
|
|||||||
cards = {"commander": None, "creatures": [], "spells": [], "lands": [], "artifacts": [], "all": []}
|
cards = {"commander": None, "creatures": [], "spells": [], "lands": [], "artifacts": [], "all": []}
|
||||||
|
|
||||||
section = "Main"
|
section = "Main"
|
||||||
with open(dck_file, "r") as f:
|
with open(dck_file, "r", encoding="utf-8") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line or line.startswith("//") or line.startswith("Name="):
|
if not line or line.startswith("//") or line.startswith("Name="):
|
||||||
@@ -380,7 +380,7 @@ def main():
|
|||||||
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
|
||||||
total_decisions = 0
|
total_decisions = 0
|
||||||
|
|
||||||
with open(args.output, "w") as f:
|
with open(args.output, "w", encoding="utf-8") as f:
|
||||||
for game_idx in range(args.games):
|
for game_idx in range(args.games):
|
||||||
# Pick two random decks
|
# Pick two random decks
|
||||||
d1_name, d2_name = random.sample(deck_names, 2)
|
d1_name, d2_name = random.sample(deck_names, 2)
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ def cmd_status(args):
|
|||||||
for f in jsonl_files:
|
for f in jsonl_files:
|
||||||
lines = 0
|
lines = 0
|
||||||
try:
|
try:
|
||||||
with open(f) as fh:
|
with open(f, encoding="utf-8") as fh:
|
||||||
lines = sum(1 for _ in fh)
|
lines = sum(1 for _ in fh)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -130,7 +130,7 @@ def cmd_inspect(args):
|
|||||||
|
|
||||||
print(f"\n Inspecting: {filepath.name}\n")
|
print(f"\n Inspecting: {filepath.name}\n")
|
||||||
count = 0
|
count = 0
|
||||||
with open(filepath) as f:
|
with open(filepath, encoding="utf-8") as f:
|
||||||
for i, line in enumerate(f):
|
for i, line in enumerate(f):
|
||||||
if i >= args.limit:
|
if i >= args.limit:
|
||||||
break
|
break
|
||||||
@@ -150,7 +150,7 @@ def cmd_inspect(args):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" [{i:>4}] ERROR: {e}")
|
print(f" [{i:>4}] ERROR: {e}")
|
||||||
|
|
||||||
with open(filepath) as f:
|
with open(filepath, encoding="utf-8") as f:
|
||||||
total = sum(1 for _ in f)
|
total = sum(1 for _ in f)
|
||||||
print(f"\n Showing {count} of {total:,} total decisions.\n")
|
print(f"\n Showing {count} of {total:,} total decisions.\n")
|
||||||
|
|
||||||
|
|||||||
+48
-13
@@ -52,14 +52,44 @@ from ml.config.scope import (
|
|||||||
)
|
)
|
||||||
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
|
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
|
||||||
|
|
||||||
logger = logging.getLogger("ml.serving")
|
# Unique logger name so log lines are attributable to this module specifically
|
||||||
|
logger = logging.getLogger("ml.serving.policy_server")
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_logits(raw):
|
||||||
|
"""
|
||||||
|
Normalise whatever self.model() returns to a plain logits tensor.
|
||||||
|
|
||||||
|
PolicyNetwork.forward() returns a raw tensor, but the model may be
|
||||||
|
wrapped (DataParallel, torch.compile) or accidentally swapped for
|
||||||
|
PolicyValueNetwork whose forward_with_value() returns (logits, value).
|
||||||
|
A dict return would cause 'dict / float' TypeError on the temperature
|
||||||
|
division -- guard against all three cases here.
|
||||||
|
"""
|
||||||
|
if isinstance(raw, (tuple, list)):
|
||||||
|
# (logits, value) from PolicyValueNetwork or similar
|
||||||
|
return raw[0]
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
# e.g. {"logits": tensor, "value": tensor}
|
||||||
|
for key in ("logits", "policy", "action_logits"):
|
||||||
|
if key in raw:
|
||||||
|
return raw[key]
|
||||||
|
# Fallback: grab the first tensor value in the dict
|
||||||
|
for v in raw.values():
|
||||||
|
if isinstance(v, torch.Tensor):
|
||||||
|
return v
|
||||||
|
raise TypeError(
|
||||||
|
f"model() returned a dict with no recognisable logits key: {list(raw.keys())}"
|
||||||
|
)
|
||||||
|
# Plain tensor -- the normal case
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
class PolicyInferenceService:
|
class PolicyInferenceService:
|
||||||
"""
|
"""
|
||||||
Loads a trained PolicyNetwork and provides action predictions.
|
Loads a trained PolicyNetwork and provides action predictions.
|
||||||
|
|
||||||
Thread-safe — uses torch.no_grad() for inference.
|
Thread-safe -- uses torch.no_grad() for inference.
|
||||||
Designed to be embedded in lab_api.py or run standalone.
|
Designed to be embedded in lab_api.py or run standalone.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -100,7 +130,7 @@ class PolicyInferenceService:
|
|||||||
"""
|
"""
|
||||||
if not TORCH_AVAILABLE:
|
if not TORCH_AVAILABLE:
|
||||||
self._load_error = "PyTorch not installed"
|
self._load_error = "PyTorch not installed"
|
||||||
logger.error("PyTorch not installed — cannot serve policy")
|
logger.error("PyTorch not installed -- cannot serve policy")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 1. Load card embeddings
|
# 1. Load card embeddings
|
||||||
@@ -146,7 +176,7 @@ class PolicyInferenceService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._load_error = str(e)
|
self._load_error = str(e)
|
||||||
logger.error("Failed to load checkpoint: %s", e)
|
logger.error("Failed to load checkpoint: %s", e, exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def reload(self, checkpoint_path: str = None) -> bool:
|
def reload(self, checkpoint_path: str = None) -> bool:
|
||||||
@@ -178,7 +208,7 @@ class PolicyInferenceService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._load_error = str(e)
|
self._load_error = str(e)
|
||||||
logger.error("Hot-reload failed: %s", e)
|
logger.error("Hot-reload failed: %s", e, exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def predict(
|
def predict(
|
||||||
@@ -224,7 +254,10 @@ class PolicyInferenceService:
|
|||||||
|
|
||||||
# Run inference
|
# Run inference
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
logits = self.model(state_tensor)
|
raw_output = self.model(state_tensor)
|
||||||
|
# Guard: unwrap tuple/dict returns (DataParallel, torch.compile,
|
||||||
|
# or accidental PolicyValueNetwork swap) before any arithmetic.
|
||||||
|
logits = _unwrap_logits(raw_output)
|
||||||
|
|
||||||
if greedy:
|
if greedy:
|
||||||
action_idx = logits.argmax(dim=-1).item()
|
action_idx = logits.argmax(dim=-1).item()
|
||||||
@@ -254,7 +287,7 @@ class PolicyInferenceService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
elapsed_ms = (time.time() - t_start) * 1000
|
elapsed_ms = (time.time() - t_start) * 1000
|
||||||
logger.error("Inference error: %s", e)
|
logger.error("Inference error: %s", e, exc_info=True)
|
||||||
return {
|
return {
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
"inference_ms": round(elapsed_ms, 2),
|
"inference_ms": round(elapsed_ms, 2),
|
||||||
@@ -286,7 +319,9 @@ class PolicyInferenceService:
|
|||||||
|
|
||||||
# Batch inference
|
# Batch inference
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
logits = self.model(state_tensor)
|
raw_output = self.model(state_tensor)
|
||||||
|
# Same guard as predict() -- unwrap before softmax/argmax.
|
||||||
|
logits = _unwrap_logits(raw_output)
|
||||||
probs = torch.softmax(logits, dim=-1).cpu().numpy()
|
probs = torch.softmax(logits, dim=-1).cpu().numpy()
|
||||||
|
|
||||||
if greedy:
|
if greedy:
|
||||||
@@ -312,7 +347,7 @@ class PolicyInferenceService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Batch inference error: %s", e)
|
logger.error("Batch inference error: %s", e, exc_info=True)
|
||||||
results = [{"error": str(e)}] * len(snapshots)
|
results = [{"error": str(e)}] * len(snapshots)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
@@ -331,9 +366,9 @@ class PolicyInferenceService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════
|
# ==========================================================
|
||||||
# Standalone FastAPI Server
|
# Standalone FastAPI Server
|
||||||
# ══════════════════════════════════════════════════════════
|
# ==========================================================
|
||||||
|
|
||||||
def create_standalone_app():
|
def create_standalone_app():
|
||||||
"""Create a standalone FastAPI app for the policy server."""
|
"""Create a standalone FastAPI app for the policy server."""
|
||||||
@@ -341,7 +376,7 @@ def create_standalone_app():
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Commander AI Lab — Policy Server",
|
title="Commander AI Lab -- Policy Server",
|
||||||
description="Serves macro-action predictions from a trained policy network",
|
description="Serves macro-action predictions from a trained policy network",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
)
|
)
|
||||||
@@ -424,7 +459,7 @@ def main():
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Commander AI Lab — Policy Inference Server"
|
description="Commander AI Lab -- Policy Inference Server"
|
||||||
)
|
)
|
||||||
parser.add_argument("--port", type=int, default=8090)
|
parser.add_argument("--port", type=int, default=8090)
|
||||||
parser.add_argument("--host", default="127.0.0.1")
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ class DecisionExporter:
|
|||||||
filename = f"ml-decisions-ppo-{self._batch_id}.jsonl"
|
filename = f"ml-decisions-ppo-{self._batch_id}.jsonl"
|
||||||
filepath = os.path.join(self.config.output_dir, filename)
|
filepath = os.path.join(self.config.output_dir, filename)
|
||||||
|
|
||||||
with open(filepath, "w") as f:
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
for record in self._records:
|
for record in self._records:
|
||||||
f.write(json.dumps(record) + "\n")
|
f.write(json.dumps(record) + "\n")
|
||||||
|
|
||||||
|
|||||||
@@ -759,7 +759,7 @@ class DistillationLoop:
|
|||||||
"""Save generation history to JSON."""
|
"""Save generation history to JSON."""
|
||||||
path = os.path.join(self.config.history_dir, "generations.json")
|
path = os.path.join(self.config.history_dir, "generations.json")
|
||||||
history = [g.to_dict() for g in self.generations]
|
history = [g.to_dict() for g in self.generations]
|
||||||
with open(path, "w") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(history, f, indent=2)
|
json.dump(history, f, indent=2)
|
||||||
logger.info(" Generation history saved: %s", path)
|
logger.info(" Generation history saved: %s", path)
|
||||||
|
|
||||||
@@ -793,7 +793,7 @@ class DistillationLoop:
|
|||||||
def _save_summary(self, summary: Dict):
|
def _save_summary(self, summary: Dict):
|
||||||
"""Save final summary to JSON."""
|
"""Save final summary to JSON."""
|
||||||
path = os.path.join(self.config.history_dir, "summary.json")
|
path = os.path.join(self.config.history_dir, "summary.json")
|
||||||
with open(path, "w") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(summary, f, indent=2, default=str)
|
json.dump(summary, f, indent=2, default=str)
|
||||||
logger.info(" Summary saved: %s", path)
|
logger.info(" Summary saved: %s", path)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from ml.training.reward import (
|
|||||||
compute_intermediate_reward,
|
compute_intermediate_reward,
|
||||||
compute_terminal_reward,
|
compute_terminal_reward,
|
||||||
)
|
)
|
||||||
|
from ml.training.decision_exporter import DecisionExporter, ExporterConfig
|
||||||
|
|
||||||
logger = logging.getLogger("ml.forge_episode_generator")
|
logger = logging.getLogger("ml.forge_episode_generator")
|
||||||
|
|
||||||
@@ -51,6 +52,8 @@ class ForgeTransition:
|
|||||||
log_prob: float
|
log_prob: float
|
||||||
value: float
|
value: float
|
||||||
done: bool
|
done: bool
|
||||||
|
# Raw snapshot retained so the exporter can reconstruct game-state context
|
||||||
|
snapshot: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -61,6 +64,8 @@ class ForgeEpisode:
|
|||||||
winner: Optional[int] = None
|
winner: Optional[int] = None
|
||||||
total_turns: int = 0
|
total_turns: int = 0
|
||||||
deck_names: List[str] = field(default_factory=list)
|
deck_names: List[str] = field(default_factory=list)
|
||||||
|
agent_seat: int = 0
|
||||||
|
playstyle: str = "midrange"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def length(self) -> int:
|
def length(self) -> int:
|
||||||
@@ -70,6 +75,10 @@ class ForgeEpisode:
|
|||||||
def total_reward(self) -> float:
|
def total_reward(self) -> float:
|
||||||
return sum(t.reward for t in self.transitions)
|
return sum(t.reward for t in self.transitions)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def won(self) -> bool:
|
||||||
|
return self.winner == self.agent_seat
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
# JSONL Parser — reads Forge ML decision snapshots
|
# JSONL Parser — reads Forge ML decision snapshots
|
||||||
@@ -82,7 +91,7 @@ def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
|
|||||||
game_id, turn, phase, active_seat, state, action, outcome
|
game_id, turn, phase, active_seat, state, action, outcome
|
||||||
"""
|
"""
|
||||||
snapshots = []
|
snapshots = []
|
||||||
with open(path, "r") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
for lineno, line in enumerate(f, 1):
|
for lineno, line in enumerate(f, 1):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
@@ -95,9 +104,44 @@ def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
|
|||||||
return snapshots
|
return snapshots
|
||||||
|
|
||||||
|
|
||||||
|
def _make_minimal_state(snap: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Build a minimal prev/curr state dict from a Forge snapshot.
|
||||||
|
|
||||||
|
compute_intermediate_reward() expects a dict with a 'players' list
|
||||||
|
where each player has 'life_total', 'cards_in_hand',
|
||||||
|
'creatures_on_battlefield', etc. Forge snapshots carry a 'players'
|
||||||
|
array directly; we forward it as-is and fall back to scalar fields
|
||||||
|
when the array is absent.
|
||||||
|
"""
|
||||||
|
if "players" in snap:
|
||||||
|
return {"players": snap["players"]}
|
||||||
|
|
||||||
|
# Fallback: reconstruct a two-player view from flat snapshot fields
|
||||||
|
agent_seat = snap.get("active_seat", 0)
|
||||||
|
opp_seat = 1 - agent_seat
|
||||||
|
players = [None, None]
|
||||||
|
|
||||||
|
def _player(life_key: str, hand_key: str, creatures_key: str) -> Dict:
|
||||||
|
return {
|
||||||
|
"life_total": snap.get(life_key, 40),
|
||||||
|
"cards_in_hand": snap.get(hand_key, 0),
|
||||||
|
"creatures_on_battlefield": snap.get(creatures_key, 0),
|
||||||
|
"cards_in_graveyard": 0,
|
||||||
|
"mana_available": snap.get("mana_available", 0),
|
||||||
|
"commander_damage_taken": 0,
|
||||||
|
"total_power_on_board": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
players[agent_seat] = _player("agent_life", "agent_hand_size", "agent_creatures")
|
||||||
|
players[opp_seat] = _player("opp_life", "opp_hand_size", "opp_creatures")
|
||||||
|
return {"players": players}
|
||||||
|
|
||||||
|
|
||||||
def snapshots_to_episode(
|
def snapshots_to_episode(
|
||||||
snapshots: List[Dict[str, Any]],
|
snapshots: List[Dict[str, Any]],
|
||||||
reward_config: Optional[RewardConfig] = None,
|
reward_config: Optional[RewardConfig] = None,
|
||||||
|
agent_seat: int = 0,
|
||||||
|
playstyle: str = "midrange",
|
||||||
) -> ForgeEpisode:
|
) -> ForgeEpisode:
|
||||||
"""Convert a sequence of Forge snapshots into a ForgeEpisode.
|
"""Convert a sequence of Forge snapshots into a ForgeEpisode.
|
||||||
|
|
||||||
@@ -111,6 +155,8 @@ def snapshots_to_episode(
|
|||||||
episode = ForgeEpisode(
|
episode = ForgeEpisode(
|
||||||
game_id=first.get("game_id", "unknown"),
|
game_id=first.get("game_id", "unknown"),
|
||||||
deck_names=first.get("deck_names", []),
|
deck_names=first.get("deck_names", []),
|
||||||
|
agent_seat=agent_seat,
|
||||||
|
playstyle=playstyle,
|
||||||
)
|
)
|
||||||
|
|
||||||
rc = reward_config or RewardConfig()
|
rc = reward_config or RewardConfig()
|
||||||
@@ -130,20 +176,26 @@ def snapshots_to_episode(
|
|||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
action_idx = ACTION_TO_IDX[MacroAction.PASS]
|
action_idx = ACTION_TO_IDX[MacroAction.PASS]
|
||||||
|
|
||||||
# Reward
|
# Reward — use correct reward.py signatures
|
||||||
is_done = i == len(snapshots) - 1
|
is_done = i == len(snapshots) - 1
|
||||||
if is_done and "outcome" in snap:
|
if is_done and "outcome" in snap:
|
||||||
|
outcome = snap["outcome"]
|
||||||
reward = compute_terminal_reward(
|
reward = compute_terminal_reward(
|
||||||
won=snap["outcome"].get("won", False),
|
won=outcome.get("won", False),
|
||||||
turns=snap.get("turn", 1),
|
draw=outcome.get("draw", False),
|
||||||
life_remaining=snap["outcome"].get("life_remaining", 0),
|
timeout=outcome.get("timeout", False),
|
||||||
config=rc,
|
config=rc,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# Build minimal prev/curr dicts for intermediate reward
|
||||||
|
prev_snap = snapshots[i - 1] if i > 0 else snap
|
||||||
|
prev_state = _make_minimal_state(prev_snap)
|
||||||
|
curr_state = _make_minimal_state(snap)
|
||||||
reward = compute_intermediate_reward(
|
reward = compute_intermediate_reward(
|
||||||
life_delta=snap.get("life_delta", 0),
|
prev_state=prev_state,
|
||||||
cards_drawn=snap.get("cards_drawn", 0),
|
curr_state=curr_state,
|
||||||
creatures_played=snap.get("creatures_played", 0),
|
action_taken=action_label,
|
||||||
|
agent_seat=agent_seat,
|
||||||
config=rc,
|
config=rc,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -154,6 +206,7 @@ def snapshots_to_episode(
|
|||||||
log_prob=snap.get("log_prob", 0.0),
|
log_prob=snap.get("log_prob", 0.0),
|
||||||
value=snap.get("value", 0.0),
|
value=snap.get("value", 0.0),
|
||||||
done=is_done,
|
done=is_done,
|
||||||
|
snapshot=snap,
|
||||||
))
|
))
|
||||||
|
|
||||||
episode.total_turns = snapshots[-1].get("turn", len(snapshots))
|
episode.total_turns = snapshots[-1].get("turn", len(snapshots))
|
||||||
@@ -163,6 +216,61 @@ def snapshots_to_episode(
|
|||||||
return episode
|
return episode
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
# DecisionExporter adapter for Forge episodes
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _export_forge_episode(episode: ForgeEpisode, exporter: DecisionExporter) -> None:
|
||||||
|
"""Feed a completed ForgeEpisode into the DecisionExporter.
|
||||||
|
|
||||||
|
Translates each ForgeTransition back into the game_state_snapshot
|
||||||
|
format expected by DecisionExporter.record_step(), then finalises
|
||||||
|
the episode so the exporter can apply its win/length gates.
|
||||||
|
|
||||||
|
The raw snapshot stored on each ForgeTransition is used directly
|
||||||
|
when available; otherwise a minimal dict is synthesised from the
|
||||||
|
transition's action index and reward so the schema is always valid.
|
||||||
|
"""
|
||||||
|
exporter.begin_episode(
|
||||||
|
agent_seat=episode.agent_seat,
|
||||||
|
playstyle=episode.playstyle,
|
||||||
|
)
|
||||||
|
|
||||||
|
for t in episode.transitions:
|
||||||
|
if t.snapshot is not None:
|
||||||
|
game_state = _make_minimal_state(t.snapshot)
|
||||||
|
# Carry through turn/phase/active_seat if present
|
||||||
|
game_state["turn"] = t.snapshot.get("turn", 1)
|
||||||
|
game_state["phase"] = t.snapshot.get("phase", "main_1")
|
||||||
|
game_state["active_seat"] = t.snapshot.get("active_seat", episode.agent_seat)
|
||||||
|
else:
|
||||||
|
# Minimal fallback — exporter only needs players list for schema
|
||||||
|
game_state = {
|
||||||
|
"turn": 1,
|
||||||
|
"phase": "main_1",
|
||||||
|
"active_seat": episode.agent_seat,
|
||||||
|
"players": [
|
||||||
|
{"life_total": 40, "cards_in_hand": 0, "creatures_on_battlefield": 0,
|
||||||
|
"cards_in_graveyard": 0, "mana_available": 0,
|
||||||
|
"commander_damage_taken": 0, "total_power_on_board": 0, "commander_tax": 0},
|
||||||
|
{"life_total": 40, "cards_in_hand": 0, "creatures_on_battlefield": 0,
|
||||||
|
"cards_in_graveyard": 0, "mana_available": 0,
|
||||||
|
"commander_damage_taken": 0, "total_power_on_board": 0, "commander_tax": 0},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
exporter.record_step(
|
||||||
|
game_state_snapshot=game_state,
|
||||||
|
action_idx=t.action,
|
||||||
|
reward=t.reward,
|
||||||
|
)
|
||||||
|
|
||||||
|
exporter.end_episode(
|
||||||
|
won=episode.won,
|
||||||
|
episode_return=episode.total_reward,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
# Async Episode Queue (producer-consumer)
|
# Async Episode Queue (producer-consumer)
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
@@ -271,6 +379,7 @@ async def ppo_consumer(
|
|||||||
queue: ForgeEpisodeQueue,
|
queue: ForgeEpisodeQueue,
|
||||||
buffer_size: int = 64,
|
buffer_size: int = 64,
|
||||||
on_batch_ready=None,
|
on_batch_ready=None,
|
||||||
|
exporter: Optional[DecisionExporter] = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Consumer coroutine: pulls episodes from the queue and batches
|
"""Consumer coroutine: pulls episodes from the queue and batches
|
||||||
them for PPO training updates.
|
them for PPO training updates.
|
||||||
@@ -279,6 +388,11 @@ async def ppo_consumer(
|
|||||||
queue: ForgeEpisodeQueue to consume from
|
queue: ForgeEpisodeQueue to consume from
|
||||||
buffer_size: number of episodes per training batch
|
buffer_size: number of episodes per training batch
|
||||||
on_batch_ready: callback(episodes) when a batch is full
|
on_batch_ready: callback(episodes) when a batch is full
|
||||||
|
exporter: Optional DecisionExporter — when provided, every
|
||||||
|
consumed episode is fed into the exporter so winning
|
||||||
|
Forge games accumulate in the distillation JSONL pool.
|
||||||
|
Call exporter.flush() after the pipeline completes to
|
||||||
|
write the JSONL file.
|
||||||
|
|
||||||
Returns total episodes consumed.
|
Returns total episodes consumed.
|
||||||
"""
|
"""
|
||||||
@@ -292,6 +406,14 @@ async def ppo_consumer(
|
|||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Feed into distillation exporter (win-gate applied inside exporter)
|
||||||
|
if exporter is not None:
|
||||||
|
try:
|
||||||
|
_export_forge_episode(episode, exporter)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[CONSUMER] Exporter error for episode %s: %s",
|
||||||
|
episode.game_id, exc)
|
||||||
|
|
||||||
batch.append(episode)
|
batch.append(episode)
|
||||||
total_consumed += 1
|
total_consumed += 1
|
||||||
|
|
||||||
@@ -322,12 +444,24 @@ async def run_forge_pipeline(
|
|||||||
max_queue: int = 256,
|
max_queue: int = 256,
|
||||||
on_batch_ready=None,
|
on_batch_ready=None,
|
||||||
reward_config: Optional[RewardConfig] = None,
|
reward_config: Optional[RewardConfig] = None,
|
||||||
) -> Dict[str, int]:
|
exporter: Optional[DecisionExporter] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Run the full Forge producer → PPO consumer pipeline.
|
"""Run the full Forge producer → PPO consumer pipeline.
|
||||||
|
|
||||||
The Forge Java batch must be started separately (via forge_runner.py
|
The Forge Java batch must be started separately (via forge_runner.py
|
||||||
or MultiThreadBatchRunner). This function handles the Python side:
|
or MultiThreadBatchRunner). This function handles the Python side:
|
||||||
reading JSONL output and feeding episodes into the PPO trainer.
|
reading JSONL output and feeding episodes into the PPO trainer.
|
||||||
|
|
||||||
|
If an exporter is provided, winning Forge episodes are recorded and
|
||||||
|
flushed to a PPO JSONL file at the end of the pipeline run. The
|
||||||
|
output path is returned in the stats dict under "exporter_file".
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from ml.training.decision_exporter import DecisionExporter, ExporterConfig
|
||||||
|
exporter = DecisionExporter(ExporterConfig(model_version="gen-1"))
|
||||||
|
stats = await run_forge_pipeline(num_games=128, exporter=exporter)
|
||||||
|
print(stats["exporter_file"]) # data/results/ml-decisions-ppo-<id>.jsonl
|
||||||
"""
|
"""
|
||||||
queue = ForgeEpisodeQueue(max_size=max_queue)
|
queue = ForgeEpisodeQueue(max_size=max_queue)
|
||||||
|
|
||||||
@@ -336,16 +470,25 @@ async def run_forge_pipeline(
|
|||||||
reward_config=reward_config)
|
reward_config=reward_config)
|
||||||
)
|
)
|
||||||
consumer_task = asyncio.create_task(
|
consumer_task = asyncio.create_task(
|
||||||
ppo_consumer(queue, buffer_size, on_batch_ready)
|
ppo_consumer(queue, buffer_size, on_batch_ready, exporter=exporter)
|
||||||
)
|
)
|
||||||
|
|
||||||
produced = await producer_task
|
produced = await producer_task
|
||||||
queue.close()
|
queue.close()
|
||||||
consumed = await consumer_task
|
consumed = await consumer_task
|
||||||
|
|
||||||
stats = {
|
# Flush exporter to disk and capture output path
|
||||||
|
exporter_file = None
|
||||||
|
if exporter is not None:
|
||||||
|
exporter_file = exporter.flush()
|
||||||
|
if exporter_file:
|
||||||
|
logger.info("[PIPELINE] Distillation export: %s stats=%s",
|
||||||
|
exporter_file, exporter.stats)
|
||||||
|
|
||||||
|
stats: Dict[str, Any] = {
|
||||||
"produced": produced,
|
"produced": produced,
|
||||||
"consumed": consumed,
|
"consumed": consumed,
|
||||||
|
"exporter_file": exporter_file,
|
||||||
**queue.stats,
|
**queue.stats,
|
||||||
}
|
}
|
||||||
logger.info("[PIPELINE] Complete: %s", stats)
|
logger.info("[PIPELINE] Complete: %s", stats)
|
||||||
|
|||||||
@@ -223,7 +223,8 @@ def load_checkpoint(path: str, device: str = "cpu"):
|
|||||||
num_layers=config.get("num_layers", TRAINING_CONFIG.num_layers),
|
num_layers=config.get("num_layers", TRAINING_CONFIG.num_layers),
|
||||||
dropout=config.get("dropout", TRAINING_CONFIG.dropout),
|
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)
|
model.to(device)
|
||||||
|
|
||||||
return model, checkpoint
|
return model, checkpoint
|
||||||
+339
-132
@@ -9,22 +9,27 @@ Proximal Policy Optimization (Clip) with:
|
|||||||
- Self-play episode collection between updates
|
- Self-play episode collection between updates
|
||||||
- Periodic checkpointing and evaluation
|
- Periodic checkpointing and evaluation
|
||||||
- Win-rate tracking against opponent policies
|
- Win-rate tracking against opponent policies
|
||||||
|
- Forge-based episode source via --data-source forge|mixed
|
||||||
|
|
||||||
Reference: Schulman et al., "Proximal Policy Optimization Algorithms", 2017
|
Reference: Schulman et al., "Proximal Policy Optimization Algorithms", 2017
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python -m ml.training.ppo_trainer --iterations 100 --episodes-per-iter 64
|
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 --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 argparse
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -51,6 +56,114 @@ from ml.training.self_play import (
|
|||||||
logger = logging.getLogger("ml.ppo")
|
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:
|
class PPOConfig:
|
||||||
"""PPO hyperparameters."""
|
"""PPO hyperparameters."""
|
||||||
|
|
||||||
@@ -91,6 +204,13 @@ class PPOConfig:
|
|||||||
|
|
||||||
# Supervised preload
|
# Supervised preload
|
||||||
load_supervised: Optional[str] = None,
|
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.iterations = iterations
|
||||||
self.episodes_per_iter = episodes_per_iter
|
self.episodes_per_iter = episodes_per_iter
|
||||||
@@ -114,10 +234,19 @@ class PPOConfig:
|
|||||||
self.eval_every = eval_every
|
self.eval_every = eval_every
|
||||||
self.eval_episodes = eval_episodes
|
self.eval_episodes = eval_episodes
|
||||||
self.load_supervised = load_supervised
|
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:
|
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):
|
def __init__(self, config: PPOConfig):
|
||||||
if not TORCH_AVAILABLE:
|
if not TORCH_AVAILABLE:
|
||||||
@@ -126,28 +255,24 @@ class PPOTrainer:
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.device = self._detect_device()
|
self.device = self._detect_device()
|
||||||
|
|
||||||
# Create actor-critic model
|
|
||||||
from ml.training.policy_network import PolicyValueNetwork
|
from ml.training.policy_network import PolicyValueNetwork
|
||||||
self.model = PolicyValueNetwork(
|
self.model = PolicyValueNetwork(
|
||||||
input_dim=STATE_DIMS.total_state_dim,
|
input_dim=STATE_DIMS.total_state_dim,
|
||||||
hidden_dim=TRAINING_CONFIG.hidden_dim,
|
hidden_dim=TRAINING_CONFIG.hidden_dim,
|
||||||
num_actions=NUM_ACTIONS,
|
num_actions=NUM_ACTIONS,
|
||||||
num_layers=TRAINING_CONFIG.num_layers,
|
num_layers=TRAINING_CONFIG.num_layers,
|
||||||
dropout=0.0, # No dropout during RL
|
dropout=0.0,
|
||||||
).to(self.device)
|
).to(self.device)
|
||||||
|
|
||||||
# Optionally load from supervised checkpoint
|
|
||||||
if config.load_supervised and os.path.exists(config.load_supervised):
|
if config.load_supervised and os.path.exists(config.load_supervised):
|
||||||
self._load_supervised_weights(config.load_supervised)
|
self._load_supervised_weights(config.load_supervised)
|
||||||
|
|
||||||
# Optimizer
|
|
||||||
self.optimizer = torch.optim.Adam(
|
self.optimizer = torch.optim.Adam(
|
||||||
self.model.parameters(),
|
self.model.parameters(),
|
||||||
lr=config.learning_rate,
|
lr=config.learning_rate,
|
||||||
eps=1e-5,
|
eps=1e-5,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Learning rate scheduler
|
|
||||||
self.scheduler = None
|
self.scheduler = None
|
||||||
if config.lr_schedule == "linear":
|
if config.lr_schedule == "linear":
|
||||||
self.scheduler = torch.optim.lr_scheduler.LinearLR(
|
self.scheduler = torch.optim.lr_scheduler.LinearLR(
|
||||||
@@ -163,7 +288,6 @@ class PPOTrainer:
|
|||||||
eta_min=config.min_lr,
|
eta_min=config.min_lr,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Rollout buffer
|
|
||||||
self.buffer = RolloutBuffer(
|
self.buffer = RolloutBuffer(
|
||||||
buffer_size=config.buffer_size,
|
buffer_size=config.buffer_size,
|
||||||
state_dim=STATE_DIMS.total_state_dim,
|
state_dim=STATE_DIMS.total_state_dim,
|
||||||
@@ -171,16 +295,23 @@ class PPOTrainer:
|
|||||||
gae_lambda=config.gae_lambda,
|
gae_lambda=config.gae_lambda,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reward config
|
|
||||||
self.reward_config = RewardConfig(
|
self.reward_config = RewardConfig(
|
||||||
gamma=config.gamma,
|
gamma=config.gamma,
|
||||||
gae_lambda=config.gae_lambda,
|
gae_lambda=config.gae_lambda,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Opponent policy
|
|
||||||
self.opponent = self._create_opponent(config.opponent)
|
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.iteration = 0
|
||||||
self.total_steps = 0
|
self.total_steps = 0
|
||||||
self.best_win_rate = 0.0
|
self.best_win_rate = 0.0
|
||||||
@@ -196,13 +327,9 @@ class PPOTrainer:
|
|||||||
return "cpu"
|
return "cpu"
|
||||||
|
|
||||||
def _load_supervised_weights(self, path: str):
|
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)
|
logger.info("Loading supervised weights from %s", path)
|
||||||
checkpoint = torch.load(path, map_location=self.device, weights_only=False)
|
checkpoint = torch.load(path, map_location=self.device, weights_only=False)
|
||||||
state_dict = checkpoint.get("model_state_dict", checkpoint)
|
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()
|
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}
|
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)
|
model_dict.update(compatible)
|
||||||
@@ -216,14 +343,10 @@ class PPOTrainer:
|
|||||||
elif opponent_type == "heuristic":
|
elif opponent_type == "heuristic":
|
||||||
return HeuristicPolicy()
|
return HeuristicPolicy()
|
||||||
elif opponent_type == "self":
|
elif opponent_type == "self":
|
||||||
# Self-play: opponent uses a snapshot of the current policy
|
|
||||||
return self._create_self_play_opponent()
|
return self._create_self_play_opponent()
|
||||||
else:
|
return HeuristicPolicy()
|
||||||
return HeuristicPolicy()
|
|
||||||
|
|
||||||
def _create_self_play_opponent(self):
|
def _create_self_play_opponent(self):
|
||||||
"""Create an opponent that uses a frozen copy of the current policy."""
|
|
||||||
|
|
||||||
class SelfPlayPolicy:
|
class SelfPlayPolicy:
|
||||||
def __init__(self, model, device):
|
def __init__(self, model, device):
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -241,11 +364,99 @@ class PPOTrainer:
|
|||||||
|
|
||||||
return SelfPlayPolicy(self.model, self.device)
|
return SelfPlayPolicy(self.model, self.device)
|
||||||
|
|
||||||
def ppo_update(self) -> Dict:
|
def _collect_forge_rollouts(self) -> Dict:
|
||||||
"""Run PPO update epochs on the collected rollout buffer.
|
"""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
|
cfg = self.config
|
||||||
total_policy_loss = 0.0
|
total_policy_loss = 0.0
|
||||||
total_value_loss = 0.0
|
total_value_loss = 0.0
|
||||||
@@ -258,56 +469,42 @@ class PPOTrainer:
|
|||||||
for batch in self.buffer.get_batches(cfg.batch_size):
|
for batch in self.buffer.get_batches(cfg.batch_size):
|
||||||
states, actions, old_log_probs, advantages, returns = batch
|
states, actions, old_log_probs, advantages, returns = batch
|
||||||
|
|
||||||
# To tensors
|
|
||||||
states_t = torch.from_numpy(states).to(self.device)
|
states_t = torch.from_numpy(states).to(self.device)
|
||||||
actions_t = torch.from_numpy(actions).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)
|
old_log_probs_t = torch.from_numpy(old_log_probs).to(self.device)
|
||||||
advantages_t = torch.from_numpy(advantages).to(self.device)
|
advantages_t = torch.from_numpy(advantages).to(self.device)
|
||||||
returns_t = torch.from_numpy(returns).to(self.device)
|
returns_t = torch.from_numpy(returns).to(self.device)
|
||||||
|
|
||||||
# Forward pass
|
|
||||||
logits, values = self.model.forward_with_value(states_t)
|
logits, values = self.model.forward_with_value(states_t)
|
||||||
values = values.squeeze(-1)
|
values = values.squeeze(-1)
|
||||||
|
|
||||||
# Action distribution
|
|
||||||
probs = F.softmax(logits, dim=-1)
|
probs = F.softmax(logits, dim=-1)
|
||||||
dist = torch.distributions.Categorical(probs)
|
dist = torch.distributions.Categorical(probs)
|
||||||
new_log_probs = dist.log_prob(actions_t)
|
new_log_probs = dist.log_prob(actions_t)
|
||||||
entropy = dist.entropy().mean()
|
entropy = dist.entropy().mean()
|
||||||
|
|
||||||
# --- Policy Loss (clipped surrogate) ---
|
|
||||||
ratio = torch.exp(new_log_probs - old_log_probs_t)
|
ratio = torch.exp(new_log_probs - old_log_probs_t)
|
||||||
surr1 = ratio * advantages_t
|
surr1 = ratio * advantages_t
|
||||||
surr2 = torch.clamp(ratio, 1.0 - cfg.clip_epsilon, 1.0 + cfg.clip_epsilon) * 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()
|
policy_loss = -torch.min(surr1, surr2).mean()
|
||||||
|
|
||||||
# --- Value Loss (clipped) ---
|
|
||||||
if cfg.value_clip > 0:
|
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_unclipped = (values - returns_t) ** 2
|
||||||
value_loss = 0.5 * value_loss_unclipped.mean()
|
value_loss = 0.5 * value_loss_unclipped.mean()
|
||||||
else:
|
else:
|
||||||
value_loss = 0.5 * ((values - returns_t) ** 2).mean()
|
value_loss = 0.5 * ((values - returns_t) ** 2).mean()
|
||||||
|
|
||||||
# --- Total Loss ---
|
|
||||||
loss = (
|
loss = (
|
||||||
policy_loss
|
policy_loss
|
||||||
+ cfg.value_loss_coeff * value_loss
|
+ cfg.value_loss_coeff * value_loss
|
||||||
- cfg.entropy_coeff * entropy
|
- cfg.entropy_coeff * entropy
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optimize
|
|
||||||
self.optimizer.zero_grad()
|
self.optimizer.zero_grad()
|
||||||
loss.backward()
|
loss.backward()
|
||||||
nn.utils.clip_grad_norm_(self.model.parameters(), cfg.max_grad_norm)
|
nn.utils.clip_grad_norm_(self.model.parameters(), cfg.max_grad_norm)
|
||||||
self.optimizer.step()
|
self.optimizer.step()
|
||||||
|
|
||||||
# Track metrics
|
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
approx_kl = ((ratio - 1) - torch.log(ratio)).mean().item()
|
approx_kl = ((ratio - 1) - torch.log(ratio)).mean().item()
|
||||||
clip_frac = ((ratio - 1.0).abs() > cfg.clip_epsilon).float().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:
|
def evaluate(self, num_episodes: int = 50) -> Dict:
|
||||||
"""Evaluate the current policy against opponents.
|
"""Evaluate the current policy against heuristic and random opponents."""
|
||||||
|
|
||||||
Returns win rates and metrics.
|
|
||||||
"""
|
|
||||||
self.model.eval()
|
self.model.eval()
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
for opp_name, opp_policy in [
|
for opp_name, opp_policy in [
|
||||||
("heuristic", HeuristicPolicy()),
|
("heuristic", HeuristicPolicy()),
|
||||||
@@ -363,18 +556,15 @@ class PPOTrainer:
|
|||||||
"avg_reward": stats["avg_reward_per_ep"],
|
"avg_reward": stats["avg_reward_per_ep"],
|
||||||
"avg_steps": stats["avg_steps_per_ep"],
|
"avg_steps": stats["avg_steps_per_ep"],
|
||||||
}
|
}
|
||||||
|
|
||||||
self.model.train()
|
self.model.train()
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def save_checkpoint(self, path: str = None, metrics: dict = None):
|
def save_checkpoint(self, path: str = None, metrics: dict = None):
|
||||||
"""Save PPO checkpoint."""
|
|
||||||
if path is None:
|
if path is None:
|
||||||
path = os.path.join(
|
path = os.path.join(
|
||||||
self.config.checkpoint_dir,
|
self.config.checkpoint_dir,
|
||||||
f"ppo_iter_{self.iteration:04d}.pt",
|
f"ppo_iter_{self.iteration:04d}.pt",
|
||||||
)
|
)
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
torch.save({
|
torch.save({
|
||||||
"iteration": self.iteration,
|
"iteration": self.iteration,
|
||||||
@@ -388,6 +578,7 @@ class PPOTrainer:
|
|||||||
"learning_rate": self.config.learning_rate,
|
"learning_rate": self.config.learning_rate,
|
||||||
"opponent": self.config.opponent,
|
"opponent": self.config.opponent,
|
||||||
"playstyle": self.config.playstyle,
|
"playstyle": self.config.playstyle,
|
||||||
|
"data_source": self.config.data_source,
|
||||||
},
|
},
|
||||||
"best_win_rate": self.best_win_rate,
|
"best_win_rate": self.best_win_rate,
|
||||||
"model_config": {
|
"model_config": {
|
||||||
@@ -401,14 +592,7 @@ class PPOTrainer:
|
|||||||
logger.info("Checkpoint saved: %s", path)
|
logger.info("Checkpoint saved: %s", path)
|
||||||
|
|
||||||
def train(self, progress_callback=None) -> Dict:
|
def train(self, progress_callback=None) -> Dict:
|
||||||
"""Run the full PPO training loop.
|
"""Run the full PPO training loop."""
|
||||||
|
|
||||||
Args:
|
|
||||||
progress_callback: Optional callable(iteration, metrics) for progress reporting
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Training summary dict
|
|
||||||
"""
|
|
||||||
cfg = self.config
|
cfg = self.config
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info("PPO Training — Commander AI Lab")
|
logger.info("PPO Training — Commander AI Lab")
|
||||||
@@ -423,100 +607,96 @@ class PPOTrainer:
|
|||||||
logger.info(" Opponent: %s", cfg.opponent)
|
logger.info(" Opponent: %s", cfg.opponent)
|
||||||
logger.info(" Device: %s", self.device)
|
logger.info(" Device: %s", self.device)
|
||||||
logger.info(" Supervised: %s", cfg.load_supervised or "None")
|
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("")
|
logger.info("")
|
||||||
|
|
||||||
|
# Start Forge producer background thread if needed
|
||||||
|
if self._forge_source:
|
||||||
|
self._forge_source.start()
|
||||||
|
|
||||||
t_start = time.time()
|
t_start = time.time()
|
||||||
|
|
||||||
for iteration in range(1, cfg.iterations + 1):
|
try:
|
||||||
self.iteration = iteration
|
for iteration in range(1, cfg.iterations + 1):
|
||||||
t_iter = time.time()
|
self.iteration = iteration
|
||||||
|
t_iter = time.time()
|
||||||
|
|
||||||
# --- Collect rollouts ---
|
rollout_stats = self._collect_rollouts_for_iter()
|
||||||
self.model.eval()
|
self.total_steps += rollout_stats["steps"]
|
||||||
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()
|
|
||||||
|
|
||||||
# --- PPO Update ---
|
update_metrics = self.ppo_update()
|
||||||
update_metrics = self.ppo_update()
|
|
||||||
|
|
||||||
# --- LR Schedule ---
|
current_lr = self.optimizer.param_groups[0]["lr"]
|
||||||
current_lr = self.optimizer.param_groups[0]["lr"]
|
if self.scheduler:
|
||||||
if self.scheduler:
|
self.scheduler.step()
|
||||||
self.scheduler.step()
|
|
||||||
|
|
||||||
# --- Evaluate ---
|
eval_results = None
|
||||||
eval_results = None
|
if iteration % cfg.eval_every == 0 or iteration == 1:
|
||||||
if iteration % cfg.eval_every == 0 or iteration == 1:
|
eval_results = self.evaluate(cfg.eval_episodes)
|
||||||
eval_results = self.evaluate(cfg.eval_episodes)
|
heuristic_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
|
||||||
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:
|
if iteration % cfg.save_every == 0:
|
||||||
self.best_win_rate = heuristic_wr
|
self.save_checkpoint()
|
||||||
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)
|
|
||||||
|
|
||||||
# --- Save periodic checkpoint ---
|
iter_time = time.time() - t_iter
|
||||||
if iteration % cfg.save_every == 0:
|
metrics = {
|
||||||
self.save_checkpoint()
|
"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 ---
|
eval_str = ""
|
||||||
iter_time = time.time() - t_iter
|
if eval_results:
|
||||||
metrics = {
|
h_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
|
||||||
"iteration": iteration,
|
r_wr = eval_results.get("random", {}).get("win_rate", 0)
|
||||||
"steps": rollout_stats["steps"],
|
eval_str = f" | eval: h={h_wr:.0%} r={r_wr:.0%}"
|
||||||
"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)
|
|
||||||
|
|
||||||
# Pretty log
|
src_tag = f"[{rollout_stats.get('source', '?')[:3]}]"
|
||||||
eval_str = ""
|
logger.info(
|
||||||
if eval_results:
|
" [%3d/%d]%s wr=%.0f%% rew=%.3f ploss=%.4f vloss=%.4f ent=%.3f kl=%.4f%s (%.1fs)",
|
||||||
h_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
|
iteration, cfg.iterations, src_tag,
|
||||||
r_wr = eval_results.get("random", {}).get("win_rate", 0)
|
rollout_stats["win_rate"] * 100,
|
||||||
eval_str = f" | eval: h={h_wr:.0%} r={r_wr:.0%}"
|
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(
|
if progress_callback:
|
||||||
" [%3d/%d] wr=%.0f%% rew=%.3f ploss=%.4f vloss=%.4f ent=%.3f kl=%.4f%s (%.1fs)",
|
progress_callback(iteration, metrics)
|
||||||
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:
|
finally:
|
||||||
progress_callback(iteration, metrics)
|
if self._forge_source:
|
||||||
|
self._forge_source.stop()
|
||||||
|
|
||||||
# --- Final Save ---
|
|
||||||
total_time = time.time() - t_start
|
total_time = time.time() - t_start
|
||||||
self.save_checkpoint()
|
self.save_checkpoint()
|
||||||
|
|
||||||
# Save training history
|
|
||||||
history_path = os.path.join(cfg.checkpoint_dir, "ppo_history.json")
|
history_path = os.path.join(cfg.checkpoint_dir, "ppo_history.json")
|
||||||
with open(history_path, "w") as f:
|
with open(history_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(self.history, f, indent=2, default=str)
|
json.dump(self.history, f, indent=2, default=str)
|
||||||
|
|
||||||
summary = {
|
summary = {
|
||||||
@@ -526,6 +706,7 @@ class PPOTrainer:
|
|||||||
"final_win_rate": self.history[-1]["win_rate"] if self.history else 0,
|
"final_win_rate": self.history[-1]["win_rate"] if self.history else 0,
|
||||||
"total_time_s": round(total_time, 1),
|
"total_time_s": round(total_time, 1),
|
||||||
"device": self.device,
|
"device": self.device,
|
||||||
|
"data_source": cfg.data_source,
|
||||||
"checkpoint_dir": cfg.checkpoint_dir,
|
"checkpoint_dir": cfg.checkpoint_dir,
|
||||||
"history_path": history_path,
|
"history_path": history_path,
|
||||||
}
|
}
|
||||||
@@ -536,6 +717,7 @@ class PPOTrainer:
|
|||||||
logger.info(" Total time: %.1f s", total_time)
|
logger.info(" Total time: %.1f s", total_time)
|
||||||
logger.info(" Total steps: %d", self.total_steps)
|
logger.info(" Total steps: %d", self.total_steps)
|
||||||
logger.info(" Best win rate: %.1f%%", self.best_win_rate * 100)
|
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(" History: %s", history_path)
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
@@ -578,12 +760,32 @@ def main():
|
|||||||
parser.add_argument("--load-supervised", default=None,
|
parser.add_argument("--load-supervised", default=None,
|
||||||
help="Path to supervised checkpoint to initialize from")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
level=logging.INFO,
|
|
||||||
format="%(message)s",
|
|
||||||
)
|
|
||||||
|
|
||||||
config = PPOConfig(
|
config = PPOConfig(
|
||||||
iterations=args.iterations,
|
iterations=args.iterations,
|
||||||
@@ -605,6 +807,11 @@ def main():
|
|||||||
eval_every=args.eval_every,
|
eval_every=args.eval_every,
|
||||||
eval_episodes=args.eval_episodes,
|
eval_episodes=args.eval_episodes,
|
||||||
load_supervised=args.load_supervised,
|
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)
|
trainer = PPOTrainer(config)
|
||||||
|
|||||||
+10
-4
@@ -322,7 +322,7 @@ class SupervisedTrainer:
|
|||||||
}
|
}
|
||||||
serializable_history.append(entry)
|
serializable_history.append(entry)
|
||||||
|
|
||||||
with open(history_path, "w") as f:
|
with open(history_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(serializable_history, f, indent=2)
|
json.dump(serializable_history, f, indent=2)
|
||||||
logger.info(" History saved: %s", history_path)
|
logger.info(" History saved: %s", history_path)
|
||||||
|
|
||||||
@@ -547,7 +547,7 @@ def get_best_device() -> str:
|
|||||||
return "cpu"
|
return "cpu"
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
name = torch.cuda.get_device_name(0)
|
name = torch.cuda.get_device_name(0)
|
||||||
mem = torch.cuda.get_device_properties(0).total_mem / (1024**3)
|
mem = torch.cuda.get_device_properties(0).total_memory / (1024**3)
|
||||||
logger.info(" CUDA device: %s (%.1f GB)", name, mem)
|
logger.info(" CUDA device: %s (%.1f GB)", name, mem)
|
||||||
return "cuda"
|
return "cuda"
|
||||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
@@ -562,6 +562,12 @@ def get_best_device() -> str:
|
|||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
# Ensure logger output is visible on console
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Commander AI Lab — Train Policy Network"
|
description="Commander AI Lab — Train Policy Network"
|
||||||
)
|
)
|
||||||
@@ -638,7 +644,7 @@ def main():
|
|||||||
|
|
||||||
# Save evaluation results
|
# Save evaluation results
|
||||||
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
||||||
with open(eval_path, "w") as f:
|
with open(eval_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(results, f, indent=2)
|
json.dump(results, f, indent=2)
|
||||||
logger.info(f" Results saved: {eval_path}")
|
logger.info(f" Results saved: {eval_path}")
|
||||||
return
|
return
|
||||||
@@ -696,7 +702,7 @@ def main():
|
|||||||
print_evaluation(results)
|
print_evaluation(results)
|
||||||
|
|
||||||
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
||||||
with open(eval_path, "w") as f:
|
with open(eval_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(results, f, indent=2)
|
json.dump(results, f, indent=2)
|
||||||
logger.info(" Eval results saved: %s", eval_path)
|
logger.info(" Eval results saved: %s", eval_path)
|
||||||
else:
|
else:
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,7 @@ class StartRequest(BaseModel):
|
|||||||
numGames: int = 100
|
numGames: int = 100
|
||||||
threads: int = 4
|
threads: int = 4
|
||||||
seed: Optional[int] = None
|
seed: Optional[int] = None
|
||||||
clock: int = 300
|
clock: int = 5000
|
||||||
deckSources: Optional[list[Optional[dict]]] = None
|
deckSources: Optional[list[Optional[dict]]] = None
|
||||||
useLearnedPolicy: bool = False
|
useLearnedPolicy: bool = False
|
||||||
policyStyle: str = "midrange"
|
policyStyle: str = "midrange"
|
||||||
@@ -170,6 +170,7 @@ class CoachGoalsRequest(BaseModel):
|
|||||||
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
|
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
|
||||||
budget: Optional[str] = None # budget, medium, no-limit
|
budget: Optional[str] = None # budget, medium, no-limit
|
||||||
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
|
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
|
||||||
|
|
||||||
class DeckGenV3SubstituteRequest(BaseModel):
|
class DeckGenV3SubstituteRequest(BaseModel):
|
||||||
card_name: str
|
card_name: str
|
||||||
substitute_name: str
|
substitute_name: str
|
||||||
+159
-102
@@ -1,102 +1,159 @@
|
|||||||
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
|
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import os
|
||||||
from datetime import datetime
|
import subprocess
|
||||||
from pathlib import Path
|
from datetime import datetime
|
||||||
from typing import Optional
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
log = logging.getLogger("commander_ai_lab.api")
|
# ── .env bootstrap ────────────────────────────────────────────
|
||||||
|
# Load .env automatically so plain `python script.py` and bare uvicorn
|
||||||
|
# both pick up FORGE_DIR, PRECON_DIR, etc. without a manual load_dotenv
|
||||||
# ══════════════════════════════════════════════════════════════
|
# call at every entry point. override=False means real env vars always win.
|
||||||
# Configuration
|
try:
|
||||||
# ══════════════════════════════════════════════════════════════
|
from dotenv import load_dotenv as _load_dotenv
|
||||||
class Config:
|
_here = Path(__file__).resolve().parent
|
||||||
"""Runtime configuration -- set via CLI args or env vars."""
|
for _candidate in [_here, _here.parent, _here.parent.parent]:
|
||||||
forge_jar: str = ""
|
_env_file = _candidate / ".env"
|
||||||
forge_dir: str = ""
|
if _env_file.is_file():
|
||||||
forge_decks_dir: str = ""
|
_load_dotenv(dotenv_path=_env_file, override=False)
|
||||||
lab_jar: str = ""
|
break
|
||||||
precon_dir: str = ""
|
except ImportError:
|
||||||
results_dir: str = "results"
|
pass # python-dotenv not installed; rely on real environment variables
|
||||||
port: int = 8080
|
# ─────────────────────────────────────────────────────────────
|
||||||
ximilar_api_key: str = ""
|
|
||||||
pplx_api_key: str = ""
|
log = logging.getLogger("commander_ai_lab.api")
|
||||||
|
|
||||||
|
|
||||||
CFG = Config()
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# Configuration
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
# ══════════════════════════════════════════════════════════════
|
class Config:
|
||||||
# In-Memory State
|
"""Runtime configuration -- set via CLI args or env vars."""
|
||||||
# ══════════════════════════════════════════════════════════════
|
forge_jar: str = ""
|
||||||
class BatchState:
|
forge_dir: str = ""
|
||||||
def __init__(self, batch_id: str, total_games: int, threads: int):
|
forge_decks_dir: str = ""
|
||||||
self.batch_id = batch_id
|
user_decks_dir: str = ""
|
||||||
self.total_games = total_games
|
lab_jar: str = ""
|
||||||
self.threads = threads
|
precon_dir: str = ""
|
||||||
self.completed_games = 0
|
results_dir: str = "results"
|
||||||
self.running = True
|
port: int = 8080
|
||||||
self.start_time = datetime.now()
|
ximilar_api_key: str = ""
|
||||||
self.elapsed_ms = 0
|
pplx_api_key: str = ""
|
||||||
self.result_path: Optional[str] = None
|
anthropic_api_key: str = ""
|
||||||
self.error: Optional[str] = None
|
|
||||||
self.process: Optional[subprocess.Popen] = None
|
|
||||||
self.log_lines: list = []
|
CFG = Config()
|
||||||
self.sims_per_sec: float = 0.0
|
|
||||||
|
# Pre-populate from environment so uvicorn/ASGI mode works without CLI args.
|
||||||
|
# CLI args (lab_api.py main()) will override these if provided.
|
||||||
active_batches: dict[str, BatchState] = {}
|
CFG.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||||
|
CFG.ximilar_api_key = os.environ.get("XIMILAR_API_KEY", "")
|
||||||
|
CFG.pplx_api_key = os.environ.get("PPLX_API_KEY", "")
|
||||||
COMMANDER_META: dict = {}
|
CFG.forge_jar = os.environ.get("FORGE_JAR", "")
|
||||||
|
CFG.forge_dir = os.environ.get("FORGE_DIR", "")
|
||||||
BUILTIN_COMMANDERS: dict = {
|
CFG.lab_jar = os.environ.get("LAB_JAR", "")
|
||||||
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
|
# PRECON_DIR / PRECON_DECKS_DIR are aliases -- prefer PRECON_DIR
|
||||||
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
|
CFG.precon_dir = os.environ.get("PRECON_DIR") or os.environ.get("PRECON_DECKS_DIR", "")
|
||||||
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
|
CFG.forge_decks_dir = os.environ.get("FORGE_DECKS_DIR", "")
|
||||||
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
|
CFG.user_decks_dir = os.environ.get(
|
||||||
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
|
"USER_DECKS_DIR",
|
||||||
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
|
os.path.join(os.path.dirname(__file__), "..", "user-decks")
|
||||||
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
|
)
|
||||||
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
|
|
||||||
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
|
|
||||||
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
|
def validate_forge_config() -> None:
|
||||||
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
|
"""Warn clearly at startup if FORGE_DIR is misconfigured."""
|
||||||
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
|
if not CFG.forge_dir:
|
||||||
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
|
log.warning("FORGE_DIR not set -- Forge card enrichment disabled. "
|
||||||
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
|
"Set FORGE_DIR to your forge-repo root or forge-gui directory.")
|
||||||
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
|
return
|
||||||
}
|
base = Path(CFG.forge_dir)
|
||||||
|
a = base / "forge-gui" / "res" / "cardsfolder"
|
||||||
|
b = base / "res" / "cardsfolder"
|
||||||
def load_commander_meta() -> None:
|
if not a.is_dir() and not b.is_dir():
|
||||||
"""Load commander meta mapping from file or fall back to builtins."""
|
log.warning(
|
||||||
meta_path = Path(__file__).parent.parent / "commander-meta.json"
|
"FORGE_DIR=%s is set but cardsfolder not found at:\n"
|
||||||
if meta_path.exists():
|
" %s\n %s\n"
|
||||||
try:
|
"Check that FORGE_DIR points to forge-repo root or forge-gui/.",
|
||||||
with open(meta_path, "r", encoding="utf-8") as f:
|
CFG.forge_dir, a, b
|
||||||
COMMANDER_META.clear()
|
)
|
||||||
COMMANDER_META.update(json.load(f))
|
else:
|
||||||
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
|
resolved = a if a.is_dir() else b
|
||||||
return
|
log.info("Forge cardsfolder confirmed at %s", resolved)
|
||||||
except Exception as e:
|
|
||||||
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
|
|
||||||
COMMANDER_META.clear()
|
# ══════════════════════════════════════════════════════════════
|
||||||
COMMANDER_META.update(BUILTIN_COMMANDERS)
|
# In-Memory State
|
||||||
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
class BatchState:
|
||||||
|
def __init__(self, batch_id: str, total_games: int, threads: int):
|
||||||
# ══════════════════════════════════════════════════════════════
|
self.batch_id = batch_id
|
||||||
# AI Profiles
|
self.total_games = total_games
|
||||||
# ══════════════════════════════════════════════════════════════
|
self.threads = threads
|
||||||
AI_PROFILES = {
|
self.completed_games = 0
|
||||||
"default": {"name": "default", "description": "Balanced \u2014 Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
|
self.running = True
|
||||||
"aggro": {"name": "aggro", "description": "Aggressive \u2014 attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
|
self.start_time = datetime.now()
|
||||||
"control": {"name": "control", "description": "Control \u2014 defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
|
self.elapsed_ms = 0
|
||||||
"combo": {"name": "combo", "description": "Combo \u2014 ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
|
self.result_path: Optional[str] = None
|
||||||
"midrange": {"name": "midrange", "description": "Midrange \u2014 flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
|
self.error: Optional[str] = None
|
||||||
}
|
self.process: Optional[subprocess.Popen] = None
|
||||||
|
self.log_lines: list = []
|
||||||
|
self.sims_per_sec: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
import asyncio as _asyncio
|
||||||
|
active_batches: dict[str, BatchState] = {}
|
||||||
|
active_batches_lock = _asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
COMMANDER_META: dict = {}
|
||||||
|
|
||||||
|
BUILTIN_COMMANDERS: dict = {
|
||||||
|
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
|
||||||
|
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
|
||||||
|
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
|
||||||
|
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
|
||||||
|
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
|
||||||
|
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
|
||||||
|
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
|
||||||
|
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
|
||||||
|
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
|
||||||
|
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
|
||||||
|
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
|
||||||
|
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
|
||||||
|
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
|
||||||
|
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
|
||||||
|
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_commander_meta() -> None:
|
||||||
|
"""Load commander meta mapping from file or fall back to builtins."""
|
||||||
|
meta_path = Path(__file__).parent.parent / "commander-meta.json"
|
||||||
|
if meta_path.exists():
|
||||||
|
try:
|
||||||
|
with open(meta_path, "r", encoding="utf-8") as f:
|
||||||
|
COMMANDER_META.clear()
|
||||||
|
COMMANDER_META.update(json.load(f))
|
||||||
|
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
|
||||||
|
COMMANDER_META.clear()
|
||||||
|
COMMANDER_META.update(BUILTIN_COMMANDERS)
|
||||||
|
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# AI Profiles
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
AI_PROFILES = {
|
||||||
|
"default": {"name": "default", "description": "Balanced — Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
|
||||||
|
"aggro": {"name": "aggro", "description": "Aggressive — attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
|
||||||
|
"control": {"name": "control", "description": "Control — defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
|
||||||
|
"combo": {"name": "combo", "description": "Combo — ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
|
||||||
|
"midrange": {"name": "midrange", "description": "Midrange — flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
|
||||||
|
}
|
||||||
+19
-5
@@ -50,6 +50,7 @@ DEFAULT_CONFIG = {
|
|||||||
"train_lr": 0.001, # learning rate
|
"train_lr": 0.001, # learning rate
|
||||||
"train_batch_size": 256, # training batch size
|
"train_batch_size": 256, # training batch size
|
||||||
"train_patience": 15, # early stopping patience
|
"train_patience": 15, # early stopping patience
|
||||||
|
"train_ds_weight": 4.0, # DeepSeek source upsampling weight (ds-*.jsonl files)
|
||||||
# Java-only settings (ignored for DeepSeek):
|
# Java-only settings (ignored for DeepSeek):
|
||||||
"java_threads": 4,
|
"java_threads": 4,
|
||||||
"java_clock": 300,
|
"java_clock": 300,
|
||||||
@@ -279,14 +280,21 @@ def run_update_weights(cfg):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def start_training(base_url, epochs, lr, batch_size, patience):
|
def start_training(base_url, epochs, lr, batch_size, patience, ds_weight=4.0):
|
||||||
"""Trigger ML training and return immediately."""
|
"""Trigger ML training and return immediately.
|
||||||
|
|
||||||
|
ds_weight: upsampling multiplier for DeepSeek decision files
|
||||||
|
(ml-decisions-ds-*.jsonl). Matches the default in
|
||||||
|
DatasetConfig.source_weights added in the recent
|
||||||
|
dataset_builder.py update.
|
||||||
|
"""
|
||||||
resp = api_post(base_url, "/api/ml/train", {
|
resp = api_post(base_url, "/api/ml/train", {
|
||||||
"epochs": epochs,
|
"epochs": epochs,
|
||||||
"lr": lr,
|
"lr": lr,
|
||||||
"batchSize": batch_size,
|
"batchSize": batch_size,
|
||||||
"patience": patience,
|
"patience": patience,
|
||||||
"rebuildDataset": True,
|
"rebuildDataset": True,
|
||||||
|
"dsWeight": ds_weight,
|
||||||
})
|
})
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@@ -416,6 +424,7 @@ def run_overnight(cfg):
|
|||||||
log.info(f" Engine: {engine}")
|
log.info(f" Engine: {engine}")
|
||||||
log.info(f" Auto-update weights: {'Yes' if cfg['auto_update_weights'] else 'No'}")
|
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(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
|
||||||
|
log.info(f" DS upsampling weight: {cfg['train_ds_weight']}x")
|
||||||
log.info("")
|
log.info("")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -510,7 +519,8 @@ def run_overnight(cfg):
|
|||||||
|
|
||||||
log.info("Starting ML training pipeline...")
|
log.info("Starting ML training pipeline...")
|
||||||
log.info(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
|
log.info(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
|
||||||
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
|
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}, "
|
||||||
|
f"DS Weight: {cfg['train_ds_weight']}x")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
start_training(
|
start_training(
|
||||||
@@ -519,6 +529,7 @@ def run_overnight(cfg):
|
|||||||
lr=cfg["train_lr"],
|
lr=cfg["train_lr"],
|
||||||
batch_size=cfg["train_batch_size"],
|
batch_size=cfg["train_batch_size"],
|
||||||
patience=cfg["train_patience"],
|
patience=cfg["train_patience"],
|
||||||
|
ds_weight=cfg["train_ds_weight"],
|
||||||
)
|
)
|
||||||
log.info("Training started, monitoring progress...")
|
log.info("Training started, monitoring progress...")
|
||||||
success = wait_for_training(base_url)
|
success = wait_for_training(base_url)
|
||||||
@@ -556,8 +567,8 @@ Examples:
|
|||||||
# Just run sims, skip both weight update and training:
|
# Just run sims, skip both weight update and training:
|
||||||
python overnight-run.py --no-train --no-update-weights
|
python overnight-run.py --no-train --no-update-weights
|
||||||
|
|
||||||
# Custom training settings:
|
# Custom training settings with higher DS upsampling:
|
||||||
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
|
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20 --ds-weight 6.0
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
parser.add_argument("--url", default="http://localhost:8080",
|
parser.add_argument("--url", default="http://localhost:8080",
|
||||||
@@ -590,6 +601,8 @@ Examples:
|
|||||||
help="Training batch size (default: 256)")
|
help="Training batch size (default: 256)")
|
||||||
parser.add_argument("--patience", type=int, default=15,
|
parser.add_argument("--patience", type=int, default=15,
|
||||||
help="Early stopping patience (default: 15)")
|
help="Early stopping patience (default: 15)")
|
||||||
|
parser.add_argument("--ds-weight", type=float, default=4.0,
|
||||||
|
help="DeepSeek source upsampling weight for training (default: 4.0)")
|
||||||
parser.add_argument("--threads", type=int, default=4,
|
parser.add_argument("--threads", type=int, default=4,
|
||||||
help="Java engine threads (default: 4)")
|
help="Java engine threads (default: 4)")
|
||||||
parser.add_argument("--clock", type=int, default=300,
|
parser.add_argument("--clock", type=int, default=300,
|
||||||
@@ -611,6 +624,7 @@ Examples:
|
|||||||
cfg["train_lr"] = args.lr
|
cfg["train_lr"] = args.lr
|
||||||
cfg["train_batch_size"] = args.batch_size
|
cfg["train_batch_size"] = args.batch_size
|
||||||
cfg["train_patience"] = args.patience
|
cfg["train_patience"] = args.patience
|
||||||
|
cfg["train_ds_weight"] = args.ds_weight
|
||||||
cfg["java_threads"] = args.threads
|
cfg["java_threads"] = args.threads
|
||||||
cfg["java_clock"] = args.clock
|
cfg["java_clock"] = args.clock
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
with open('ml/eval/forge_evaluator.py', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Fix 1: >= 2 to >= 3
|
||||||
|
content = content.replace('deck_files) >= 2', 'deck_files) >= 3')
|
||||||
|
|
||||||
|
# Fix 2: replace call to _run_forge_via_subprocess with _parse_batch_json
|
||||||
|
content = content.replace(
|
||||||
|
'return self._run_forge_via_subprocess(service)',
|
||||||
|
'return self._parse_batch_json(service)'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix 3: Insert _parse_batch_json method before _run_forge_via_subprocess
|
||||||
|
new_method = '''
|
||||||
|
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, encoding='utf-8') 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
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
content = content.replace(
|
||||||
|
' def _run_forge_via_subprocess(',
|
||||||
|
new_method + ' def _run_forge_via_subprocess('
|
||||||
|
)
|
||||||
|
|
||||||
|
with open('ml/eval/forge_evaluator.py', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
import py_compile
|
||||||
|
py_compile.compile('ml/eval/forge_evaluator.py', doraise=True)
|
||||||
|
print('Syntax OK - patch applied')
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Phase 1 patch: no-op mana pool display functions in lan-client/ui.js
|
||||||
|
Run from anywhere — edit UI_JS_PATH to point at your file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
UI_JS_PATH = r"D:\ForgeCommander\commander-ai-lab\lan-client\ui.js"
|
||||||
|
|
||||||
|
with open(UI_JS_PATH, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
original_size = len(content)
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
# ── Change 1: no-op renderManaPool ───────────────────────────────────────────
|
||||||
|
old1 = re.search(
|
||||||
|
r'function renderManaPool\(playerIdx\) \{[^}]*?display\.style\.display[^}]*?\}',
|
||||||
|
content, re.DOTALL
|
||||||
|
)
|
||||||
|
if old1:
|
||||||
|
content = content[:old1.start()] + \
|
||||||
|
"function renderManaPool(playerIdx) {\n // mana pool display removed (Phase 1)\n}" + \
|
||||||
|
content[old1.end():]
|
||||||
|
changes.append("renderManaPool -> stub")
|
||||||
|
else:
|
||||||
|
print("WARNING: renderManaPool pattern not matched — skipping")
|
||||||
|
|
||||||
|
# ── Change 2: no-op showManaPool ─────────────────────────────────────────────
|
||||||
|
old2 = re.search(
|
||||||
|
r'function showManaPool\(\) \{.*?\}',
|
||||||
|
content, re.DOTALL
|
||||||
|
)
|
||||||
|
if old2:
|
||||||
|
content = content[:old2.start()] + \
|
||||||
|
"function showManaPool() {\n // mana pool display removed (Phase 1)\n}" + \
|
||||||
|
content[old2.end():]
|
||||||
|
changes.append("showManaPool -> stub")
|
||||||
|
else:
|
||||||
|
print("WARNING: showManaPool pattern not matched — skipping")
|
||||||
|
|
||||||
|
# ── Change 3: remove Mana Auto-Clear row from renderSettingsPanel ─────────────
|
||||||
|
mana_row = (
|
||||||
|
" +\n '<div class=\"settings-row\"><span>Mana Auto-Clear</span>'"
|
||||||
|
" + renderToggle('manaAutoClear', gameState.manaAutoClear) + '</div>';"
|
||||||
|
)
|
||||||
|
if mana_row in content:
|
||||||
|
content = content.replace(mana_row, ";", 1)
|
||||||
|
changes.append("Mana Auto-Clear row removed from renderSettingsPanel")
|
||||||
|
else:
|
||||||
|
print("WARNING: Mana Auto-Clear row not found — skipping")
|
||||||
|
|
||||||
|
# ── Write ─────────────────────────────────────────────────────────────────────
|
||||||
|
if changes:
|
||||||
|
with open(UI_JS_PATH, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
print(f"Done. {original_size} -> {len(content)} chars ({len(content)-original_size:+d})")
|
||||||
|
for c in changes:
|
||||||
|
print(f" ✓ {c}")
|
||||||
|
else:
|
||||||
|
print("No changes applied.")
|
||||||
@@ -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]
|
[Commander]
|
||||||
1 Satya, Aetherflux Genius
|
1 Satya, Aetherflux Genius
|
||||||
[Main]
|
[Main]
|
||||||
1 Adarkar Wastes
|
1 Cayth, Famed Mechanist
|
||||||
1 Aether Hub
|
1 Sphinx of the Revelation
|
||||||
|
1 Filigree Racer
|
||||||
|
1 Localized Destruction
|
||||||
1 Aether Refinery
|
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 Aethergeode Miner
|
||||||
1 Aethersphere Harvester
|
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 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 Brudiclad, Telchor Engineer
|
||||||
1 Burnished Hart
|
1 Coveted Jewel
|
||||||
1 Castle Vantress
|
1 Goldspan Dragon
|
||||||
1 Cayth, Famed Mechanist
|
1 Legion Loyalty
|
||||||
|
1 Mystic Gate
|
||||||
|
1 Akroma's Will
|
||||||
|
1 Adarkar Wastes
|
||||||
|
1 Shivan Reef
|
||||||
1 Coalition Relic
|
1 Coalition Relic
|
||||||
1 Combustible Gearhulk
|
1 Combustible Gearhulk
|
||||||
1 Command Tower
|
1 Aetherworks Marvel
|
||||||
|
1 Angel of Invention
|
||||||
1 Confiscation Coup
|
1 Confiscation Coup
|
||||||
1 Conversion Apparatus
|
1 Aetherstorm Roc
|
||||||
1 Coveted Jewel
|
1 Aethersquall Ancient
|
||||||
1 Decoction Module
|
|
||||||
1 Demolition Field
|
|
||||||
1 Era of Innovation
|
|
||||||
1 Farewell
|
|
||||||
1 Filigree Racer
|
|
||||||
1 Frostboil Snarl
|
1 Frostboil Snarl
|
||||||
|
1 Austere Command
|
||||||
1 Furycalm Snarl
|
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 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 Professional Face-Breaker
|
||||||
1 Razorfield Ripper
|
1 Farewell
|
||||||
1 Roil Cartographer
|
1 Port Town
|
||||||
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 Temple of Triumph
|
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 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
|
1 Whirler Virtuoso
|
||||||
7 Plains
|
1 Talisman of Creativity
|
||||||
7 Island
|
1 Swords to Plowshares
|
||||||
6 Mountain
|
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
|
||||||
Loaded 100 of 321 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user