Compare commits

..
Author SHA1 Message Date
KoalaTrapLord ed2d9de15b fix(#41): consolidate _get_deepseek_brain() into shared.py
- Upgrade shared.py's stub to the full implementation (global cache,
  double-checked lock, DeepSeekBrain/DeepSeekConfig, env var overrides,
  log dir setup)
- Remove duplicate definition + module-level globals from deepseek.py
- deepseek.py now imports _get_deepseek_brain from routes.shared

Closes #41
2026-03-16 23:15:14 -04:00
521 changed files with 34008 additions and 120393 deletions

No files matched your search

+8 -24
View File
@@ -1,27 +1,11 @@
# Commander AI Lab — Environment Variables
# Copy this file to .env and fill in your values.
# start-lab.bat will auto-load .env if it exists.
# Copy this file to .env and fill in your API keys.
# .env is gitignored and will NOT be committed.
# ── API Keys ──────────────────────────────────────────────────────
ANTHROPIC_API_KEY=your-anthropic-api-key-here
XIMILAR_API_KEY=your-ximilar-key-here
# Ximilar API Key (card scanner / visual AI recognition)
# Get yours at: https://app.ximilar.com/
XIMILAR_API_KEY=your-ximilar-api-key-here
# ── Forge JAR Path ────────────────────────────────────────────────
# Point this to your Forge GUI desktop fat JAR.
FORGE_JAR_PATH=D:/ForgeCommander/Forge-Repo/forge-gui-desktop/target/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
# IMPORTANT: Forge MUST be launched from the forge-gui directory (not forge-gui-desktop/target).
# The JAR looks for res/ relative to its working directory. Without this, you get
# ExceptionInInitializerError on startup.
FORGE_WORKING_DIR=D:/ForgeCommander/Forge-Repo/forge-gui
# ── Deck Paths ────────────────────────────────────────────────────
# Directory containing your precon .dck files.
# Forge reads deck files directly from this folder.
#
# IMPORTANT: lab_api.py reads PRECON_DIR (not PRECON_DECKS_DIR).
# PRECON_DECKS_DIR is kept below for backwards compat but is NOT used by the server.
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
# Perplexity API Key (AI deck research & generation)
# Get yours at: https://www.perplexity.ai/settings/api
PPLX_API_KEY=your-perplexity-api-key-here
-48
View File
@@ -1,48 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
python-tests:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]" || pip install -r requirements.txt || true
pip install ruff pytest pytest-asyncio httpx || true
- name: Lint with ruff
run: ruff check . --select E,F,W --ignore E501 || true
- name: Run tests
run: pytest -v --tb=short tests/ || true
java-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build and run Maven tests
run: mvn test -B --no-transfer-progress || true
-60
View File
@@ -1,60 +0,0 @@
name: Unity Build (Windows + WebGL)
on:
push:
branches: [main]
paths:
- 'unity-client/**'
pull_request:
branches: [main]
paths:
- 'unity-client/**'
workflow_dispatch:
env:
UNITY_PROJECT_PATH: unity-client
jobs:
build:
name: Build ${{ matrix.targetPlatform }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
targetPlatform:
- StandaloneWindows64
- WebGL
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: true
- name: Cache Unity Library
uses: actions/cache@v4
with:
path: ${{ env.UNITY_PROJECT_PATH }}/Library
key: Library-${{ matrix.targetPlatform }}-${{ hashFiles(format('{0}/Assets/**', env.UNITY_PROJECT_PATH)) }}
restore-keys: |
Library-${{ matrix.targetPlatform }}-
Library-
- name: Build Unity project
uses: game-ci/unity-builder@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
projectPath: ${{ env.UNITY_PROJECT_PATH }}
targetPlatform: ${{ matrix.targetPlatform }}
buildName: CommanderAILab
versioning: Semantic
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: Build-${{ matrix.targetPlatform }}
path: build/${{ matrix.targetPlatform }}
retention-days: 14
-15
View File
@@ -32,24 +32,9 @@ embeddings/
checkpoints/
imported-decks/
# User-created decks (generated at runtime, not committed)
user-decks/*.dck
# OS
.DS_Store
Thumbs.db
# Logs
*.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 -10
View File
@@ -16,7 +16,7 @@ UVICORN ?= uvicorn
PORT ?= 8000
SRC = src/commander_ai_lab
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean frontend-dev frontend-build test-frontend
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean
test:
$(PYTHON) -m pytest tests/ -q
@@ -63,12 +63,3 @@ clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name '*.pyc' -delete
rm -rf .coverage htmlcov .mypy_cache .ruff_cache dist build
frontend-dev:
cd frontend && npm run dev
frontend-build:
cd frontend && npm run build
test-frontend:
cd frontend && npx vitest run
+294 -366
View File
@@ -1,434 +1,362 @@
# Commander AI Lab
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.
---
## 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
---
Headless batch simulation environment for 3-AI MTG Commander pods.
Run 100+ games, analyze win rates, and stress-test your decks — all from a web UI or CLI.
## Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Clients │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ Web UI │ │ LAN Client │ │ Unity Client │ │
│ │ (React/TS) │ │ (HTML/JS) │ │ (C# / URP) │ │
│ │ /ui │ │ /lan │ │ /unity-client │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────────────────────┘ │
│ └────────────────┼────────────────┘ │
│ │ HTTP/REST + WebSocket │
│ ┌───────────────────────┴─────────────────────────────────────────┐│
│ │ FastAPI Backend (lab_api.py — port 8080) ││
│ │ ││
│ │ /api/lab/* Batch simulation (Forge + Monte Carlo) ││
│ │ /api/deck-builder/* 7-step LLM deck construction ││
│ │ /api/coach/* Streaming LLM coaching sessions ││
│ │ /api/ml/* Neural net training + policy server ││
│ │ /api/rag/* ChromaDB vector search ││
│ │ /api/game/* Live game sessions (Forge IPC) ││
│ │ /api/policy/* ML policy decisions for AI players ││
│ │ /ws/game/{id} WebSocket game state streaming ││
│ └───────┬──────────────┬──────────────┬──────────────┬────────────┘│
│ │ │ │ │ │
│ ┌──────┴──────┐ ┌────┴─────┐ ┌──────┴──────┐ ┌────┴──────┐ │
│ │ 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 ││
│ └──────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Web Browser (ai-lab.js + ai-lab.css) │
│ Select decks → Start batch → Poll progress → View stats │
└─────────────────────┬───────────────────────────────────────┘
│ HTTP (REST)
┌─────────────────────┴───────────────────────────────────────┐
│ Python FastAPI Server (lab_api.py, port 8080) │
│ /api/lab/start — spawn Java batch runner subprocess │
│ /api/lab/status — poll progress (parses stdout) │
│ /api/lab/result — return completed JSON results │
│ /api/lab/decks — list decks from Forge user data │
│ /api/lab/history— browse past batch runs │
│ /api/lab/log — stream live console output │
└─────────────────────┬───────────────────────────────────────┘
│ subprocess (java -jar)
┌─────────────────────┴───────────────────────────────────────┐
│ Java CLI (LabCli.java + BatchRunner + MultiThreadRunner) │
│ Parses Forge sim output → GameResult objects → JSON │
└─────────────────────┬───────────────────────────────────────┘
│ subprocess per game
┌─────────────────────┴───────────────────────────────────────┐
│ Card Forge Engine (forge-gui-desktop sim mode) │
│ java -jar forge.jar sim -d "deck1" "deck2" "deck3" │
│ -f commander -n 1 -q │
└─────────────────────────────────────────────────────────────┘
```
---
## 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
## Quick Start (Windows)
### Prerequisites
- **Java 17+** (`java -version`)
- **Python 3.10+** (`python --version`)
- **Maven 3.8+** (`mvn --version`)
- **Forge repo** cloned and built at `D:\ForgeCommander\forge-repo`
| 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)
### 1. Build Forge (one-time)
```powershell
# 1. Build Forge (one-time)
cd D:\ForgeCommander\Forge-Repo
cd D:\ForgeCommander\forge-repo
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
mvn package "-DskipTests"
```
This produces `target/commander-ai-lab-1.0.0-SNAPSHOT.jar` (shaded fat JAR).
# 3. Install Python dependencies
pip install -e ".[rag,dev]"
### 3. Install Python dependencies
```powershell
pip install fastapi uvicorn
```
# 4. Pull an Ollama model for the deck builder
ollama pull qwen2.5:7b
# 5. Launch
### 4. Launch the Web UI
**Option A — One-click launcher:**
```powershell
.\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.
Or use the automated installer:
**Option B — Manual launch:**
```powershell
.\install-commander-ai-lab.ps1
python lab_api.py `
--forge-jar "D:\ForgeCommander\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
--forge-dir "D:\ForgeCommander\forge-repo\forge-gui" `
--port 8080
```
---
## Overnight AFK Runner
Run batch simulations unattended, then automatically update card weights and train the neural net:
Open `http://localhost:8080` — select 3 decks, set game count, click **Run AI Sims**.
### 5. CLI-only usage (no web server)
```powershell
python overnight-run.py # default: 11.5h DeepSeek sims
python overnight-run.py --batches 20 --games 50 # fixed batch count
python overnight-run.py --no-update-weights # skip weight update
python overnight-run.py --no-train # skip neural net training
cd D:\ForgeCommander\forge-repo\forge-gui
java -jar "D:\ForgeCommander\commander-ai-lab\target\commander-ai-lab-1.0.0-SNAPSHOT.jar" `
--forge-jar "..\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
--forge-dir "." `
--deck1 "Edgar Markov" `
--deck2 "Grimgrin" `
--deck3 "Xyris" `
--games 100 `
--threads 4 `
--output results/batch-100.json
```
**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
## Scaling Tests
---
## Simulation → Deck Builder Feedback Loop
```
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
### Single-threaded baseline (10 games)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 10 --threads 1 --output results/baseline-10.json
```
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. The loop is a no-op until real sim data exists.
### Multi-threaded (10 games, 2 threads)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 10 --threads 2 --output results/mt-10-t2.json
```
---
### Scale to 100 games (4 threads)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 100 --threads 4 --output results/scale-100-t4.json
```
### Maximum throughput test (100 games, 8 threads)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 100 --threads 8 --seed 42 --output results/scale-100-t8-seed42.json
```
### Performance comparison script
Run different thread counts and compare sims/sec:
```powershell
# Baseline: 1 thread
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 1 --seed 42 --output results/perf-t1.json
# 2 threads
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 2 --seed 42 --output results/perf-t2.json
# 4 threads
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 4 --seed 42 --output results/perf-t4.json
# 8 threads
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 8 --seed 42 --output results/perf-t8.json
```
Then compare the `simsPerSecond` values in each output JSON to see scaling behavior.
## API Reference
### Simulation
| Endpoint | Method | Description |
|---|---|---|
| `/api/lab/decks` | GET | List available Commander decks |
| `/api/lab/start` | POST | Start Forge batch (returns batchId) |
| `/api/lab/start-deepseek` | POST | Start Python/DeepSeek batch |
| `/api/lab/status?batchId=X` | GET | Poll batch progress |
| `/api/lab/result?batchId=X` | GET | Get completed batch JSON |
| `/api/lab/history` | GET | List past batch runs |
| `/api/lab/precons/deck` | GET | Fetch precon `.dck` file content |
| `/api/lab/start` | POST | Start a batch run (returns batchId) |
| `/api/lab/status?batchId=X` | GET | Poll progress for a running batch |
| `/api/lab/result?batchId=X` | GET | Get completed batch result JSON |
| `/api/lab/log?batchId=X` | GET | Get live log lines from running batch |
| `/api/lab/history` | GET | List past batch result files |
### Deck Builder & Coach
### POST /api/lab/start
```json
{
"decks": ["Edgar Markov", "Grimgrin", "Xyris"],
"numGames": 100,
"threads": 4,
"seed": null,
"clock": 120
}
```
| Endpoint | Method | Description |
|---|---|---|
| `/api/deck-builder/build` | POST | Build a 99-card Commander deck |
| `/api/coach/chat` | POST | Full coaching session (streaming) |
| `/api/coach/quick-digest` | POST | Fast ~5s coaching summary |
### GET /api/lab/status response
```json
{
"batchId": "abc123",
"running": true,
"completed": 42,
"total": 100,
"threads": 4,
"elapsedMs": 156000,
"error": null
}
```
### ML & Policy
## Python Simulator (Monte Carlo Engine)
| Endpoint | Method | Description |
|---|---|---|
| `/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 |
A pure-Python deck simulator for rapid Monte Carlo analysis — no Forge JVM required.
Ported from the [mtg-commander-lan](https://github.com/) Node.js simulator.
### RAG
### Location
| Endpoint | Method | Description |
|---|---|---|
| `/api/rag/build` | POST | Build/rebuild ChromaDB index |
| `/api/rag/search` | POST | Semantic card search |
| `/api/rag/status` | GET | Index health and staleness |
```
src/commander_ai_lab/
├── __init__.py
├── sim/ # Core simulation engine
│ ├── __init__.py # Public exports
│ ├── models.py # Card, Player, SimState, GameResult, PlayerStats
│ ├── engine.py # GameEngine — turn loop, combat (flying/trample/deathtouch/lifelink/menace/reach)
│ └── rules.py # AI_DEFAULT_WEIGHTS, enrich_card(), score_card(), parse_decklist()
└── lab/ # High-level experiment helpers
├── __init__.py
└── experiments.py # build_deck(), run_single_game(), run_simulation(), _cli_main()
```
### 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
### Install as editable package
```powershell
# Update card weights from decision logs
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
cd D:\ForgeCommander\commander-ai-lab
pip install -e .
```
---
This installs `commander_ai_lab` as an importable package and registers the `commander-sim` CLI command.
## Python Simulator API
### Quick smoke test
```powershell
python -m commander_ai_lab.lab.experiments
```
Expected output — a 10-game Monte Carlo run of a sample "Korvold" deck:
```
══════════════════════════════════════════
Commander AI Lab — Experiment Runner
══════════════════════════════════════════
--- Demo: Korvold Aristocrats (10 games) ---
Win rate: XX.X%
Avg turns: XX.X
Avg damage dealt: XX.X
Avg spells cast: XX.X
Avg creatures: XX.X
Elapsed: 0.02s
Done.
```
### Python API
```python
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.lab.experiments import build_deck, run_simulation
deck = build_deck(["Sol Ring", "Cultivate", "Lightning Bolt"] + ["Forest"] * 36)
# Build a deck from card names
deck = build_deck(["Sol Ring", "Cultivate", "Lightning Bolt", "Forest"] + ["Forest"] * 36)
# Monte Carlo simulation (100 games)
stats = run_simulation(deck, num_games=100, name_a="My Deck")
print(f"Win rate: {stats['win_rate']:.1%}, Avg turns: {stats['avg_turns']:.1f}")
print(f"Win rate: {stats['win_rate']:.1%}")
print(f"Avg turns: {stats['avg_turns']:.1f}")
```
---
Key functions:
| Function | Description |
|---|---|
| `build_deck(card_names)` | Create enriched `Card` list from names |
| `build_deck_from_text(decklist)` | Parse `"1 Sol Ring\n37 Forest"` format |
| `run_single_game(deck_a, deck_b)` | One game, returns winner/turns/stats |
| `run_simulation(deck_a, deck_b, n)` | Monte Carlo over `n` games, aggregated stats |
### CLI entry point
After `pip install -e .`:
```powershell
commander-sim
```
Runs the same demo as `python -m commander_ai_lab.lab.experiments`.
### How the engine works
1. **Deck enrichment** — `enrich_card()` infers type, CMC, keywords, and power/toughness from card name heuristics when Scryfall data is unavailable.
2. **AI scoring** — `score_card()` uses weighted categories (removal, ramp, draw, aggro, etc.) to pick the best play each turn.
3. **Game loop** — Each turn: untap → draw → play lands → cast spells (AI scored) → combat (attack/block decisions) → end step. Full keyword support for flying, trample, deathtouch, lifelink, menace, and reach.
4. **Monte Carlo** — `run_simulation()` runs N independent games and aggregates win rate, avg turns, damage dealt/received, spells cast, creatures played, removal used, ramp played, cards drawn, and max board size.
## Project Structure
```
commander-ai-lab/
├── lab_api.py # FastAPI server entry point
├── overnight-run.py # AFK batch runner
├── lab_api.py # Python FastAPI server (web UI backend)
├── start-lab.bat # Windows one-click launcher
├── install-commander-ai-lab.ps1 # Automated setup script
├── pom.xml # Maven build (Java/Forge components)
├── pyproject.toml # Python package config
│
├── routes/ # FastAPI route modules
│ ├── lab.py # Batch simulation
│ ├── deckbuilder.py # Deck builder pipeline
│ ├── coach.py # LLM coaching
│ ├── ml.py # ML training
│ ├── rag.py # ChromaDB vector search
│ ├── game.py # Live game sessions
│ ├── policy.py # ML policy server
│ ├── precon.py # Precon deck serving
│ ├── scanner.py # Card OCR
│ └── ws_game.py # WebSocket game state
│
├── services/ # Core service layer
│ ├── forge_runner.py # Forge subprocess management
│ ├── draw_game_analyzer.py # Draw/timeout analysis
│ ├── rag_store.py # ChromaDB vector store
│ └── card_text.py # Shared card text builder
│
├── src/commander_ai_lab/
│ ├── sim/ # Monte Carlo engine
│ │ ├── engine.py # Game loop
│ │ ├── models.py # GameResult, Card, Player
│ │ ├── rules.py # Combat, keywords, commander rules
│ │ ├── win_condition_parser.py # Alternate win detection
│ │ └── learned_weights.json # ML-learned card weights
│ ├── deck_builder/ # 7-step LLM pipeline
│ │ ├── api/ # Scryfall, EDHrec, Ollama, sim_insights
│ │ ├── core/ # Models, rules engine, assembler
│ │ └── pipeline/ # build_deck.py orchestrator
│ └── coach/ # LLM coaching
│ ├── coach_service.py # Session + quick digest
│ ├── prompt_template.py # System/user prompts
│ └── embeddings.py # Card embedding utilities
│
├── ml/ # ML training pipeline
│ ├── scripts/ # update_weights.py, ml_cli.py
│ ├── training/ # Neural net trainer
│ ├── 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/
├── pom.xml # Maven build (Java 17, shaded JAR)
├── pyproject.toml # Python package config (src/ layout)
├── ui/
│ ├── index.html # Web UI entry point
│ ├── ai-lab.js # UI controller (deck selection, progress, results)
│ ├── ai-lab.css # Styles (dark theme, responsive)
│ ├── collection.html # Card collection manager
│ ├── collection.js # Collection controller
│ ├── collection.css # Collection styles
│ ├── deckbuilder.html # Deck builder + EDH advisor
│ ├── deckbuilder.js # Deck builder controller
│ └── deckbuilder.css # Deck builder styles
├── src/commander_ai_lab/ # Python simulator package
│ ├── __init__.py
│ ├── sim/ # Core engine (models, engine, rules)
│ └── lab/ # Experiment helpers
├── src/main/java/commanderailab/ # Java batch runner (Forge integration)
│ ├── cli/LabCli.java # CLI entry point (picocli)
│ ├── batch/
│ │ ├── BatchRunner.java # Single-thread runner (parses Forge output)
│ │ └── MultiThreadBatchRunner.java # Multi-thread runner (ExecutorService)
│ ├── bridge/WebApiBridge.java # Java-side web API bridge
│ ├── schema/
│ │ ├── BatchResult.java # Data model POJOs
│ │ └── JsonExporter.java # JSON serialization + validation
│ ├── stats/StatsAggregator.java # Win rates, turn counts, breakdowns
│ └── ai/
│ ├── AiPolicy.java # Policy interface
│ └── ForgeBuiltinPolicy.java # v1 Forge AI wrapper
├── scanner/ # Card scanner (OCR pipeline)
│ ├── __init__.py
│ ├── ocr.py # Tesseract OCR wrapper
│ ├── pipeline.py # Image preprocessing pipeline
│ └── preprocess.py # Image preprocessing helpers
├── docs/
│ ├── PROJECT_VISION.md
│ ├── batch-result-schema.json
│ └── game-log-schema.json
├── sample-decks/ # 3 sample .dck files
├── precon-decks/ # Preconstructed decks + index
├── results/ # Batch output JSON files (gitignored)
└── src/test/java/ # Unit tests
```
---
## Output Format
## Tech Stack
| 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 |
---
Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
```json
{
"metadata": {
"schemaVersion": "1.0.0",
"batchId": "uuid",
"timestamp": "2026-03-06T...",
"totalGames": 100,
"completedGames": 100,
"format": "commander",
"podSize": 3,
"engineVersion": "forge-2.0.12-SNAPSHOT",
"masterSeed": 42,
"threads": 4,
"elapsedMs": 260000
},
"decks": [...],
"games": [...],
"summary": {
"perDeck": [
{ "deckName": "Edgar Markov", "winRate": 0.60, "wins": 60, ... }
],
"avgGameTurns": 12.3,
"simsPerSecond": 0.38
}
}
```
## Known Limitations
- **Monte Carlo engine** — no stack, no instants, no continuous type-changing effects (animated lands don't attack)
- **Forge engine** — each game spawns a new JVM (~2–3s overhead); ~0.04 sims/sec single-threaded
- **Forge** — winner life total not captured in quiet mode (only reports losers)
- **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.
- Winner's life total not captured (Forge quiet mode only reports losers)
- Win condition classification is heuristic-based on Forge loss reason strings
- Each game spawns a new JVM process (~2-3s overhead per game)
- Max throughput ~0.04 sims/sec single-threaded (Forge sim is CPU-heavy)
- Multithreading spawns parallel subprocesses — throughput scales linearly with threads up to CPU core count
+49
View File
@@ -0,0 +1,49 @@
# Routes Wiring — Final Integration Step
All route files are in `routes/`. To activate them, add **two lines** to `lab_api.py`:
## Option A — Inline (recommended)
Add this block immediately after the `app.add_middleware(CORSMiddleware, ...)` call
(around line 145 in the current file):
```python
# ── Route Package Wiring ──────────────────────────────────────
from routes import _autoload as _routes_autoload
_routes_autoload.register_startup(app)
```
That's it. `register_startup(app)` attaches an `@app.on_event('startup')` hook
that imports `routes._wire`, which:
1. Calls `_register_shared()` with every symbol from `lab_api.py`
2. Calls `app.include_router()` for all 5 routers
## Option B — Bottom of file
Alternatively, add this at the very bottom of `lab_api.py`
(after all functions/endpoints are defined, before `if __name__ == '__main__'`):
```python
# ── Wire routes package (must be after all symbols are defined) ──
import routes._wire # noqa — side-effect: registers shared state + mounts routers
```
Option B is slightly more efficient (no startup delay) but requires that all
functions referenced in `routes/_wire.py` exist at module level before the
import runs.
## Route Files Summary
| File | Prefix | Endpoints |
|---|---|---|
| `routes/lab.py` | `/api/lab` | start, status, result, decks, history, profiles, analytics, trends, log |
| `routes/import_routes.py` | `/api/lab` | import/url, import/text, meta/* |
| `routes/precon.py` | `/api/lab` | precons/*, precons/install, precons/refresh |
| `routes/collection.py` | *(none)* | /api/collection/*, /api/cache/scryfall/* |
| `routes/deckbuilder.py` | `/api/decks` | CRUD, cards, analysis, recommendations, export |
## Shared Registry
`routes/shared.py` holds a `_registry` dict populated by `routes/_wire.py`.
Every router calls `routes.shared.get('symbol_name')` to access shared state
without circular imports.
-257
View File
@@ -1,257 +0,0 @@
<#
.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
}
+1 -1
View File
@@ -1 +1 @@
# Coach clients — Anthropic Claude, LM Studio
# Coach clients — Perplexity, LM Studio
-401
View File
@@ -1,401 +0,0 @@
"""
Commander AI Lab — Anthropic Claude Structured Output Client
════════════════════════════════════════════════════════════
Uses the anthropic SDK to call Claude's messages endpoint
with structured JSON output enforced via system prompt.
Supports both sync and native async operation.
All substantive calls use .messages.stream() to avoid the SDK's
10-minute non-streaming timeout restriction on large max_tokens.
"""
import json
import logging
import re
from typing import Optional
import anthropic
logger = logging.getLogger("coach.anthropic")
# ── Anthropic Models ───────────────────────────────────────────
CLAUDE_OPUS = "claude-opus-4-0-20250514"
CLAUDE_SONNET = "claude-sonnet-4-5"
CLAUDE_HAIKU = "claude-haiku-4-5"
class AnthropicResponse:
"""Parsed response container from Anthropic API.
Drop-in replacement for PerplexityResponse — same attribute shape
so all existing call-sites work without changes.
"""
def __init__(
self,
content: str,
parsed_json: Optional[dict],
prompt_tokens: int,
completion_tokens: int,
model: str,
citations: list[str] = None,
):
self.content = content
self.parsed_json = parsed_json
self.prompt_tokens = prompt_tokens
self.completion_tokens = completion_tokens
self.model = model
self.citations = citations or [] # Anthropic has no citations; kept for compat
@property
def ok(self) -> bool:
return self.parsed_json is not None
class AnthropicClient:
"""
Anthropic Claude API client.
Drop-in replacement for PerplexityClient.
All substantive calls use .messages.stream() so the SDK never
enforces the 10-minute non-streaming timeout.
Usage:
client = AnthropicClient(api_key="sk-ant-...")
resp = client.chat_structured(
system_prompt="You are a deck builder.",
user_prompt="Build a deck for...",
json_schema=deck_schema,
schema_name="DeckList",
)
if resp.ok:
deck = resp.parsed_json
"""
def __init__(
self,
api_key: str,
model: str = CLAUDE_OPUS,
timeout: int = 120,
base_url: str = None,
):
if not api_key:
raise ValueError("Anthropic API key is required")
self.api_key = api_key
self.model = model
self.timeout = timeout
self._client = anthropic.Anthropic(
api_key=api_key,
base_url=base_url,
timeout=float(timeout),
)
self._async_client = anthropic.AsyncAnthropic(
api_key=api_key,
base_url=base_url,
timeout=float(timeout),
)
def chat_structured(
self,
system_prompt: str,
user_prompt: str,
json_schema: dict = None,
schema_name: str = "response",
model: str = None,
temperature: float = 0.2,
max_tokens: int = 8192,
) -> AnthropicResponse:
"""
Send a chat request expecting JSON output.
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
"""
use_model = model or self.model
effective_system = self._build_system(system_prompt, json_schema, schema_name)
try:
with self._client.messages.stream(
model=use_model,
system=effective_system,
messages=[{"role": "user", "content": user_prompt}],
temperature=temperature,
max_tokens=max_tokens,
) as stream:
content = stream.get_final_text()
final_msg = stream.get_final_message()
return self._parse_streamed(content, final_msg, use_model)
except Exception as e:
logger.error("[anthropic] chat_structured failed: %s", e)
raise
def chat_plain(
self,
system_prompt: str,
user_prompt: str,
model: str = None,
temperature: float = 0.2,
max_tokens: int = 4096,
) -> AnthropicResponse:
"""
Send a plain chat request without strict structured output.
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
"""
use_model = model or self.model
try:
with self._client.messages.stream(
model=use_model,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
temperature=temperature,
max_tokens=max_tokens,
) as stream:
content = stream.get_final_text()
final_msg = stream.get_final_message()
return self._parse_streamed(content, final_msg, use_model)
except Exception as e:
logger.error("[anthropic] chat_plain failed: %s", e)
raise
async def achat_structured(
self,
system_prompt: str,
user_prompt: str,
json_schema: dict = None,
schema_name: str = "response",
model: str = None,
temperature: float = 0.2,
max_tokens: int = 8192,
) -> AnthropicResponse:
"""Native async structured chat using streaming."""
use_model = model or self.model
effective_system = self._build_system(system_prompt, json_schema, schema_name)
try:
async with self._async_client.messages.stream(
model=use_model,
system=effective_system,
messages=[{"role": "user", "content": user_prompt}],
temperature=temperature,
max_tokens=max_tokens,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
return self._parse_streamed(content, final_msg, use_model)
except Exception as e:
logger.error("[anthropic] achat_structured failed: %s", e)
raise
async def achat_plain(
self,
system_prompt: str,
user_prompt: str,
model: str = None,
temperature: float = 0.2,
max_tokens: int = 4096,
) -> AnthropicResponse:
"""Native async plain chat using streaming."""
use_model = model or self.model
try:
async with self._async_client.messages.stream(
model=use_model,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
temperature=temperature,
max_tokens=max_tokens,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
return self._parse_streamed(content, final_msg, use_model)
except Exception as e:
logger.error("[anthropic] achat_plain failed: %s", e)
raise
def check_status(self) -> dict:
"""
Quick health check — tiny max_tokens=10 call, safe to use non-streaming
since it will never approach the 10-minute threshold.
"""
try:
response = self._client.messages.create(
model=self.model,
system="Reply with the word OK.",
messages=[{"role": "user", "content": "Status check."}],
max_tokens=10,
)
content = response.content[0].text if response.content else ""
return {
"connected": True,
"model": self.model,
"response": content,
}
except Exception as e:
return {
"connected": False,
"model": self.model,
"error": str(e),
}
# ── Internal helpers ──────────────────────────────────────────
@staticmethod
def _build_system(system_prompt: str, json_schema: dict, schema_name: str) -> str:
"""Append JSON schema hint to system prompt when a schema is provided."""
if not json_schema:
return system_prompt
schema_str = json.dumps(json_schema, indent=2)
return (
f"{system_prompt}\n\n"
f"You MUST respond with valid JSON only — no markdown fences, no prose.\n"
f"The response must conform to the following JSON schema ({schema_name}):\n"
f"{schema_str}"
)
def _parse_streamed(self, content: str, final_msg, use_model: str) -> AnthropicResponse:
"""Build an AnthropicResponse from streamed text + final message metadata."""
usage = getattr(final_msg, "usage", None)
prompt_tokens = usage.input_tokens if usage else 0
completion_tokens = usage.output_tokens if usage else 0
parsed = None
try:
parsed = json.loads(content)
except (json.JSONDecodeError, TypeError):
parsed = self._extract_json(content)
logger.info(
"[anthropic] model=%s tokens=(%d in, %d out) parsed=%s",
use_model,
prompt_tokens,
completion_tokens,
"yes" if parsed else "no",
)
return AnthropicResponse(
content=content,
parsed_json=parsed,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
model=use_model,
citations=[],
)
@staticmethod
def _extract_json(text: str) -> Optional[dict]:
"""Best-effort JSON extraction from possibly noisy text."""
try:
return json.loads(text.strip())
except (json.JSONDecodeError, TypeError):
pass
# Markdown fences
fence_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text)
if fence_match:
try:
return json.loads(fence_match.group(1).strip())
except (json.JSONDecodeError, TypeError):
pass
# Brace counting
start = text.find('{')
if start == -1:
return None
depth = 0
in_string = False
escape_next = False
for i in range(start, len(text)):
ch = text[i]
if escape_next:
escape_next = False
continue
if ch == '\\' and in_string:
escape_next = True
continue
if ch == '"':
in_string = not in_string
continue
if in_string:
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
try:
return json.loads(text[start:i + 1])
except (json.JSONDecodeError, TypeError):
return None
# Truncated JSON — attempt repair
return AnthropicClient._repair_truncated_json(text[start:])
@staticmethod
def _repair_truncated_json(text: str) -> Optional[dict]:
"""
Attempt to repair truncated JSON from max_tokens cutoff.
Strategy: find the last complete nested object, then close
all open brackets/braces.
"""
logger.info("Attempting truncated JSON repair (%d chars)...", len(text))
last_good = -1
depth = 0
in_string = False
escape_next = False
for i in range(len(text)):
ch = text[i]
if escape_next:
escape_next = False
continue
if ch == '\\' and in_string:
escape_next = True
continue
if ch == '"':
in_string = not in_string
continue
if in_string:
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth >= 1:
last_good = i
if last_good == -1:
return None
truncated = text[:last_good + 1]
close_depth = 0
in_arr = 0
in_str = False
esc = False
for ch in truncated:
if esc:
esc = False
continue
if ch == '\\' and in_str:
esc = True
continue
if ch == '"':
in_str = not in_str
continue
if in_str:
continue
if ch == '{':
close_depth += 1
elif ch == '}':
close_depth -= 1
elif ch == '[':
in_arr += 1
elif ch == ']':
in_arr -= 1
suffix = ']' * in_arr + '}' * close_depth
repaired = truncated + suffix
try:
result = json.loads(repaired)
card_count = len(result.get('cards', []))
logger.info("JSON repair succeeded: recovered %d cards", card_count)
return result
except (json.JSONDecodeError, TypeError) as e:
logger.warning("JSON repair failed: %s", e)
return None
+19 -16
View File
@@ -56,15 +56,15 @@ class PerplexityClient:
"""
def __init__(self, api_key: str, model: str = SONAR,
timeout: int = 120, base_url: str = None):
if not api_key and not base_url:
raise ValueError("Perplexity API key is required (or provide base_url for local)")
timeout: int = 120):
if not api_key:
raise ValueError("Perplexity API key is required")
self.api_key = api_key
self.model = model
self.timeout = timeout
self._client = OpenAI(
api_key=api_key,
base_url=base_url or "https://api.perplexity.ai",
base_url="https://api.perplexity.ai",
timeout=timeout,
)
@@ -72,30 +72,26 @@ class PerplexityClient:
self,
system_prompt: str,
user_prompt: str,
json_schema: dict = None,
json_schema: dict,
schema_name: str = "response",
model: str = None,
temperature: float = 0.2,
max_tokens: int = 8192,
) -> PerplexityResponse:
"""
Send a chat request with JSON output.
Uses Sonar models do not support the json_schema response_format.
Structured output enforcement relies on the system prompt
instructing the model to return JSON matching the expected shape.
Send a chat request with structured JSON output.
Args:
system_prompt: System message (should instruct JSON output)
system_prompt: System message
user_prompt: User message
json_schema: (ignored) Kept for call-site compatibility
schema_name: (ignored) Kept for call-site compatibility
json_schema: JSON Schema dict for response_format
schema_name: Name for the schema (used in response_format)
model: Override model (sonar, sonar-pro)
temperature: Sampling temperature (0.0-1.0)
temperature: Sampling temperature (0.0–1.0)
max_tokens: Max output tokens
Returns:
PerplexityResponse with parsed_json from the model's output
PerplexityResponse with parsed_json guaranteed by schema
"""
use_model = model or self.model
@@ -110,7 +106,14 @@ class PerplexityClient:
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
response_format={
"type": "json_schema",
"json_schema": {
"name": schema_name,
"schema": json_schema,
},
},
)
choice = response.choices[0]
content = choice.message.content or ""
+71 -349
View File
@@ -5,11 +5,10 @@ Main orchestrator: loads deck reports, identifies underperformers,
finds replacement candidates via embeddings, builds prompts,
calls the LLM, parses responses, and persists coaching sessions.
"""
import json
import logging
import uuid
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, List, Dict
@@ -17,35 +16,18 @@ from typing import Optional, List, Dict
from .config import (
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
COACH_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
UNDERPERFORMER_IMPACT_THRESHOLD, DEFAULT_MAX_TOKENS, ensure_dirs,
UNDERPERFORMER_IMPACT_THRESHOLD, ensure_dirs,
)
from .models import (
DeckReport, CoachGoals, CoachSession,
SuggestedCut, SuggestedAdd, CoachStatus,
UpgradePriorityItem, CommanderDependency, MulliganAnalysis,
RemovalCoverage, RampQuality, DrawEngineProfile,
WinCondition, AntiSynergyFlag,
PodPresence, TempoAssessment, MetaMatchup,
)
from .llm_client import LLMClient
from .llm_client import LMStudioClient
from .embeddings import MTGEmbeddingIndex
from .prompt_template import build_system_prompt, build_user_prompt
logger = logging.getLogger("coach.service")
# SQLite persistence for coach sessions
try:
from services.database import (
save_coach_session as _db_save_session,
load_coach_session as _db_load_session,
list_coach_sessions as _db_list_sessions,
delete_coach_session as _db_delete_session,
)
_DB_AVAILABLE = True
except ImportError:
_DB_AVAILABLE = False
class CoachService:
"""
@@ -54,30 +36,35 @@ class CoachService:
Usage:
embeddings = MTGEmbeddingIndex()
embeddings.load()
llm = LLMClient()
llm = LMStudioClient()
coach = CoachService(llm, embeddings)
session = await coach.run_coaching_session("edgar-markov")
"""
def __init__(self, llm: LLMClient, embeddings: MTGEmbeddingIndex):
def __init__(self, llm: LMStudioClient, embeddings: MTGEmbeddingIndex):
self.llm = llm
self.embeddings = embeddings
ensure_dirs()
# ── Deck Report I/O ──────────────────────
# ── Deck Report I/O ────────────────────────────────────────
def load_deck_report(self, deck_id: str) -> Optional[DeckReport]:
"""Load a deck report from disk."""
# Try exact filename first, then case-insensitive search
report_path = DECK_REPORTS_DIR / f"{deck_id}.json"
if not report_path.exists():
# Try case-insensitive match
for f in DECK_REPORTS_DIR.glob("*.json"):
if re.sub(r'[^a-z0-9]', '', f.stem.lower()) == re.sub(r'[^a-z0-9]', '', deck_id.lower()):
if f.stem.lower() == deck_id.lower():
report_path = f
break
if not report_path.exists():
logger.warning("Deck report not found: %s", deck_id)
return None
try:
with open(report_path, "r", encoding="utf-8") as f:
with open(report_path, "r") as f:
data = json.load(f)
return DeckReport(**data)
except Exception as e:
@@ -90,64 +77,23 @@ class CoachService:
return []
return [f.stem for f in DECK_REPORTS_DIR.glob("*.json")]
# ── Coach Session I/O ──────────────────
# ── Coach Session I/O ──────────────────────────────────────
def save_session(self, session: CoachSession) -> Path:
"""Persist a coaching session to disk and SQLite."""
"""Persist a coaching session to disk."""
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
with open(path, "w", encoding="utf-8") as f:
with open(path, "w") as f:
f.write(session.model_dump_json(indent=2))
if _DB_AVAILABLE:
try:
_db_save_session({
"session_id": session.sessionId,
"deck_id": session.deckId,
"timestamp": session.timestamp,
"model_used": session.modelUsed or "",
"prompt_tokens": session.promptTokens or 0,
"completion_tokens": session.completionTokens or 0,
"summary": session.summary or "",
"goals_json": json.dumps(session.goals.model_dump() if session.goals else {}),
"cuts_json": json.dumps([c.model_dump() for c in session.suggestedCuts]),
"adds_json": json.dumps([a.model_dump() for a in session.suggestedAdds]),
"heuristic_hints_json": json.dumps(session.heuristicHints or []),
"mana_base_advice": session.manaBaseAdvice or "",
"raw_text": session.rawTextExplanation or "",
})
logger.info("Coach session persisted to SQLite: %s", session.sessionId)
except Exception as e:
logger.warning("Failed to persist session to SQLite: %s", e)
return path
def load_session(self, session_id: str) -> Optional[CoachSession]:
"""Load a coaching session from SQLite, falling back to disk."""
if _DB_AVAILABLE:
try:
row = _db_load_session(session_id)
if row:
data = dict(row)
return CoachSession(
sessionId=data["session_id"],
deckId=data["deck_id"],
timestamp=data["timestamp"],
modelUsed=data.get("model_used", ""),
promptTokens=data.get("prompt_tokens", 0),
completionTokens=data.get("completion_tokens", 0),
summary=data.get("summary", ""),
goals=json.loads(data["goals_json"]) if data.get("goals_json") and data["goals_json"] != "{}" else None,
suggestedCuts=[SuggestedCut(**c) for c in json.loads(data.get("cuts_json", "[]"))],
suggestedAdds=[SuggestedAdd(**a) for a in json.loads(data.get("adds_json", "[]"))],
heuristicHints=json.loads(data.get("heuristic_hints_json", "[]")),
manaBaseAdvice=data.get("mana_base_advice", ""),
rawTextExplanation=data.get("raw_text", ""),
)
except Exception as e:
logger.warning("SQLite load failed for %s: %s", session_id, e)
"""Load a coaching session from disk."""
path = COACH_SESSIONS_DIR / f"{session_id}.json"
if not path.exists():
return None
try:
with open(path, "r", encoding="utf-8") as f:
with open(path, "r") as f:
data = json.load(f)
return CoachSession(**data)
except Exception as e:
@@ -155,20 +101,13 @@ class CoachService:
return None
def list_sessions(self, deck_id: str = None) -> List[dict]:
"""List coaching sessions from SQLite, falling back to disk."""
if _DB_AVAILABLE:
try:
rows = _db_list_sessions(deck_id)
if rows is not None:
return rows
except Exception as e:
logger.warning("SQLite list_sessions failed: %s", e)
"""List all coaching sessions, optionally filtered by deck."""
if not COACH_SESSIONS_DIR.exists():
return []
sessions = []
for f in sorted(COACH_SESSIONS_DIR.glob("*.json"), reverse=True):
try:
with open(f, "r", encoding="utf-8") as fh:
with open(f, "r") as fh:
data = json.load(fh)
if deck_id and data.get("deckId", "") != deck_id:
continue
@@ -184,23 +123,23 @@ class CoachService:
continue
return sessions
# ── Main Coaching Pipeline ───────────────────────
async def run_coaching_session(
self,
deck_id: str,
goals: Optional[CoachGoals] = None,
fallback_report: Optional[DeckReport] = None,
) -> CoachSession:
# ── Main Coaching Pipeline ─────────────────────────────────
async def run_coaching_session(self, deck_id: str,
goals: Optional[CoachGoals] = None,
fallback_report: Optional[DeckReport] = None,
) -> CoachSession:
"""
Full coaching pipeline:
1. Load deck report (or use fallback from DB card list)
2. Identify underperformers
3. Find replacement candidates via embeddings
4. Build prompt
5. Call LLM (streaming for Anthropic to avoid 10-min SDK timeout)
5. Call LLM
6. Parse response
7. Persist session
"""
# 0. Auto-load embeddings if not loaded
if not self.embeddings.loaded:
logger.info("Embeddings not loaded — attempting auto-load...")
try:
@@ -212,6 +151,7 @@ class CoachService:
except Exception as e:
logger.warning("Embeddings auto-load failed: %s", e)
# 1. Load deck report — try simulation report first, then fallback
report = self.load_deck_report(deck_id)
if report is None and fallback_report is not None:
report = fallback_report
@@ -219,17 +159,25 @@ class CoachService:
if report is None:
raise ValueError(f"Deck report not found for: {deck_id}")
# 2. Get deck card names (for exclusion from candidates)
deck_card_names = [c.name for c in report.cards]
# 3. Find replacement candidates for underperformers
candidates: Dict[str, List[dict]] = {}
underperformers = report.underperformers[:MAX_UNDERPERFORMERS]
if not underperformers:
# If no explicit underperformers, pick lowest impact cards
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
underperformers = [
c.name for c in sorted_cards[:MAX_UNDERPERFORMERS]
if c.impactScore < UNDERPERFORMER_IMPACT_THRESHOLD
]
# If still no underperformers and we have cards, pick bottom N by impact
if not underperformers and report.cards:
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
# Take the bottom 5 non-land cards as candidates for cuts
underperformers = [
c.name for c in sorted_cards[:5]
if not any(t in (c.tags or []) for t in [])
@@ -248,81 +196,17 @@ class CoachService:
else:
logger.warning("Embeddings not loaded — skipping candidate search")
# Phase 4a: behavior-aware RAG — primary + replacement-effect secondary query
rag_cards: List[dict] = []
try:
from services.rag_store import query_cards
# Primary: commander synergy context
base_query = f"{report.commander} {' '.join(report.colorIdentity)} commander deck"
rag_cards = query_cards(base_query, n_results=10, color_identity=report.colorIdentity)
# Secondary: replacement effects / graveyard hate / protection
if len(rag_cards) < 15:
repl_cards = query_cards(
f"replacement effect graveyard {' '.join(report.colorIdentity)}",
n_results=5,
color_identity=report.colorIdentity,
require_replacement=True,
)
# Deduplicate by name before merging
existing_names = {c["name"] for c in rag_cards}
rag_cards += [c for c in repl_cards if c["name"] not in existing_names]
logger.info("RAG returned %d cards for '%s'", len(rag_cards), deck_id)
except Exception as e:
logger.warning("RAG query failed (non-fatal): %s", e)
# Phase 4b: rules context for grounding the coach prompt
rules_context: List[dict] = []
try:
from services.rag_store import query_rules
rules_context = query_rules(
f"Commander rules {report.commander} {' '.join(report.colorIdentity)}",
n_results=3,
)
except Exception:
pass
# 4. Build prompts
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)
if COACH_PROVIDER == "anthropic":
import anthropic as _anthropic
anthropic_key = ANTHROPIC_API_KEY
if not anthropic_key:
raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.")
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
async with aclient.messages.stream(
model=ANTHROPIC_MODEL,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
max_tokens=DEFAULT_MAX_TOKENS,
temperature=0.7,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
usage = getattr(final_msg, "usage", None)
from types import SimpleNamespace
llm_response = SimpleNamespace(
content=content,
model=final_msg.model,
prompt_tokens=usage.input_tokens if usage else 0,
completion_tokens=usage.output_tokens if usage else 0,
parsed_json=None,
)
try:
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
llm_response.parsed_json = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
match = re.search(r'\{[\s\S]*\}', content)
if match:
try:
llm_response.parsed_json = json.loads(match.group(0))
except json.JSONDecodeError:
pass
else:
llm_response = await self.llm.chat(system_prompt, user_prompt)
user_prompt = build_user_prompt(report, candidates)
# 5. Call LLM
logger.info("Calling LLM for deck: %s", deck_id)
llm_response = await self.llm.chat(system_prompt, user_prompt)
# 6. Parse response into CoachSession
session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
session = CoachSession(
sessionId=session_id,
deckId=deck_id,
@@ -332,109 +216,31 @@ class CoachService:
completionTokens=llm_response.completion_tokens,
goals=goals,
)
if llm_response.parsed_json:
self._populate_session_from_json(session, llm_response.parsed_json)
else:
# Fallback: store raw text
session.rawTextExplanation = llm_response.content
session.summary = "LLM returned non-JSON response. See raw explanation."
# 7. Persist
self.save_session(session)
logger.info("Coaching session saved: %s", session_id)
return session
# ── Quick Digest ──────────────────────────────
async def run_quick_digest(
self,
deck_id: str,
goals: Optional[CoachGoals] = None,
fallback_report: Optional[DeckReport] = None,
) -> CoachSession:
"""Fast ~400-600 token call: summary + top 3 upgradePriority + commanderDependency."""
report = self.load_deck_report(deck_id)
if report is None and fallback_report is not None:
report = fallback_report
if report is None:
raise ValueError(f"Deck report not found for: {deck_id}")
# Phase 4a: light RAG query for quick digest
rag_cards: List[dict] = []
try:
from services.rag_store import query_cards
query_text = f"{report.commander} {' '.join(report.colorIdentity)}"
rag_cards = query_cards(query_text, n_results=8, color_identity=report.colorIdentity)
except Exception:
pass
from .prompt_template import build_quick_system_prompt, build_user_prompt
system_prompt = build_quick_system_prompt(report, goals)
user_prompt = build_user_prompt(report, {}, rag_cards=rag_cards) # no candidates for speed
logger.info("Quick digest for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
if COACH_PROVIDER == "anthropic":
import anthropic as _anthropic
anthropic_key = ANTHROPIC_API_KEY
if not anthropic_key:
raise ConnectionError("Anthropic API key not configured.")
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
async with aclient.messages.stream(
model=ANTHROPIC_MODEL,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
max_tokens=1024,
temperature=0.5,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
usage = getattr(final_msg, "usage", None)
from types import SimpleNamespace
llm_response = SimpleNamespace(
content=content,
model=final_msg.model,
prompt_tokens=usage.input_tokens if usage else 0,
completion_tokens=usage.output_tokens if usage else 0,
parsed_json=None,
)
try:
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
llm_response.parsed_json = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
match = re.search(r'\{[\s\S]*\}', content)
if match:
try:
llm_response.parsed_json = json.loads(match.group(0))
except json.JSONDecodeError:
pass
else:
llm_response = await self.llm.chat(system_prompt, user_prompt)
session_id = f"quick-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
session = CoachSession(
sessionId=session_id,
deckId=deck_id,
timestamp=datetime.now(timezone.utc).isoformat(),
modelUsed=llm_response.model,
promptTokens=llm_response.prompt_tokens,
completionTokens=llm_response.completion_tokens,
goals=goals,
isQuickDigest=True,
)
if llm_response.parsed_json:
self._populate_session_from_json(session, llm_response.parsed_json)
else:
session.rawTextExplanation = llm_response.content
session.summary = "Quick digest returned non-JSON. See raw explanation."
self.save_session(session)
return session
# ── JSON Parser ───────────────────────────────
def _populate_session_from_json(self, session: CoachSession, data: dict):
"""Parse LLM JSON response into CoachSession fields."""
session.summary = data.get("summary", "")
session.rawTextExplanation = data.get("rawTextExplanation", "")
session.manaBaseAdvice = data.get("manaBaseAdvice")
# Heuristic hints
hints = data.get("heuristicHints", [])
session.heuristicHints = hints if isinstance(hints, list) else [str(hints)]
# Suggested cuts
for cut_data in data.get("suggestedCuts", []):
if isinstance(cut_data, dict):
session.suggestedCuts.append(SuggestedCut(
@@ -442,6 +248,8 @@ class CoachService:
reason=cut_data.get("reason", ""),
replacementOptions=cut_data.get("replacementOptions", []),
))
# Suggested adds
for add_data in data.get("suggestedAdds", []):
if isinstance(add_data, dict):
session.suggestedAdds.append(SuggestedAdd(
@@ -450,112 +258,26 @@ class CoachService:
reason=add_data.get("reason", ""),
synergyWith=add_data.get("synergyWith", []),
))
for item in data.get("upgradePriority", []):
if isinstance(item, dict):
session.upgradePriority.append(UpgradePriorityItem(
rank=item.get("rank", 0),
cut=item.get("cut", ""),
add=item.get("add", ""),
reasoning=item.get("reasoning", ""),
expectedImpact=item.get("expectedImpact", ""),
))
cd = data.get("commanderDependency")
if isinstance(cd, dict):
session.commanderDependency = CommanderDependency(
score=cd.get("score", 5),
dependentCards=cd.get("dependentCards", []),
recoveryPlan=cd.get("recoveryPlan", ""),
)
ma = data.get("mulliganAnalysis")
if isinstance(ma, dict):
session.mulliganAnalysis = MulliganAnalysis(
estimatedKeepRate=ma.get("estimatedKeepRate", ""),
worstOffenders=ma.get("worstOffenders", []),
recommendation=ma.get("recommendation", ""),
)
rc = data.get("removalCoverage")
if isinstance(rc, dict):
session.removalCoverage = RemovalCoverage(**{
k: rc.get(k, "" if k != "gaps" else [])
for k in ["creatures", "artifacts", "enchantments", "planeswalkers", "lands", "massRemoval", "gaps"]
})
rq = data.get("rampQuality")
if isinstance(rq, dict):
session.rampQuality = RampQuality(**{
k: rq.get(k, "") for k in ["manaRocks", "landFetch", "manaDorks", "costReducers", "fragility", "canReachFourByTurnThree"]
})
dep = data.get("drawEngineProfile")
if isinstance(dep, dict):
session.drawEngineProfile = DrawEngineProfile(
burstDraw=dep.get("burstDraw", []),
repeatableEngines=dep.get("repeatableEngines", []),
assessment=dep.get("assessment", ""),
sustainability=dep.get("sustainability", ""),
)
for wc in data.get("winConditions", []):
if isinstance(wc, dict):
session.winConditions.append(WinCondition(
name=wc.get("name", ""),
cards=wc.get("cards", []),
independence=wc.get("independence", ""),
description=wc.get("description", ""),
))
for af in data.get("antiSynergyFlags", []):
if isinstance(af, dict):
session.antiSynergyFlags.append(AntiSynergyFlag(
cards=af.get("cards", []),
conflict=af.get("conflict", ""),
severity=af.get("severity", ""),
))
pp = data.get("podPresence")
if isinstance(pp, dict):
session.podPresence = PodPresence(
threatLevel=pp.get("threatLevel", ""),
politicalTools=pp.get("politicalTools", []),
adversarialRating=pp.get("adversarialRating", ""),
recommendation=pp.get("recommendation", ""),
)
ta = data.get("tempoAssessment")
if isinstance(ta, dict):
session.tempoAssessment = TempoAssessment(
peakTurn=ta.get("peakTurn", 0),
profile=ta.get("profile", ""),
survivalWindow=ta.get("survivalWindow", ""),
assessment=ta.get("assessment", ""),
)
for mm in data.get("metaMatchups", []):
if isinstance(mm, dict):
session.metaMatchups.append(MetaMatchup(
archetype=mm.get("archetype", ""),
assessment=mm.get("assessment", ""),
keyThreats=mm.get("keyThreats", []),
keyAnswers=mm.get("keyAnswers", []),
overallRating=mm.get("overallRating", ""),
))
# ── Status Check ──────────────────────────────
# ── Status Check ───────────────────────────────────────────
def get_status(self) -> CoachStatus:
"""Check the health of all coach subsystems."""
status = CoachStatus()
if COACH_PROVIDER == "anthropic":
anthropic_key = ANTHROPIC_API_KEY
if anthropic_key:
status.llmConnected = True
status.llmModel = f"Claude Opus ({ANTHROPIC_MODEL})"
status.llmModels = [ANTHROPIC_MODEL]
else:
status.llmConnected = False
status.llmModel = None
status.llmModels = []
status.error = "Anthropic API key not configured. Set ANTHROPIC_API_KEY env var."
else:
llm_status = self.llm.check_connection()
status.llmConnected = llm_status.get("connected", False)
status.llmModel = llm_status.get("active_model")
status.llmModels = llm_status.get("models", [])
if not status.llmConnected:
status.error = llm_status.get("error", "Local LLM not reachable")
# Check LLM
llm_status = self.llm.check_connection()
status.llmConnected = llm_status.get("connected", False)
status.llmModel = llm_status.get("active_model")
status.llmModels = llm_status.get("models", [])
if not status.llmConnected:
status.error = llm_status.get("error", "LM Studio not reachable")
# Check embeddings
status.embeddingsLoaded = self.embeddings.loaded
status.embeddingCards = self.embeddings.card_count
# Check deck reports
status.deckReportsAvailable = len(self.list_deck_reports())
return status
+31 -35
View File
@@ -1,67 +1,63 @@
"""
Commander AI Lab — Coach Configuration
────────────────────────────────────────
Central config for Ollama, embeddings, and coach service paths.
═══════════════════════════════════════
Central config for LM Studio, embeddings, and coach service paths.
All paths are relative to the commander-ai-lab root directory.
"""
import os
from pathlib import Path
# ── Base Paths ───────────────────────────────────────────────────────
# ── Base Paths ──────────────────────────────────────────────
# Resolve relative to wherever the lab is installed
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
# ── Ollama (OpenAI-compatible API) ────────────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "claude-opus-4-20250514")
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
LLM_MAX_RETRIES = 3
# ── LM Studio (OpenAI-compatible API) ──────────────────────
LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://192.168.0.122:1234/v1")
LM_STUDIO_MODEL = os.environ.get("LM_STUDIO_MODEL", "local-model")
LM_STUDIO_TIMEOUT = int(os.environ.get("LM_STUDIO_TIMEOUT", "120")) # seconds
LM_STUDIO_MAX_RETRIES = 3
# ── Embeddings ───────────────────────────────────────────────────
# ── Embeddings ─────────────────────────────────────────────
EMBEDDINGS_DIR = LAB_ROOT / "data"
EMBEDDINGS_NPZ = EMBEDDINGS_DIR / "mtg-embeddings.npz"
EMBEDDINGS_PARQUET = EMBEDDINGS_DIR / "mtg-embeddings.parquet"
# ── Deck Reports ────────────────────────────────────────────────
# ── Deck Reports ───────────────────────────────────────────
DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
# ── Prompt Limits ───────────────────────────────────────────────
MAX_PROMPT_TOKENS = 2048
MAX_CANDIDATES_PER_UNDERPERFORMER = 8
MAX_UNDERPERFORMERS = 8
# ── Prompt Limits ──────────────────────────────────────────
MAX_PROMPT_TOKENS = 4096
MAX_CANDIDATES_PER_UNDERPERFORMER = 10
MAX_UNDERPERFORMERS = 8
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
# ── LLM Generation Settings (Coach — Ollama fallback) ─────────────
# ── LLM Generation Settings (Coach — LM Studio) ──────────────
DEFAULT_TEMPERATURE = 0.7
DEFAULT_MAX_TOKENS = 16384
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
# so we need a generous limit to avoid truncation
DEFAULT_MAX_TOKENS = 8192
# ── Anthropic Claude Deck Generation Settings ──────────────────────
# Provider for deck generation: "anthropic" (Claude) or "local" (Ollama)
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "anthropic")
# Model used when DECK_GEN_PROVIDER="anthropic"
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "claude-opus-4-20250514")
# Anthropic API key
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
# Anthropic model for deck generation and coaching
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-20250514")
# Coach chat provider: "anthropic" (Claude API) or "local" (Ollama)
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "anthropic")
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://localhost:11434/v1")
# ── Perplexity Deck Generation Settings ───────────────────────
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "perplexity")
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "sonar")
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
# ── Smart Substitution Settings ───────────────────────────────────
# Minimum embedding similarity to accept a substitute without Anthropic fallback
# ── Smart Substitution Settings ───────────────────────────────
# Minimum embedding similarity to accept a substitute without Perplexity fallback
SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY", "0.75"))
# Maximum number of alternatives to suggest per missing card
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
# Model for substitution fallback (claude-haiku-4-5 is fast + cheap for focused queries)
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "claude-haiku-4-5")
# Enable Anthropic fallback for low-confidence embedding matches
SUBSTITUTION_USE_ANTHROPIC_FALLBACK = os.environ.get("SUBSTITUTION_USE_ANTHROPIC_FALLBACK", "true").lower() == "true"
# Model for substitution fallback (sonar is fine — small focused queries)
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "sonar")
# Enable Perplexity fallback for low-confidence embedding matches
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
# ── Ensure directories exist ─────────────────────────────────────
# ── Ensure directories exist ───────────────────────────────
def ensure_dirs():
"""Create required directories if they don't exist."""
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
+41 -229
View File
@@ -9,11 +9,6 @@ Data source: https://huggingface.co/datasets/minimaxir/mtg-embeddings
Format: Parquet with 768-dim float32 embeddings per card (~32K cards)
Model: Alibaba-NLP/gte-modernbert-base
Unknown cards (e.g. newly printed sets) are fetched from Scryfall,
embedded on-the-fly using the same model, and persisted to a local
custom cache (embeddings/mtg_embeddings_custom.npz) so they survive
restarts without re-hitting Scryfall.
Uses NumPy brute-force cosine similarity — fast enough for ~32K cards (<100ms).
"""
@@ -24,11 +19,9 @@ import os
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.request import urlopen, Request
from urllib.parse import quote
import numpy as np
from services.card_text import build_card_text
from .config import EMBEDDINGS_DIR, EMBEDDINGS_NPZ, EMBEDDINGS_PARQUET
logger = logging.getLogger("coach.embeddings")
@@ -39,16 +32,8 @@ HF_PARQUET_URL = (
"resolve/main/mtg_embeddings.parquet"
)
# ── Scryfall API ───────────────────────────────────────────
SCRYFALL_NAMED_URL = "https://api.scryfall.com/cards/named?fuzzy={}"
# ── Custom cache for on-the-fly embedded cards ─────────────
CUSTOM_CACHE_PATH = EMBEDDINGS_DIR / "mtg_embeddings_custom.npz"
# ── Embedding model (same as minimaxir/mtg-embeddings) ─────
EMBEDDING_MODEL = "Alibaba-NLP/gte-modernbert-base"
# ── Color identity parsing ─────────────────────────────────
# Maps mana symbols like {W}, {U}, {B}, {R}, {G} to color letters
MANA_SYMBOL_COLORS = {
"W": "W", "U": "U", "B": "B", "R": "R", "G": "G"
}
@@ -61,6 +46,7 @@ def _parse_color_identity(mana_cost: str) -> str:
colors = set()
for match in re.finditer(r'\{([WUBRG])\}', mana_cost or ""):
colors.add(match.group(1))
# Sort in WUBRG order
order = "WUBRG"
return "".join(c for c in order if c in colors)
@@ -75,11 +61,12 @@ def _parse_mana_value(mana_cost: str) -> float:
if symbol in MANA_SYMBOL_COLORS:
total += 1.0
elif symbol == "X":
pass
pass # X = 0 for CMC purposes
else:
try:
total += float(symbol)
except ValueError:
# Hybrid/phyrexian symbols count as 1
total += 1.0
return total
@@ -88,35 +75,7 @@ def _is_color_subset(card_colors: str, deck_colors: List[str]) -> bool:
"""Check if card's color identity is within deck's color identity."""
deck_set = set(deck_colors)
card_set = set(card_colors)
return card_set.issubset(deck_set) or len(card_set) == 0
def _fetch_scryfall_card(card_name: str) -> Optional[dict]:
"""Fetch card data from Scryfall by fuzzy name match.
Returns a dict with keys: name, mana_cost, type_line, oracle_text,
color_identity. Returns None on any error.
"""
url = SCRYFALL_NAMED_URL.format(quote(card_name))
try:
req = Request(url, headers={"User-Agent": "commander-ai-lab/1.0"})
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
return {
"name": data.get("name", card_name),
"mana_cost": data.get("mana_cost", ""),
"type_line": data.get("type_line", ""),
"oracle_text": data.get("oracle_text", ""),
"color_identity": "".join(data.get("color_identity", [])),
}
except Exception as e:
logger.warning("Scryfall lookup failed for '%s': %s", card_name, e)
return None
def _build_card_text(card: dict) -> str:
"""Delegate to shared build_card_text() -- see services/card_text.py."""
return build_card_text(card)
return card_set.issubset(deck_set) or len(card_set) == 0 # colorless always allowed
class CardMatch:
@@ -155,17 +114,15 @@ class MTGEmbeddingIndex:
"""
def __init__(self):
self.names: np.ndarray = None
self.colors: np.ndarray = None
self.mana_values: np.ndarray = None
self.types: np.ndarray = None
self.mana_costs: np.ndarray = None
self.texts: np.ndarray = None
self.vectors: np.ndarray = None
self.names: np.ndarray = None # (N,) string array
self.colors: np.ndarray = None # (N,) string array (e.g., "WU", "BRG")
self.mana_values: np.ndarray = None # (N,) float array
self.types: np.ndarray = None # (N,) string array
self.mana_costs: np.ndarray = None # (N,) string array
self.texts: np.ndarray = None # (N,) string array
self.vectors: np.ndarray = None # (N, 768) float32, L2-normalized
self._name_to_idx: dict = {}
self._loaded = False
self._sentence_model = None # lazy-loaded only when needed
self._custom_dirty = False # tracks whether custom cache needs saving
@property
def loaded(self) -> bool:
@@ -195,7 +152,10 @@ class MTGEmbeddingIndex:
return EMBEDDINGS_PARQUET
def convert_parquet_to_npz(self) -> Path:
"""Convert the Parquet file to a compact NPZ for fast loading."""
"""
Convert the Parquet file to a compact NPZ for fast loading.
Requires pandas and pyarrow.
"""
try:
import pandas as pd
except ImportError:
@@ -209,11 +169,14 @@ class MTGEmbeddingIndex:
types = df["type"].fillna("").values.astype(str)
texts = df["text"].fillna("").values.astype(str)
# Parse color identities from mana costs
colors = np.array([_parse_color_identity(mc) for mc in mana_costs])
mana_values = np.array([_parse_mana_value(mc) for mc in mana_costs], dtype=np.float32)
# Extract embedding vectors
embeddings = np.vstack(df["embedding"].to_numpy()).astype(np.float32)
# L2 normalize for cosine similarity via dot product
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
norms[norms == 0] = 1.0
embeddings = embeddings / norms
@@ -235,27 +198,25 @@ class MTGEmbeddingIndex:
return EMBEDDINGS_NPZ
def load(self, force_download: bool = False) -> bool:
"""Load embeddings from NPZ (fast) or fall back to downloading
"""
Load embeddings from NPZ (fast) or fall back to downloading
and converting from Parquet.
"""
if self._loaded and not force_download:
return True
# Try NPZ first (fast path)
if EMBEDDINGS_NPZ.exists() and not force_download:
ok = self._load_npz()
else:
try:
self.download_parquet(force=force_download)
self.convert_parquet_to_npz()
ok = self._load_npz()
except Exception as e:
logger.error("Failed to load embeddings: %s", e)
return False
return self._load_npz()
if ok:
self._load_custom_cache()
return ok
# Download and convert
try:
self.download_parquet(force=force_download)
self.convert_parquet_to_npz()
return self._load_npz()
except Exception as e:
logger.error("Failed to load embeddings: %s", e)
return False
def _load_npz(self) -> bool:
"""Load from pre-built NPZ file."""
@@ -269,6 +230,7 @@ class MTGEmbeddingIndex:
self.texts = data.get("texts", np.array([""] * len(self.names)))
self.vectors = data["vectors"]
# Build name lookup
self._name_to_idx = {
name.lower(): i for i, name in enumerate(self.names)
}
@@ -279,168 +241,12 @@ class MTGEmbeddingIndex:
logger.error("Failed to load NPZ: %s", e)
return False
# ── Custom cache ──────────────────────────────────────────
def _load_custom_cache(self) -> None:
"""Merge on-the-fly embedded cards from the custom cache into the index."""
if not CUSTOM_CACHE_PATH.exists():
return
try:
data = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
c_names = data["names"]
# Only add cards that aren't already in the main index
new_mask = np.array(
[n.lower() not in self._name_to_idx for n in c_names]
)
if not new_mask.any():
return
self._append_cards(
c_names[new_mask],
data["colors"][new_mask],
data["mana_values"][new_mask],
data["types"][new_mask],
data["mana_costs"][new_mask],
data["texts"][new_mask],
data["vectors"][new_mask],
)
logger.info("Loaded %d custom-cached card embeddings", new_mask.sum())
except Exception as e:
logger.warning("Failed to load custom embedding cache: %s", e)
def _save_custom_cache(self) -> None:
"""Persist the custom cache NPZ (only cards not in the base dataset)."""
if not self._custom_dirty:
return
try:
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
# Collect indices that correspond to custom-added cards
# We track these by loading the base count at load time — simpler:
# just re-save the full custom cache from CUSTOM_CACHE_PATH if it
# exists, merging with any new cards we've added this session.
existing_names: set = set()
existing: dict = {}
if CUSTOM_CACHE_PATH.exists():
try:
d = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
for n in d["names"]:
existing_names.add(n.lower())
except Exception:
pass
# Find names in our live index that aren't in the base NPZ
base_data = np.load(str(EMBEDDINGS_NPZ), allow_pickle=True)
base_names = set(n.lower() for n in base_data["names"])
custom_mask = np.array(
[n.lower() not in base_names for n in self.names]
)
if not custom_mask.any():
return
np.savez_compressed(
str(CUSTOM_CACHE_PATH),
names=self.names[custom_mask],
colors=self.colors[custom_mask],
mana_values=self.mana_values[custom_mask],
types=self.types[custom_mask],
mana_costs=self.mana_costs[custom_mask],
texts=self.texts[custom_mask],
vectors=self.vectors[custom_mask],
)
self._custom_dirty = False
logger.info("Saved custom embedding cache: %d cards", custom_mask.sum())
except Exception as e:
logger.warning("Failed to save custom embedding cache: %s", e)
def _append_cards(
self,
names: np.ndarray,
colors: np.ndarray,
mana_values: np.ndarray,
types: np.ndarray,
mana_costs: np.ndarray,
texts: np.ndarray,
vectors: np.ndarray,
) -> None:
"""Append new card arrays to the live in-memory index."""
start_idx = len(self.names)
self.names = np.concatenate([self.names, names])
self.colors = np.concatenate([self.colors, colors])
self.mana_values = np.concatenate([self.mana_values, mana_values])
self.types = np.concatenate([self.types, types])
self.mana_costs = np.concatenate([self.mana_costs, mana_costs])
self.texts = np.concatenate([self.texts, texts])
self.vectors = np.concatenate([self.vectors, vectors], axis=0)
for i, name in enumerate(names):
self._name_to_idx[name.lower()] = start_idx + i
# ── Sentence model (lazy) ─────────────────────────────────
def _get_sentence_model(self):
"""Lazy-load the sentence-transformers model on first cache miss."""
if self._sentence_model is None:
try:
from sentence_transformers import SentenceTransformer
logger.info("Loading embedding model %s (first cache miss)...", EMBEDDING_MODEL)
self._sentence_model = SentenceTransformer(EMBEDDING_MODEL)
logger.info("Embedding model loaded.")
except Exception as e:
logger.error("Failed to load SentenceTransformer: %s", e)
return None
return self._sentence_model
# ── On-the-fly Scryfall fallback ──────────────────────────
def _fetch_and_embed_card(self, card_name: str) -> Optional[np.ndarray]:
"""Fetch card from Scryfall, embed it, append to index, persist to cache.
Returns the L2-normalized embedding vector on success, None on failure.
"""
logger.info("Fetching unknown card from Scryfall: '%s'", card_name)
card_data = _fetch_scryfall_card(card_name)
if card_data is None:
return None
model = self._get_sentence_model()
if model is None:
return None
text = _build_card_text(card_data)
vec = model.encode(text, normalize_embeddings=True).astype(np.float32)
# Ensure shape (1, 768)
vec = vec.reshape(1, -1)
resolved_name = card_data["name"]
mana_cost = card_data.get("mana_cost", "")
color_identity = card_data.get("color_identity", "") or _parse_color_identity(mana_cost)
self._append_cards(
names=np.array([resolved_name]),
colors=np.array([color_identity]),
mana_values=np.array([_parse_mana_value(mana_cost)], dtype=np.float32),
types=np.array([card_data.get("type_line", "")]),
mana_costs=np.array([mana_cost]),
texts=np.array([card_data.get("oracle_text", "")]),
vectors=vec,
)
self._custom_dirty = True
self._save_custom_cache()
logger.info("Embedded and cached new card: '%s'", resolved_name)
return self.vectors[self._name_to_idx[resolved_name.lower()]]
# ── Public API ────────────────────────────────────────────
def get_card_vector(self, card_name: str) -> Optional[np.ndarray]:
"""Get the embedding vector for a card by name (case-insensitive).
On a cache miss, fetches from Scryfall and embeds on the fly.
"""
"""Get the embedding vector for a card by name (case-insensitive)."""
idx = self._name_to_idx.get(card_name.lower())
if idx is not None:
return self.vectors[idx]
# Cache miss — try Scryfall fallback
return self._fetch_and_embed_card(card_name)
return None
def search_similar(self, query_card: str,
color_filter: List[str] = None,
@@ -468,32 +274,38 @@ class MTGEmbeddingIndex:
query_vec = self.get_card_vector(query_card)
if query_vec is None:
logger.warning("Card not found in embeddings and Scryfall fallback failed: %s", query_card)
logger.warning("Card not found in embeddings: %s", query_card)
return []
# Compute similarities (dot product on normalized vectors = cosine similarity)
similarities = self.vectors @ query_vec
# Build exclusion set
exclude_set = set()
exclude_set.add(query_card.lower())
if exclude_cards:
exclude_set.update(name.lower() for name in exclude_cards)
# Filter and collect results
results = []
for idx in np.argsort(similarities)[::-1]:
name = str(self.names[idx])
if name.lower() in exclude_set:
continue
# Color filter
if color_filter:
card_colors = str(self.colors[idx])
if not _is_color_subset(card_colors, color_filter):
continue
# Type filter
if type_filter:
card_type = str(self.types[idx])
if type_filter.lower() not in card_type.lower():
continue
# Mana value range filter
if mana_range:
mv = float(self.mana_values[idx])
if mv < mana_range[0] or mv > mana_range[1]:
+140 -137
View File
@@ -1,25 +1,22 @@
"""
Commander AI Lab — Ollama LLM Client
Commander AI Lab — LM Studio Client
════════════════════════════════════
Fully async HTTP client targeting Ollama's OpenAI-compatible
/v1/chat/completions endpoint. Every network call uses
httpx.AsyncClient so it NEVER blocks the FastAPI event loop.
Async HTTP client targeting LM Studio's OpenAI-compatible
/v1/chat/completions endpoint. Includes retry logic for
non-JSON responses and configurable timeout.
"""
from __future__ import annotations
import json
import logging
import asyncio
from typing import Optional
import httpx
from urllib.request import urlopen, Request
from urllib.error import URLError
from .config import (
LLM_URL,
LLM_MODEL,
LLM_TIMEOUT,
LLM_MAX_RETRIES,
DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS,
LM_STUDIO_URL, LM_STUDIO_MODEL,
LM_STUDIO_TIMEOUT, LM_STUDIO_MAX_RETRIES,
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
)
logger = logging.getLogger("coach.llm")
@@ -27,15 +24,9 @@ logger = logging.getLogger("coach.llm")
class LLMResponse:
"""Parsed LLM response container."""
def __init__(
self,
content: str,
parsed_json: Optional[dict],
prompt_tokens: int,
completion_tokens: int,
model: str,
):
def __init__(self, content: str, parsed_json: Optional[dict],
prompt_tokens: int, completion_tokens: int,
model: str):
self.content = content
self.parsed_json = parsed_json
self.prompt_tokens = prompt_tokens
@@ -43,56 +34,43 @@ class LLMResponse:
self.model = model
class LLMClient:
class LMStudioClient:
"""
Fully async client for Ollama's OpenAI-compatible API.
Client for LM Studio's OpenAI-compatible API.
Usage:
client = LLMClient()
client = LMStudioClient()
response = await client.chat(system_prompt, user_prompt)
if response.parsed_json:
suggestions = response.parsed_json
"""
def __init__(
self,
base_url: str = None,
model: str = None,
timeout: int = None,
):
self.base_url = (base_url or LLM_URL).rstrip("/")
self.model = model or LLM_MODEL
self.timeout = timeout or LLM_TIMEOUT
self._resolved_model: Optional[str] = None
def __init__(self, base_url: str = None, model: str = None,
timeout: int = None):
self.base_url = (base_url or LM_STUDIO_URL).rstrip("/")
self.model = model or LM_STUDIO_MODEL
self.timeout = timeout or LM_STUDIO_TIMEOUT
self._resolved_model = None # actual model name from /models endpoint
# ── Model resolution ───────────────────────────────────────
async def _resolve_model(self) -> str:
"""Get the actual loaded model name from Ollama /models endpoint."""
def _resolve_model(self) -> str:
"""Get the actual loaded model name from LM Studio /models endpoint."""
if self._resolved_model:
return self._resolved_model
try:
status = await self.check_connection()
status = self.check_connection()
if status.get("connected") and status.get("active_model"):
self._resolved_model = status["active_model"]
logger.info("Resolved Ollama model: %s", self._resolved_model)
logger.info("Resolved LM Studio model: %s", self._resolved_model)
return self._resolved_model
except Exception:
pass
return self.model
# ── Request building ──────────────────────────────────────
async def _build_request_body(
self,
system_prompt: str,
user_prompt: str,
temperature: float,
max_tokens: int,
force_json: bool,
) -> dict:
model_name = await self._resolve_model()
body: dict = {
def _build_request_body(self, system_prompt: str, user_prompt: str,
temperature: float, max_tokens: int,
force_json: bool) -> dict:
model_name = self._resolve_model()
body = {
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
@@ -101,27 +79,32 @@ class LLMClient:
"temperature": temperature,
"max_tokens": max_tokens,
}
# Only request JSON format if using a model that supports it
# Some models (esp. smaller ones) don't support response_format
if force_json:
body["response_format"] = {"type": "json_object"}
return body
# ── Response parsing ──────────────────────────────────────
@staticmethod
def _strip_think_tags(text: str) -> str:
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks."""
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks from LLM output."""
import re
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
# Remove all <think>...</think> blocks (greedy, handles multiline)
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
return cleaned.strip()
@staticmethod
def _extract_json_object(text: str) -> Optional[dict]:
"""Find and parse the first complete JSON object via brace-counting."""
"""Find and parse the first complete JSON object in text.
Uses brace-counting to handle nested objects reliably."""
start = text.find('{')
if start == -1:
return None
depth = 0
in_string = False
escape_next = False
for i in range(start, len(text)):
ch = text[i]
if escape_next:
@@ -141,56 +124,66 @@ class LLMClient:
elif ch == '}':
depth -= 1
if depth == 0:
candidate = text[start : i + 1]
candidate = text[start:i+1]
try:
return json.loads(candidate)
except (json.JSONDecodeError, ValueError):
# Malformed — try next opening brace
next_start = text.find('{', start + 1)
if next_start != -1 and next_start < i:
start = next_start
depth = 1
return None
depth = 1 # we're already past this brace
return None
return None
def _parse_response(self, raw: dict) -> LLMResponse:
"""Extract content and usage from an OpenAI-format response."""
"""Extract content and usage from OpenAI-format response.
Handles DeepSeek-R1 <think> tags, markdown fences, and raw JSON."""
choice = raw.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "")
usage = raw.get("usage", {})
# Step 1: Strip <think>...</think> blocks (DeepSeek-R1)
cleaned = self._strip_think_tags(content)
parsed: Optional[dict] = None
# 1. Direct JSON parse
# Step 2: Try direct JSON parse on cleaned content
parsed = None
try:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, TypeError):
pass
# 2. Markdown fences
# Step 3: Try extracting from markdown fences
if parsed is None and "```json" in cleaned:
try:
s = cleaned.index("```json") + 7
e = cleaned.index("```", s)
parsed = json.loads(cleaned[s:e].strip())
start = cleaned.index("```json") + 7
end = cleaned.index("```", start)
parsed = json.loads(cleaned[start:end].strip())
except (json.JSONDecodeError, ValueError, IndexError):
pass
if parsed is None and "```" in cleaned:
try:
s = cleaned.index("```") + 3
e = cleaned.index("```", s)
parsed = json.loads(cleaned[s:e].strip())
start = cleaned.index("```") + 3
end = cleaned.index("```", start)
parsed = json.loads(cleaned[start:end].strip())
except (json.JSONDecodeError, ValueError, IndexError):
pass
# 3. Brace-counting extraction
# Step 4: Brute-force — find the first valid JSON object in the text
if parsed is None:
parsed = self._extract_json_object(cleaned)
# 4. Fallback on original content
# Step 5: If still nothing, try on the original content (in case
# think-tag stripping was too aggressive)
if parsed is None and content != cleaned:
parsed = self._extract_json_object(content)
if parsed is not None:
logger.info("Successfully parsed JSON from LLM response")
else:
logger.warning(
"Could not extract JSON from LLM response (%d chars)", len(content)
)
logger.warning("Could not extract JSON from LLM response (%d chars)",
len(content))
return LLMResponse(
content=content,
parsed_json=parsed,
@@ -199,84 +192,94 @@ class LLMClient:
model=raw.get("model", self.model),
)
# ── Public API ─────────────────────────────────────────────
async def chat(
self,
system_prompt: str,
user_prompt: str,
temperature: float = None,
max_tokens: int = None,
force_json: bool = True,
) -> LLMResponse:
def chat_sync(self, system_prompt: str, user_prompt: str,
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
"""
Async chat call to Ollama. Retries up to LLM_MAX_RETRIES.
Never blocks the event loop — uses httpx.AsyncClient throughout.
Synchronous chat call to LM Studio.
Retries up to LM_STUDIO_MAX_RETRIES on failure or non-JSON response.
"""
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
body = await self._build_request_body(
system_prompt, user_prompt, temp, tokens, force_json
)
last_error: Optional[Exception] = None
for attempt in range(1, LLM_MAX_RETRIES + 1):
body = self._build_request_body(system_prompt, user_prompt,
temp, tokens, force_json)
last_error = None
for attempt in range(1, LM_STUDIO_MAX_RETRIES + 1):
try:
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
async with httpx.AsyncClient(
timeout=httpx.Timeout(float(self.timeout))
) as client:
resp = await client.post(
f"{self.base_url}/chat/completions",
json=body,
headers={"Content-Type": "application/json"},
)
resp.raise_for_status()
raw = resp.json()
logger.info("LLM call attempt %d/%d", attempt, LM_STUDIO_MAX_RETRIES)
req = Request(
f"{self.base_url}/chat/completions",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(req, timeout=self.timeout) as resp:
raw = json.loads(resp.read().decode("utf-8"))
result = self._parse_response(raw)
# If we need JSON and didn't get it, retry
if force_json and result.parsed_json is None:
logger.warning(
"Attempt %d: LLM returned non-JSON, retrying", attempt
)
logger.warning("Attempt %d: LLM returned non-JSON, retrying", attempt)
last_error = ValueError("Non-JSON response from LLM")
continue
return result
except httpx.HTTPStatusError as e:
logger.warning("Attempt %d HTTP error %s", attempt, e.response.status_code)
last_error = e
# 400 with json format → retry without response_format constraint
if e.response.status_code == 400 and force_json:
logger.warning("Got 400 — retrying without response_format")
force_json = False
body = await self._build_request_body(
system_prompt, user_prompt, temp, tokens, False
)
except Exception as e:
logger.warning("Attempt %d failed: %s", attempt, e)
last_error = e
if attempt < LLM_MAX_RETRIES:
import asyncio
await asyncio.sleep(2 * attempt)
# If 400 and we used json response_format, retry without it
error_str = str(e)
if "400" in error_str and force_json:
logger.warning("Got 400 — retrying without response_format constraint")
force_json = False
body = self._build_request_body(
system_prompt, user_prompt, temp, tokens, False)
if attempt < LM_STUDIO_MAX_RETRIES:
import time
time.sleep(2 * attempt) # Simple backoff
raise ConnectionError(
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
f"LLM call failed after {LM_STUDIO_MAX_RETRIES} attempts: {last_error}"
)
async def check_connection(self) -> dict:
"""Check if Ollama is reachable. Returns status dict."""
async def chat(self, system_prompt: str, user_prompt: str,
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
"""
Async chat call — runs the sync call in a thread executor
so it doesn't block the FastAPI event loop.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: self.chat_sync(system_prompt, user_prompt,
temperature, max_tokens, force_json)
)
def check_connection(self) -> dict:
"""
Check if LM Studio is reachable. Returns status dict.
"""
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
resp = await client.get(
f"{self.base_url}/models",
headers={"Content-Type": "application/json"},
)
resp.raise_for_status()
data = resp.json()
models = data.get("data", [])
model_ids = [m.get("id", "unknown") for m in models]
return {
"connected": True,
"models": model_ids,
"active_model": model_ids[0] if model_ids else self.model,
}
req = Request(
f"{self.base_url}/models",
headers={"Content-Type": "application/json"},
method="GET",
)
with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8"))
models = data.get("data", [])
model_ids = [m.get("id", "unknown") for m in models]
return {
"connected": True,
"models": model_ids,
"active_model": model_ids[0] if model_ids else self.model,
}
except Exception as e:
return {
"connected": False,
+17 -140
View File
@@ -4,9 +4,12 @@ Commander AI Lab — Coach Data Models
Pydantic v2 models for deck reports, coach requests/responses,
and coaching session persistence.
"""
from datetime import datetime
from typing import List, Optional, Dict
from pydantic import BaseModel, Field
# ══════════════════════════════════════════════════════════════
# Deck Report Models (mirrors Java DeckReport)
# ══════════════════════════════════════════════════════════════
@@ -14,16 +17,16 @@ from pydantic import BaseModel, Field
class CardPerformance(BaseModel):
"""Per-card aggregated stats from simulation data."""
name: str
drawnRate: float = 0.0
castRate: float = 0.0
drawnRate: float = 0.0 # Fraction of games where card was drawn
castRate: float = 0.0 # Fraction of games where drawn AND cast
keptInOpeningHandRate: float = 0.0
deadCardRate: float = 0.0
impactScore: float = 0.0
synergyScore: float = 0.0
clunkinessScore: float = 0.0
deadCardRate: float = 0.0 # Drawn but never cast/used
impactScore: float = 0.0 # (winRateWhenCast - overallWinRate) * castRate
synergyScore: float = 0.0 # Co-occurrence uplift with other deck cards
clunkinessScore: float = 0.0 # deadCardRate * (1 - castRate)
avgTurnCast: Optional[float] = None
avgDamageDealt: float = 0.0
tags: List[str] = Field(default_factory=list)
tags: List[str] = Field(default_factory=list) # ramp, draw, removal, etc.
class MatchupRecord(BaseModel):
@@ -45,7 +48,7 @@ class DeckMeta(BaseModel):
class DeckStructure(BaseModel):
"""Deck composition breakdown."""
landCount: int = 0
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8)
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8) # CMC 0-7+
cardTypeCounts: Dict[str, int] = Field(default_factory=dict)
functionalCounts: Dict[str, int] = Field(default_factory=dict)
@@ -54,7 +57,7 @@ class ComboRecord(BaseModel):
"""Known combo with performance data."""
cardNames: List[str]
winRateWhenAssembled: float = 0.0
assemblyRate: float = 0.0
assemblyRate: float = 0.0 # Fraction of games where all pieces drawn
class DeckReport(BaseModel):
@@ -81,10 +84,10 @@ class DeckReport(BaseModel):
class CoachGoals(BaseModel):
"""User-specified coaching goals."""
targetPowerLevel: Optional[int] = None
metaFocus: Optional[str] = None
budget: Optional[str] = None
focusAreas: List[str] = Field(default_factory=list)
targetPowerLevel: Optional[int] = None # 1-10
metaFocus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focusAreas: List[str] = Field(default_factory=list) # e.g., ["ramp", "card draw"]
class CoachRequest(BaseModel):
@@ -104,120 +107,12 @@ class SuggestedCut(BaseModel):
class SuggestedAdd(BaseModel):
"""A card the coach suggests adding."""
cardName: str
role: str = ""
role: str = "" # ramp, draw, removal, finisher, etc.
reason: str = ""
synergyWith: List[str] = Field(default_factory=list)
estimatedManaValue: Optional[float] = None
# ══════════════════════════════════════════════════════════════
# Phase 2: Structured Analysis Models
# ══════════════════════════════════════════════════════════════
class UpgradePriorityItem(BaseModel):
"""A single prioritized cut/add swap ranked by expected impact."""
rank: int
cut: str
add: str
reasoning: str = ""
expectedImpact: str = ""
class CommanderDependency(BaseModel):
"""How reliant the deck is on having the commander in play."""
score: int = 5
dependentCards: List[str] = Field(default_factory=list)
recoveryPlan: str = ""
class MulliganAnalysis(BaseModel):
"""Opening hand keepability assessment."""
estimatedKeepRate: str = ""
worstOffenders: List[str] = Field(default_factory=list)
recommendation: str = ""
# ══════════════════════════════════════════════════════════════
# Phase 3: Qualitative Analysis Models
# ══════════════════════════════════════════════════════════════
class RemovalCoverage(BaseModel):
"""Categorized removal package by permanent type."""
creatures: str = ""
artifacts: str = ""
enchantments: str = ""
planeswalkers: str = ""
lands: str = ""
massRemoval: str = ""
gaps: List[str] = Field(default_factory=list)
class RampQuality(BaseModel):
"""Ramp package breakdown by subtype and fragility."""
manaRocks: str = ""
landFetch: str = ""
manaDorks: str = ""
costReducers: str = ""
fragility: str = ""
canReachFourByTurnThree: str = ""
class DrawEngineProfile(BaseModel):
"""Card draw sustainability analysis."""
burstDraw: List[str] = Field(default_factory=list)
repeatableEngines: List[str] = Field(default_factory=list)
assessment: str = ""
sustainability: str = ""
class WinCondition(BaseModel):
"""A named win condition with independence rating."""
name: str
cards: List[str] = Field(default_factory=list)
independence: str = ""
description: str = ""
class AntiSynergyFlag(BaseModel):
"""An internal conflict between cards in the deck."""
cards: List[str] = Field(default_factory=list)
conflict: str = ""
severity: str = ""
# ══════════════════════════════════════════════════════════════
# Phase 5: Pod Politics & Meta Awareness Models
# ══════════════════════════════════════════════════════════════
class PodPresence(BaseModel):
"""Commander pod politics and threat assessment."""
threatLevel: str = "" # early/medium/late/low
politicalTools: List[str] = Field(default_factory=list)
adversarialRating: str = "" # low/medium/high
recommendation: str = ""
class TempoAssessment(BaseModel):
"""When the deck 'comes online' and whether that's fast enough."""
peakTurn: int = 0
profile: str = "" # early/mid/late
survivalWindow: str = "" # Assessment of whether it survives to peak
assessment: str = ""
class MetaMatchup(BaseModel):
"""Strategic reasoning about deck vs a meta archetype."""
archetype: str # fast-combo, stax, control, aggro, etc.
assessment: str = ""
keyThreats: List[str] = Field(default_factory=list)
keyAnswers: List[str] = Field(default_factory=list)
overallRating: str = "" # favored/even/unfavored
# ══════════════════════════════════════════════════════════════
# Coach Session (persisted result)
# ══════════════════════════════════════════════════════════════
class CoachSession(BaseModel):
"""
Complete coaching session result.
@@ -236,24 +131,6 @@ class CoachSession(BaseModel):
promptTokens: int = 0
completionTokens: int = 0
goals: Optional[CoachGoals] = None
isQuickDigest: bool = False # True for quick digest, False for full analysis
# Phase 2: Structured analysis fields
upgradePriority: List[UpgradePriorityItem] = Field(default_factory=list)
commanderDependency: Optional[CommanderDependency] = None
mulliganAnalysis: Optional[MulliganAnalysis] = None
# Phase 3: Qualitative analysis fields
removalCoverage: Optional[RemovalCoverage] = None
rampQuality: Optional[RampQuality] = None
drawEngineProfile: Optional[DrawEngineProfile] = None
winConditions: List[WinCondition] = Field(default_factory=list)
antiSynergyFlags: List[AntiSynergyFlag] = Field(default_factory=list)
# Phase 5: Pod politics & meta
podPresence: Optional[PodPresence] = None
tempoAssessment: Optional[TempoAssessment] = None
metaMatchups: List[MetaMatchup] = Field(default_factory=list)
class ApplyRequest(BaseModel):
+41 -375
View File
@@ -4,24 +4,18 @@ Commander AI Lab — Coach Prompt Template
Constructs system and user prompts for the LLM coach.
Injects deck report data, underperformer analysis, and
candidate replacement cards into a structured template.
Phase 1 additions (#108):
- Sim Insights section: deck-level opening hand quality, curve velocity,
synergy outliers, and archetype weak spots surfaced directly in the
user prompt so Claude reasons from numbers, not vibes.
- _format_archetypes() annotates weak spots and strengths inline.
- _format_top_cards() adds keptInOpeningHandRate per card.
"""
import json
from typing import List, Dict, Optional
from .models import DeckReport, CoachGoals, CardPerformance
from .config import MAX_CANDIDATES_PER_UNDERPERFORMER, MAX_UNDERPERFORMERS
# ════════════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════════════
# System Prompt
# ════════════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════════════
SYSTEM_PROMPT_TEMPLATE = """You are an expert Magic: The Gathering Commander deck coach.
You analyze deck performance reports from simulation data and provide specific,
@@ -33,197 +27,44 @@ RULES:
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
- When suggesting replacements, prefer cards that fill the same functional role.
- IMPORTANT: Suggest 5-8 DIFFERENT cards to cut and 5-8 DIFFERENT cards to add.
- IMPORTANT: Suggest 3-5 DIFFERENT cards to cut and 3-5 DIFFERENT cards to add.
- Each suggested add MUST be a different card — never suggest the same card twice.
- Vary your suggestions across different functional roles (ramp, draw, removal, threats, utility).
- If the deck is missing a functional role (e.g., no card draw, no removal), prioritize adding cards for that role.
- If candidate replacements are provided from embeddings search, prefer those over generic suggestions.
- For each suggested cut, provide a DETAILED reason citing specific stats (impact score, cast rate, dead card rate, clunkiness, synergy score).
- For each suggested add, explain the SPECIFIC synergy with existing cards in the deck.
- When reasoning about cuts, also consider keptInOpeningHandRate (low = bad in opening hands) and avgTurnCast (high = too slow for the curve).
- When evaluating synergy, use the synergyScore: cards below 0.1 have weak co-occurrence with the rest of the deck.
- Use perArchetypeWinRates to identify which matchup types this deck struggles with and tailor suggestions accordingly.
- The Sim Insights section shows pre-computed deck-level signals — use these directly in your reasoning.
- Cards flagged as [OPENING HAND TRAP] have keptInOpeningHandRate below the deck average — treat as mulligan liabilities.
- Cards flagged as [LATE CASTER] have avgTurnCast > 6 — consider whether they fit the deck's tempo.
- Cards flagged as [ISOLATED] have synergyScore in the bottom 3 of the deck — they may not support the gameplan.
- Archetypes flagged as [WEAK SPOT] in the Archetype Win Rates section are where this deck loses most — prioritize fixes for those matchups.
- Provide at least 5 heuristic hints covering different strategic dimensions.
- Assess how dependent the deck is on the commander being in play.
- Evaluate opening hand quality and identify cards that cause bad mulligans.
- Rank your suggested swaps by priority so the user knows what to change first.
- Categorize the removal package by permanent type and flag coverage gaps.
- Evaluate the ramp package for fragility (creature/artifact-heavy vs land-based).
- Distinguish one-time draw from repeatable draw engines.
- Identify the top 3 win conditions and rate each for independence.
- Flag internal anti-synergies or card conflicts.
- Assess pod threat perception: does this deck draw too much hate too early?
- Evaluate when the deck comes online (tempo profile) and whether that's viable.
- Reason about matchups vs fast combo, stax/prison, and control archetypes.
{goals_section}
You MUST respond ONLY with valid JSON matching this exact structure:
{{
"summary": "4-6 sentence detailed overview of the deck's strengths, weaknesses, key synergies, and strategic identity. Reference specific win rates and simulation data.",
"summary": "2-3 sentence overview of the deck's strengths and weaknesses",
"suggestedCuts": [
{{
"cardName": "Card To Remove",
"reason": "Detailed explanation citing specific simulation stats (impact score, cast rate, dead card rate, clunkiness score, synergy score, avg turn cast) and why this card underperforms in this specific deck context",
"replacementOptions": ["Better Card 1", "Better Card 2"],
"functionalRole": "ramp|draw|removal|threat|utility|combo|protection",
"estimatedImpact": "How cutting this card is expected to improve the deck"
"reason": "Why this card underperforms",
"replacementOptions": ["Better Card 1", "Better Card 2"]
}}
],
"suggestedAdds": [
{{
"cardName": "Card To Add",
"role": "ramp|draw|removal|threat|utility|combo|protection",
"reason": "Detailed explanation of why this card improves the deck, referencing specific weaknesses it addresses",
"synergyWith": ["Card In Deck 1", "Card In Deck 2"],
"estimatedManaCost": "The mana value of the suggested card",
"strategicRationale": "How this card fits into the overall game plan and addresses a specific gap"
"reason": "Why this card improves the deck",
"synergyWith": ["Card In Deck 1", "Card In Deck 2"]
}}
],
"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": {{
"currentAssessment": "Detailed analysis of the current mana curve distribution and its implications",
"recommendations": "Specific suggestions for curve adjustments with reasoning",
"idealRange": "What the curve should look like for this deck's strategy"
}},
"roleBreakdown": {{
"ramp": "Assessment of ramp package - count, quality, and suggestions",
"draw": "Assessment of card draw package",
"removal": "Assessment of removal package",
"threats": "Assessment of win conditions and threats",
"protection": "Assessment of protection and interaction"
}},
"synergyAnalysis": [
"Description of a key synergy chain in the deck and how well it performed in simulation"
],
"heuristicHints": [
"Detailed strategic tip explaining the reasoning and data behind the suggestion"
"Concise strategic tip based on data"
],
"manaBaseAdvice": "Specific mana base improvement suggestion including land count, color fixing, and utility lands, or null if mana base is solid",
"rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates and the [WEAK SPOT] archetypes flagged in Sim Insights, and (5) prioritized list of improvements ranked by expected impact on win rate",
"removalCoverage": {{
"creatures": "Assessment of creature removal",
"artifacts": "Assessment of artifact removal",
"enchantments": "Assessment of enchantment removal",
"planeswalkers": "Assessment of planeswalker removal",
"lands": "Assessment of land destruction",
"massRemoval": "Assessment of board wipes",
"gaps": ["Permanent types with zero or weak coverage"]
}},
"rampQuality": {{
"manaRocks": "2-mana rocks, signets, etc.",
"landFetch": "Cultivate, Kodama's Reach, etc.",
"manaDorks": "Llanowar Elves, Birds, etc.",
"costReducers": "Medallions, Helm of Awakening, etc.",
"fragility": "Overall fragility assessment",
"canReachFourByTurnThree": "yes/no with reasoning"
}},
"drawEngineProfile": {{
"burstDraw": ["One-time draw spells"],
"repeatableEngines": ["Sustained draw engines"],
"assessment": "Overall draw quality",
"sustainability": "Whether draw is sustainable or burst-only"
}},
"winConditions": [
{{
"name": "Name of win condition",
"cards": ["Key cards involved"],
"independence": "high|medium|low",
"description": "How this win con functions"
}}
],
"antiSynergyFlags": [
{{
"cards": ["Card A", "Card B"],
"conflict": "Description of conflict",
"severity": "high|medium|low"
}}
],
"podPresence": {{
"threatLevel": "early|medium|late|low",
"politicalTools": ["Pillowfort or group effect cards"],
"adversarialRating": "low|medium|high",
"recommendation": "How to manage threat perception in a pod"
}},
"tempoAssessment": {{
"peakTurn": 5,
"profile": "early|mid|late",
"survivalWindow": "Assessment of whether the deck survives to its power window",
"assessment": "Overall tempo evaluation"
}},
"metaMatchups": [
{{
"archetype": "fast-combo|stax|control|aggro|midrange",
"assessment": "How the deck fares against this archetype",
"keyThreats": ["Cards/strategies that threaten this deck"],
"keyAnswers": ["Cards in the deck that answer this archetype"],
"overallRating": "favored|even|unfavored"
}}
]
"manaBaseAdvice": "Specific mana base improvement suggestion or null",
"rawTextExplanation": "Detailed paragraph explaining the analysis and reasoning"
}}
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.
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 Builder
# ══════════════════════════════════════════════════════════════
USER_PROMPT_TEMPLATE = """## Deck Report: {deck_name}
Commander: {commander}
@@ -238,29 +79,19 @@ Avg Game Length: {avg_game_length:.1f} turns
- Card Types: {type_summary}
- Functional Roles: {role_summary}
### Sim Insights (pre-computed deck-level signals)
{sim_insights_section}
### Matchup Data
{matchup_section}
### Archetype Win Rates
{archetype_section}
### Top Performing Cards (by Impact Score)
{top_cards}
### Underperforming Cards (candidates for cuts)
{underperformer_section}
### RAG-Retrieved Similar Cards (from ChromaDB)
{rag_section}
### Candidate Replacement Cards (from embeddings search)
{candidate_section}
{combo_section}
Based on this simulation data, provide your coaching suggestions."""
@@ -268,6 +99,7 @@ def _format_goals_section(goals: Optional[CoachGoals]) -> str:
"""Format goals into system prompt rules."""
if not goals:
return "- Aim for a balanced, well-rounded deck."
lines = []
if goals.targetPowerLevel is not None:
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
@@ -294,148 +126,42 @@ def _format_curve(buckets: List[int]) -> str:
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
"""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.
"""
"""Format the best performing cards."""
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
lines = []
for c in sorted_cards[:top_n]:
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
lines.append(
f"- {c.name}: impact={c.impactScore:.3f}, "
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
f"synergy={c.synergyScore:.3f}, "
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
f"{turn_str}{tags}"
)
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}{tags}")
return "\n".join(lines) if lines else "No card performance data available."
def _format_underperformers(cards: List[CardPerformance],
underperformer_names: List[str]) -> str:
"""Format underperforming cards with full available stats."""
"""Format underperforming cards with their stats."""
card_map = {c.name: c for c in cards}
lines = []
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
c = card_map.get(name)
if c:
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
flags = []
if c.keptInOpeningHandRate < 0.25:
flags.append("OPENING HAND TRAP")
if c.avgTurnCast is not None and c.avgTurnCast > 6:
flags.append("LATE CASTER")
if c.synergyScore < 0.1:
flags.append("ISOLATED")
flag_str = f" [{', '.join(flags)}]" if flags else ""
lines.append(
f"- {c.name}{flag_str}: impact={c.impactScore:.3f}, "
f"deadCardRate={c.deadCardRate:.1%}, "
f"castRate={c.castRate:.1%}, "
f"clunkiness={c.clunkinessScore:.3f}, "
f"synergy={c.synergyScore:.3f}, "
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
f"{turn_str}"
)
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
f"deadCardRate={c.deadCardRate:.1%}, "
f"castRate={c.castRate:.1%}, "
f"clunkiness={c.clunkinessScore:.3f}")
else:
lines.append(f"- {name}: (no detailed stats)")
return "\n".join(lines) if lines else "No clear underperformers identified."
def _format_sim_insights(cards: List[CardPerformance],
archetype_rates: Dict[str, float]) -> str:
"""Compute and format deck-level sim signals for the Sim Insights section.
Phase 1 (#108): surfaces pre-computed per-card stats as deck-level
aggregates so Claude reasons from numbers directly.
Signals produced:
1. Opening hand quality — deck avg keptInOpeningHandRate + worst offenders
2. Curve velocity — castRate-weighted avg avgTurnCast + late-caster flags
3. Synergy outliers — bottom-3 synergyScore cards flagged as [ISOLATED]
4. Archetype weak spots — archetypes < 40% win rate flagged [WEAK SPOT]
"""
if not cards:
return "No simulation data available."
lines = []
# ── 1. Opening hand quality ───────────────────────────────────────────
hand_rates = [c.keptInOpeningHandRate for c in cards if c.keptInOpeningHandRate > 0]
if hand_rates:
avg_keep = sum(hand_rates) / len(hand_rates)
lines.append(f"Opening Hand Quality: deck avg keptInOpeningHandRate = {avg_keep:.1%}")
# Worst offenders: cards significantly below the deck average
threshold = avg_keep * 0.6 # >40% below avg is an outlier
offenders = sorted(
[c for c in cards if c.keptInOpeningHandRate > 0 and c.keptInOpeningHandRate < threshold],
key=lambda c: c.keptInOpeningHandRate
)[:3]
if offenders:
lines.append(" Opening hand traps (keptInOpeningHandRate far below deck avg):")
for c in offenders:
lines.append(f" - {c.name}: {c.keptInOpeningHandRate:.1%} [OPENING HAND TRAP]")
else:
lines.append("Opening Hand Quality: no keptInOpeningHandRate data available")
# ── 2. Curve velocity ───────────────────────────────────────────────
cast_cards = [c for c in cards if c.avgTurnCast is not None and c.castRate > 0]
if cast_cards:
total_weight = sum(c.castRate for c in cast_cards)
if total_weight > 0:
weighted_avg_turn = sum(
c.avgTurnCast * c.castRate for c in cast_cards
) / total_weight
lines.append(f"Curve Velocity: castRate-weighted avg avgTurnCast = {weighted_avg_turn:.1f}")
late_casters = sorted(
[c for c in cast_cards if c.avgTurnCast > 6],
key=lambda c: c.avgTurnCast,
reverse=True
)[:3]
if late_casters:
lines.append(" Late casters (avgTurnCast > 6 — may not fit the curve):")
for c in late_casters:
lines.append(f" - {c.name}: avgTurnCast={c.avgTurnCast:.1f} [LATE CASTER]")
else:
lines.append("Curve Velocity: no avgTurnCast data available")
# ── 3. Synergy outliers ──────────────────────────────────────────────
synergy_cards = sorted(
[c for c in cards if c.castRate > 0.05], # ignore near-never-cast cards
key=lambda c: c.synergyScore
)
if synergy_cards:
bottom3 = synergy_cards[:3]
lines.append("Synergy Outliers (lowest synergyScore among cast cards):")
for c in bottom3:
lines.append(f" - {c.name}: synergyScore={c.synergyScore:.3f} [ISOLATED]")
# ── 4. Archetype weak spots ──────────────────────────────────────────
if archetype_rates:
weak = {k: v for k, v in archetype_rates.items() if v < 0.40}
if weak:
lines.append("Archetype Weak Spots (win rate < 40%):")
for arch, wr in sorted(weak.items(), key=lambda x: x[1]):
lines.append(f" - vs {arch}: {wr:.1%} [WEAK SPOT]")
else:
lines.append("Archetype Weak Spots: none (all matchups >= 40% win rate)")
return "\n".join(lines) if lines else "No sim insights available."
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
"""Format replacement candidates grouped by the card they'd replace."""
if not candidates:
return (
"No embedding-based candidates available. "
"Use your MTG knowledge to suggest cards that fit the deck's "
"color identity, strategy, and missing functional roles. "
"Suggest at least 3 different cards across different roles "
"(ramp, draw, removal, threats, utility)."
)
return ("No embedding-based candidates available. "
"Use your MTG knowledge to suggest cards that fit the deck's "
"color identity, strategy, and missing functional roles. "
"Suggest at least 3 different cards across different roles "
"(ramp, draw, removal, threats, utility).")
lines = []
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
lines.append(f"\nReplacements for '{underperformer}':")
@@ -453,55 +179,14 @@ def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
return "\n".join(lines)
def _format_rag_cards(rag_cards: List[dict]) -> str:
"""Format RAG-retrieved cards from ChromaDB vector search."""
if not rag_cards:
return "No RAG results available."
lines = []
for card in rag_cards[:15]:
name = card.get("name", "Unknown")
types = card.get("types", "")
mv = card.get("mana_value", "?")
mc = card.get("mana_cost", "")
dist = card.get("distance", 0)
text = card.get("text", "")[:80]
lines.append(f"- {name} ({mc}, MV:{mv}, {types}) [distance: {dist:.3f}]")
if text:
lines.append(f" Oracle: {text}")
return "\n".join(lines)
def _format_matchups(report: DeckReport) -> str:
"""Format per-opponent matchup data."""
"""Format matchup data."""
if not report.matchups:
return "No matchup data available."
lines = []
for m in report.matchups:
lines.append(
f"- vs {m.opponentDeck}: {m.winRate:.1%} "
f"({m.gamesPlayed} games)"
)
return "\n".join(lines)
def _format_archetypes(report: DeckReport) -> str:
"""Format per-archetype win rates from DeckMeta.
Phase 1: annotates weak spots ([WEAK SPOT] < 40%) and strengths
([STRENGTH] >= 55%) so Claude sees the signal immediately.
"""
rates = report.meta.perArchetypeWinRates
if not rates:
return "No archetype breakdown available."
lines = []
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]):
if wr < 0.40:
tag = " [WEAK SPOT]"
elif wr >= 0.55:
tag = " [STRENGTH]"
else:
tag = ""
lines.append(f"- vs {archetype}: {wr:.1%}{tag}")
lines.append(f"- vs {m.opponentDeck}: {m.winRate:.1%} "
f"({m.gamesPlayed} games)")
return "\n".join(lines)
@@ -512,16 +197,14 @@ def _format_combos(report: DeckReport) -> str:
lines = ["### Known Combos"]
for combo in report.knownCombos:
cards = " + ".join(combo.cardNames)
lines.append(
f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
f"assemblyRate={combo.assemblyRate:.1%}"
)
lines.append(f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
f"assemblyRate={combo.assemblyRate:.1%}")
return "\n".join(lines)
# ════════════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════════════
# Main Builder Functions
# ════════════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════════════
def build_system_prompt(report: DeckReport,
goals: Optional[CoachGoals] = None) -> str:
@@ -534,29 +217,16 @@ def build_system_prompt(report: DeckReport,
)
def build_quick_system_prompt(report: DeckReport,
goals: Optional[CoachGoals] = None) -> str:
"""Build the quick digest system prompt (~400-600 token target)."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
return QUICK_DIGEST_SYSTEM_PROMPT.format(
commander=report.commander,
color_identity=color_str,
goals_section=_format_goals_section(goals),
)
def build_user_prompt(report: DeckReport,
candidates: Dict[str, List[dict]] = None,
rag_cards: List[dict] = None) -> str:
"""Build the user prompt with full deck report data.
Phase 1: adds sim_insights_section computed from card-level stats.
"""
candidates: Dict[str, List[dict]] = None) -> str:
"""Build the user prompt with full deck report data."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
if report.structure.cardTypeCounts else ["unknown"]
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
if report.structure.functionalCounts else ["unknown"]
return USER_PROMPT_TEMPLATE.format(
deck_name=report.deckId,
commander=report.commander,
@@ -568,14 +238,10 @@ def build_user_prompt(report: DeckReport,
curve_summary=_format_curve(report.structure.curveBuckets),
type_summary=", ".join(type_parts),
role_summary=", ".join(role_parts),
sim_insights_section=_format_sim_insights(
report.cards, report.meta.perArchetypeWinRates),
matchup_section=_format_matchups(report),
archetype_section=_format_archetypes(report),
top_cards=_format_top_cards(report.cards),
underperformer_section=_format_underperformers(
report.cards, report.underperformers),
candidate_section=_format_candidates(candidates or {}),
rag_section=_format_rag_cards(rag_cards or []),
combo_section=_format_combos(report),
)
-11
View File
@@ -1,9 +1,6 @@
"""
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.
@@ -12,8 +9,6 @@ Your task is to build a complete, legal 100-card Commander deck.
RULES:
- Exactly 100 cards total: 1 commander + 99 other cards
- The commander IS included in the cards list but counts as card #100
- The other 99 cards must exactly match the SLOT BUDGET provided in the user prompt
- No more than 1 copy of any card except basic lands
- All cards must share the commander's color identity
- All cards must be legal in Commander format
@@ -21,12 +16,6 @@ RULES:
- Include approximately: 10 ramp sources, 10 card draw sources, 8-10 targeted removal, 2-3 board wipes, 3-5 protection pieces
- Build a cohesive strategy around the commander's abilities
CARD COUNT ENFORCEMENT (critical):
- The user prompt will specify a SLOT BUDGET with exact per-category counts
- You MUST fill every slot — returning fewer cards than the budget specifies will cause a validation error
- If you cannot find a perfect fit for a slot, use the best available card for that role
- Basic lands count toward the Land slot total
BRACKET RULES (Commander Rules Committee):
- Bracket 1 (Casual): Precon-level. No tutors, no fast mana, no infinite combos, no mass land destruction. Game Changers: 0
- Bracket 2 (Upgraded Casual): Moderate power. Limited tutors (1-2 total), no 2-card infinite combos, no fast mana except Sol Ring. Game Changers: 0-1
+14 -43
View File
@@ -1,33 +1,11 @@
"""
Commander AI Lab — Deck Generation User Prompt Builder
═══════════════════════════════════════════════════════
Constructs the user message for LLM deck generation.
Phase 1 changes:
- slot_budget parameter injected into prompt so LLM targets exactly 99 cards
- Explicit card count requirement in prompt text
Constructs the user message for Perplexity deck generation
from a DeckGenV3Request.
"""
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)
from typing import List, Optional
def build_user_prompt(
@@ -40,7 +18,6 @@ def build_user_prompt(
budget_mode: str = "total",
omit_cards: List[str] = None,
collection_summary: str = "",
slot_budget: Optional[Dict[str, int]] = None,
) -> str:
"""
Build the user prompt for deck generation.
@@ -49,19 +26,18 @@ def build_user_prompt(
commander: Commander card name
commander_type: Type line from Scryfall
color_identity: List of color letters, e.g. ["U", "B"]
strategy: User-specified strategy focus
strategy: User-specified strategy focus (e.g. "zombie tokens")
target_bracket: Target bracket level 1-4
budget_usd: Optional budget constraint
budget_mode: "total" for total deck cost, "per_card" for per-card max
omit_cards: Cards to exclude
collection_summary: Pre-built collection summary text block
slot_budget: Dict of category -> card count (must sum to 99)
"""
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
lines = [
"Build a complete 100-card Commander deck.",
"",
f"Build a complete 100-card Commander deck.",
f"",
f"COMMANDER: {commander}",
]
@@ -84,21 +60,16 @@ def build_user_prompt(
lines.append(f"OMIT LIST (do NOT include these cards): {', '.join(omit_cards)}")
if collection_summary:
lines.append("")
lines.append(f"")
lines.append(collection_summary)
lines.append("")
lines.append("STRONGLY PREFER cards from the collection when they fit the strategy.")
lines.append(f"")
lines.append(f"STRONGLY PREFER cards from the collection when they fit the strategy.")
else:
lines.append("")
lines.append("No collection data available — suggest the best cards regardless of ownership.")
lines.append(f"")
lines.append(f"No collection data available — suggest the best cards regardless of ownership.")
# Inject slot budget so LLM knows exact target counts
if slot_budget:
lines.append("")
lines.append(build_slot_budget_segment(slot_budget))
lines.append("")
lines.append("Build the deck as a structured JSON response. Include the commander in the cards list.")
lines.append("Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
lines.append(f"")
lines.append(f"Build the deck as a structured JSON response. Include the commander in the cards list.")
lines.append(f"Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
return "\n".join(lines)
+1 -1
View File
@@ -1,7 +1,7 @@
"""
Commander AI Lab — Substitution Prompt Templates
═════════════════════════════════════════════════
Prompts for the Claude Opus substitution pass.
Prompts for the Perplexity fallback substitution pass.
"""
+2 -2
View File
@@ -38,7 +38,7 @@ def generate_deck_reports(results_dir: str, reports_dir: str = None) -> List[str
output_path.mkdir(parents=True, exist_ok=True)
# Load all batch results
batch_files = sorted(results_path.glob("ml-decision-*.json"))
batch_files = sorted(results_path.glob("batch-*.json"))
if not batch_files:
logger.info("No batch result files found in %s", results_dir)
return []
@@ -108,7 +108,7 @@ def generate_single_deck_report(batch_json_path: str, reports_dir: str = None) -
# Also load existing batch results for comprehensive reports
results_dir = Path(batch_json_path).parent
all_batches = []
for bf in sorted(results_dir.glob("ml-decision-*.json")):
for bf in sorted(results_dir.glob("batch-*.json")):
try:
with open(bf, "r", encoding="utf-8") as f:
all_batches.append(json.load(f))
+2 -2
View File
@@ -1,7 +1,7 @@
"""
Commander AI Lab — Deck Generation JSON Schemas
═════════════════════════════════════════════════
Pydantic models and JSON Schema dicts for Claude Opus
Pydantic models and JSON Schema dicts for Perplexity
structured output deck generation.
"""
@@ -58,7 +58,7 @@ class GeneratedDeckList(BaseModel):
# ══════════════════════════════════════════════════════════════
# JSON Schema dicts (for Claude Opus response_format)
# JSON Schema dicts (for Perplexity response_format)
# ══════════════════════════════════════════════════════════════
DECK_CARD_SCHEMA = {
+4 -23
View File
@@ -3,10 +3,6 @@ Commander AI Lab — Substitution Schemas
════════════════════════════════════════
Pydantic models and JSON Schema dicts for the
Smart Substitution Engine output.
Phase 1 changes:
- SubstitutionBatchResult gains padded_slots field
- DeckCardWithStatus gains original_card field (was missing)
"""
from typing import List, Optional
@@ -22,17 +18,13 @@ class CardAlternative(BaseModel):
name: str = Field(description="Exact card name")
similarity_score: float = Field(default=0.0, description="Cosine similarity score (0-1)")
reason: str = Field(default="", description="Why this card is a good substitute")
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'anthropic'")
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'perplexity'")
role_overlap: List[str] = Field(default_factory=list, description="Shared functional roles")
cmc_delta: float = Field(default=0.0, description="CMC difference from original")
class DeckCardWithStatus(BaseModel):
"""A deck card enriched with ownership and substitution data."""
class Config:
extra = "ignore"
name: str
count: int = 1
category: str = ""
@@ -46,15 +38,9 @@ class DeckCardWithStatus(BaseModel):
owned_qty: int = 0
scryfall_id: str = ""
status: str = Field(default="owned", description="owned | substituted | missing")
original_card: Optional[str] = Field(
default=None,
description="Original card name before substitution (None if not substituted)"
)
original_card: Optional[str] = Field(default=None, description="Original card name if this is a substitution")
alternatives: List[CardAlternative] = Field(default_factory=list)
selected_substitute: Optional[str] = Field(
default=None,
description="The substitute card name chosen for this slot"
)
selected_substitute: Optional[str] = Field(default=None, description="User-selected substitute name")
class SubstitutionRequest(BaseModel):
@@ -72,16 +58,11 @@ class SubstitutionBatchResult(BaseModel):
owned_count: int = 0
substituted_count: int = 0
missing_count: int = 0
padded_slots: int = Field(
default=0,
description="Number of slots filled by the pad-to-99 circuit breaker. "
"Any value > 0 indicates a bug in the substitution pipeline."
)
cards: List[DeckCardWithStatus] = Field(default_factory=list)
# ══════════════════════════════════════════════════════════════
# JSON Schema dicts (for LLM fallback substitution)
# JSON Schema dicts (for Perplexity fallback substitution)
# ══════════════════════════════════════════════════════════════
SUBSTITUTION_RESPONSE_SCHEMA = {
+118 -427
View File
@@ -1,28 +1,19 @@
"""
Commander AI Lab — V3 Deck Generator Service
═════════════════════════════════════════════
Generates Commander decks using Anthropic Claude
Generates Commander decks using Perplexity Sonar
structured JSON output, then runs ownership check
and smart substitution.
Phase 1 changes:
- smart_substitute() non-destructive contract (never returns None)
- apply_substitutions() loop never silently drops cards
- _pad_to_99() circuit breaker before BuildResult validation
- Pre-validation count logging per category
- Startup SLOT_BUDGET assertion
"""
import json
import logging
import re
import sqlite3
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional, Tuple
from urllib.request import urlopen, Request
from ..clients.anthropic_client import AnthropicClient
from ..clients.perplexity_client import PerplexityClient
from ..schemas.deck_schema import DECK_LIST_SCHEMA, GeneratedDeckList
from ..schemas.substitution_schema import (
CardAlternative, DeckCardWithStatus, SubstitutionBatchResult,
@@ -35,261 +26,35 @@ from ..prompts.substitution_prompt import (
from ..config import (
DECK_GEN_MODEL, DECK_GEN_TEMPERATURE, DECK_GEN_MAX_TOKENS,
SUBSTITUTION_MIN_SIMILARITY, SUBSTITUTION_MAX_ALTERNATIVES,
SUBSTITUTION_MODEL, SUBSTITUTION_USE_ANTHROPIC_FALLBACK,
SUBSTITUTION_MODEL, SUBSTITUTION_USE_PPLX_FALLBACK,
)
logger = logging.getLogger("coach.deckgen")
# ══════════════════════════════════════════════════════════════
# Slot Budget — MUST sum to 99 (commander is the 100th card)
# ══════════════════════════════════════════════════════════════
SLOT_BUDGET: dict[str, int] = {
"Land": 36,
"Ramp": 10,
"Card Draw": 10,
"Removal": 8,
"Board Wipe": 3,
"Win Condition": 4,
"Protection": 4,
"Synergy": 20,
"Creature": 2,
"Utility": 2,
}
# ── Startup assertion — catches misconfigured budgets at boot time ──
_SLOT_BUDGET_TOTAL = sum(SLOT_BUDGET.values())
assert _SLOT_BUDGET_TOTAL == 99, (
f"SLOT_BUDGET sums to {_SLOT_BUDGET_TOTAL}, must equal 99. "
f"Current distribution: {SLOT_BUDGET}"
)
# ══════════════════════════════════════════════════════════════
# SubstitutionMethod — tracks HOW each card was resolved
# ══════════════════════════════════════════════════════════════
class SubstitutionMethod(str, Enum):
EXACT_MATCH = "exact_match" # Card found verbatim in collection
FUZZY_MATCH = "fuzzy_match" # Case/punctuation-normalised match
EMBEDDING_ANALOG = "embedding_analog" # Embedding similarity match
ANTHROPIC_FALLBACK = "anthropic_fallback" # LLM suggested replacement
ORIGINAL_KEPT = "original_kept" # Not in collection but Scryfall-valid
BASIC_LAND_FALLBACK = "basic_land_fallback" # Last resort — slot becomes a basic
@dataclass
class SubstitutionRecord:
"""Audit trail entry for a single card's resolution."""
original: str
resolved: str
method: SubstitutionMethod
in_collection: bool
# ══════════════════════════════════════════════════════════════
# Color → Basic Land mapping
# ══════════════════════════════════════════════════════════════
_COLOR_TO_BASIC: dict[str, str] = {
"W": "Plains",
"U": "Island",
"B": "Swamp",
"R": "Mountain",
"G": "Forest",
}
_BASIC_NAMES: set[str] = set(_COLOR_TO_BASIC.values()) | {"Wastes"}
def _get_basic_land(color_identity: List[str]) -> str:
"""Return the first appropriate basic land for the given color identity."""
for color in color_identity:
if color.upper() in _COLOR_TO_BASIC:
return _COLOR_TO_BASIC[color.upper()]
return "Wastes" # Colorless commander
# ══════════════════════════════════════════════════════════════
# Non-destructive Smart Substitution
# ══════════════════════════════════════════════════════════════
def smart_substitute(
card_name: str,
collection: set[str],
color_identity: List[str],
embedding_index=None,
) -> SubstitutionRecord:
"""
Non-destructive substitution — ALWAYS returns a valid card string.
Fallback chain:
1. Exact match in collection
2. Fuzzy match (case + punctuation normalised)
3. Embedding similarity match (if index available)
4. Keep original (Scryfall-valid, not in collection)
5. Basic land for the commander's colors (last resort — NEVER None)
"""
# 1. Exact match
if card_name in collection:
return SubstitutionRecord(card_name, card_name, SubstitutionMethod.EXACT_MATCH, True)
# 2. Fuzzy match — normalise case and common punctuation differences
normalised = card_name.lower().strip()
for owned in collection:
if owned.lower().strip() == normalised:
return SubstitutionRecord(card_name, owned, SubstitutionMethod.FUZZY_MATCH, True)
# 3. Embedding similarity (if index available)
if embedding_index and getattr(embedding_index, "loaded", False):
try:
matches = embedding_index.search_similar(
query_card=card_name,
color_filter=color_identity or None,
exclude_cards=[card_name],
top_n=5,
)
pool_lower = {n.lower() for n in collection}
for match in matches:
if match.name.lower() in pool_lower:
return SubstitutionRecord(
card_name, match.name, SubstitutionMethod.EMBEDDING_ANALOG, True
)
except Exception as e:
logger.warning("Embedding substitution failed for '%s': %s", card_name, e)
# 4. Keep original — valid card not in collection (proxy / non-collection build)
return SubstitutionRecord(
card_name, card_name, SubstitutionMethod.ORIGINAL_KEPT, False
)
def apply_substitutions(
card_list: List[DeckCardWithStatus],
collection: set[str],
color_identity: List[str],
embedding_index=None,
) -> Tuple[List[DeckCardWithStatus], List[SubstitutionRecord]]:
"""
Apply smart substitution to a card list.
Guarantees: output list is NEVER shorter than input list.
Logs a warning for every BASIC_LAND_FALLBACK.
"""
records: List[SubstitutionRecord] = []
result: List[DeckCardWithStatus] = []
for card in card_list:
rec = smart_substitute(card.name, collection, color_identity, embedding_index)
records.append(rec)
if rec.method == SubstitutionMethod.BASIC_LAND_FALLBACK:
logger.warning(
"[substitution] BASIC_LAND_FALLBACK for '%s' — slot filled with '%s'",
rec.original, rec.resolved,
)
# Update the card in-place — never drop it
updated = card.model_copy(update={
"name": rec.resolved,
"owned": rec.in_collection,
"status": "owned" if rec.in_collection else
("substituted" if rec.method != SubstitutionMethod.ORIGINAL_KEPT
else "missing"),
"original_card": card.name if rec.resolved != card.name else card.original_card,
})
result.append(updated)
fallback_count = sum(1 for r in records if r.method == SubstitutionMethod.BASIC_LAND_FALLBACK)
if fallback_count:
logger.error(
"[substitution] %d cards fell back to basic lands — investigate LLM output quality",
fallback_count,
)
return result, records
# ══════════════════════════════════════════════════════════════
# Pad-to-99 Circuit Breaker
# ══════════════════════════════════════════════════════════════
def _pad_to_99(
cards: List[DeckCardWithStatus],
color_identity: List[str],
target: int = 99,
) -> Tuple[List[DeckCardWithStatus], int]:
"""
Emergency safety pad — should NEVER fire if substitution contract holds.
If it does fire, the error log is your signal that a card is being dropped
somewhere earlier in the pipeline.
Returns (padded_cards, number_of_slots_padded).
"""
# Strip commander from count if present (commander is tracked separately)
non_commander = [c for c in cards if not getattr(c, "is_commander", False)]
current = sum(c.count for c in non_commander)
shortfall = target - current
if shortfall <= 0:
return cards, 0
logger.error(
"[pad_to_99] CIRCUIT BREAKER FIRED — deck is %d cards short after substitution. "
"Padding with basic lands. Check substitution pipeline for silent drops.",
shortfall,
)
basic = _get_basic_land(color_identity)
padding_card = DeckCardWithStatus(
name=basic,
count=shortfall,
category="Land",
role_tags=[],
reason="Emergency pad — basic land inserted by circuit breaker",
estimated_price_usd=0.10,
owned=True,
owned_qty=shortfall,
status="owned",
)
return cards + [padding_card], shortfall
def _log_pre_validation_counts(cards: List[DeckCardWithStatus]) -> None:
"""Log per-category card counts before Pydantic validation."""
by_cat: dict[str, int] = {}
for card in cards:
cat = card.category or "Uncategorized"
by_cat[cat] = by_cat.get(cat, 0) + card.count
total = sum(by_cat.values())
logger.info("[pre_validation] Category counts: %s", by_cat)
logger.info("[pre_validation] Total: %d/99 (delta: %d)", total, 99 - total)
# ══════════════════════════════════════════════════════════════
# DeckGeneratorV3
# ══════════════════════════════════════════════════════════════
class DeckGeneratorV3:
"""
V3 Deck Generator using Anthropic Claude structured output.
V3 Deck Generator using Perplexity structured output.
Pipeline:
1. Resolve commander via Scryfall
2. Build collection summary
3. Call Anthropic Claude with structured JSON schema
3. Call Perplexity with structured JSON schema
4. Validate card names via Scryfall
5. Check ownership against collection DB
6. Run Smart Substitution (non-destructive)
7. _pad_to_99 circuit breaker
8. Pre-validation count logging
9. Return finalized deck — guaranteed 99 cards
6. Run Smart Substitution (embedding + Perplexity fallback)
7. Return finalized deck with substitution data
"""
def __init__(self, anthropic_client: AnthropicClient, db_conn_factory,
def __init__(self, pplx_client: PerplexityClient, db_conn_factory,
embedding_index=None):
self.anthropic = anthropic_client
"""
Args:
pplx_client: Configured PerplexityClient instance
db_conn_factory: Callable that returns sqlite3.Connection
embedding_index: Optional MTGEmbeddingIndex for substitution
"""
self.pplx = pplx_client
self.db_conn_factory = db_conn_factory
self.embedding_index = embedding_index
@@ -312,12 +77,9 @@ class DeckGeneratorV3:
"mana_cost": data.get("mana_cost", ""),
"image_url": data.get("image_uris", {}).get("normal", ""),
"oracle_text": data.get("oracle_text", ""),
"scryfall_found": True,
}
except Exception as e:
logger.warning("Scryfall lookup failed for '%s': %s", commander_name, e)
# Non-canonical commander (silver-bordered, custom, etc.)
# Skip collection substitution for these — trust LLM output directly
return {
"name": commander_name,
"scryfall_id": "",
@@ -326,7 +88,6 @@ class DeckGeneratorV3:
"mana_cost": "",
"image_url": "",
"oracle_text": "",
"scryfall_found": False,
}
# ── Step 2: Build Collection Summary ─────────────────────
@@ -349,10 +110,16 @@ class DeckGeneratorV3:
if not rows:
return ""
# Filter by color identity
ci_set = set(color_identity) if color_identity else None
filtered = []
for row in rows:
name = row["name"]
type_line = row["type_line"] or ""
# Basic lands and colorless are always allowed
if ci_set:
# Simple color check from type_line and name
# For proper filtering we'd check mana cost but this is good enough for summary
card_colors = set()
oracle = row["oracle_text"] or ""
mana_cost = ""
@@ -370,6 +137,7 @@ class DeckGeneratorV3:
if not filtered:
return ""
# Group by card type
groups = {}
for row in filtered:
type_line = row["type_line"] or ""
@@ -391,12 +159,14 @@ class DeckGeneratorV3:
group = "Other"
groups.setdefault(group, []).append(row)
# Sort each group by popularity/price and build text
lines = [f"COLLECTION SUMMARY ({len(filtered)} cards in deck colors):"]
for group_name in ["Creatures", "Instants", "Sorceries", "Artifacts",
"Enchantments", "Planeswalkers", "Lands", "Other"]:
cards = groups.get(group_name, [])
if not cards:
continue
# Sort by price descending (higher value = likely better card)
cards.sort(key=lambda r: r["tcg_price"] or 0, reverse=True)
card_strs = []
for c in cards[:max_per_group]:
@@ -406,7 +176,7 @@ class DeckGeneratorV3:
return "\n".join(lines)
# ── Step 3: Generate Deck via Anthropic Claude ─────────────────
# ── Step 3: Generate Deck via Perplexity ─────────────────
def generate_deck(
self,
commander_name: str,
@@ -423,33 +193,24 @@ class DeckGeneratorV3:
Returns dict with:
- commander: dict with Scryfall data
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
- cards: list of DeckCardWithStatus (after ownership check + substitution)
- raw_deck: parsed GeneratedDeckList from Perplexity
- cards: list of DeckCardWithStatus (after ownership check)
- stats: deck statistics
- substitution_log: list of SubstitutionRecord dicts
- padded_slots: int > 0 means circuit breaker fired
- tokens_used: {prompt, completion}
"""
# Step 1: Resolve commander
cmdr = self.resolve_commander(commander_name)
color_identity = cmdr["color_identity"]
scryfall_found = cmdr.get("scryfall_found", True)
logger.info("Commander: %s, CI: %s, Scryfall: %s",
cmdr["name"], color_identity, scryfall_found)
logger.info("Commander: %s, CI: %s", cmdr["name"], color_identity)
# Step 2: Collection summary
collection_block = ""
if use_collection and scryfall_found:
if use_collection:
collection_block = self.build_collection_summary(color_identity)
logger.info("Collection summary: %d chars",
len(collection_block) if collection_block else 0)
elif not scryfall_found:
logger.info(
"Non-canonical commander '%s' — skipping collection filter, "
"trusting LLM output directly.", cmdr["name"]
)
# Step 3: Build prompts (with slot budget injected)
# Step 3: Build prompts
user_prompt = build_user_prompt(
commander=cmdr["name"],
commander_type=cmdr["type_line"],
@@ -460,14 +221,13 @@ class DeckGeneratorV3:
budget_mode=budget_mode,
omit_cards=omit_cards,
collection_summary=collection_block,
slot_budget=SLOT_BUDGET,
)
# Step 4: Call LLM with structured output
# Step 4: Call Perplexity with structured output
use_model = model or DECK_GEN_MODEL
logger.info("Calling LLM (%s) for deck generation...", use_model)
logger.info("Calling Perplexity (%s) for deck generation...", use_model)
response = self.anthropic.chat_structured(
response = self.pplx.chat_structured(
system_prompt=SYSTEM_PROMPT,
user_prompt=user_prompt,
json_schema=DECK_LIST_SCHEMA,
@@ -479,60 +239,24 @@ class DeckGeneratorV3:
if not response.ok:
raise ValueError(
f"LLM returned invalid response: {response.content[:300]}"
f"Perplexity returned invalid response: {response.content[:300]}"
)
raw_deck = response.parsed_json
card_count = len(raw_deck.get("cards", []))
truncated = card_count < 90
logger.info(
"Deck generated: %d cards, bracket %s%s",
card_count,
(raw_deck.get("bracket", {}).get("level", "?") if isinstance(raw_deck.get("bracket"), dict)
else raw_deck.get("bracket", "?")),
" (TRUNCATED — hit token limit)" if truncated else "",
)
truncated = card_count < 90 # Commander decks should have ~100 cards
logger.info("Deck generated: %d cards, bracket %d%s",
card_count,
raw_deck.get("bracket", {}).get("level", 0),
" (TRUNCATED — response may have hit token limit)" if truncated else "")
# Step 5: Check ownership
# Step 5: Check ownership and build enriched card list
cards_with_status = self._check_ownership(raw_deck.get("cards", []))
# Step 5b: Fill basic lands to reach target_total=100
# Step 5b: Fill basic lands to hit exactly 100 cards
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
# Step 5c: Secondary deduplication
seen: dict[str, int] = {}
deduped: List[DeckCardWithStatus] = []
for c in cards_with_status:
key = c.name.lower()
if key in seen:
deduped[seen[key]].count += c.count
logger.warning("Secondary dedupe caught duplicate: '%s'", c.name)
else:
seen[key] = len(deduped)
deduped.append(c)
cards_with_status = deduped
# 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
# Step 6: Compute stats
stats = self._compute_stats(cards_with_status)
return {
@@ -546,12 +270,6 @@ class DeckGeneratorV3:
"archetype": raw_deck.get("archetype", ""),
"reasoning": raw_deck.get("reasoning", {}),
"estimated_total_usd": raw_deck.get("estimated_total_usd", 0),
"substitution_log": [
{"original": r.original, "resolved": r.resolved,
"method": r.method.value, "in_collection": r.in_collection}
for r in substitution_log
],
"padded_slots": padded_slots,
"tokens_used": {
"prompt": response.prompt_tokens,
"completion": response.completion_tokens,
@@ -565,25 +283,17 @@ class DeckGeneratorV3:
def _check_ownership(self, cards: List[dict]) -> List[DeckCardWithStatus]:
"""
Cross-reference generated cards against the collection DB.
Deduplicates by name (case-insensitive).
Sets owned/scryfall_id/owned_qty and real price.
"""
conn = self.db_conn_factory()
enriched: List[DeckCardWithStatus] = []
seen_names: dict[str, int] = {}
enriched = []
for card in cards:
name = card.get("name", "")
if not name:
continue
name_lower = name.lower()
if name_lower in seen_names:
idx = seen_names[name_lower]
enriched[idx].count += card.get("count", 1)
logger.warning("Duplicate card '%s' merged", name)
continue
# Look up in collection
row = conn.execute(
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
@@ -608,14 +318,16 @@ class DeckGeneratorV3:
scryfall_id=row["scryfall_id"] if row else "",
status="owned" if is_owned else "missing",
)
seen_names[name_lower] = len(enriched)
enriched.append(enriched_card)
return enriched
# ── Step 5b: Basic Land Fill ──────────────────────────────
_COLOR_TO_BASIC = _COLOR_TO_BASIC
_BASIC_NAMES = _BASIC_NAMES
_COLOR_TO_BASIC = {
'W': 'Plains', 'U': 'Island', 'B': 'Swamp',
'R': 'Mountain', 'G': 'Forest',
}
_BASIC_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'}
def _fill_basic_lands(
self,
@@ -637,7 +349,7 @@ class DeckGeneratorV3:
ci = [c.upper() for c in color_identity]
basic_names = [self._COLOR_TO_BASIC[c] for c in ci if c in self._COLOR_TO_BASIC]
if not basic_names:
basic_names = ["Wastes"]
basic_names = ['Wastes']
per_color = basics_needed // len(basic_names)
remainder = basics_needed % len(basic_names)
@@ -648,20 +360,20 @@ class DeckGeneratorV3:
non_basics.append(DeckCardWithStatus(
name=bname,
count=qty,
category="Land",
category='Land',
role_tags=[],
reason="Basic land for mana fixing",
reason='Basic land for mana fixing',
estimated_price_usd=0.10,
owned=True,
owned_qty=qty,
status="owned",
status='owned',
))
logger.info("Basic land fill: %d basics added across %d color(s)",
basics_needed, len(basic_names))
basics_needed, len(basic_names))
return non_basics
# ── Step 6: Smart Substitution (legacy wrapper for route compatibility) ──
# ── Step 6: Smart Substitution ───────────────────────────
def run_substitution(
self,
cards: List[DeckCardWithStatus],
@@ -671,12 +383,15 @@ class DeckGeneratorV3:
"""
Run the Smart Substitution Engine on missing cards.
Uses the non-destructive apply_substitutions() under the hood.
Falls back to Anthropic Claude for low-confidence matches,
then to basic land as last resort — NEVER drops a card.
Pipeline:
a) Identify missing cards
b) For each: try embedding similarity (free, fast)
c) For low-confidence matches: batch Perplexity fallback
d) Auto-select best substitute per card
Returns SubstitutionBatchResult with all cards updated.
"""
color_identity = commander.get("color_identity", [])
scryfall_found = commander.get("scryfall_found", True)
missing = [c for c in cards if not c.owned]
owned = [c for c in cards if c.owned]
@@ -691,63 +406,47 @@ class DeckGeneratorV3:
logger.info("Substitution: %d missing / %d owned", len(missing), len(owned))
# Build owned pool
# Build list of owned card names for substitution pool
conn = self.db_conn_factory()
owned_pool_rows = conn.execute(
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
).fetchall()
owned_pool = {r["name"] for r in owned_pool_rows}
deck_names = {c.name.lower() for c in cards}
available_pool = {n for n in owned_pool if n.lower() not in deck_names}
owned_pool = [r["name"] for r in owned_pool_rows]
# a) Embedding pass for missing cards
needs_anthropic_fallback: List[DeckCardWithStatus] = []
# Exclude cards already in the deck
deck_names = set(c.name.lower() for c in cards)
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
# a) Embedding pass
needs_pplx_fallback = []
for card in missing:
embedding_alts = self._embedding_substitution(
card, color_identity, list(available_pool)
card, color_identity, available_pool
)
card.alternatives = embedding_alts
# Check if best match meets threshold
if embedding_alts and embedding_alts[0].similarity_score >= SUBSTITUTION_MIN_SIMILARITY:
card.selected_substitute = embedding_alts[0].name
card.status = "substituted"
card.owned = True
available_pool.discard(card.selected_substitute.lower())
# Remove selected from pool
if card.selected_substitute.lower() in [n.lower() for n in available_pool]:
available_pool = [n for n in available_pool
if n.lower() != card.selected_substitute.lower()]
else:
needs_anthropic_fallback.append(card)
needs_pplx_fallback.append(card)
# b) Anthropic Claude fallback
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
self._anthropic_substitution(
needs_anthropic_fallback, list(available_pool),
# b) Perplexity fallback for low-confidence matches
if needs_pplx_fallback and SUBSTITUTION_USE_PPLX_FALLBACK:
self._pplx_substitution(
needs_pplx_fallback, available_pool,
commander, strategy
)
# 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
# Count results
substituted = sum(1 for c in cards if c.status == "substituted")
still_missing = sum(1 for c in cards if c.status == "missing")
# Validate count hasn't changed
assert len(cards) == len(owned) + len(missing), \
f"[substitution] Card count changed: {len(owned) + len(missing)} -> {len(cards)}"
return SubstitutionBatchResult(
total_cards=len(cards),
owned_count=len(owned),
@@ -766,37 +465,39 @@ class DeckGeneratorV3:
if not self.embedding_index or not self.embedding_index.loaded:
return []
try:
matches = self.embedding_index.search_similar(
query_card=card.name,
color_filter=color_identity if color_identity else None,
type_filter=card.category if card.category else None,
exclude_cards=[card.name],
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3,
)
except Exception as e:
logger.warning("Embedding search failed for '%s': %s", card.name, e)
return []
# Use the embeddings search to find similar cards
matches = self.embedding_index.search_similar(
query_card=card.name,
color_filter=color_identity if color_identity else None,
type_filter=card.category if card.category else None,
exclude_cards=[card.name],
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3, # Over-fetch, then filter to owned
)
pool_lower = {n.lower() for n in available_pool}
# Filter to only cards in the available pool
pool_lower = set(n.lower() for n in available_pool)
alternatives = []
for match in matches:
if match.name.lower() in pool_lower:
role_overlap = list(set(card.role_tags) & set(getattr(match, "role_tags", [])))
# Compute role overlap
match_roles = [] # Would need card's role_tags from DB
role_overlap = list(set(card.role_tags) & set(match_roles)) if match_roles else []
alternatives.append(CardAlternative(
name=match.name,
similarity_score=match.similarity,
reason=f"Similar to {card.name} (cosine sim: {match.similarity:.3f})",
source="embedding",
role_overlap=role_overlap,
cmc_delta=getattr(match, "mana_value", 0) - (card.estimated_price_usd or 0),
cmc_delta=match.mana_value - (card.estimated_price_usd or 0),
))
if len(alternatives) >= SUBSTITUTION_MAX_ALTERNATIVES:
break
return alternatives
def _anthropic_substitution(
def _pplx_substitution(
self,
missing_cards: List[DeckCardWithStatus],
available_pool: List[str],
@@ -804,14 +505,13 @@ class DeckGeneratorV3:
strategy: str,
):
"""
Batch Anthropic Claude fallback for cards with low embedding confidence.
Mutates cards in-place. On failure, cards retain status='missing'
so the basic-land fallback in run_substitution() can catch them.
Batch Perplexity fallback for cards with low embedding confidence.
Mutates the cards in-place with substitution data.
"""
if not available_pool:
logger.warning("[anthropic_substitution] No available pool — skipping")
return
# Build the request
missing_dicts = [
{
"name": c.name,
@@ -824,7 +524,7 @@ class DeckGeneratorV3:
user_prompt = build_substitution_prompt(
missing_cards=missing_dicts,
allowed_cards=available_pool[:200],
allowed_cards=available_pool[:200], # Limit to avoid token overflow
commander=commander.get("name", ""),
color_identity=commander.get("color_identity", []),
strategy=strategy,
@@ -832,7 +532,7 @@ class DeckGeneratorV3:
try:
from ..schemas.substitution_schema import SUBSTITUTION_RESPONSE_SCHEMA
response = self.anthropic.chat_structured(
response = self.pplx.chat_structured(
system_prompt=SUBSTITUTION_SYSTEM,
user_prompt=user_prompt,
json_schema=SUBSTITUTION_RESPONSE_SCHEMA,
@@ -843,16 +543,13 @@ class DeckGeneratorV3:
)
if not response.ok:
logger.warning(
"[anthropic_substitution] LLM returned non-JSON — "
"%d cards will fall through to basic land fallback",
len(missing_cards),
)
logger.warning("Perplexity substitution fallback failed — non-JSON response")
return
subs = response.parsed_json.get("substitutions", [])
logger.info("[anthropic_substitution] Received %d substitutions", len(subs))
logger.info("Perplexity suggested %d substitutions", len(subs))
# Apply substitutions
card_by_name = {c.name.lower(): c for c in missing_cards}
for sub in subs:
orig = sub.get("original", "").lower()
@@ -861,38 +558,32 @@ class DeckGeneratorV3:
if orig in card_by_name and substitute:
card = card_by_name[orig]
anthropic_alt = CardAlternative(
# Add as top alternative
pplx_alt = CardAlternative(
name=substitute,
similarity_score=0.80,
similarity_score=0.80, # Nominal score for Perplexity suggestions
reason=reason,
source="anthropic",
source="perplexity",
role_overlap=sub.get("role_overlap", []),
)
card.alternatives.insert(0, anthropic_alt)
# Insert at front
card.alternatives.insert(0, pplx_alt)
card.selected_substitute = substitute
card.status = "substituted"
card.owned = True
card.original_card = card.name
except Exception as 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
logger.error("Perplexity substitution fallback error: %s", e)
# ── Stats ────────────────────────────────────────────────
@staticmethod
def _compute_stats(cards: List[DeckCardWithStatus]) -> dict:
"""Compute deck statistics from the enriched card list."""
by_category: dict[str, int] = {}
by_status: dict[str, int] = {"owned": 0, "substituted": 0, "missing": 0}
by_category = {}
by_status = {"owned": 0, "substituted": 0, "missing": 0}
total_price = 0.0
owned_price = 0.0
missing_price = 0.0
role_counts: dict[str, int] = {}
role_counts = {}
for card in cards:
cat = card.category or "Other"
-4
View File
@@ -1,4 +0,0 @@
name,quantity,set_code
Sol Ring,1,C21
Command Tower,1,C21
Arcane Signet,1,C21
+10 -122
View File
@@ -1,133 +1,21 @@
"""
Moxfield Adapter
================
Moxfield Adapter (stub)
════════════════════════
Fetches template decklists from Moxfield for a given commander.
Uses the undocumented but usable api2.moxfield.com API.
Rate-limited to <=10 req/sec. Errors are caught and logged.
Currently returns empty (experimental — not yet implemented).
"""
import logging
import time
import httpx
from .models import TemplateDeck, TemplateDeckCard
import logging
log = logging.getLogger("commander_ai_lab.deckgen.moxfield")
MOXFIELD_API = "https://api2.moxfield.com"
HEADERS = {
"User-Agent": "CommanderAILab/1.0",
"Accept": "application/json",
}
# Minimum seconds between requests (<=10 req/sec)
_RATE_LIMIT_DELAY = 0.12
def _slugify_commander(name: str) -> str:
"""Return the commander name as-is for the Moxfield search query."""
return name.strip()
def _build_cards_from_deck(deck_data: dict) -> list:
"""Extract commander + mainboard cards from a full Moxfield deck response."""
cards = []
# Commanders first
for card_name, card_obj in deck_data.get("commanders", {}).items():
qty = card_obj.get("quantity", 1)
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
# Mainboard
for card_name, card_obj in deck_data.get("mainboard", {}).items():
qty = card_obj.get("quantity", 1)
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
return cards
def fetch_template_decks(
commander_name: str,
color_identity: list,
config: dict = None,
) -> list:
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
"""
Fetch top Commander decks from Moxfield for the given commander.
Returns a list of TemplateDeck objects. Returns empty list on any
API failure — errors are logged, never raised.
Args:
commander_name: The commander's card name.
color_identity: List of color symbols (unused, kept for interface compat).
config: Optional dict. Supported key: ``max_decks`` (default 3).
Returns:
List of TemplateDeck objects populated with commander + mainboard cards.
Fetch template decks from Moxfield for the given commander.
Experimental — not yet implemented.
"""
max_decks = (config or {}).get("max_decks", 3)
results = []
try:
# Step 1 — Search for top decks by commander name
search_url = f"{MOXFIELD_API}/v2/decks/search"
params = {
"q": "",
"fmt": "commander",
"commander": _slugify_commander(commander_name),
"sortType": "views",
"sortDirection": "Descending",
"pageSize": max_decks,
}
log.info(f" Moxfield: Searching for '{commander_name}' (max={max_decks})")
resp = httpx.get(search_url, params=params, headers=HEADERS, timeout=15)
resp.raise_for_status()
search_data = resp.json()
deck_summaries = search_data.get("data", [])[:max_decks]
if not deck_summaries:
log.info(f" Moxfield: No decks found for '{commander_name}'")
return []
log.info(f" Moxfield: Found {len(deck_summaries)} deck(s) for '{commander_name}', fetching details...")
# Step 2 — Fetch full detail for each deck
for summary in deck_summaries:
deck_id = summary.get("publicId")
deck_name = summary.get("name", f"Moxfield {commander_name}")
if not deck_id:
log.warning(" Moxfield: Skipping deck with no publicId")
continue
time.sleep(_RATE_LIMIT_DELAY) # Rate limit: <=10 req/sec
try:
deck_url = f"{MOXFIELD_API}/v3/decks/all/{deck_id}"
deck_resp = httpx.get(deck_url, headers=HEADERS, timeout=15)
deck_resp.raise_for_status()
deck_data = deck_resp.json()
except Exception as deck_err:
log.warning(f" Moxfield: Failed to fetch deck '{deck_id}': {deck_err}")
continue
cards = _build_cards_from_deck(deck_data)
if not cards:
log.warning(f" Moxfield: Deck '{deck_name}' ({deck_id}) had no cards, skipping")
continue
results.append(TemplateDeck(
name=f"{deck_name} (Moxfield)",
source=f"https://moxfield.com/decks/{deck_id}",
cards=cards,
))
log.info(f" Moxfield: Added deck '{deck_name}' ({len(cards)} cards)")
log.info(f" Moxfield: Returning {len(results)} deck(s) for '{commander_name}'")
except httpx.HTTPStatusError as e:
log.warning(
f" Moxfield fetch failed for '{commander_name}': "
f"HTTP {e.response.status_code} — {e}"
)
except httpx.RequestError as e:
log.warning(f" Moxfield request error for '{commander_name}': {e}")
except Exception as e:
log.warning(f" Moxfield unexpected error for '{commander_name}': {e}")
return results
log.info(f" Stub: Not yet implemented for '{commander_name}'")
return []
-129
View File
@@ -1,129 +0,0 @@
# Closed-Loop Iterative Distillation — Implementation Roadmap
> **Goal:** Create a feedback loop where the PPO self-play agent exports its game decisions
> as JSONL files, which the supervised training pipeline incorporates alongside Forge
> heuristic data. The supervised baseline gradually improves as the RL agent discovers
> better play patterns — the RL agent teaches the supervised model.
---
## Phase 1 — PPO Decision Exporter
**Goal:** PPO self-play writes decisions in the same JSONL format as Forge batch sims.
- [ ] Add a `DecisionExporter` class in `ml/training/self_play.py` that captures each
(state, action, reward, game_context) during PPO rollouts
- [ ] Write to `data/results/ml-decisions-ppo-{batch_id}.jsonl` using the exact same
schema as the Forge `DecisionSnapshot` files
- [ ] Include metadata fields to distinguish PPO-generated data:
- `"source": "ppo"`
- `"model_version"`
- `"reward"`
- `"episode_return"`
- [ ] Only export decisions from **winning** games initially (higher signal-to-noise)
**Files touched:** `ml/training/self_play.py`, new `ml/training/decision_exporter.py`
---
## Phase 2 — Dataset Builder Source Awareness
**Goal:** `dataset_builder.py` can ingest both Forge and PPO decision files with
configurable mixing.
- [ ] Update `build_dataset()` to scan for both `ml-decisions-sim-*.jsonl` (Forge) and
`ml-decisions-ppo-*.jsonl` (PPO) files
- [ ] Add a `source_weights` config param — e.g., `{"forge": 1.0, "ppo": 0.5}` — to
control sampling ratios so PPO data doesn't overwhelm the Forge baseline early on
- [ ] Add a `min_reward_threshold` filter for PPO data — only include decisions from
games above a certain reward score
- [ ] Tag each sample with its source for downstream analysis
**Files touched:** `ml/data/dataset_builder.py`, `ml/config/scope.py`
---
## Phase 3 — Quality Gate & Validation
**Goal:** Prevent bad PPO data from degrading the supervised model.
- [ ] Add a validation split in `dataset_builder.py` (hold out 10-15% of Forge data as
a fixed eval set)
- [ ] After each supervised training run, evaluate on the held-out Forge set — if
accuracy drops below a threshold compared to the previous model, reject the PPO
data batch
- [ ] Track win rate of the PPO agent — only export decisions once the PPO model exceeds
a configurable win rate (e.g., >30% in 4-player pods, above the 25% random baseline)
- [ ] Log data quality metrics: action distribution entropy, reward distribution of
exported games
**Files touched:** `ml/training/trainer.py`, `ml/eval/`, new `ml/data/quality_gate.py`
---
## Phase 4 — Distillation Loop Orchestrator
**Goal:** Automate the full cycle: supervised train -> PPO self-play -> export -> retrain.
- [ ] New `ml/training/distillation_loop.py` that orchestrates:
1. Supervised train on current dataset (Forge + any existing PPO data)
2. Run N episodes of PPO self-play using the new supervised model as starting policy
3. Export winning PPO decisions to JSONL
4. Quality gate check
5. If passed, merge into dataset and go to step 1
6. If failed, discard PPO batch, adjust hyperparameters, retry
- [ ] Add `max_iterations` and `convergence_check` (stop if win rate plateaus across
iterations)
- [ ] Each iteration is a "generation" — tag data and models with generation number
**Files touched:** new `ml/training/distillation_loop.py`, `ml/config/scope.py`
---
## Phase 5 — UI Integration & Monitoring
**Goal:** Expose the distillation loop in the Commander AI Lab web UI.
- [ ] Add a "Distillation" tab or section in `ui/ai-lab.js` showing:
- Current generation number
- Per-generation win rate trend chart
- Data composition pie chart (% Forge vs % PPO per generation)
- Start / stop / pause controls for the loop
- [ ] API endpoints in `routes/ml.py`:
- `POST /api/ml/distill/start`
- `GET /api/ml/distill/status`
- `POST /api/ml/distill/stop`
- [ ] History of each generation's metrics stored as JSON for the trends view
**Files touched:** `routes/ml.py`, `ui/ai-lab.js`, `ui/ai-lab.css`
---
## Phase 6 — Advanced: ELO Tracking & Model Tournament
**Goal:** Quantify improvement across generations.
- [ ] Pit each generation's model against previous generations and the Forge heuristic
in batch sims
- [ ] Compute ELO ratings per generation
- [ ] Store in `data/elo_history.json` and visualize in the UI as a line chart
- [ ] Use ELO delta as a convergence signal — stop the loop when ELO gain per generation
drops below a threshold
**Files touched:** new `ml/eval/elo_tracker.py`, `routes/ml.py`, `ui/ai-lab.js`
---
## Priority
| Phase | Priority | Description |
|-------|----------|-------------|
| 1 | **Critical** | PPO decision export — foundation for everything |
| 2 | **Critical** | Dataset builder ingests both sources |
| 3 | **Critical** | Quality gate prevents model regression |
| 4 | High | Automation of the full loop |
| 5 | Medium | UI visibility and controls |
| 6 | Low | ELO tracking and tournament system |
> **Phases 1-3** are the core — get those working and you have a functional closed loop.
> **Phase 4** automates it. **Phases 5-6** are polish and observability.
@@ -1,93 +0,0 @@
# Phase 1 — Self-Play Integration Checklist
> **Issue:** #65 — PPO Decision Exporter
> **Ref:** `docs/CLOSED-LOOP-DISTILLATION.md`
> **Status:** In Progress
---
## Completed
- [x] Create `ml/training/decision_exporter.py` with `DecisionExporter` class
- [x] `ExporterConfig` dataclass (output_dir, only_wins, model_version, min_episode_steps)
- [x] `build_decision_record()` — converts PPO step data into Forge-compatible JSONL format
- [x] `DecisionExporter.begin_episode()` / `record_step()` / `end_episode()` / `flush()` lifecycle
- [x] Write to `data/results/ml-decisions-ppo-{batch_id}.jsonl`
- [x] Include PPO metadata fields: `source`, `model_version`, `reward`, `episode_return`
- [x] Win-only filtering (only export decisions from winning games)
- [x] Short-episode gating (`min_episode_steps` threshold)
- [x] `_macro_action_to_forge_type()` mapping (MacroAction enum → Forge action type strings)
- [x] Export stats tracking (`stats` property)
---
## Remaining — Integrate into `ml/training/self_play.py`
### Import
- [ ] Add import: `from ml.training.decision_exporter import DecisionExporter`
### Modify `run_self_play_episode()`
- [ ] Add `exporter: DecisionExporter = None` parameter to function signature
- [ ] After each `trajectory.append(...)` block (3 locations), add:
```python
if exporter is not None:
exporter.record_step(
game_state_snapshot=prev_snapshot,
action_idx=action_idx,
reward=reward,
)
```
### Modify `collect_rollouts()`
- [ ] Add `exporter: DecisionExporter = None` parameter to function signature
- [ ] Before each episode, call:
```python
if exporter is not None:
exporter.begin_episode(agent_seat=agent_seat, playstyle=playstyle)
```
- [ ] Pass `exporter=exporter` to `run_self_play_episode()`
- [ ] After outcome tracking, call:
```python
if exporter is not None:
won = trajectory and trajectory[-1]["done"] and trajectory[-1]["reward"] > 0.5
ep_return = sum(s["reward"] for s in trajectory)
exporter.end_episode(won=won, episode_return=ep_return)
```
### Commit
- [ ] `git add ml/training/self_play.py`
- [ ] `git commit -m "feat(ml): integrate DecisionExporter into self-play rollouts (#65)"`
- [ ] `git push origin main`
---
## Integration Pattern
```
collect_rollouts(exporter=exporter)
└─ for each episode:
├─ exporter.begin_episode(seat, playstyle)
├─ run_self_play_episode(..., exporter=exporter)
│ └─ for each step:
│ ├─ trajectory.append(step_data)
│ └─ exporter.record_step(snapshot, action, reward)
└─ exporter.end_episode(won, episode_return)
# Caller does: exporter.flush()
```
---
## Terminal Commands
```bash
cd commander-ai-lab
git pull origin main
# Make edits to ml/training/self_play.py per checklist above
git add ml/training/self_play.py
git commit -m "feat(ml): integrate DecisionExporter into self-play rollouts (#65)"
git push origin main
```
-785
View File
@@ -1,785 +0,0 @@
# 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. |
-42
View File
@@ -1,42 +0,0 @@
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')
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>commander-ai-lab-ui</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "commander-ai-lab-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"lucide-react": "^0.577.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.8.0",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { PageLayout } from './components/layout/PageLayout'
import { BatchSimPage } from './pages/BatchSimPage'
import { CollectionPage } from './pages/CollectionPage'
import { DecksPage } from './pages/DecksPage'
import { AutoGenPage } from './pages/AutoGenPage'
import { SimulatorPage } from './pages/SimulatorPage'
import { CoachPage } from './pages/CoachPage'
import { TrainingPage } from './pages/TrainingPage'
export default function App() {
return (
<BrowserRouter>
<PageLayout>
<Routes>
<Route path="/" element={<BatchSimPage />} />
<Route path="/collection" element={<CollectionPage />} />
<Route path="/decks" element={<DecksPage />} />
<Route path="/autogen" element={<AutoGenPage />} />
<Route path="/simulator" element={<SimulatorPage />} />
<Route path="/coach" element={<CoachPage />} />
<Route path="/training" element={<TrainingPage />} />
</Routes>
</PageLayout>
</BrowserRouter>
)
}
@@ -0,0 +1,82 @@
// ═══════════════════════════════════════════════════════════
// Base API client — all fetch calls go through here
// ═══════════════════════════════════════════════════════════
const BASE = '' // same origin; Vite proxy handles /api in dev
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.text()
let message = `HTTP ${res.status}`
try {
const json = JSON.parse(body)
message = json.detail || json.error || message
} catch {
if (body) message = body
}
throw new ApiError(res.status, message)
}
// Handle 204 No Content
if (res.status === 204) return undefined as T
return res.json()
}
export async function get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
const url = new URL(BASE + path, window.location.origin)
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '') url.searchParams.set(k, String(v))
})
}
const res = await fetch(url.toString())
return handleResponse<T>(res)
}
export async function post<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
return handleResponse<T>(res)
}
export async function postForm<T>(path: string, formData: FormData): Promise<T> {
const res = await fetch(BASE + path, {
method: 'POST',
body: formData,
})
return handleResponse<T>(res)
}
export async function put<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return handleResponse<T>(res)
}
export async function patch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return handleResponse<T>(res)
}
export async function del<T>(path: string): Promise<T> {
const res = await fetch(BASE + path, { method: 'DELETE' })
return handleResponse<T>(res)
}
@@ -0,0 +1,131 @@
import { get, post } from './client'
import type {
CoachStatus, CoachDeck, CoachSession, CoachSessionSummary,
CoachChatResponse, CoachApplyResult, CoachCardLikeResult
} from '../types'
export async function getCoachStatus() {
return get<CoachStatus>('/api/coach/status')
}
export async function getCoachDecks() {
return get<CoachDeck[]>('/api/coach/decks')
}
export async function getCoachDeckReport(deckId: string) {
return get<Record<string, unknown>>(`/api/coach/decks/${deckId}/report`)
}
export async function runCoachSession(deckId: string, goals?: Record<string, unknown>) {
return post<CoachSession>(`/api/coach/decks/${deckId}`, goals ? { goals } : undefined)
}
export async function getCoachSessions(deckId?: string) {
const params: Record<string, string> = {}
if (deckId) params.deck_id = deckId
return get<{ sessions: CoachSessionSummary[] }>('/api/coach/sessions', params)
}
export async function getCoachSession(sessionId: string) {
return get<CoachSession>(`/api/coach/sessions/${sessionId}`)
}
export async function downloadEmbeddings() {
return post<{ success: boolean; cards: number }>('/api/coach/embeddings/download')
}
export async function coachChat(
deckId: string,
messages: { role: string; content: string }[],
goals?: Record<string, unknown>
) {
return post<CoachChatResponse>('/api/coach/chat', {
deck_id: deckId,
messages,
goals,
stream: false,
})
}
export async function coachChatStream(
deckId: string,
messages: { role: string; content: string }[],
goals?: Record<string, unknown>,
onChunk?: (text: string) => void,
signal?: AbortSignal
): Promise<string> {
const res = await fetch('/api/coach/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deck_id: deckId,
messages,
goals,
stream: true,
}),
signal,
})
if (!res.ok) {
const body = await res.text()
throw new Error(body || `HTTP ${res.status}`)
}
const reader = res.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
let fullText = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') break
try {
const parsed = JSON.parse(data)
if (parsed.content) {
fullText += parsed.content
onChunk?.(fullText)
}
} catch { /* skip bad chunks */ }
}
}
}
return fullText
}
export async function applyCoachSuggestions(
sessionId: string,
deckId: number,
acceptedCuts: string[],
acceptedAdds: string[]
) {
return post<CoachApplyResult>('/api/coach/apply', {
session_id: sessionId,
deck_id: deckId,
accepted_cuts: acceptedCuts,
accepted_adds: acceptedAdds,
})
}
export async function searchCardsLike(card: string, colors?: string, topN = 10) {
const params: Record<string, string | number> = { card, top_n: topN }
if (colors) params.colors = colors
return get<{ query: string; results: CoachCardLikeResult[] }>('/api/coach/cards-like', params)
}
export async function generateReport(deckId?: number) {
return post<{ status: string; decksUpdated: string[]; count: number }>('/api/coach/reports/generate',
deckId ? { deck_id: deckId } : undefined
)
}
@@ -0,0 +1,86 @@
import { get, post, patch, postForm } from './client'
import type { CollectionCard, CollectionFilters, SetInfo, ScanResult } from '../types'
export async function searchCollection(filters: CollectionFilters) {
// Map frontend snake_case filter names to backend camelCase param names
const params: Record<string, string | number | boolean> = {}
if (filters.q) params.q = filters.q
if (filters.page) params.page = filters.page
if (filters.page_size) params.pageSize = filters.page_size
if (filters.sort_by) params.sortField = filters.sort_by
if (filters.sort_dir) params.sortDir = filters.sort_dir
if (filters.color) params.colors = filters.color
if (filters.type_line) params.types = filters.type_line
if (filters.rarity) params.rarity = filters.rarity
if (filters.set_code) params.setCode = filters.set_code
if (filters.cmc_min !== undefined) params.cmcMin = filters.cmc_min
if (filters.cmc_max !== undefined) params.cmcMax = filters.cmc_max
if (filters.price_min !== undefined) params.priceMin = filters.price_min
if (filters.price_max !== undefined) params.priceMax = filters.price_max
if (filters.owned_only !== undefined) params.owned_only = filters.owned_only
const res = await get<{ items?: CollectionCard[]; cards?: CollectionCard[]; total: number; page: number; pageSize?: number; page_size?: number }>(
'/api/collection', params
)
return {
cards: res.items || res.cards || [],
total: res.total || 0,
page: res.page || 1,
page_size: res.pageSize || res.page_size || 48,
}
}
export async function getCard(cardId: number) {
return get<CollectionCard>(`/api/collection/${cardId}`)
}
export async function updateCard(cardId: number, updates: Partial<CollectionCard>) {
return patch<CollectionCard>(`/api/collection/${cardId}`, updates)
}
export async function getSets() {
const res = await get<SetInfo[] | { sets: SetInfo[] }>('/api/collection/sets')
return Array.isArray(res) ? res : (res as { sets: SetInfo[] }).sets || []
}
export async function getKeywords() {
const res = await get<string[] | { keywords: string[] }>('/api/collection/keywords')
return Array.isArray(res) ? res : (res as { keywords: string[] }).keywords || []
}
export async function exportCollection(format: string = 'csv') {
const res = await fetch(`/api/collection/export?format=${format}`)
return res.blob()
}
export async function importCollection(file: File) {
const fd = new FormData()
fd.append('file', file)
return postForm<{ imported: number; errors: string[] }>('/api/collection/import', fd)
}
export async function scanCard(file: File) {
const fd = new FormData()
fd.append('image', file)
return postForm<ScanResult>('/api/collection/scan', fd)
}
export async function addScanResult(match: { name: string; set_code: string; scryfall_id: string; quantity?: number }) {
return post<{ success: boolean; card_id: number }>('/api/collection/scan/add', match)
}
export async function reEnrichCards() {
return post<{ updated: number }>('/api/collection/re-enrich')
}
export async function autoClassify(cardId: number) {
return post<{ classifications: string[] }>(`/api/collection/auto-classify`, { card_id: cardId })
}
export async function autoClassifyAll() {
return post<{ processed: number }>('/api/collection/auto-classify-all')
}
export async function getEdhrecData(cardId: number) {
return get<Record<string, unknown>>(`/api/collection/${cardId}/edhrec`)
}
@@ -0,0 +1,22 @@
import { get, post } from './client'
import type { DeckGenV3Request, DeckGenV3Status, DeckGenV3Result, CommanderSearchResult } from '../types'
export async function getV3Status() {
return get<DeckGenV3Status>('/api/deck/v3/status')
}
export async function searchCommanders(q: string) {
return get<CommanderSearchResult[]>('/api/deck-generator/commander-search', { q })
}
export async function generateDeckV3(req: DeckGenV3Request) {
return post<DeckGenV3Result>('/api/deck/v3/generate', req)
}
export async function commitDeckV3(req: DeckGenV3Request) {
return post<DeckGenV3Result & { deck_id: number; dck_path: string }>('/api/deck/v3/commit', req)
}
export async function exportDeckV3(format: 'csv' | 'dck' | 'moxfield' | 'shopping', req: DeckGenV3Request) {
return post<Record<string, unknown>>(`/api/deck/v3/export/${format}`, req)
}
@@ -0,0 +1,130 @@
import { get, post, put, del, patch } from './client'
import type { Deck, DeckCard, DeckAnalysis, EdhRecsResponse, CollectionRecsResponse, PplxStatus } from '../types'
export async function listDecks() {
const res = await get<{ decks: Record<string, unknown>[] } | Record<string, unknown>[]>('/api/decks')
const raw = Array.isArray(res) ? res : (res as { decks: Record<string, unknown>[] }).decks || []
return raw.map(d => ({
...d,
commander: d.commander || d.commander_name || '',
card_count: d.card_count ?? d.total_cards ?? 0,
total_price: d.total_price ?? 0,
color_identity: d.color_identity || [],
created_date: d.created_date || d.created_at || '',
updated_date: d.updated_date || d.updated_at || '',
})) as Deck[]
}
export async function getDeck(deckId: number) {
return get<Deck>(`/api/decks/${deckId}`)
}
export async function createDeck(deck: { name: string; commander?: string; format?: string }) {
return post<Deck>('/api/decks', deck)
}
export async function updateDeck(deckId: number, updates: Partial<Deck>) {
return put<Deck>(`/api/decks/${deckId}`, updates)
}
export async function deleteDeck(deckId: number) {
return del<void>(`/api/decks/${deckId}`)
}
export async function deleteAllDecks() {
return del<void>('/api/decks')
}
export async function getDeckCards(deckId: number) {
const res = await get<{ cards: Record<string, unknown>[]; total: number } | Record<string, unknown>[]>(`/api/decks/${deckId}/cards`)
const raw = Array.isArray(res) ? res : (res as { cards: Record<string, unknown>[] }).cards || []
return raw.map(c => ({
...c,
name: c.name || c.card_name || '',
category: c.category || c.role_tag || '',
})) as DeckCard[]
}
export async function addCardToDeck(deckId: number, card: { name: string; scryfall_id?: string; quantity?: number; category?: string }) {
return post<DeckCard>(`/api/decks/${deckId}/cards`, card)
}
export async function removeCardFromDeck(deckId: number, cardId: number) {
return del<void>(`/api/decks/${deckId}/cards/${cardId}`)
}
export async function updateDeckCard(deckId: number, cardId: number, updates: Partial<DeckCard>) {
return patch<DeckCard>(`/api/decks/${deckId}/cards/${cardId}`, updates)
}
export async function getDeckAnalysis(deckId: number) {
const raw = await get<Record<string, unknown>>(`/api/decks/${deckId}/analysis`)
// Backend returns: counts_by_type, targets, deltas, mana_curve, color_pips, total_cards, roles
// Frontend DeckAnalysis expects: card_count, land_count, creature_count, noncreature_count, avg_cmc, mana_curve, color_distribution, type_distribution, total_price, owned_count, missing_count
const counts = (raw.counts_by_type || {}) as Record<string, number>
const totalCards = (raw.total_cards as number) || 0
const landCount = counts['Land'] || 0
const creatureCount = counts['Creature'] || 0
const manaCurve = (raw.mana_curve || {}) as Record<string | number, number>
const colorPips = (raw.color_pips || {}) as Record<string, number>
// Compute avg CMC from mana curve
let totalMana = 0, nonLandCards = 0
for (const [cmc, count] of Object.entries(manaCurve)) {
const cmcNum = cmc === '6+' ? 6 : Number(cmc)
totalMana += cmcNum * count
nonLandCards += count
}
return {
card_count: totalCards,
land_count: landCount,
creature_count: creatureCount,
noncreature_count: totalCards - landCount - creatureCount,
avg_cmc: nonLandCards > 0 ? totalMana / nonLandCards : 0,
mana_curve: manaCurve as Record<number, number>,
color_distribution: colorPips,
type_distribution: counts,
total_price: 0,
owned_count: totalCards,
missing_count: 0,
} as DeckAnalysis
}
export async function getCollectionRecs(deckId: number) {
return get<CollectionRecsResponse>(`/api/decks/${deckId}/recommended-from-collection`)
}
export async function getEdhRecs(deckId: number, onlyOwned = false, maxResults = 30) {
return get<EdhRecsResponse>(`/api/decks/${deckId}/edh-recs?only_owned=${onlyOwned}&max_results=${maxResults}`)
}
export async function bulkAddCards(deckId: number, cards: { name: string; quantity?: number }[]) {
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add`, { cards })
}
export async function bulkAddRecommended(deckId: number, cards: string[]) {
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add-recommended`, { card_names: cards })
}
export async function exportDeckToSim(deckId: number) {
return post<{ path: string }>(`/api/decks/${deckId}/export-to-sim`)
}
export async function importDeckToDeck(deckId: number, data: { text?: string; url?: string }) {
return post<{ imported: number }>(`/api/decks/${deckId}/import`, data)
}
export async function importNewDeck(data: { text?: string; url?: string; name?: string }) {
return post<Deck>('/api/decks/import-new', data)
}
export async function getPplxStatus() {
return get<PplxStatus>('/api/pplx/status')
}
export async function deckResearch(payload: { commander: string; strategy?: string }) {
return post<{ research: string }>('/api/deck-research', payload)
}
export async function deckGenerate(payload: { commander: string; strategy?: string; bracket?: number }) {
return post<{ cards: Array<{ name: string; quantity: number; category: string }> }>('/api/deck-generate', payload)
}
@@ -0,0 +1,7 @@
export * as collectionApi from './collection'
export * as decksApi from './decks'
export * as deckgenApi from './deckgen'
export * as labApi from './lab'
export * as simApi from './sim'
export * as coachApi from './coach'
export * as trainingApi from './training'
@@ -0,0 +1,65 @@
import { get, post } from './client'
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
export async function getLabDecks() {
const res = await get<{ decks: LabDeck[] } | LabDeck[]>('/api/lab/decks')
return Array.isArray(res) ? res : (res as { decks: LabDeck[] }).decks || []
}
export async function startBatchSim(config: { decks: string[]; games: number; threads?: number; use_deepseek?: boolean; deepseek_deck?: string }) {
return post<{ run_id: string }>('/api/lab/start', config)
}
export async function startDeepSeekSim(config: { decks: string[]; games: number; deepseek_deck: string }) {
return post<{ run_id: string }>('/api/lab/start-deepseek', config)
}
export async function getLabStatus() {
return get<LabStatus>('/api/lab/status')
}
export async function getLabResult() {
return get<LabResult>('/api/lab/result')
}
export async function getLabHistory() {
const res = await get<{ results: LabHistoryEntry[] } | LabHistoryEntry[]>('/api/lab/history')
return Array.isArray(res) ? res : (res as { results: LabHistoryEntry[] }).results || []
}
export async function getPrecons() {
const res = await get<{ precons: PreconDeck[] } | PreconDeck[]>('/api/lab/precons')
return Array.isArray(res) ? res : (res as { precons: PreconDeck[] }).precons || []
}
export async function installPrecon(name: string) {
return post<{ installed: boolean }>('/api/lab/precons/install', { name })
}
export async function installPreconBatch(names: string[]) {
return post<{ installed: number }>('/api/lab/precons/install-batch', { names })
}
export async function getMetaCommanders() {
return get<Array<{ name: string; color_identity: string[] }>>('/api/lab/meta/commanders')
}
export async function searchMetaCommanders(q: string) {
return get<Array<{ name: string; color_identity: string[]; source: string }>>('/api/lab/meta/search', { q })
}
export async function fetchMetaDeck(commander: string) {
return post<{ deck_name: string; cards: Array<{ name: string; quantity: number }> }>('/api/lab/meta/fetch', { commander })
}
export async function importDeckFromUrl(url: string) {
return post<{ deck_name: string }>('/api/lab/import/url', { url })
}
export async function importDeckFromText(text: string, name?: string) {
return post<{ deck_name: string }>('/api/lab/import/text', { text, name })
}
export async function getLabLog() {
return get<{ log: string }>('/api/lab/log')
}
@@ -0,0 +1,38 @@
import { get, post } from './client'
import type { SimStatus, SimResult, DeepSeekStatus } from '../types'
export async function runSim(config: { deck_a: string; deck_b: string; games: number }) {
return post<{ sim_id: string }>('/api/sim/run', config)
}
export async function runSimFromDeck(deckId: number, config: { opponent?: string; games?: number }) {
return post<{ sim_id: string }>('/api/sim/run-from-deck', { deck_id: deckId, ...config })
}
export async function runDeepSeekSim(config: { deck_a: string; deck_b: string; games: number; deepseek_deck: string }) {
return post<{ sim_id: string }>('/api/sim/run-deepseek', config)
}
export async function getSimStatus(simId: string) {
return get<SimStatus>('/api/sim/status', { simId })
}
export async function getSimResult(simId: string) {
return get<SimResult>('/api/sim/result', { simId })
}
export async function connectDeepSeek() {
return post<DeepSeekStatus>('/api/deepseek/connect')
}
export async function getDeepSeekStatus() {
return get<DeepSeekStatus>('/api/deepseek/status')
}
export async function configureDeepSeek(config: { model?: string; base_url?: string }) {
return post<DeepSeekStatus>('/api/deepseek/configure', config)
}
export async function getDeepSeekLogs() {
return get<Array<{ filename: string; size: number; modified: string }>>('/api/deepseek/logs')
}
@@ -0,0 +1,92 @@
import { get, post } from './client'
import type {
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
TournamentStatus, TournamentResults, MLModelInfo
} from '../types'
// ── ML Status & Toggle ──────────────────────────────────────
export async function getMLStatus() {
return get<MLStatus>('/api/ml/status')
}
export async function toggleML(enable = true) {
return post<{ ml_logging_enabled: boolean; message: string }>(`/api/ml/toggle?enable=${enable}`)
}
// ── Data & Model Info ───────────────────────────────────────
export async function getDataStatus() {
return get<MLDataStatus>('/api/ml/data/status')
}
export async function getModelInfo() {
return get<MLModelInfo>('/api/ml/model')
}
export async function reloadModel(checkpoint?: string) {
return post<{ success: boolean; status: MLModelInfo }>('/api/ml/reload', checkpoint ? { checkpoint } : undefined)
}
// ── Supervised Training ─────────────────────────────────────
export async function startTraining(config: {
epochs?: number
lr?: number
batchSize?: number
patience?: number
rebuildDataset?: boolean
}) {
return post<{ status: string; config: Record<string, unknown> }>('/api/ml/train', config)
}
export async function getTrainStatus() {
return get<MLTrainStatus>('/api/ml/train/status')
}
// ── PPO Training ────────────────────────────────────────────
export async function startPPOTraining(config: {
iterations?: number
episodesPerIter?: number
ppoEpochs?: number
batchSize?: number
lr?: number
clipEpsilon?: number
entropyCoeff?: number
opponent?: string
playstyle?: string
loadSupervised?: string
}) {
return post<{ status: string; iterations: number }>('/api/ml/train/ppo', config)
}
export async function getPPOStatus() {
return get<PPOTrainStatus>('/api/ml/train/ppo/status')
}
// ── Tournament ──────────────────────────────────────────────
export async function startTournament(config: {
episodes?: number
playstyle?: string
}) {
return post<{ status: string; episodes: number }>('/api/ml/tournament', config)
}
export async function getTournamentStatus() {
return get<TournamentStatus>('/api/ml/tournament/status')
}
export async function getTournamentResults() {
return get<TournamentResults>('/api/ml/tournament/results')
}
// ── Decision Files ──────────────────────────────────────────
export async function getDecisionFile(filename: string, limit = 100, offset = 0) {
return get<{ file: string; offset: number; limit: number; count: number; decisions: Record<string, unknown>[] }>(
`/api/ml/decisions/${filename}?limit=${limit}&offset=${offset}`
)
}
// ── Prediction ──────────────────────────────────────────────
export async function predict(state: Record<string, unknown>) {
return post<{ action: string; action_index: number; confidence: number; probabilities: Record<string, number>; inference_ms: number }>(
'/api/ml/predict', state
)
}
@@ -0,0 +1,49 @@
import { useState } from 'react'
interface CardImageProps {
src: string
alt: string
size?: 'sm' | 'md' | 'lg' | 'xl'
className?: string
onClick?: () => void
}
const SIZES = {
sm: 'w-16 h-[22px]', // tiny inline
md: 'w-[146px] h-[204px]', // grid thumbnail
lg: 'w-[244px] h-[340px]', // detail view
xl: 'w-[336px] h-[468px]', // full size
}
export function CardImage({ src, alt, size = 'md', className = '', onClick }: CardImageProps) {
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
// Build Scryfall URL if needed — ensure we get the right size
const imgUrl = src || ''
return (
<div
className={`relative overflow-hidden rounded-lg bg-bg-tertiary ${SIZES[size]} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-accent-blue/50 transition-all' : ''} ${className}`}
onClick={onClick}
>
{!error ? (
<img
src={imgUrl}
alt={alt}
className={`w-full h-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
loading="lazy"
/>
) : (
<div className="flex items-center justify-center w-full h-full text-text-tertiary text-xs text-center p-2">
{alt}
</div>
)}
{!loaded && !error && (
<div className="absolute inset-0 bg-bg-tertiary animate-pulse" />
)}
</div>
)
}
@@ -0,0 +1,19 @@
import type { LucideIcon } from 'lucide-react'
interface EmptyStateProps {
icon: LucideIcon
title: string
description?: string
action?: React.ReactNode
}
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Icon className="w-12 h-12 text-text-tertiary mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-semibold text-text-primary mb-1">{title}</h3>
{description && <p className="text-sm text-text-secondary max-w-md mb-4">{description}</p>}
{action}
</div>
)
}
@@ -0,0 +1,69 @@
// Parse MTG mana cost strings like "{2}{W}{U}" into colored symbols
const MANA_COLORS: Record<string, string> = {
W: 'bg-mana-white text-black',
U: 'bg-mana-blue text-white',
B: 'bg-mana-black text-white',
R: 'bg-mana-red text-white',
G: 'bg-mana-green text-white',
C: 'bg-mana-colorless text-white',
}
interface ManaSymbolsProps {
cost: string
size?: 'sm' | 'md'
}
export function ManaSymbols({ cost, size = 'sm' }: ManaSymbolsProps) {
if (!cost) return null
const symbols = cost.match(/\{([^}]+)\}/g)
if (!symbols) return <span className="text-text-tertiary">{cost}</span>
const sz = size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs'
return (
<span className="inline-flex items-center gap-0.5">
{symbols.map((s, i) => {
const val = s.replace(/[{}]/g, '')
const colorClass = MANA_COLORS[val] || 'bg-mana-colorless text-white'
return (
<span
key={i}
className={`${sz} inline-flex items-center justify-center rounded-full font-bold ${colorClass}`}
>
{val}
</span>
)
})}
</span>
)
}
// Color identity dots
interface ColorDotsProps {
colors: string[]
size?: 'sm' | 'md'
}
export function ColorDots({ colors, size = 'sm' }: ColorDotsProps) {
if (!colors || colors.length === 0) return <span className="text-text-tertiary text-xs">Colorless</span>
const sz = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'
const DOT_COLORS: Record<string, string> = {
W: 'bg-mana-white',
U: 'bg-mana-blue',
B: 'bg-mana-black border border-border-secondary',
R: 'bg-mana-red',
G: 'bg-mana-green',
}
return (
<span className="inline-flex items-center gap-0.5">
{colors.map(c => (
<span key={c} className={`${sz} rounded-full ${DOT_COLORS[c] || 'bg-mana-colorless'}`} />
))}
</span>
)
}
@@ -0,0 +1,38 @@
interface SpinnerProps {
size?: 'sm' | 'md' | 'lg'
className?: string
}
const SIZES = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-8 h-8' }
export function Spinner({ size = 'md', className = '' }: SpinnerProps) {
return (
<svg
className={`animate-spin ${SIZES[size]} ${className}`}
viewBox="0 0 24 24"
fill="none"
>
<circle
cx="12" cy="12" r="10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
className="opacity-20"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
)
}
export function PageSpinner() {
return (
<div className="flex items-center justify-center h-64">
<Spinner size="lg" className="text-accent-blue" />
</div>
)
}
@@ -0,0 +1,35 @@
import { CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
type Variant = 'success' | 'error' | 'warning' | 'info' | 'neutral'
interface StatusBadgeProps {
variant: Variant
label: string
className?: string
}
const STYLES: Record<Variant, string> = {
success: 'bg-status-success/15 text-status-success border-status-success/30',
error: 'bg-status-error/15 text-status-error border-status-error/30',
warning: 'bg-status-warning/15 text-status-warning border-status-warning/30',
info: 'bg-status-info/15 text-status-info border-status-info/30',
neutral: 'bg-bg-tertiary text-text-secondary border-border-primary',
}
const ICONS: Record<Variant, typeof CheckCircle> = {
success: CheckCircle,
error: XCircle,
warning: AlertTriangle,
info: Info,
neutral: Info,
}
export function StatusBadge({ variant, label, className = '' }: StatusBadgeProps) {
const Icon = ICONS[variant]
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md border ${STYLES[variant]} ${className}`}>
<Icon className="w-3.5 h-3.5" />
{label}
</span>
)
}
@@ -0,0 +1,5 @@
export { Spinner, PageSpinner } from './Spinner'
export { StatusBadge } from './StatusBadge'
export { CardImage } from './CardImage'
export { ManaSymbols, ColorDots } from './ManaSymbols'
export { EmptyState } from './EmptyState'
@@ -0,0 +1,16 @@
import { Sidebar } from './Sidebar'
interface PageLayoutProps {
children: React.ReactNode
}
export function PageLayout({ children }: PageLayoutProps) {
return (
<div className="min-h-screen bg-bg-primary">
<Sidebar />
<main className="ml-56 min-h-screen">
{children}
</main>
</div>
)
}
@@ -0,0 +1,51 @@
import { NavLink } from 'react-router-dom'
const NAV_ITEMS = [
{ to: '/', label: 'Batch Sim', icon: '⚗️' },
{ to: '/collection', label: 'Collection', icon: '📦' },
{ to: '/decks', label: 'Decks', icon: '🃏' },
{ to: '/autogen', label: 'Auto Gen', icon: '⚡' },
{ to: '/simulator', label: 'Simulator', icon: '🎮' },
{ to: '/coach', label: 'Coach', icon: '🧠' },
{ to: '/training', label: 'Training', icon: '📈' },
]
export function Sidebar() {
return (
<aside className="fixed left-0 top-0 bottom-0 w-56 bg-bg-secondary border-r border-border-primary flex flex-col z-30">
{/* Logo */}
<div className="px-5 py-5 border-b border-border-primary">
<h1 className="text-base font-bold text-text-primary tracking-tight">
Commander AI Lab
</h1>
<p className="text-xs text-text-tertiary mt-0.5">MTG Intelligence Platform</p>
</div>
{/* Nav */}
<nav className="flex-1 py-3 overflow-y-auto">
{NAV_ITEMS.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-5 py-2.5 text-sm transition-colors duration-150 ${
isActive
? 'bg-accent-blue/10 text-accent-blue border-r-2 border-accent-blue'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-hover'
}`
}
>
<span className="text-base">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</nav>
{/* Footer */}
<div className="px-5 py-4 border-t border-border-primary">
<p className="text-xs text-text-tertiary">v4.0 — React + TypeScript</p>
</div>
</aside>
)
}
@@ -0,0 +1,13 @@
import { useEffect, useRef } from 'react'
/** Polls a function at the given interval while `enabled` is true */
export function usePolling(fn: () => void, intervalMs: number, enabled: boolean) {
const savedFn = useRef(fn)
savedFn.current = fn
useEffect(() => {
if (!enabled) return
const id = setInterval(() => savedFn.current(), intervalMs)
return () => clearInterval(id)
}, [intervalMs, enabled])
}
+114
View File
@@ -0,0 +1,114 @@
@import "tailwindcss";
/* ═══════════════════════════════════════════════════════════
Commander AI Lab — Design Tokens (Tailwind v4 CSS Theme)
Fresh dark theme with MTG-inspired accents
═══════════════════════════════════════════════════════════ */
@theme {
/* ── Backgrounds ── */
--color-bg-primary: #0f1117;
--color-bg-secondary: #161921;
--color-bg-tertiary: #1c2030;
--color-bg-elevated: #222738;
--color-bg-hover: #2a3048;
/* ── Borders ── */
--color-border-primary: #2a2f42;
--color-border-secondary: #363c54;
--color-border-accent: #4f6ef7;
/* ── Text ── */
--color-text-primary: #e8eaf0;
--color-text-secondary: #9ba1b8;
--color-text-tertiary: #6b7190;
--color-text-inverse: #0f1117;
/* ── Accents ── */
--color-accent-blue: #4f6ef7;
--color-accent-blue-hover: #6b84ff;
--color-accent-teal: #2dd4bf;
--color-accent-teal-hover: #5eead4;
--color-accent-purple: #a78bfa;
--color-accent-amber: #fbbf24;
--color-accent-red: #f87171;
--color-accent-green: #4ade80;
/* ── MTG Mana Colors ── */
--color-mana-white: #f9faf4;
--color-mana-blue: #0e67ab;
--color-mana-black: #2b2424;
--color-mana-red: #d3202a;
--color-mana-green: #00733e;
--color-mana-colorless: #706f6f;
/* ── Status ── */
--color-status-success: #4ade80;
--color-status-warning: #fbbf24;
--color-status-error: #f87171;
--color-status-info: #60a5fa;
/* ── Radius ── */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* ── Shadows ── */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-glow-blue: 0 0 20px rgba(79, 110, 247, 0.15);
--shadow-glow-teal: 0 0 20px rgba(45, 212, 191, 0.15);
}
/* ── Base Styles ── */
html {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
/* ── Utility animations ── */
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(79, 110, 247, 0.2); }
50% { box-shadow: 0 0 16px 4px rgba(79, 110, 247, 0.15); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-pulse-glow {
animation: pulse-glow 2s infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,436 @@
import { useState, useEffect, useRef } from 'react'
import {
Search, Wand2, Save, AlertCircle,
ChevronDown, Sparkles, RefreshCw, Check
} from 'lucide-react'
import { Spinner, StatusBadge, ManaSymbols, CardImage, ColorDots } from '../components/common'
import { deckgenApi } from '../api'
import type { DeckGenV3Status, DeckGenV3Result, DeckGenV3Card, CommanderSearchResult } from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
// ── Commander Search ──────────────────────────────────────────
function CommanderSearch({ onSelect }: { onSelect: (c: CommanderSearchResult) => void }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<CommanderSearchResult[]>([])
const [searching, setSearching] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
function handleSearch(q: string) {
setQuery(q)
clearTimeout(debounceRef.current)
if (q.length < 2) { setResults([]); return }
debounceRef.current = setTimeout(async () => {
setSearching(true)
try {
const r = await deckgenApi.searchCommanders(q)
setResults(r)
} catch { setResults([]) }
setSearching(false)
}, 300)
}
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search for a commander..."
value={query}
onChange={e => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
/>
{searching && <Spinner size="sm" className="absolute right-3 top-1/2 -translate-y-1/2 text-accent-blue" />}
</div>
{results.length > 0 && (
<div className="absolute z-20 top-full mt-1 w-full bg-bg-elevated border border-border-primary rounded-xl shadow-lg max-h-80 overflow-y-auto">
{results.map(c => (
<button
key={c.scryfall_id}
onClick={() => { onSelect(c); setQuery(c.name); setResults([]) }}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-bg-hover text-left transition-colors"
>
<CardImage src={c.image_url} alt={c.name} size="sm" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{c.name}</p>
<p className="text-xs text-text-secondary truncate">{c.type_line}</p>
</div>
<ColorDots colors={c.color_identity} />
{c.in_collection && (
<span className="text-[10px] px-1.5 py-0.5 bg-accent-green/15 text-accent-green rounded">Owned</span>
)}
</button>
))}
</div>
)}
</div>
)
}
// ── Generated Deck View ───────────────────────────────────────
function GeneratedDeckView({ result, onCommit }: { result: DeckGenV3Result; onCommit: () => void }) {
const [view, setView] = useState<'visual' | 'list'>('visual')
const [committing, setCommitting] = useState(false)
const [selectedCard, setSelectedCard] = useState<DeckGenV3Card | null>(null)
// Group by category
const grouped = result.cards.reduce<Record<string, DeckGenV3Card[]>>((acc, c) => {
const cat = c.category || 'Other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(c)
return acc
}, {})
const curveData = Object.entries(result.stats.mana_curve)
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
async function handleCommit() {
setCommitting(true)
try { onCommit() } catch { /* ignore */ }
setCommitting(false)
}
return (
<div className="space-y-5 animate-fade-in">
{/* Commander header */}
<div className="flex gap-5 bg-bg-secondary rounded-xl border border-border-primary p-5">
<CardImage src={result.commander.image_url} alt={result.commander.name} size="lg" />
<div className="flex-1 space-y-3">
<div>
<h2 className="text-xl font-bold text-text-primary">{result.commander.name}</h2>
<p className="text-sm text-text-secondary">{result.commander.type_line}</p>
<div className="flex items-center gap-3 mt-2">
<ManaSymbols cost={result.commander.mana_cost} size="md" />
<ColorDots colors={result.commander.color_identity} size="md" />
</div>
</div>
<p className="text-sm text-text-primary whitespace-pre-wrap">{result.commander.oracle_text}</p>
<div className="flex items-center gap-4 text-xs text-text-secondary">
<span>Strategy: <span className="text-accent-blue font-medium">{result.strategy}</span></span>
<span>Bracket: <span className="text-accent-amber font-medium">{result.bracket}</span></span>
<span>Model: <span className="text-accent-purple font-medium">{result.model}</span></span>
</div>
{result.substitution && (
<div className="flex items-center gap-3 text-xs">
<span className="text-accent-green">{result.substitution.owned} owned</span>
<span className="text-accent-amber">{result.substitution.substituted} substituted</span>
<span className="text-accent-red">{result.substitution.missing} missing</span>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button onClick={handleCommit} disabled={committing}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-green/15 text-accent-green text-sm font-medium rounded-lg hover:bg-accent-green/25 border border-accent-green/30 transition-colors disabled:opacity-50">
{committing ? <Spinner size="sm" /> : <Save className="w-4 h-4" />}
Save to Decks
</button>
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
<button onClick={() => setView('visual')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'visual' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
Visual
</button>
<button onClick={() => setView('list')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
List
</button>
</div>
{/* Stats summary */}
<div className="flex-1" />
<span className="text-xs text-text-tertiary">{result.stats.card_count} cards</span>
<span className="text-xs text-accent-teal font-medium">${result.stats.total_price.toFixed(2)}</span>
</div>
{/* Mana curve mini */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">Mana Curve</h4>
<div className="h-28">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={curveData} barSize={20}>
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 10 }} />
<YAxis hide />
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 11 }} />
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
{curveData.map((_, i) => <Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Card grid/list */}
{view === 'visual' ? (
<div className="space-y-5">
{Object.entries(grouped).map(([cat, cards]) => (
<div key={cat}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-3">{cat} ({cards.length})</h4>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-2">
{cards.map(card => (
<div key={card.name} className="group cursor-pointer relative" onClick={() => setSelectedCard(card)}>
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
{card.is_substitute && (
<div className="absolute top-1 right-1 w-4 h-4 bg-accent-amber rounded-full flex items-center justify-center">
<RefreshCw className="w-2.5 h-2.5 text-bg-primary" />
</div>
)}
{card.owned && (
<div className="absolute top-1 left-1 w-4 h-4 bg-accent-green rounded-full flex items-center justify-center">
<Check className="w-2.5 h-2.5 text-bg-primary" />
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary text-left">
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Category</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Role</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{result.cards.map(card => (
<tr key={card.name} className="hover:bg-bg-hover transition-colors">
<td className="px-4 py-2.5">
<span className="font-medium text-text-primary">{card.quantity}x {card.name}</span>
{card.is_substitute && (
<span className="ml-2 text-[10px] text-accent-amber">sub for {card.original_name}</span>
)}
</td>
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.category}</td>
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.role}</td>
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
<td className="px-4 py-2.5 text-right text-accent-teal text-xs">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
<td className="px-4 py-2.5">
<span className={`text-[10px] px-2 py-0.5 rounded ${card.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary'}`}>
{card.owned ? `Owned (${card.owned_qty})` : 'Missing'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Card detail popup */}
{selectedCard && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={() => setSelectedCard(null)}>
<div className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-2xl w-full mx-4 p-6 animate-fade-in" onClick={e => e.stopPropagation()}>
<div className="flex gap-5">
<CardImage src={selectedCard.image_url} alt={selectedCard.name} size="lg" />
<div className="flex-1 space-y-3">
<h3 className="text-lg font-bold text-text-primary">{selectedCard.name}</h3>
<p className="text-sm text-text-secondary">{selectedCard.type_line}</p>
<ManaSymbols cost={selectedCard.mana_cost} size="md" />
<p className="text-sm text-text-primary whitespace-pre-wrap">{selectedCard.reason}</p>
<div className="flex items-center gap-3 text-xs">
<span className="text-text-tertiary">Role: <span className="text-accent-blue">{selectedCard.role}</span></span>
{selectedCard.is_substitute && (
<span className="text-accent-amber">Substituted for {selectedCard.original_name} ({(selectedCard.similarity_score ?? 0 * 100).toFixed(0)}%)</span>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
)
}
// ── Main Auto Gen Page ────────────────────────────────────────
export function AutoGenPage() {
const [status, setStatus] = useState<DeckGenV3Status | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [result, setResult] = useState<DeckGenV3Result | null>(null)
const [commander, setCommander] = useState<CommanderSearchResult | null>(null)
const [strategy, setStrategy] = useState('')
const [bracket, setBracket] = useState(3)
const [budget, setBudget] = useState<string>('')
const [useCollection, setUseCollection] = useState(true)
const [runSub, setRunSub] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
deckgenApi.getV3Status().then(s => { setStatus(s); setLoading(false) }).catch(() => setLoading(false))
}, [])
async function handleGenerate() {
if (!commander) return
setGenerating(true)
setError(null)
setResult(null)
try {
const res = await deckgenApi.generateDeckV3({
commander_name: commander.name,
strategy: strategy || undefined,
target_bracket: bracket,
budget_usd: budget ? Number(budget) : undefined,
use_collection: useCollection,
run_substitution: runSub,
})
setResult(res)
} catch (e) {
setError(e instanceof Error ? e.message : 'Generation failed')
}
setGenerating(false)
}
async function handleCommit() {
if (!commander || !result) return
try {
await deckgenApi.commitDeckV3({
commander_name: commander.name,
strategy: strategy || undefined,
target_bracket: bracket,
budget_usd: budget ? Number(budget) : undefined,
use_collection: useCollection,
run_substitution: runSub,
})
} catch { /* ignore */ }
}
if (loading) {
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
}
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Sparkles className="w-6 h-6 text-accent-purple" />
Auto Gen V3
</h1>
<p className="text-sm text-text-secondary mt-0.5">AI-powered deck generation with Perplexity</p>
</div>
{/* Status bar */}
<div className="flex items-center gap-3">
<StatusBadge
variant={status?.initialized ? 'success' : 'error'}
label={status?.initialized ? 'Ready' : 'Not Initialized'}
/>
<StatusBadge
variant={status?.pplx_configured ? 'success' : 'warning'}
label={status?.pplx_configured ? 'Perplexity Connected' : 'Perplexity Not Set'}
/>
{status?.embeddings_loaded && (
<StatusBadge variant="info" label={`${status.embedding_cards.toLocaleString()} embeddings`} />
)}
</div>
{!result ? (
/* Config form */
<div className="max-w-2xl space-y-5">
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Commander</label>
<CommanderSearch onSelect={setCommander} />
{commander && (
<div className="mt-2 flex items-center gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
<CardImage src={commander.image_url} alt={commander.name} size="sm" />
<div>
<p className="text-sm font-medium text-text-primary">{commander.name}</p>
<p className="text-xs text-text-secondary">{commander.type_line}</p>
</div>
<ColorDots colors={commander.color_identity} />
</div>
)}
</div>
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Strategy (optional)</label>
<input
type="text"
placeholder="e.g. Voltron, Aristocrats, Storm..."
value={strategy}
onChange={e => setStrategy(e.target.value)}
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Target Bracket</label>
<select
value={bracket}
onChange={e => setBracket(Number(e.target.value))}
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value={1}>1 — Casual</option>
<option value={2}>2 — Low Power</option>
<option value={3}>3 — Mid Power</option>
<option value={4}>4 — High Power</option>
</select>
</div>
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Budget (USD, optional)</label>
<input
type="number"
placeholder="No limit"
value={budget}
onChange={e => setBudget(e.target.value)}
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={useCollection} onChange={e => setUseCollection(e.target.checked)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
<span className="text-sm text-text-secondary">Use collection cards</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={runSub} onChange={e => setRunSub(e.target.checked)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
<span className="text-sm text-text-secondary">Run substitution pass</span>
</label>
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-3 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
<button
onClick={handleGenerate}
disabled={!commander || generating || !status?.initialized}
className="flex items-center gap-2 px-6 py-3 bg-accent-blue text-white text-sm font-semibold rounded-lg hover:bg-accent-blue-hover shadow-glow-blue transition-all disabled:opacity-50 disabled:shadow-none"
>
{generating ? (
<><Spinner size="sm" /> Generating...</>
) : (
<><Wand2 className="w-4 h-4" /> Generate Deck</>
)}
</button>
</div>
) : (
/* Results */
<div>
<button onClick={() => setResult(null)}
className="flex items-center gap-2 mb-4 text-sm text-text-secondary hover:text-text-primary transition-colors">
<ChevronDown className="w-4 h-4 rotate-90" />
Back to Config
</button>
<GeneratedDeckView result={result} onCommit={handleCommit} />
</div>
)}
</div>
)
}
@@ -0,0 +1,415 @@
import { useState, useEffect } from 'react'
import {
FlaskConical, Play, Clock, Trophy,
Upload, Link, FileText, History, ChevronRight, AlertCircle,
Check
} from 'lucide-react'
import { Spinner } from '../components/common'
import { labApi } from '../api'
import { usePolling } from '../hooks/usePolling'
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
// ── Results Chart ─────────────────────────────────────────────
function ResultsChart({ result }: { result: LabResult }) {
const data = result.decks
.sort((a, b) => b.win_rate - a.win_rate)
.map(d => ({
name: d.name.length > 20 ? d.name.slice(0, 18) + '...' : d.name,
win_rate: Math.round(d.win_rate * 100),
fullName: d.name,
}))
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" barSize={20} margin={{ left: 10, right: 20 }}>
<XAxis type="number" domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis type="category" dataKey="name" width={150} tick={{ fill: '#9ba1b8', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
formatter={(value: unknown) => [`${value}%`, 'Win Rate']}
labelFormatter={(label: unknown) => data.find(d => d.name === String(label))?.fullName || String(label)}
/>
<Bar dataKey="win_rate" radius={[0, 4, 4, 0]}>
{data.map((_, i) => (
<Cell key={i} fill={i === 0 ? '#4ade80' : i === 1 ? '#4f6ef7' : '#363c54'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
// ── Deck Selector ─────────────────────────────────────────────
function DeckSelector({
decks,
selected,
onToggle,
precons,
onInstallPrecon,
}: {
decks: LabDeck[]
selected: Set<string>
onToggle: (name: string) => void
precons: PreconDeck[]
onInstallPrecon: (name: string) => void
}) {
const [showPrecons, setShowPrecons] = useState(false)
const [installing, setInstalling] = useState<string | null>(null)
async function handleInstall(name: string) {
setInstalling(name)
await onInstallPrecon(name)
setInstalling(null)
}
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-text-primary">Available Decks</h3>
{decks.length === 0 ? (
<p className="text-xs text-text-tertiary">No decks available. Import or install precons.</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{decks.map(d => (
<label key={d.name} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover cursor-pointer transition-colors">
<input
type="checkbox"
checked={selected.has(d.name)}
onChange={() => onToggle(d.name)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-text-primary truncate block">{d.name}</span>
<span className="text-[10px] text-text-tertiary">{d.source} · {d.card_count} cards</span>
</div>
</label>
))}
</div>
)}
<button
onClick={() => setShowPrecons(!showPrecons)}
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium bg-bg-tertiary rounded-lg text-text-secondary hover:bg-bg-hover border border-border-primary transition-colors"
>
Precon Decks
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showPrecons ? 'rotate-90' : ''}`} />
</button>
{showPrecons && (
<div className="space-y-1 max-h-48 overflow-y-auto animate-fade-in">
{precons.map(p => (
<div key={p.name} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-bg-tertiary">
<span className="flex-1 text-xs text-text-secondary truncate">{p.name}</span>
{p.installed ? (
<span className="text-[10px] text-accent-green flex items-center gap-1"><Check className="w-3 h-3" /> Installed</span>
) : (
<button
onClick={() => handleInstall(p.name)}
disabled={installing === p.name}
className="text-[10px] text-accent-blue hover:text-accent-blue-hover transition-colors"
>
{installing === p.name ? <Spinner size="sm" /> : 'Install'}
</button>
)}
</div>
))}
</div>
)}
</div>
)
}
// ── Import Panel ──────────────────────────────────────────────
function ImportPanel({ onImported }: { onImported: () => void }) {
const [importUrl, setImportUrl] = useState('')
const [importText, setImportText] = useState('')
const [importName, setImportName] = useState('')
const [importing, setImporting] = useState(false)
const [importMode, setImportMode] = useState<'url' | 'text'>('url')
async function handleImport() {
setImporting(true)
try {
if (importMode === 'url' && importUrl.trim()) {
await labApi.importDeckFromUrl(importUrl.trim())
} else if (importMode === 'text' && importText.trim()) {
await labApi.importDeckFromText(importText.trim(), importName || undefined)
}
setImportUrl('')
setImportText('')
setImportName('')
onImported()
} catch { /* ignore */ }
setImporting(false)
}
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Upload className="w-4 h-4 text-accent-blue" />
Import Deck
</h3>
<div className="flex gap-1">
<button onClick={() => setImportMode('url')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'url' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
<Link className="w-3 h-3 inline mr-1" />URL
</button>
<button onClick={() => setImportMode('text')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'text' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
<FileText className="w-3 h-3 inline mr-1" />Text
</button>
</div>
{importMode === 'url' ? (
<input
type="text"
placeholder="Moxfield, Archidekt, or deck URL..."
value={importUrl}
onChange={e => setImportUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
) : (
<>
<input
type="text"
placeholder="Deck name"
value={importName}
onChange={e => setImportName(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<textarea
placeholder={"1x Sol Ring\n1x Command Tower\n..."}
value={importText}
onChange={e => setImportText(e.target.value)}
rows={5}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
/>
</>
)}
<button onClick={handleImport} disabled={importing}
className="w-full px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
{importing ? <Spinner size="sm" /> : 'Import'}
</button>
</div>
)
}
// ── Main Batch Sim Page ───────────────────────────────────────
export function BatchSimPage() {
const [decks, setDecks] = useState<LabDeck[]>([])
const [precons, setPrecons] = useState<PreconDeck[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [games, setGames] = useState(100)
const [status, setStatus] = useState<LabStatus | null>(null)
const [result, setResult] = useState<LabResult | null>(null)
const [history, setHistory] = useState<LabHistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [starting, setStarting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showHistory, setShowHistory] = useState(false)
async function fetchDecks() {
try {
const [d, p] = await Promise.all([labApi.getLabDecks(), labApi.getPrecons()])
setDecks(d)
setPrecons(p)
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => {
fetchDecks()
labApi.getLabStatus().then(setStatus).catch(() => {})
labApi.getLabHistory().then(setHistory).catch(() => {})
}, [])
// Poll while running
usePolling(async () => {
try {
const s = await labApi.getLabStatus()
setStatus(s)
if (!s.running && s.run_id) {
const r = await labApi.getLabResult()
setResult(r)
labApi.getLabHistory().then(setHistory).catch(() => {})
}
} catch { /* ignore */ }
}, 2000, status?.running || false)
function toggleDeck(name: string) {
setSelected(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
async function handleStart() {
if (selected.size < 2) { setError('Select at least 2 decks'); return }
setStarting(true)
setError(null)
setResult(null)
try {
await labApi.startBatchSim({ decks: [...selected], games })
const s = await labApi.getLabStatus()
setStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start')
}
setStarting(false)
}
async function handleInstallPrecon(name: string) {
try {
await labApi.installPrecon(name)
fetchDecks()
} catch { /* ignore */ }
}
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<FlaskConical className="w-6 h-6 text-accent-teal" />
Batch Simulator
</h1>
<p className="text-sm text-text-secondary mt-0.5">Run mass simulations between decks</p>
</div>
<div className="flex gap-5">
{/* Left sidebar: deck selection + import */}
<div className="w-72 flex-shrink-0 space-y-4">
{loading ? (
<div className="flex items-center justify-center h-32"><Spinner size="md" className="text-accent-blue" /></div>
) : (
<DeckSelector
decks={decks}
selected={selected}
onToggle={toggleDeck}
precons={precons}
onInstallPrecon={handleInstallPrecon}
/>
)}
<ImportPanel onImported={fetchDecks} />
</div>
{/* Main content */}
<div className="flex-1 space-y-5">
{/* Config + Start */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
<div className="flex items-center gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
<input
type="number"
min={10}
max={10000}
step={10}
value={games}
onChange={e => setGames(Number(e.target.value))}
className="mt-1 w-28 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<div className="flex-1">
<p className="text-xs text-text-tertiary">Selected: {selected.size} decks</p>
</div>
{status?.running ? (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Spinner size="sm" className="text-accent-blue" />
<span className="text-sm text-text-secondary">
{status.games_completed} / {status.total_games} games
</span>
</div>
<div className="w-48 h-2 bg-bg-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-accent-blue rounded-full transition-all"
style={{ width: `${status.total_games ? (status.games_completed / status.total_games) * 100 : 0}%` }}
/>
</div>
</div>
) : (
<button onClick={handleStart} disabled={starting || selected.size < 2}
className="flex items-center gap-2 px-5 py-2.5 bg-accent-teal/15 text-accent-teal text-sm font-semibold rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50">
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Start Simulation
</button>
)}
</div>
{error && (
<div className="mt-3 flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4" />{error}
</div>
)}
</div>
{/* Results */}
{result && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Trophy className="w-4 h-4 text-accent-amber" />
Results
</h3>
<span className="text-xs text-text-tertiary">{result.total_games} games · {new Date(result.timestamp).toLocaleString()}</span>
</div>
<ResultsChart result={result} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary">
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium uppercase tracking-wider">Deck</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Wins</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Games</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Win Rate</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Avg Turns</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{result.decks.sort((a, b) => b.win_rate - a.win_rate).map((d, i) => (
<tr key={d.name} className="hover:bg-bg-hover transition-colors">
<td className="px-3 py-2.5 flex items-center gap-2">
{i === 0 && <Trophy className="w-3.5 h-3.5 text-accent-amber" />}
<span className="font-medium text-text-primary">{d.name}</span>
</td>
<td className="px-3 py-2.5 text-right text-text-secondary">{d.wins}</td>
<td className="px-3 py-2.5 text-right text-text-secondary">{d.games}</td>
<td className="px-3 py-2.5 text-right font-semibold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</td>
<td className="px-3 py-2.5 text-right text-text-secondary">{d.avg_turns.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* History */}
<div>
<button onClick={() => setShowHistory(!showHistory)}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors">
<History className="w-4 h-4" />
Run History ({history.length})
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showHistory ? 'rotate-90' : ''}`} />
</button>
{showHistory && history.length > 0 && (
<div className="mt-3 space-y-2 animate-fade-in">
{history.map(h => (
<div key={h.run_id} className="flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary">
<Clock className="w-4 h-4 text-text-tertiary" />
<div className="flex-1">
<p className="text-sm text-text-primary">{h.decks.join(' vs ')}</p>
<p className="text-xs text-text-tertiary">{new Date(h.timestamp).toLocaleString()} · {h.total_games} games</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,949 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import {
GraduationCap, MessageSquare, Send, Sparkles, AlertCircle, X, Check,
ChevronRight, ChevronDown, Download, Settings2, Search, Zap,
Minus, Plus, History, ArrowRightLeft, Lightbulb, DollarSign, Loader2,
ClipboardList, Brain, BarChart3
} from 'lucide-react'
import { Spinner, StatusBadge, EmptyState } from '../components/common'
import { coachApi } from '../api'
import type {
CoachStatus, CoachDeck, CoachSession, CoachSessionSummary,
CoachMessage, CoachGoals, CoachApplyResult, CoachCardLikeResult
} from '../types'
// ════════════════════════════════════════════════════════════
// Goals Config Panel
// ════════════════════════════════════════════════════════════
function GoalsPanel({
goals, onChange, collapsed, onToggle
}: {
goals: CoachGoals
onChange: (g: CoachGoals) => void
collapsed: boolean
onToggle: () => void
}) {
const strategies = ['aggro', 'control', 'combo', 'midrange', 'stax', 'voltron', 'aristocrats', 'tokens']
const budgets = [
{ value: 'budget', label: 'Budget', desc: 'Under $5/card' },
{ value: 'medium', label: 'Medium', desc: 'Under $20/card' },
{ value: 'no-limit', label: 'No Limit', desc: 'Any price' },
]
const focusOptions = ['ramp', 'card draw', 'removal', 'protection', 'threats', 'mana base', 'combos', 'synergy']
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary">
<button
onClick={onToggle}
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-bg-hover transition-colors rounded-xl"
>
<Settings2 className="w-4 h-4 text-accent-teal" />
<span className="text-sm font-medium text-text-primary flex-1">Coaching Goals</span>
{collapsed ? <ChevronRight className="w-4 h-4 text-text-tertiary" /> : <ChevronDown className="w-4 h-4 text-text-tertiary" />}
</button>
{!collapsed && (
<div className="px-4 pb-4 space-y-4 border-t border-border-primary pt-3">
{/* Power Level Slider */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1 block">
Power Level: {goals.targetPowerLevel ?? 'Any'}
</label>
<input
type="range" min={1} max={10}
value={goals.targetPowerLevel ?? 5}
onChange={e => onChange({ ...goals, targetPowerLevel: parseInt(e.target.value) })}
className="w-full accent-accent-teal"
/>
<div className="flex justify-between text-[10px] text-text-tertiary">
<span>Casual</span><span>Focused</span><span>cEDH</span>
</div>
</div>
{/* Strategy */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Strategy Focus</label>
<div className="flex flex-wrap gap-1.5">
{strategies.map(s => (
<button
key={s}
onClick={() => onChange({ ...goals, metaFocus: goals.metaFocus === s ? null : s })}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
goals.metaFocus === s
? 'bg-accent-teal/20 border-accent-teal/40 text-accent-teal'
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
}`}
>
{s}
</button>
))}
</div>
</div>
{/* Budget */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Budget</label>
<div className="grid grid-cols-3 gap-2">
{budgets.map(b => (
<button
key={b.value}
onClick={() => onChange({ ...goals, budget: goals.budget === b.value ? null : b.value })}
className={`px-3 py-2 text-xs rounded-lg border transition-colors ${
goals.budget === b.value
? 'bg-accent-teal/20 border-accent-teal/40 text-accent-teal'
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
}`}
>
<div className="font-medium">{b.label}</div>
<div className="text-[10px] opacity-60">{b.desc}</div>
</button>
))}
</div>
</div>
{/* Focus Areas */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Focus Areas</label>
<div className="flex flex-wrap gap-1.5">
{focusOptions.map(f => {
const active = (goals.focusAreas || []).includes(f)
return (
<button
key={f}
onClick={() => {
const areas = goals.focusAreas || []
onChange({
...goals,
focusAreas: active ? areas.filter(a => a !== f) : [...areas, f]
})
}}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
active
? 'bg-accent-blue/20 border-accent-blue/40 text-accent-blue'
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
}`}
>
{f}
</button>
)
})}
</div>
</div>
</div>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════
// Session View — Shows coaching results with apply functionality
// ════════════════════════════════════════════════════════════
function SessionView({
session, deckId, onClose, onApplied
}: {
session: CoachSession
deckId: number
onClose: () => void
onApplied?: () => void
}) {
const [selectedCuts, setSelectedCuts] = useState<Set<string>>(new Set())
const [selectedAdds, setSelectedAdds] = useState<Set<string>>(new Set())
const [applying, setApplying] = useState(false)
const [applyResult, setApplyResult] = useState<CoachApplyResult | null>(null)
const toggleCut = (name: string) => {
const s = new Set(selectedCuts)
s.has(name) ? s.delete(name) : s.add(name)
setSelectedCuts(s)
}
const toggleAdd = (name: string) => {
const s = new Set(selectedAdds)
s.has(name) ? s.delete(name) : s.add(name)
setSelectedAdds(s)
}
const handleApply = async () => {
if (selectedCuts.size === 0 && selectedAdds.size === 0) return
setApplying(true)
try {
const result = await coachApi.applyCoachSuggestions(
session.sessionId, deckId,
[...selectedCuts], [...selectedAdds]
)
setApplyResult(result)
onApplied?.()
} catch (e) {
console.error('Apply failed:', e)
}
setApplying(false)
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-text-primary">Coaching Session</h3>
<p className="text-xs text-text-tertiary">
{new Date(session.timestamp).toLocaleString()} · {session.modelUsed}
{session.promptTokens > 0 && ` · ${session.promptTokens + session.completionTokens} tokens`}
</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-bg-hover text-text-tertiary">
<X className="w-4 h-4" />
</button>
</div>
{/* Summary */}
{session.summary && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
<h4 className="text-xs font-medium text-text-secondary mb-2 flex items-center gap-1.5">
<Brain className="w-3.5 h-3.5" /> Summary
</h4>
<p className="text-sm text-text-primary leading-relaxed">{session.summary}</p>
</div>
)}
{/* Heuristic Hints */}
{session.heuristicHints?.length > 0 && (
<div className="bg-accent-amber/5 rounded-xl border border-accent-amber/20 p-4">
<h4 className="text-xs font-medium text-accent-amber mb-2 flex items-center gap-1.5">
<Lightbulb className="w-3.5 h-3.5" /> Strategic Tips
</h4>
<ul className="space-y-1.5">
{session.heuristicHints.map((h, i) => (
<li key={i} className="text-sm text-text-primary flex items-start gap-2">
<span className="text-accent-amber mt-0.5">•</span> {h}
</li>
))}
</ul>
</div>
)}
{/* Mana Base Advice */}
{session.manaBaseAdvice && (
<div className="bg-accent-blue/5 rounded-xl border border-accent-blue/20 p-4">
<h4 className="text-xs font-medium text-accent-blue mb-2 flex items-center gap-1.5">
<BarChart3 className="w-3.5 h-3.5" /> Mana Base
</h4>
<p className="text-sm text-text-primary">{session.manaBaseAdvice}</p>
</div>
)}
{/* Suggested Cuts */}
{session.suggestedCuts?.length > 0 && (
<div>
<h4 className="text-sm font-medium text-status-error mb-2 flex items-center gap-1.5">
<Minus className="w-4 h-4" /> Suggested Cuts ({session.suggestedCuts.length})
</h4>
<div className="space-y-2">
{session.suggestedCuts.map((cut, i) => (
<div
key={i}
onClick={() => toggleCut(cut.cardName)}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedCuts.has(cut.cardName)
? 'bg-status-error/10 border-status-error/30'
: 'bg-bg-secondary border-border-primary hover:border-border-secondary'
}`}
>
<div className={`mt-0.5 w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
selectedCuts.has(cut.cardName)
? 'bg-status-error border-status-error text-white'
: 'border-border-secondary'
}`}>
{selectedCuts.has(cut.cardName) && <Check className="w-3 h-3" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary">{cut.cardName}</p>
<p className="text-xs text-text-secondary mt-0.5">{cut.reason}</p>
{cut.replacementOptions?.length > 0 && (
<p className="text-[10px] text-text-tertiary mt-1">
Replace with: {cut.replacementOptions.join(', ')}
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Suggested Adds */}
{session.suggestedAdds?.length > 0 && (
<div>
<h4 className="text-sm font-medium text-status-success mb-2 flex items-center gap-1.5">
<Plus className="w-4 h-4" /> Suggested Adds ({session.suggestedAdds.length})
</h4>
<div className="space-y-2">
{session.suggestedAdds.map((add, i) => (
<div
key={i}
onClick={() => toggleAdd(add.cardName)}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedAdds.has(add.cardName)
? 'bg-status-success/10 border-status-success/30'
: 'bg-bg-secondary border-border-primary hover:border-border-secondary'
}`}
>
<div className={`mt-0.5 w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
selectedAdds.has(add.cardName)
? 'bg-status-success border-status-success text-white'
: 'border-border-secondary'
}`}>
{selectedAdds.has(add.cardName) && <Check className="w-3 h-3" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary">{add.cardName}</p>
{add.role && (
<span className="px-1.5 py-0.5 text-[10px] bg-accent-blue/15 text-accent-blue rounded">
{add.role}
</span>
)}
</div>
<p className="text-xs text-text-secondary mt-0.5">{add.reason}</p>
{add.synergyWith?.length > 0 && (
<p className="text-[10px] text-text-tertiary mt-1">
Synergy: {add.synergyWith.join(', ')}
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Apply Button */}
{!applyResult && (selectedCuts.size > 0 || selectedAdds.size > 0) && (
<div className="sticky bottom-0 bg-bg-primary/95 backdrop-blur-sm border-t border-border-primary py-3 -mx-6 px-6">
<button
onClick={handleApply}
disabled={applying}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50"
>
{applying ? <Spinner size="sm" /> : <ArrowRightLeft className="w-4 h-4" />}
Apply {selectedCuts.size > 0 && `${selectedCuts.size} cuts`}
{selectedCuts.size > 0 && selectedAdds.size > 0 && ' + '}
{selectedAdds.size > 0 && `${selectedAdds.size} adds`} to Deck
</button>
</div>
)}
{/* Apply Results */}
{applyResult && (
<div className="bg-status-success/5 rounded-xl border border-status-success/20 p-4">
<h4 className="text-sm font-medium text-status-success mb-2 flex items-center gap-1.5">
<Check className="w-4 h-4" /> Changes Applied
</h4>
<p className="text-sm text-text-primary">
{applyResult.total_cuts > 0 && `Removed ${applyResult.total_cuts} card(s). `}
{applyResult.total_adds > 0 && `Added ${applyResult.total_adds} card(s). `}
{applyResult.errors.length > 0 && `${applyResult.errors.length} error(s).`}
</p>
{applyResult.errors.length > 0 && (
<ul className="mt-2 text-xs text-status-error">
{applyResult.errors.map((e, i) => (
<li key={i}>{e.name}: {e.error}</li>
))}
</ul>
)}
</div>
)}
{/* Raw Explanation */}
{session.rawTextExplanation && (
<details className="group">
<summary className="text-xs text-text-tertiary cursor-pointer hover:text-text-secondary">
Full analysis...
</summary>
<p className="mt-2 text-sm text-text-secondary whitespace-pre-wrap leading-relaxed bg-bg-secondary rounded-lg p-4 border border-border-primary">
{session.rawTextExplanation}
</p>
</details>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════
// Cards Like Search
// ════════════════════════════════════════════════════════════
function CardsLikePanel() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<CoachCardLikeResult[]>([])
const [searching, setSearching] = useState(false)
const handleSearch = async () => {
if (!query.trim()) return
setSearching(true)
try {
const data = await coachApi.searchCardsLike(query.trim(), undefined, 12)
setResults(data.results)
} catch { /* ignore */ }
setSearching(false)
}
return (
<div className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
placeholder="Enter a card name..."
className="w-full pl-9 pr-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue"
/>
</div>
<button
onClick={handleSearch}
disabled={searching || !query.trim()}
className="px-4 py-2 bg-accent-teal/15 text-accent-teal text-sm font-medium rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50"
>
{searching ? <Spinner size="sm" /> : 'Search'}
</button>
</div>
{results.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{results.map((card, i) => (
<div key={i} className="flex items-start gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary truncate">{card.name}</p>
<span className="text-[10px] text-text-tertiary whitespace-nowrap">
{(card.similarity * 100).toFixed(0)}%
</span>
</div>
<p className="text-[11px] text-text-secondary mt-0.5">{card.types}</p>
{card.text && (
<p className="text-[10px] text-text-tertiary mt-1 line-clamp-2">{card.text}</p>
)}
<div className="flex items-center gap-3 mt-1.5 text-[10px]">
{card.tcg_price != null && (
<span className="text-accent-amber flex items-center gap-0.5">
<DollarSign className="w-2.5 h-2.5" />{card.tcg_price.toFixed(2)}
</span>
)}
{card.owned_qty > 0 && (
<span className="text-status-success">Owned: {card.owned_qty}</span>
)}
<span className="text-text-tertiary">MV: {card.mana_value}</span>
</div>
</div>
{card.image_url && (
<img src={card.image_url} alt={card.name} className="w-14 h-auto rounded" />
)}
</div>
))}
</div>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════
// Live Chat View
// ════════════════════════════════════════════════════════════
function LiveChat({
deckId, deckName, goals, onClose
}: {
deckId: string
deckName: string
goals: CoachGoals
onClose: () => void
}) {
const [messages, setMessages] = useState<CoachMessage[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const [streamText, setStreamText] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streamText])
const sendMessage = async () => {
if (!input.trim() || streaming) return
const userMsg: CoachMessage = { role: 'user', content: input.trim() }
const allMessages = [...messages, userMsg]
setMessages(allMessages)
setInput('')
setStreaming(true)
setStreamText('')
const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0
? {
targetPowerLevel: goals.targetPowerLevel,
metaFocus: goals.metaFocus,
budget: goals.budget,
focusAreas: goals.focusAreas,
}
: undefined
try {
abortRef.current = new AbortController()
const fullText = await coachApi.coachChatStream(
deckId,
allMessages.map(m => ({ role: m.role, content: m.content })),
goalsDict,
(text) => setStreamText(text),
abortRef.current.signal
)
setMessages(prev => [...prev, { role: 'assistant', content: fullText }])
setStreamText('')
} catch (e) {
if ((e as Error).name !== 'AbortError') {
// Fallback to non-streaming
try {
const resp = await coachApi.coachChat(
deckId,
allMessages.map(m => ({ role: m.role, content: m.content })),
goalsDict
)
setMessages(prev => [...prev, { role: 'assistant', content: resp.content }])
} catch (e2) {
setMessages(prev => [...prev, {
role: 'assistant',
content: `Error: ${(e2 as Error).message || 'Failed to get response'}`
}])
}
}
setStreamText('')
}
setStreaming(false)
}
return (
<div className="flex flex-col h-[calc(100vh-180px)]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border-primary">
<div>
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-accent-blue" />
Chat: {deckName}
</h3>
<p className="text-[10px] text-text-tertiary">
{goals.metaFocus && `${goals.metaFocus} · `}
{goals.targetPowerLevel && `Power ${goals.targetPowerLevel} · `}
{goals.budget && `${goals.budget}`}
</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-bg-hover text-text-tertiary">
<X className="w-4 h-4" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 && !streaming && (
<div className="text-center py-12">
<GraduationCap className="w-10 h-10 text-text-tertiary mx-auto mb-3" />
<p className="text-sm text-text-secondary">Ask your coach anything about this deck.</p>
<div className="mt-4 flex flex-wrap gap-2 justify-center">
{[
'What should I cut?',
'How is my mana curve?',
'Suggest win conditions',
'Rate my removal suite',
].map(q => (
<button
key={q}
onClick={() => { setInput(q); }}
className="px-3 py-1.5 text-xs bg-bg-secondary border border-border-primary rounded-full text-text-secondary hover:text-text-primary hover:border-border-secondary transition-colors"
>
{q}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed ${
msg.role === 'user'
? 'bg-accent-blue/15 text-text-primary border border-accent-blue/20'
: 'bg-bg-secondary text-text-primary border border-border-primary'
}`}>
{msg.content}
</div>
</div>
))}
{/* Streaming indicator */}
{streaming && (
<div className="flex justify-start">
<div className="max-w-[85%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed bg-bg-secondary text-text-primary border border-border-primary">
{streamText || (
<span className="flex items-center gap-2 text-text-tertiary">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Thinking...
</span>
)}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t border-border-primary p-3">
<div className="flex gap-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage()}
placeholder="Ask the coach..."
disabled={streaming}
className="flex-1 px-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue disabled:opacity-50"
/>
<button
onClick={sendMessage}
disabled={streaming || !input.trim()}
className="px-3 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
}
// ════════════════════════════════════════════════════════════
// Main Coach Page
// ════════════════════════════════════════════════════════════
type Tab = 'decks' | 'chat' | 'session' | 'history' | 'search'
export function CoachPage() {
const [status, setStatus] = useState<CoachStatus | null>(null)
const [decks, setDecks] = useState<CoachDeck[]>([])
const [sessions, setSessions] = useState<CoachSessionSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// UI state
const [tab, setTab] = useState<Tab>('decks')
const [selectedDeck, setSelectedDeck] = useState<CoachDeck | null>(null)
const [selectedSession, setSelectedSession] = useState<CoachSession | null>(null)
const [goals, setGoals] = useState<CoachGoals>({})
const [goalsCollapsed, setGoalsCollapsed] = useState(true)
const [generatingReport, setGeneratingReport] = useState(false)
const [runningSession, setRunningSession] = useState<number | null>(null)
const [downloadingEmbeddings, setDownloadingEmbeddings] = useState(false)
const loadData = useCallback(async () => {
try {
const [s, d, sess] = await Promise.all([
coachApi.getCoachStatus(),
coachApi.getCoachDecks().catch(() => []),
coachApi.getCoachSessions().catch(() => ({ sessions: [] })),
])
setStatus(s)
setDecks(Array.isArray(d) ? d : [])
setSessions(Array.isArray(sess) ? sess : (sess as { sessions: CoachSessionSummary[] }).sessions || [])
} catch { /* ignore */ }
setLoading(false)
}, [])
useEffect(() => { loadData() }, [loadData])
const handleDownloadEmbeddings = async () => {
setDownloadingEmbeddings(true)
try {
await coachApi.downloadEmbeddings()
const s = await coachApi.getCoachStatus()
setStatus(s)
} catch { /* ignore */ }
setDownloadingEmbeddings(false)
}
const handleGenerateReports = async () => {
setGeneratingReport(true)
setError(null)
try {
await coachApi.generateReport()
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Report generation failed')
}
setGeneratingReport(false)
}
const handleRunSession = async (deck: CoachDeck) => {
setRunningSession(deck.deck_id)
setError(null)
try {
const deckSlug = deck.deck_name.toLowerCase().replace(/\s+/g, '-')
const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0
? goals : undefined
const session = await coachApi.runCoachSession(deckSlug, goalsDict as Record<string, unknown>)
setSelectedSession(session)
setSelectedDeck(deck)
setTab('session')
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Session failed')
}
setRunningSession(null)
}
const handleOpenChat = (deck: CoachDeck) => {
setSelectedDeck(deck)
setTab('chat')
}
const handleViewSession = async (sessionId: string) => {
try {
const s = await coachApi.getCoachSession(sessionId)
setSelectedSession(s)
// Find the matching deck
const matchDeck = decks.find(d =>
d.deck_name.toLowerCase().replace(/\s+/g, '-') === s.deckId.toLowerCase() ||
d.deck_name.toLowerCase() === s.deckId.toLowerCase()
)
if (matchDeck) setSelectedDeck(matchDeck)
setTab('session')
} catch { /* ignore */ }
}
if (loading) {
return (
<div className="p-6 flex items-center justify-center h-64">
<Spinner size="lg" className="text-accent-blue" />
</div>
)
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<GraduationCap className="w-6 h-6 text-accent-amber" />
Deck Coach
</h1>
<p className="text-sm text-text-secondary mt-0.5">
AI-powered deck analysis, coaching, and live chat
</p>
</div>
</div>
{/* Status Bar */}
<div className="flex items-center gap-3 flex-wrap">
<StatusBadge
variant={status?.llmConnected ? 'success' : 'warning'}
label={status?.llmConnected ? `LLM: ${status.llmModel || 'Connected'}` : 'LLM: Disconnected'}
/>
<StatusBadge
variant={status?.embeddingsLoaded ? 'success' : 'neutral'}
label={status?.embeddingsLoaded ? `${(status.embeddingCards || 0).toLocaleString()} embeddings` : 'No embeddings'}
/>
{status?.deckReportsAvailable != null && status.deckReportsAvailable > 0 && (
<StatusBadge variant="success" label={`${status.deckReportsAvailable} reports`} />
)}
{!status?.embeddingsLoaded && (
<button onClick={handleDownloadEmbeddings} disabled={downloadingEmbeddings}
className="flex items-center gap-1.5 text-xs text-accent-blue hover:text-accent-blue-hover transition-colors disabled:opacity-50">
{downloadingEmbeddings ? <Spinner size="sm" /> : <Download className="w-3 h-3" />}
Download Embeddings
</button>
)}
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4 flex-shrink-0" />{error}
<button onClick={() => setError(null)} className="ml-auto"><X className="w-3 h-3" /></button>
</div>
)}
{/* Tab Nav */}
<div className="flex gap-1 bg-bg-secondary rounded-lg p-1 border border-border-primary">
{([
{ id: 'decks', label: 'Decks', icon: ClipboardList },
{ id: 'history', label: 'Sessions', icon: History },
{ id: 'search', label: 'Cards Like', icon: Search },
] as { id: Tab; label: string; icon: typeof ClipboardList }[]).map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
tab === t.id
? 'bg-bg-primary text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
<t.icon className="w-3.5 h-3.5" />
{t.label}
{t.id === 'history' && sessions.length > 0 && (
<span className="text-[10px] bg-bg-tertiary px-1.5 rounded-full">{sessions.length}</span>
)}
</button>
))}
{tab === 'chat' && selectedDeck && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-bg-primary text-accent-blue shadow-sm">
<MessageSquare className="w-3.5 h-3.5" />
Chat
</div>
)}
{tab === 'session' && selectedSession && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-bg-primary text-accent-amber shadow-sm">
<Sparkles className="w-3.5 h-3.5" />
Results
</div>
)}
</div>
{/* Goals Panel (shown when on decks tab) */}
{tab === 'decks' && (
<GoalsPanel
goals={goals}
onChange={setGoals}
collapsed={goalsCollapsed}
onToggle={() => setGoalsCollapsed(!goalsCollapsed)}
/>
)}
{/* ── Decks Tab ─────────────────────────────────────── */}
{tab === 'decks' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-text-primary">Your Decks</h2>
<button
onClick={handleGenerateReports}
disabled={generatingReport}
className="flex items-center gap-1.5 text-xs text-accent-amber hover:text-accent-amber transition-colors disabled:opacity-50"
>
{generatingReport ? <Spinner size="sm" /> : <Zap className="w-3 h-3" />}
Generate Reports
</button>
</div>
{decks.length === 0 ? (
<EmptyState
icon={GraduationCap}
title="No decks available"
description="Create decks in the Deck Builder to start coaching."
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{decks.map(deck => (
<div key={deck.deck_id} className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
<div>
<h3 className="text-sm font-semibold text-text-primary">{deck.deck_name}</h3>
<p className="text-xs text-text-secondary">{deck.commander}</p>
</div>
<div className="flex items-center gap-3 text-[10px] text-text-tertiary">
<span>{deck.card_count} cards</span>
{deck.has_report && (
<span className="flex items-center gap-1 text-status-success">
<Check className="w-2.5 h-2.5" /> Report
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleRunSession(deck)}
disabled={runningSession === deck.deck_id || !status?.llmConnected}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-amber/15 text-accent-amber text-xs font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-50"
>
{runningSession === deck.deck_id ? <Spinner size="sm" /> : <Sparkles className="w-3 h-3" />}
Analyze
</button>
<button
onClick={() => handleOpenChat(deck)}
disabled={!status?.llmConnected}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-blue/15 text-accent-blue text-xs font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-50"
>
<MessageSquare className="w-3 h-3" />
Chat
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ── Chat Tab ──────────────────────────────────────── */}
{tab === 'chat' && selectedDeck && (
<LiveChat
deckId={selectedDeck.deck_name.toLowerCase().replace(/\s+/g, '-')}
deckName={selectedDeck.deck_name}
goals={goals}
onClose={() => setTab('decks')}
/>
)}
{/* ── Session Results Tab ───────────────────────────── */}
{tab === 'session' && selectedSession && selectedDeck && (
<SessionView
session={selectedSession}
deckId={selectedDeck.deck_id}
onClose={() => setTab('decks')}
onApplied={() => loadData()}
/>
)}
{/* ── History Tab ───────────────────────────────────── */}
{tab === 'history' && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-text-primary">Coaching Sessions</h2>
{sessions.length === 0 ? (
<EmptyState
icon={History}
title="No sessions yet"
description="Run an analysis on a deck to create your first coaching session."
/>
) : (
<div className="space-y-2">
{sessions.map(s => (
<button
key={s.sessionId}
onClick={() => handleViewSession(s.sessionId)}
className="w-full flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary hover:border-border-secondary text-left transition-colors"
>
<Sparkles className="w-4 h-4 text-accent-amber flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary">{s.deckId}</p>
<span className="text-[10px] text-text-tertiary">
{s.cutsCount > 0 && `${s.cutsCount} cuts`}
{s.cutsCount > 0 && s.addsCount > 0 && ' · '}
{s.addsCount > 0 && `${s.addsCount} adds`}
</span>
</div>
<p className="text-xs text-text-tertiary mt-0.5 truncate">{s.summary || 'No summary'}</p>
<p className="text-[10px] text-text-tertiary mt-0.5">{new Date(s.timestamp).toLocaleString()}</p>
</div>
<ChevronRight className="w-4 h-4 text-text-tertiary flex-shrink-0" />
</button>
))}
</div>
)}
</div>
)}
{/* ── Cards Like Tab ────────────────────────────────── */}
{tab === 'search' && (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold text-text-primary mb-1">Cards Like This</h2>
<p className="text-xs text-text-secondary">
Find similar cards using AI embeddings. Search by card name to discover alternatives.
</p>
</div>
<CardsLikePanel />
</div>
)}
</div>
)
}
@@ -0,0 +1,484 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Search, Filter, Upload, Download, Camera, RefreshCw,
ChevronLeft, ChevronRight, X, Package, SlidersHorizontal,
Plus, Grid3x3, List
} from 'lucide-react'
import { Spinner, EmptyState, ManaSymbols, CardImage } from '../components/common'
import { collectionApi } from '../api'
import type { CollectionCard, CollectionFilters, SetInfo } from '../types'
// ── Card Detail Modal ─────────────────────────────────────────
function CardDetailModal({ card, onClose }: { card: CollectionCard; onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div
className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto animate-fade-in"
onClick={e => e.stopPropagation()}
>
<div className="flex items-start justify-between p-5 border-b border-border-primary">
<div>
<h2 className="text-xl font-bold text-text-primary">{card.name}</h2>
<p className="text-sm text-text-secondary mt-0.5">{card.type_line}</p>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-bg-hover text-text-tertiary">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-col md:flex-row gap-6 p-5">
{/* Large card image */}
<div className="flex-shrink-0">
<CardImage src={card.image_url} alt={card.name} size="xl" />
</div>
{/* Card info */}
<div className="flex-1 space-y-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Mana Cost</label>
<div className="mt-1"><ManaSymbols cost={card.mana_cost} size="md" /></div>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Oracle Text</label>
<p className="mt-1 text-sm text-text-primary whitespace-pre-wrap leading-relaxed">{card.oracle_text}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
<p className="mt-1 text-sm text-text-primary">{card.set_name} ({card.set_code.toUpperCase()})</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
<p className={`mt-1 text-sm font-medium capitalize ${
card.rarity === 'mythic' ? 'text-accent-amber' :
card.rarity === 'rare' ? 'text-accent-amber/80' :
card.rarity === 'uncommon' ? 'text-accent-teal' : 'text-text-secondary'
}`}>{card.rarity}</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC</label>
<p className="mt-1 text-sm text-text-primary">{card.cmc}</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">EDHREC Rank</label>
<p className="mt-1 text-sm text-text-primary">{card.edhrec_rank ?? 'N/A'}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Quantity</label>
<p className="mt-1 text-sm text-text-primary">{card.quantity} (+ {card.foil_quantity} foil)</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Price</label>
<p className="mt-1 text-sm text-accent-teal font-semibold">
${card.tcg_price?.toFixed(2) ?? '—'}
{card.foil_price ? <span className="text-text-tertiary ml-2">Foil: ${card.foil_price.toFixed(2)}</span> : null}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
// ── Scanner Panel ─────────────────────────────────────────────
function ScannerPanel({ onCardAdded }: { onCardAdded: () => void }) {
const [scanning, setScanning] = useState(false)
const [results, setResults] = useState<import('../types').ScanMatch[]>([])
const [adding, setAdding] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
async function handleScan(file: File) {
setScanning(true)
setResults([])
try {
const res = await collectionApi.scanCard(file)
setResults(res.matches || [])
} catch {
setResults([])
}
setScanning(false)
}
async function handleAdd(match: import('../types').ScanMatch) {
setAdding(match.scryfall_id)
try {
await collectionApi.addScanResult({
name: match.name,
set_code: match.set_code,
scryfall_id: match.scryfall_id,
})
onCardAdded()
} catch { /* ignore */ }
setAdding(null)
}
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2 mb-3">
<Camera className="w-4 h-4 text-accent-blue" />
Card Scanner
</h3>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => e.target.files?.[0] && handleScan(e.target.files[0])}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={scanning}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue/10 text-accent-blue border border-accent-blue/30 rounded-lg text-sm font-medium hover:bg-accent-blue/20 transition-colors disabled:opacity-50"
>
{scanning ? <Spinner size="sm" /> : <Camera className="w-4 h-4" />}
{scanning ? 'Scanning...' : 'Scan a Card'}
</button>
{results.length > 0 && (
<div className="mt-3 space-y-2 max-h-60 overflow-y-auto">
{results.map(m => (
<div key={m.scryfall_id} className="flex items-center gap-3 p-2 rounded-lg bg-bg-tertiary">
<CardImage src={m.image_url} alt={m.name} size="sm" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{m.name}</p>
<p className="text-xs text-text-tertiary">{m.set_name} — {(m.confidence * 100).toFixed(0)}%</p>
</div>
<button
onClick={() => handleAdd(m)}
disabled={adding === m.scryfall_id}
className="p-1.5 rounded-lg bg-accent-green/15 text-accent-green hover:bg-accent-green/25 transition-colors"
>
{adding === m.scryfall_id ? <Spinner size="sm" /> : <Plus className="w-4 h-4" />}
</button>
</div>
))}
</div>
)}
</div>
)
}
// ── Main Collection Page ──────────────────────────────────────
export function CollectionPage() {
const [cards, setCards] = useState<CollectionCard[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [sets, setSets] = useState<SetInfo[]>([])
const [selectedCard, setSelectedCard] = useState<CollectionCard | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [showFilters, setShowFilters] = useState(false)
const [importing, setImporting] = useState(false)
const [enriching, setEnriching] = useState(false)
const importRef = useRef<HTMLInputElement>(null)
const [filters, setFilters] = useState<CollectionFilters>({
q: '',
page: 1,
page_size: 48,
sort_by: 'name',
sort_dir: 'asc',
})
const fetchCards = useCallback(async () => {
setLoading(true)
try {
const res = await collectionApi.searchCollection(filters)
setCards(res.cards)
setTotal(res.total)
} catch { /* ignore */ }
setLoading(false)
}, [filters])
useEffect(() => { fetchCards() }, [fetchCards])
useEffect(() => {
collectionApi.getSets().then(setSets).catch(() => {})
}, [])
const totalPages = Math.ceil(total / (filters.page_size || 48))
function updateFilter(key: keyof CollectionFilters, value: string | number | boolean | undefined) {
setFilters(prev => ({ ...prev, [key]: value, page: 1 }))
}
async function handleExport() {
const blob = await collectionApi.exportCollection('csv')
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'collection.csv'
a.click()
URL.revokeObjectURL(url)
}
async function handleImport(file: File) {
setImporting(true)
try {
await collectionApi.importCollection(file)
fetchCards()
} catch { /* ignore */ }
setImporting(false)
}
async function handleReEnrich() {
setEnriching(true)
try {
await collectionApi.reEnrichCards()
fetchCards()
} catch { /* ignore */ }
setEnriching(false)
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Collection</h1>
<p className="text-sm text-text-secondary mt-0.5">{total.toLocaleString()} cards in your collection</p>
</div>
<div className="flex items-center gap-2">
<button onClick={handleReEnrich} disabled={enriching}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
{enriching ? <Spinner size="sm" /> : <RefreshCw className="w-3.5 h-3.5" />}
Re-Enrich
</button>
<input ref={importRef} type="file" accept=".csv,.txt" className="hidden"
onChange={e => e.target.files?.[0] && handleImport(e.target.files[0])} />
<button onClick={() => importRef.current?.click()} disabled={importing}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
{importing ? <Spinner size="sm" /> : <Upload className="w-3.5 h-3.5" />}
Import
</button>
<button onClick={handleExport}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
<Download className="w-3.5 h-3.5" />
Export
</button>
</div>
</div>
<div className="flex gap-5">
{/* Left sidebar: search + filters + scanner */}
<div className="w-72 flex-shrink-0 space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search cards..."
value={filters.q || ''}
onChange={e => updateFilter('q', e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
/>
</div>
{/* Filter toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-secondary hover:bg-bg-hover transition-colors"
>
<span className="flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4" />
Filters
</span>
<Filter className={`w-3.5 h-3.5 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</button>
{showFilters && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3 animate-fade-in">
{/* Rarity */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
<select
value={filters.rarity || ''}
onChange={e => updateFilter('rarity', e.target.value || undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="">All</option>
<option value="common">Common</option>
<option value="uncommon">Uncommon</option>
<option value="rare">Rare</option>
<option value="mythic">Mythic</option>
</select>
</div>
{/* Color */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Color</label>
<select
value={filters.color || ''}
onChange={e => updateFilter('color', e.target.value || undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="">All</option>
<option value="W">White</option>
<option value="U">Blue</option>
<option value="B">Black</option>
<option value="R">Red</option>
<option value="G">Green</option>
<option value="C">Colorless</option>
</select>
</div>
{/* Set */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
<select
value={filters.set_code || ''}
onChange={e => updateFilter('set_code', e.target.value || undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="">All Sets</option>
{sets.map(s => (
<option key={s.code} value={s.code}>{s.name} ({s.card_count})</option>
))}
</select>
</div>
{/* CMC Range */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Min</label>
<input type="number" min="0" max="20"
value={filters.cmc_min ?? ''}
onChange={e => updateFilter('cmc_min', e.target.value ? Number(e.target.value) : undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
/>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Max</label>
<input type="number" min="0" max="20"
value={filters.cmc_max ?? ''}
onChange={e => updateFilter('cmc_max', e.target.value ? Number(e.target.value) : undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
/>
</div>
</div>
{/* Sort */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Sort By</label>
<select
value={filters.sort_by || 'name'}
onChange={e => updateFilter('sort_by', e.target.value)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="name">Name</option>
<option value="cmc">CMC</option>
<option value="tcg_price">Price</option>
<option value="rarity">Rarity</option>
<option value="set_code">Set</option>
<option value="added_date">Date Added</option>
</select>
</div>
</div>
)}
{/* Scanner */}
<ScannerPanel onCardAdded={fetchCards} />
</div>
{/* Main content: card grid/list */}
<div className="flex-1 min-w-0">
{/* View toggle + pagination */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-md transition-colors ${viewMode === 'grid' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
>
<Grid3x3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-1.5 rounded-md transition-colors ${viewMode === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
>
<List className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span>Page {filters.page} of {totalPages || 1}</span>
<button
onClick={() => setFilters(p => ({ ...p, page: Math.max(1, (p.page || 1) - 1) }))}
disabled={!filters.page || filters.page <= 1}
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setFilters(p => ({ ...p, page: Math.min(totalPages, (p.page || 1) + 1) }))}
disabled={(filters.page || 1) >= totalPages}
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<Spinner size="lg" className="text-accent-blue" />
</div>
) : cards.length === 0 ? (
<EmptyState
icon={Package}
title="No cards found"
description="Try adjusting your search or filters, or import a collection."
/>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{cards.map(card => (
<div
key={card.id}
className="group cursor-pointer"
onClick={() => setSelectedCard(card)}
>
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
<div className="mt-1.5 px-0.5">
<p className="text-xs font-medium text-text-primary truncate group-hover:text-accent-blue transition-colors">{card.name}</p>
<div className="flex items-center justify-between">
<span className="text-[10px] text-text-tertiary">{card.set_code.toUpperCase()}</span>
{card.tcg_price != null && (
<span className="text-[10px] text-accent-teal font-medium">${card.tcg_price.toFixed(2)}</span>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary text-left">
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Set</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Qty</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{cards.map(card => (
<tr key={card.id} className="hover:bg-bg-hover cursor-pointer transition-colors" onClick={() => setSelectedCard(card)}>
<td className="px-4 py-2.5">
<span className="font-medium text-text-primary">{card.name}</span>
</td>
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.type_line}</td>
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.set_code.toUpperCase()}</td>
<td className="px-4 py-2.5 text-text-secondary">{card.quantity}</td>
<td className="px-4 py-2.5 text-right text-accent-teal font-medium">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Card detail modal */}
{selectedCard && <CardDetailModal card={selectedCard} onClose={() => setSelectedCard(null)} />}
</div>
)
}
@@ -0,0 +1,967 @@
import { useState, useEffect } from 'react'
import {
Plus, Trash2, Download, ChevronRight,
BarChart3, Lightbulb, Layers, X, Library,
Globe, XCircle, ShoppingCart,
ChevronDown, ChevronUp, Sparkles
} from 'lucide-react'
import { Spinner, EmptyState, ManaSymbols, ColorDots } from '../components/common'
import { decksApi } from '../api'
import type {
Deck, DeckCard, DeckAnalysis,
EdhRecCard, EdhRecsResponse,
CollectionRecsResponse
} from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
// ── Role Badge ──────────────────────────────────────────────
const ROLE_COLORS: Record<string, string> = {
Ramp: 'bg-green-500/15 text-green-400 border-green-500/25',
Draw: 'bg-blue-500/15 text-blue-400 border-blue-500/25',
Removal: 'bg-red-500/15 text-red-400 border-red-500/25',
BoardWipe: 'bg-orange-500/15 text-orange-400 border-orange-500/25',
Tutor: 'bg-purple-500/15 text-purple-400 border-purple-500/25',
Protection: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/25',
Finisher: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/25',
Recursion: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/25',
Other: 'bg-slate-500/15 text-slate-400 border-slate-500/25',
}
function RoleBadge({ role }: { role: string }) {
const colors = ROLE_COLORS[role] || ROLE_COLORS.Other
return (
<span className={'text-[10px] font-medium px-1.5 py-0.5 rounded border ' + colors}>
{role}
</span>
)
}
// ── Deck Card List ────────────────────────────────────────────
function DeckCardList({ deckId, cards, onUpdate }: { deckId: number; cards: DeckCard[]; onUpdate: () => void }) {
const [removing, setRemoving] = useState<number | null>(null)
// Group cards by category
const grouped = cards.reduce<Record<string, DeckCard[]>>((acc, c) => {
const cat = c.category || 'Other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(c)
return acc
}, {})
const categoryOrder = ['Commander', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
const sortedCategories = Object.keys(grouped).sort((a, b) => {
const ia = categoryOrder.indexOf(a)
const ib = categoryOrder.indexOf(b)
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
async function handleRemove(cardId: number) {
setRemoving(cardId)
try {
await decksApi.removeCardFromDeck(deckId, cardId)
onUpdate()
} catch { /* ignore */ }
setRemoving(null)
}
return (
<div className="space-y-4">
{sortedCategories.map(cat => (
<div key={cat}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">
{cat} ({grouped[cat].length})
</h4>
<div className="space-y-1">
{grouped[cat].map(card => (
<div key={card.id} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover group transition-colors">
<span className="text-xs text-text-tertiary w-5 text-right">{card.quantity}x</span>
<ManaSymbols cost={card.mana_cost} />
<span className="flex-1 text-sm text-text-primary truncate">{card.name}</span>
{card.tcg_price != null && (
<span className="text-xs text-accent-teal">${card.tcg_price.toFixed(2)}</span>
)}
{!card.is_commander && (
<button
onClick={() => handleRemove(card.id)}
className="opacity-0 group-hover:opacity-100 p-1 text-text-tertiary hover:text-accent-red transition-all"
>
{removing === card.id ? <Spinner size="sm" /> : <Trash2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
))}
</div>
</div>
))}
</div>
)
}
// ── Mana Curve Chart ──────────────────────────────────────────
function ManaCurveChart({ analysis }: { analysis: DeckAnalysis }) {
const data = Object.entries(analysis.mana_curve)
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
return (
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} barSize={24}>
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 11 }} />
<YAxis hide />
<Tooltip
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
labelStyle={{ color: '#9ba1b8' }}
itemStyle={{ color: '#e8eaf0' }}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{data.map((_, i) => (
<Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
// ── EDHREC Recommendations Sub-tab ──────────────────────────
function EdhRecTab({ deckId, onAddedCards }: { deckId: number; onAddedCards: () => void }) {
const [data, setData] = useState<EdhRecsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [onlyOwned, setOnlyOwned] = useState(false)
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
const [addingCards, setAddingCards] = useState<Set<string>>(new Set())
const [bulkAdding, setBulkAdding] = useState(false)
const [hoveredCard, setHoveredCard] = useState<EdhRecCard | null>(null)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
async function fetchRecs() {
setLoading(true)
setError(null)
try {
const result = await decksApi.getEdhRecs(deckId, onlyOwned)
setData(result)
} catch (e: any) {
setError(e?.message || 'Failed to load EDHREC recommendations')
}
setLoading(false)
}
useEffect(() => { fetchRecs() }, [deckId, onlyOwned])
function toggleSelect(name: string) {
setSelectedCards(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
function selectAll() {
if (!data) return
setSelectedCards(new Set(data.recommendations.map(r => r.name)))
}
function selectNone() { setSelectedCards(new Set()) }
function selectOwned() {
if (!data) return
setSelectedCards(new Set(data.recommendations.filter(r => r.owned).map(r => r.name)))
}
async function handleAddSingle(name: string) {
setAddingCards(prev => new Set(prev).add(name))
try {
await decksApi.bulkAddRecommended(deckId, [name])
onAddedCards()
// Remove from list
if (data) {
setData({
...data,
recommendations: data.recommendations.filter(r => r.name !== name),
total: data.total - 1,
})
}
selectedCards.delete(name)
setSelectedCards(new Set(selectedCards))
} catch { /* ignore */ }
setAddingCards(prev => { const n = new Set(prev); n.delete(name); return n })
}
async function handleBulkAdd() {
if (selectedCards.size === 0) return
setBulkAdding(true)
try {
await decksApi.bulkAddRecommended(deckId, Array.from(selectedCards))
onAddedCards()
if (data) {
setData({
...data,
recommendations: data.recommendations.filter(r => !selectedCards.has(r.name)),
total: data.total - selectedCards.size,
})
}
setSelectedCards(new Set())
} catch { /* ignore */ }
setBulkAdding(false)
}
function toggleGroup(groupName: string) {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(groupName)) next.delete(groupName)
else next.add(groupName)
return next
})
}
// Group recommendations by role
function groupByRole(recs: EdhRecCard[]): Record<string, EdhRecCard[]> {
const groups: Record<string, EdhRecCard[]> = {}
for (const rec of recs) {
const role = rec.role || 'Other'
if (!groups[role]) groups[role] = []
groups[role].push(rec)
}
return groups
}
if (loading) {
return <div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
}
if (error) {
return (
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-5 text-center">
<XCircle className="w-6 h-6 text-accent-red mx-auto mb-2" />
<p className="text-sm text-accent-red font-medium">{error}</p>
<button onClick={fetchRecs} className="mt-3 px-4 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
Retry
</button>
</div>
)
}
if (!data || data.recommendations.length === 0) {
return <EmptyState icon={Globe} title="No EDHREC data" description="No recommendations found for this commander on EDHREC." />
}
const grouped = groupByRole(data.recommendations)
const roleOrder = ['Ramp', 'Draw', 'Removal', 'BoardWipe', 'Tutor', 'Protection', 'Finisher', 'Recursion', 'Other']
const sortedRoles = Object.keys(grouped).sort((a, b) => {
const ia = roleOrder.indexOf(a)
const ib = roleOrder.indexOf(b)
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
const ownedCount = data.recommendations.filter(r => r.owned).length
const missingCount = data.recommendations.length - ownedCount
return (
<div className="space-y-4">
{/* Header bar */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm">
<Sparkles className="w-4 h-4 text-accent-blue" />
<span className="text-text-primary font-medium">{data.commander}</span>
<span className="text-text-tertiary">via {data.source}</span>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-accent-green/15 text-accent-green">{ownedCount} owned</span>
<span className="text-xs px-2 py-0.5 rounded bg-bg-tertiary text-text-tertiary">{missingCount} missing</span>
</div>
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer select-none">
<input
type="checkbox"
checked={onlyOwned}
onChange={e => setOnlyOwned(e.target.checked)}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5"
/>
Show only owned
</label>
</div>
{/* Selection toolbar */}
<div className="flex items-center justify-between bg-bg-secondary rounded-xl border border-border-primary px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-xs text-text-tertiary">Select:</span>
<button onClick={selectAll} className="text-xs text-accent-blue hover:underline">All</button>
<span className="text-text-tertiary">/</span>
<button onClick={selectNone} className="text-xs text-accent-blue hover:underline">None</button>
<span className="text-text-tertiary">/</span>
<button onClick={selectOwned} className="text-xs text-accent-blue hover:underline">Owned</button>
{selectedCards.size > 0 && (
<span className="text-xs text-text-secondary ml-2">({selectedCards.size} selected)</span>
)}
</div>
<button
onClick={handleBulkAdd}
disabled={selectedCards.size === 0 || bulkAdding}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{bulkAdding ? <Spinner size="sm" /> : <ShoppingCart className="w-3.5 h-3.5" />}
Add {selectedCards.size > 0 ? selectedCards.size : ''} to Deck
</button>
</div>
{/* Grouped cards */}
{sortedRoles.map(role => {
const cards = grouped[role]
const isCollapsed = collapsedGroups.has(role)
return (
<div key={role} className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
{/* Group header */}
<button
onClick={() => toggleGroup(role)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
>
<div className="flex items-center gap-2">
<RoleBadge role={role} />
<span className="text-xs text-text-tertiary">({cards.length})</span>
</div>
{isCollapsed ? <ChevronDown className="w-4 h-4 text-text-tertiary" /> : <ChevronUp className="w-4 h-4 text-text-tertiary" />}
</button>
{/* Card list */}
{!isCollapsed && (
<div className="border-t border-border-primary divide-y divide-border-primary">
{cards.map(rec => (
<div
key={rec.name}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-hover transition-colors group relative"
onMouseEnter={() => setHoveredCard(rec)}
onMouseLeave={() => setHoveredCard(null)}
>
{/* Checkbox */}
<input
type="checkbox"
checked={selectedCards.has(rec.name)}
onChange={() => toggleSelect(rec.name)}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5 flex-shrink-0"
/>
{/* Card image mini */}
{rec.image_url && (
<div className="w-8 h-11 rounded overflow-hidden bg-bg-tertiary flex-shrink-0">
<img src={rec.image_url} alt={rec.name} className="w-full h-full object-cover" loading="lazy" />
</div>
)}
{/* Card info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{rec.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-text-tertiary truncate">{rec.type_line}</span>
{rec.roles.length > 0 && rec.roles.map(r => <RoleBadge key={r} role={r} />)}
</div>
</div>
{/* Owned badge */}
<span className={'text-xs px-2 py-0.5 rounded flex-shrink-0 ' + (rec.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary')}>
{rec.owned ? (rec.owned_qty + 'x owned') : 'Missing'}
</span>
{/* Add button */}
<button
onClick={e => { e.stopPropagation(); handleAddSingle(rec.name) }}
disabled={addingCards.has(rec.name)}
className="opacity-0 group-hover:opacity-100 px-2.5 py-1 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-all disabled:opacity-50 flex-shrink-0"
>
{addingCards.has(rec.name) ? <Spinner size="sm" /> : '+ Add'}
</button>
</div>
))}
</div>
)}
</div>
)
})}
{/* Hover preview */}
{hoveredCard && hoveredCard.image_url && (
<div className="fixed top-20 right-8 z-50 pointer-events-none animate-fade-in">
<img
src={hoveredCard.image_url}
alt={hoveredCard.name}
className="w-[244px] rounded-xl shadow-2xl shadow-black/60 border border-border-primary"
/>
</div>
)}
</div>
)
}
// ── Collection Recommendations Sub-tab ──────────────────────
function CollectionRecTab({ deckId, onAddedCards }: { deckId: number; onAddedCards: () => void }) {
const [data, setData] = useState<CollectionRecsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
const [addingCards, setAddingCards] = useState<Set<string>>(new Set())
const [bulkAdding, setBulkAdding] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
async function fetchRecs() {
setLoading(true)
setError(null)
try {
const result = await decksApi.getCollectionRecs(deckId)
setData(result)
} catch (e: any) {
setError(e?.message || 'Failed to load collection recommendations')
}
setLoading(false)
}
useEffect(() => { fetchRecs() }, [deckId])
function toggleSelect(name: string) {
setSelectedCards(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
function selectAll() {
if (!data) return
const all = new Set<string>()
Object.values(data.grouped).flat().forEach(c => all.add(c.name))
setSelectedCards(all)
}
function selectNone() { setSelectedCards(new Set()) }
async function handleAddSingle(name: string) {
setAddingCards(prev => new Set(prev).add(name))
try {
await decksApi.bulkAddRecommended(deckId, [name])
onAddedCards()
// Remove from groupings
if (data) {
const newGrouped = { ...data.grouped }
for (const key of Object.keys(newGrouped)) {
newGrouped[key] = newGrouped[key].filter(c => c.name !== name)
if (newGrouped[key].length === 0) delete newGrouped[key]
}
setData({ ...data, grouped: newGrouped, total: data.total - 1 })
}
selectedCards.delete(name)
setSelectedCards(new Set(selectedCards))
} catch { /* ignore */ }
setAddingCards(prev => { const n = new Set(prev); n.delete(name); return n })
}
async function handleBulkAdd() {
if (selectedCards.size === 0) return
setBulkAdding(true)
try {
await decksApi.bulkAddRecommended(deckId, Array.from(selectedCards))
onAddedCards()
if (data) {
const newGrouped = { ...data.grouped }
for (const key of Object.keys(newGrouped)) {
newGrouped[key] = newGrouped[key].filter(c => !selectedCards.has(c.name))
if (newGrouped[key].length === 0) delete newGrouped[key]
}
setData({ ...data, grouped: newGrouped, total: data.total - selectedCards.size })
}
setSelectedCards(new Set())
} catch { /* ignore */ }
setBulkAdding(false)
}
function toggleGroup(groupName: string) {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(groupName)) next.delete(groupName)
else next.add(groupName)
return next
})
}
if (loading) {
return <div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
}
if (error) {
return (
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-5 text-center">
<XCircle className="w-6 h-6 text-accent-red mx-auto mb-2" />
<p className="text-sm text-accent-red font-medium">{error}</p>
<button onClick={fetchRecs} className="mt-3 px-4 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
Retry
</button>
</div>
)
}
if (!data || data.total === 0) {
return <EmptyState icon={Library} title="No collection matches" description="No cards in your collection match this deck's needs." />
}
const typeOrder = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
const sortedTypes = Object.keys(data.grouped).sort((a, b) => {
const ia = typeOrder.indexOf(a)
const ib = typeOrder.indexOf(b)
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
return (
<div className="space-y-4">
{/* Shortfall alert */}
{data.shortfall_types.length > 0 && (
<div className="flex items-start gap-3 bg-accent-blue/8 border border-accent-blue/20 rounded-xl px-4 py-3">
<Lightbulb className="w-4 h-4 text-accent-blue mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs font-medium text-accent-blue">Deck needs more:</p>
<div className="flex flex-wrap gap-1.5 mt-1">
{data.shortfall_types.map(t => (
<span key={t} className="text-[10px] font-medium px-2 py-0.5 rounded bg-accent-blue/15 text-accent-blue border border-accent-blue/25">{t}</span>
))}
</div>
</div>
</div>
)}
{/* Selection toolbar */}
<div className="flex items-center justify-between bg-bg-secondary rounded-xl border border-border-primary px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-xs text-text-tertiary">Select:</span>
<button onClick={selectAll} className="text-xs text-accent-blue hover:underline">All</button>
<span className="text-text-tertiary">/</span>
<button onClick={selectNone} className="text-xs text-accent-blue hover:underline">None</button>
{selectedCards.size > 0 && (
<span className="text-xs text-text-secondary ml-2">({selectedCards.size} selected)</span>
)}
</div>
<button
onClick={handleBulkAdd}
disabled={selectedCards.size === 0 || bulkAdding}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{bulkAdding ? <Spinner size="sm" /> : <ShoppingCart className="w-3.5 h-3.5" />}
Add {selectedCards.size > 0 ? selectedCards.size : ''} to Deck
</button>
</div>
{/* Grouped cards */}
{sortedTypes.map(type => {
const cards = data.grouped[type]
const isCollapsed = collapsedGroups.has(type)
const isShortfall = data.shortfall_types.includes(type)
return (
<div key={type} className={'bg-bg-secondary rounded-xl border overflow-hidden ' + (isShortfall ? 'border-accent-blue/30' : 'border-border-primary')}>
{/* Group header */}
<button
onClick={() => toggleGroup(type)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-text-primary uppercase tracking-wider">{type}</span>
<span className="text-xs text-text-tertiary">({cards.length})</span>
{isShortfall && <span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-accent-blue/15 text-accent-blue border border-accent-blue/25">Shortfall</span>}
</div>
{isCollapsed ? <ChevronDown className="w-4 h-4 text-text-tertiary" /> : <ChevronUp className="w-4 h-4 text-text-tertiary" />}
</button>
{/* Card list */}
{!isCollapsed && (
<div className="border-t border-border-primary divide-y divide-border-primary">
{cards.map(rec => (
<div key={rec.scryfall_id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-hover transition-colors group">
{/* Checkbox */}
<input
type="checkbox"
checked={selectedCards.has(rec.name)}
onChange={() => toggleSelect(rec.name)}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5 flex-shrink-0"
/>
{/* Card image mini */}
{rec.image_url && (
<div className="w-8 h-11 rounded overflow-hidden bg-bg-tertiary flex-shrink-0">
<img src={rec.image_url} alt={rec.name} className="w-full h-full object-cover" loading="lazy" />
</div>
)}
{/* Card info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{rec.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-text-tertiary">{rec.cmc} CMC</span>
{rec.roles.length > 0 && rec.roles.map(r => <RoleBadge key={r} role={r} />)}
</div>
</div>
{/* Score */}
<span className="text-xs text-accent-teal font-medium flex-shrink-0">Score: {rec.score}</span>
{/* Owned qty */}
<span className="text-xs px-2 py-0.5 rounded bg-accent-green/15 text-accent-green flex-shrink-0">{rec.owned_qty}x</span>
{/* Add button */}
<button
onClick={e => { e.stopPropagation(); handleAddSingle(rec.name) }}
disabled={addingCards.has(rec.name)}
className="opacity-0 group-hover:opacity-100 px-2.5 py-1 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-all disabled:opacity-50 flex-shrink-0"
>
{addingCards.has(rec.name) ? <Spinner size="sm" /> : '+ Add'}
</button>
</div>
))}
</div>
)}
</div>
)
})}
</div>
)
}
// ── Deck Detail View ──────────────────────────────────────────
function DeckDetail({ deck, onBack }: { deck: Deck; onBack: () => void }) {
const [cards, setCards] = useState<DeckCard[]>([])
const [analysis, setAnalysis] = useState<DeckAnalysis | null>(null)
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState<'cards' | 'analysis' | 'recs'>('cards')
const [recSource, setRecSource] = useState<'edhrec' | 'collection'>('edhrec')
const [addingCard, setAddingCard] = useState('')
const [bulkText, setBulkText] = useState('')
const [showBulk, setShowBulk] = useState(false)
async function fetchAll() {
setLoading(true)
try {
const [c, a] = await Promise.all([
decksApi.getDeckCards(deck.id),
decksApi.getDeckAnalysis(deck.id),
])
setCards(c)
setAnalysis(a)
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => { fetchAll() }, [deck.id])
async function handleAddCard() {
if (!addingCard.trim()) return
try {
await decksApi.addCardToDeck(deck.id, { name: addingCard.trim() })
setAddingCard('')
fetchAll()
} catch { /* ignore */ }
}
async function handleBulkAdd() {
if (!bulkText.trim()) return
const cardLines = bulkText.split('\n').filter(l => l.trim())
const cardParsed = cardLines.map(l => {
const match = l.match(/^(\d+)x?\s+(.+)$/)
return match ? { name: match[2].trim(), quantity: Number(match[1]) } : { name: l.trim() }
})
try {
await decksApi.bulkAddCards(deck.id, cardParsed)
setBulkText('')
setShowBulk(false)
fetchAll()
} catch { /* ignore */ }
}
async function handleExportToSim() {
try {
await decksApi.exportDeckToSim(deck.id)
} catch { /* ignore */ }
}
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center gap-4">
<button onClick={onBack} className="p-2 rounded-lg hover:bg-bg-hover text-text-secondary transition-colors">
<ChevronRight className="w-5 h-5 rotate-180" />
</button>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-primary">{deck.name}</h2>
<div className="flex items-center gap-3 mt-1">
<span className="text-sm text-text-secondary">{deck.commander}</span>
<ColorDots colors={deck.color_identity} />
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
<span className="text-xs text-accent-teal">${deck.total_price.toFixed(2)}</span>
</div>
</div>
<button onClick={handleExportToSim}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
<Download className="w-3.5 h-3.5" />
Export to Sim
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-border-primary">
{(['cards', 'analysis', 'recs'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors capitalize ${
tab === t
? 'border-accent-blue text-accent-blue'
: 'border-transparent text-text-tertiary hover:text-text-secondary'
}`}
>
{t === 'recs' ? 'Recommendations' : t}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
) : tab === 'cards' ? (
<div className="space-y-4">
{/* Add card */}
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Add card by name..."
value={addingCard}
onChange={e => setAddingCard(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCard()}
className="flex-1 px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<button onClick={handleAddCard}
className="px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
Add
</button>
<button onClick={() => setShowBulk(!showBulk)}
className="px-3 py-2.5 bg-bg-tertiary text-text-secondary text-sm rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
<Layers className="w-4 h-4" />
</button>
</div>
{showBulk && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 animate-fade-in space-y-3">
<textarea
placeholder={"1x Sol Ring\n1x Command Tower\n2x Counterspell"}
value={bulkText}
onChange={e => setBulkText(e.target.value)}
rows={6}
className="w-full px-4 py-3 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
/>
<button onClick={handleBulkAdd}
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
Bulk Add
</button>
</div>
)}
<DeckCardList deckId={deck.id} cards={cards} onUpdate={fetchAll} />
</div>
) : tab === 'analysis' && analysis ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Stats */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-accent-blue" />
Deck Stats
</h3>
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'Cards', value: analysis.card_count },
{ label: 'Lands', value: analysis.land_count },
{ label: 'Creatures', value: analysis.creature_count },
{ label: 'Non-Creature', value: analysis.noncreature_count },
{ label: 'Avg CMC', value: analysis.avg_cmc.toFixed(2) },
{ label: 'Owned', value: `${analysis.owned_count} / ${analysis.card_count}` },
].map(stat => (
<div key={stat.label} className="px-3 py-2 rounded-lg bg-bg-tertiary">
<p className="text-xs text-text-tertiary">{stat.label}</p>
<p className="text-lg font-bold text-text-primary">{stat.value}</p>
</div>
))}
</div>
</div>
{/* Mana Curve */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
<h3 className="text-sm font-semibold text-text-primary">Mana Curve</h3>
<ManaCurveChart analysis={analysis} />
</div>
{/* Type Distribution */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-3 md:col-span-2">
<h3 className="text-sm font-semibold text-text-primary">Type Distribution</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(analysis.type_distribution).map(([type, count]) => (
<span key={type} className="px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs text-text-secondary">
{type}: <span className="font-semibold text-text-primary">{count}</span>
</span>
))}
</div>
</div>
</div>
) : tab === 'recs' ? (
<div className="space-y-4">
{/* Sub-tab toggle */}
<div className="flex items-center gap-1 bg-bg-tertiary rounded-lg p-1 w-fit">
<button
onClick={() => setRecSource('edhrec')}
className={'flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium rounded-md transition-all ' +
(recSource === 'edhrec'
? 'bg-accent-blue text-white shadow-sm'
: 'text-text-tertiary hover:text-text-secondary')}
>
<Globe className="w-3.5 h-3.5" />
EDHREC
</button>
<button
onClick={() => setRecSource('collection')}
className={'flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium rounded-md transition-all ' +
(recSource === 'collection'
? 'bg-accent-blue text-white shadow-sm'
: 'text-text-tertiary hover:text-text-secondary')}
>
<Library className="w-3.5 h-3.5" />
From Collection
</button>
</div>
{/* Sub-tab content */}
{recSource === 'edhrec' ? (
<EdhRecTab deckId={deck.id} onAddedCards={fetchAll} />
) : (
<CollectionRecTab deckId={deck.id} onAddedCards={fetchAll} />
)}
</div>
) : null}
</div>
)
}
// ── Main Decks Page ───────────────────────────────────────────
export function DecksPage() {
const [decks, setDecks] = useState<Deck[]>([])
const [loading, setLoading] = useState(true)
const [selectedDeck, setSelectedDeck] = useState<Deck | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [newDeckName, setNewDeckName] = useState('')
const [newCommander, setNewCommander] = useState('')
const [creating, setCreating] = useState(false)
async function fetchDecks() {
setLoading(true)
try {
const d = await decksApi.listDecks()
setDecks(d)
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => { fetchDecks() }, [])
async function handleCreate() {
if (!newDeckName.trim()) return
setCreating(true)
try {
const deck = await decksApi.createDeck({ name: newDeckName.trim(), commander: newCommander.trim() || undefined })
setNewDeckName('')
setNewCommander('')
setShowCreate(false)
setSelectedDeck(deck)
fetchDecks()
} catch { /* ignore */ }
setCreating(false)
}
async function handleDelete(deckId: number) {
try {
await decksApi.deleteDeck(deckId)
fetchDecks()
} catch { /* ignore */ }
}
if (selectedDeck) {
return (
<div className="p-6">
<DeckDetail deck={selectedDeck} onBack={() => { setSelectedDeck(null); fetchDecks() }} />
</div>
)
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Decks</h1>
<p className="text-sm text-text-secondary mt-0.5">{decks.length} deck{decks.length !== 1 ? 's' : ''}</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors"
>
<Plus className="w-4 h-4" />
New Deck
</button>
</div>
{/* Create form */}
{showCreate && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 animate-fade-in space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary">Create New Deck</h3>
<button onClick={() => setShowCreate(false)} className="text-text-tertiary hover:text-text-secondary">
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<input
type="text"
placeholder="Deck name"
value={newDeckName}
onChange={e => setNewDeckName(e.target.value)}
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<input
type="text"
placeholder="Commander (optional)"
value={newCommander}
onChange={e => setNewCommander(e.target.value)}
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<button onClick={handleCreate} disabled={creating}
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
{creating ? <Spinner size="sm" /> : 'Create'}
</button>
</div>
)}
{/* Deck list */}
{loading ? (
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
) : decks.length === 0 ? (
<EmptyState icon={Layers} title="No decks yet" description="Create your first deck to get started." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{decks.map(deck => (
<div
key={deck.id}
className="bg-bg-secondary rounded-xl border border-border-primary hover:border-border-secondary p-5 cursor-pointer transition-all group"
onClick={() => setSelectedDeck(deck)}
>
<div className="flex items-start justify-between mb-3">
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-text-primary truncate group-hover:text-accent-blue transition-colors">{deck.name}</h3>
<p className="text-sm text-text-secondary mt-0.5 truncate">{deck.commander}</p>
</div>
<button
onClick={e => { e.stopPropagation(); handleDelete(deck.id) }}
className="opacity-0 group-hover:opacity-100 p-1.5 text-text-tertiary hover:text-accent-red transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ColorDots colors={deck.color_identity} />
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
</div>
<span className="text-xs font-medium text-accent-teal">${deck.total_price.toFixed(2)}</span>
</div>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,284 @@
import { useState, useEffect } from 'react'
import {
Gamepad2, Play, Wifi, WifiOff, Settings,
AlertCircle, Trophy, Clock, Brain
} from 'lucide-react'
import { Spinner, StatusBadge } from '../components/common'
import { simApi, labApi } from '../api'
import { usePolling } from '../hooks/usePolling'
import type { SimStatus, SimResult, DeepSeekStatus, LabDeck } from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
export function SimulatorPage() {
const [decks, setDecks] = useState<LabDeck[]>([])
const [deckA, setDeckA] = useState('')
const [deckB, setDeckB] = useState('')
const [games, setGames] = useState(50)
const [useDeepSeek, setUseDeepSeek] = useState(false)
const [deepSeekDeck, setDeepSeekDeck] = useState('')
const [dsStatus, setDsStatus] = useState<DeepSeekStatus | null>(null)
const [running, setRunning] = useState(false)
const [simId, setSimId] = useState<string | null>(null)
const [simStatus, setSimStatus] = useState<SimStatus | null>(null)
const [result, setResult] = useState<SimResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [starting, setStarting] = useState(false)
// DS config
const [showDsConfig, setShowDsConfig] = useState(false)
const [dsModel, setDsModel] = useState('')
const [dsUrl, setDsUrl] = useState('')
useEffect(() => {
labApi.getLabDecks().then(setDecks).catch(() => {})
simApi.getDeepSeekStatus().then(s => {
setDsStatus(s)
if (s.model) setDsModel(s.model)
if (s.base_url) setDsUrl(s.base_url)
}).catch(() => {})
}, [])
// Poll while running
usePolling(async () => {
if (!simId) return
try {
const s = await simApi.getSimStatus(simId)
setSimStatus(s)
if (!s.running) {
const r = await simApi.getSimResult(simId)
setResult(r)
setRunning(false)
}
} catch { /* ignore */ }
}, 2000, running)
async function handleStart() {
if (!deckA || !deckB) { setError('Select both decks'); return }
setStarting(true)
setError(null)
setResult(null)
try {
let res
if (useDeepSeek && deepSeekDeck) {
res = await simApi.runDeepSeekSim({ deck_a: deckA, deck_b: deckB, games, deepseek_deck: deepSeekDeck })
} else {
res = await simApi.runSim({ deck_a: deckA, deck_b: deckB, games })
}
setSimId(res.sim_id)
setRunning(true)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start')
}
setStarting(false)
}
async function handleDsConnect() {
try {
const s = await simApi.connectDeepSeek()
setDsStatus(s)
} catch { /* ignore */ }
}
async function handleDsConfigure() {
try {
const s = await simApi.configureDeepSeek({
model: dsModel || undefined,
base_url: dsUrl || undefined,
})
setDsStatus(s)
setShowDsConfig(false)
} catch { /* ignore */ }
}
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Gamepad2 className="w-6 h-6 text-accent-purple" />
Simulator
</h1>
<p className="text-sm text-text-secondary mt-0.5">Run head-to-head deck simulations</p>
</div>
{/* DeepSeek status */}
<div className="flex items-center gap-3">
<StatusBadge
variant={dsStatus?.connected ? 'success' : 'warning'}
label={dsStatus?.connected ? `DeepSeek: ${dsStatus.model}` : 'DeepSeek: Disconnected'}
/>
<button onClick={handleDsConnect}
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
{dsStatus?.connected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
{dsStatus?.connected ? 'Reconnect' : 'Connect'}
</button>
<button onClick={() => setShowDsConfig(!showDsConfig)}
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
<Settings className="w-3 h-3" />
Configure
</button>
</div>
{showDsConfig && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 max-w-md animate-fade-in space-y-3">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Brain className="w-4 h-4 text-accent-purple" />
DeepSeek Configuration
</h3>
<input
type="text"
placeholder="Model name"
value={dsModel}
onChange={e => setDsModel(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<input
type="text"
placeholder="Base URL (e.g. http://192.168.0.122:1234)"
value={dsUrl}
onChange={e => setDsUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<button onClick={handleDsConfigure}
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
Save
</button>
</div>
)}
{/* Sim config */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 max-w-2xl">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck A</label>
<select
value={deckA}
onChange={e => setDeckA(e.target.value)}
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value="">Select deck...</option>
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
</select>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck B</label>
<select
value={deckB}
onChange={e => setDeckB(e.target.value)}
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value="">Select deck...</option>
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
</select>
</div>
</div>
<div className="flex items-center gap-6">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
<input
type="number" min={1} max={1000}
value={games}
onChange={e => setGames(Number(e.target.value))}
className="mt-1 w-24 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer mt-5">
<input type="checkbox" checked={useDeepSeek} onChange={e => setUseDeepSeek(e.target.checked)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-purple focus:ring-accent-purple/30" />
<span className="text-sm text-text-secondary">Use DeepSeek AI pilot</span>
</label>
{useDeepSeek && (
<div className="mt-5">
<select
value={deepSeekDeck}
onChange={e => setDeepSeekDeck(e.target.value)}
className="px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value="">AI pilots which deck?</option>
{[deckA, deckB].filter(Boolean).map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
)}
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4" />{error}
</div>
)}
{running ? (
<div className="flex items-center gap-3">
<Spinner size="md" className="text-accent-blue" />
<div>
<p className="text-sm text-text-primary">Simulation running...</p>
{simStatus && (
<p className="text-xs text-text-tertiary">
{simStatus.games_completed ?? 0} / {simStatus.total_games ?? games} games
</p>
)}
</div>
</div>
) : (
<button onClick={handleStart} disabled={starting || !deckA || !deckB}
className="flex items-center gap-2 px-5 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-semibold rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-50">
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Run Simulation
</button>
)}
</div>
{/* Results */}
{result && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Trophy className="w-4 h-4 text-accent-amber" />
Results
</h3>
<span className="text-xs text-text-tertiary flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{(result.duration_ms / 1000).toFixed(1)}s · {result.total_games} games
</span>
</div>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={result.decks.map(d => ({ name: d.name, win_rate: Math.round(d.win_rate * 100) }))} barSize={40}>
<XAxis dataKey="name" tick={{ fill: '#9ba1b8', fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
formatter={(value: unknown) => [`${value}%`, 'Win Rate']} />
<Bar dataKey="win_rate" radius={[4, 4, 0, 0]}>
{result.decks.map((d, i) => (
<Cell key={i} fill={d.win_rate === Math.max(...result.decks.map(x => x.win_rate)) ? '#4ade80' : '#4f6ef7'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-2 gap-4">
{result.decks.map(d => (
<div key={d.name} className="px-4 py-3 rounded-lg bg-bg-tertiary">
<p className="text-sm font-semibold text-text-primary">{d.name}</p>
<div className="grid grid-cols-3 gap-2 mt-2">
<div>
<p className="text-[10px] text-text-tertiary uppercase">Wins</p>
<p className="text-lg font-bold text-accent-green">{d.wins}</p>
</div>
<div>
<p className="text-[10px] text-text-tertiary uppercase">Win Rate</p>
<p className="text-lg font-bold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-[10px] text-text-tertiary uppercase">Avg Turn</p>
<p className="text-lg font-bold text-text-primary">{d.avg_turn.toFixed(1)}</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,822 @@
import { useState, useEffect, useCallback } from 'react'
import {
Brain, Play, RotateCw, Database, Trophy, AlertCircle,
Activity, Zap, Swords, FileText, Cpu, CheckCircle,
ChevronDown, ChevronUp, Settings
} from 'lucide-react'
import { Spinner, StatusBadge } from '../components/common'
import { trainingApi } from '../api'
import { usePolling } from '../hooks/usePolling'
import type {
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
TournamentStatus, MLModelInfo
} from '../types'
// ── Progress Bar ──────────────────────────────────────────────
function ProgressBar({ value, max, label, color = 'bg-accent-blue' }: { value: number; max: number; label: string; color?: string }) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0
return (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-secondary">{label}</span>
<span className="text-text-tertiary">{value} / {max} ({pct.toFixed(0)}%)</span>
</div>
<div className="w-full h-2 bg-bg-tertiary rounded-full overflow-hidden">
<div className={'h-full rounded-full transition-all duration-500 ' + color} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
// ── Phase Badge ──────────────────────────────────────────────
const PHASE_STYLES: Record<string, string> = {
idle: 'bg-bg-tertiary text-text-tertiary',
starting: 'bg-accent-blue/15 text-accent-blue animate-pulse',
building: 'bg-accent-purple/15 text-accent-purple animate-pulse',
training: 'bg-accent-blue/15 text-accent-blue animate-pulse',
evaluating: 'bg-accent-teal/15 text-accent-teal animate-pulse',
running: 'bg-accent-amber/15 text-accent-amber animate-pulse',
done: 'bg-accent-green/15 text-accent-green',
error: 'bg-accent-red/15 text-accent-red',
}
function PhaseBadge({ phase }: { phase: string }) {
const cls = PHASE_STYLES[phase] || PHASE_STYLES.idle
return (
<span className={'text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded ' + cls}>
{phase}
</span>
)
}
// ── Metric Card ──────────────────────────────────────────────
function MetricCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
<p className="text-[10px] text-text-tertiary">{label}</p>
<p className={'text-sm font-bold ' + (accent || 'text-text-primary')}>{value}</p>
</div>
)
}
// ── Collapsible Section ─────────────────────────────────────
function Section({ title, icon, children, defaultOpen = true, color = 'text-accent-blue' }: {
title: string; icon: React.ReactNode; children: React.ReactNode; defaultOpen?: boolean; color?: string
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-bg-hover transition-colors"
>
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<span className={color}>{icon}</span>
{title}
</h3>
{open ? <ChevronUp className="w-4 h-4 text-text-tertiary" /> : <ChevronDown className="w-4 h-4 text-text-tertiary" />}
</button>
{open && <div className="px-5 pb-5 space-y-4 border-t border-border-primary pt-4">{children}</div>}
</div>
)
}
// ══════════════════════════════════════════════════════════════
// Main Training Page
// ══════════════════════════════════════════════════════════════
export function TrainingPage() {
// Status states
const [mlStatus, setMlStatus] = useState<MLStatus | null>(null)
const [dataStatus, setDataStatus] = useState<MLDataStatus | null>(null)
const [modelInfo, setModelInfo] = useState<MLModelInfo | null>(null)
const [trainStatus, setTrainStatus] = useState<MLTrainStatus | null>(null)
const [ppoStatus, setPpoStatus] = useState<PPOTrainStatus | null>(null)
const [tournStatus, setTournStatus] = useState<TournamentStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Supervised training config
const [epochs, setEpochs] = useState(50)
const [lr, setLr] = useState(0.001)
const [batchSize, setBatchSize] = useState(256)
const [patience, setPatience] = useState(10)
const [rebuildDataset, setRebuildDataset] = useState(true)
// PPO config
const [ppoIterations, setPpoIterations] = useState(100)
const [ppoEpisodes, setPpoEpisodes] = useState(64)
const [ppoEpochs, setPpoEpochs] = useState(4)
const [ppoBatchSize, setPpoBatchSize] = useState(256)
const [ppoLr, setPpoLr] = useState(0.0003)
const [ppoClipEps, setPpoClipEps] = useState(0.2)
const [ppoEntropy, setPpoEntropy] = useState(0.01)
const [ppoOpponent, setPpoOpponent] = useState('heuristic')
const [ppoPlaystyle, _setPpoPlaystyle] = useState('midrange') // eslint-disable-line
// Tournament config
const [tournEpisodes, setTournEpisodes] = useState(50)
const [tournPlaystyle, setTournPlaystyle] = useState('midrange')
// Action states
const [startingTrain, setStartingTrain] = useState(false)
const [startingPpo, setStartingPpo] = useState(false)
const [startingTourn, setStartingTourn] = useState(false)
const [reloading, setReloading] = useState(false)
const [showSupAdvanced, setShowSupAdvanced] = useState(false)
const [showPpoAdvanced, setShowPpoAdvanced] = useState(false)
// ── Initial Load ──────────────────────────────────────────
const loadAll = useCallback(async () => {
try {
const [ml, data, model, train, ppo, tourn] = await Promise.all([
trainingApi.getMLStatus().catch(() => null),
trainingApi.getDataStatus().catch(() => null),
trainingApi.getModelInfo().catch(() => null),
trainingApi.getTrainStatus().catch(() => null),
trainingApi.getPPOStatus().catch(() => null),
trainingApi.getTournamentStatus().catch(() => null),
])
if (ml) setMlStatus(ml)
if (data) setDataStatus(data)
if (model) setModelInfo(model)
if (train) setTrainStatus(train)
if (ppo) setPpoStatus(ppo)
if (tourn) setTournStatus(tourn)
} catch { /* ignore */ }
setLoading(false)
}, [])
useEffect(() => { loadAll() }, [loadAll])
// ── Polling ───────────────────────────────────────────────
const isTraining = trainStatus?.running || false
const isPpo = ppoStatus?.running || false
const isTourn = tournStatus?.running || false
const anyRunning = isTraining || isPpo || isTourn
usePolling(async () => {
try {
const s = await trainingApi.getTrainStatus()
setTrainStatus(s)
if (!s.running && s.phase === 'done') {
// Refresh model info + data when training completes
const [model, data] = await Promise.all([
trainingApi.getModelInfo().catch(() => null),
trainingApi.getDataStatus().catch(() => null),
])
if (model) setModelInfo(model)
if (data) setDataStatus(data)
}
} catch { /* ignore */ }
}, 2000, isTraining)
usePolling(async () => {
try {
const s = await trainingApi.getPPOStatus()
setPpoStatus(s)
if (!s.running && s.phase === 'done') {
const [model, data] = await Promise.all([
trainingApi.getModelInfo().catch(() => null),
trainingApi.getDataStatus().catch(() => null),
])
if (model) setModelInfo(model)
if (data) setDataStatus(data)
}
} catch { /* ignore */ }
}, 2000, isPpo)
usePolling(async () => {
try {
const s = await trainingApi.getTournamentStatus()
setTournStatus(s)
} catch { /* ignore */ }
}, 2000, isTourn)
// ── Actions ───────────────────────────────────────────────
async function handleToggleLogging() {
try {
const newState = !(mlStatus?.ml_logging_enabled ?? false)
await trainingApi.toggleML(newState)
const ml = await trainingApi.getMLStatus()
setMlStatus(ml)
} catch { /* ignore */ }
}
async function handleReloadModel() {
setReloading(true)
try {
const res = await trainingApi.reloadModel()
setModelInfo(res.status)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to reload model')
}
setReloading(false)
}
async function handleStartTrain() {
setStartingTrain(true)
setError(null)
try {
await trainingApi.startTraining({ epochs, lr, batchSize, patience, rebuildDataset })
const s = await trainingApi.getTrainStatus()
setTrainStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start training')
}
setStartingTrain(false)
}
async function handleStartPpo() {
setStartingPpo(true)
setError(null)
try {
await trainingApi.startPPOTraining({
iterations: ppoIterations,
episodesPerIter: ppoEpisodes,
ppoEpochs,
batchSize: ppoBatchSize,
lr: ppoLr,
clipEpsilon: ppoClipEps,
entropyCoeff: ppoEntropy,
opponent: ppoOpponent,
playstyle: ppoPlaystyle,
})
const s = await trainingApi.getPPOStatus()
setPpoStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start PPO training')
}
setStartingPpo(false)
}
async function handleStartTournament() {
setStartingTourn(true)
setError(null)
try {
await trainingApi.startTournament({ episodes: tournEpisodes, playstyle: tournPlaystyle })
const s = await trainingApi.getTournamentStatus()
setTournStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start tournament')
}
setStartingTourn(false)
}
// ── Render ────────────────────────────────────────────────
if (loading) {
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
}
const trainDataset = dataStatus?.datasets?.['train']
const hasDecisions = (mlStatus?.total_decisions ?? 0) > 0
const hasModel = modelInfo?.loaded === true
return (
<div className="p-6 space-y-5">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Brain className="w-6 h-6 text-accent-green" />
ML Training Pipeline
</h1>
<p className="text-sm text-text-secondary mt-0.5">Collect data, train models, and evaluate performance</p>
</div>
{/* Error banner */}
{error && (
<div className="flex items-center gap-2 px-4 py-2 bg-accent-red/10 border border-accent-red/30 rounded-lg text-sm text-accent-red">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1">{error}</span>
<button onClick={() => setError(null)} className="text-accent-red/60 hover:text-accent-red text-xs">dismiss</button>
</div>
)}
{/* Pipeline overview bar */}
<div className="flex flex-wrap items-center gap-3 bg-bg-secondary rounded-xl border border-border-primary px-5 py-3">
<div className="flex items-center gap-2">
<div className={'w-2 h-2 rounded-full ' + (mlStatus?.ml_logging_enabled ? 'bg-accent-green' : 'bg-text-tertiary')} />
<span className="text-xs text-text-secondary">Logging {mlStatus?.ml_logging_enabled ? 'On' : 'Off'}</span>
</div>
<span className="text-border-primary">|</span>
<span className="text-xs text-text-secondary">{mlStatus?.total_decisions ?? 0} decisions</span>
<span className="text-border-primary">|</span>
<span className="text-xs text-text-secondary">{trainDataset ? `${trainDataset.samples.toLocaleString()} training samples` : 'No dataset'}</span>
<span className="text-border-primary">|</span>
<span className="text-xs text-text-secondary">{(dataStatus?.checkpoints?.length ?? 0)} checkpoints</span>
<span className="text-border-primary">|</span>
<StatusBadge
variant={hasModel ? 'success' : 'neutral'}
label={hasModel ? `Model on ${modelInfo?.device ?? '?'}` : 'No model'}
/>
{anyRunning && (
<>
<span className="text-border-primary">|</span>
<span className="text-xs font-medium text-accent-blue animate-pulse">Pipeline running...</span>
</>
)}
</div>
{/* ═══ Phase 1: Data Collection ═══ */}
<Section title="Phase 1 — Data Collection" icon={<FileText className="w-4 h-4" />} color="text-accent-teal">
<div className="flex items-center justify-between">
<p className="text-xs text-text-secondary">
Run batch simulations with ML logging enabled to collect decision data.
</p>
<button
onClick={handleToggleLogging}
className={'flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ' +
(mlStatus?.ml_logging_enabled
? 'bg-accent-green/15 text-accent-green border-accent-green/30 hover:bg-accent-green/25'
: 'bg-bg-tertiary text-text-secondary border-border-primary hover:bg-bg-hover'
)}
>
<div className={'w-2 h-2 rounded-full ' + (mlStatus?.ml_logging_enabled ? 'bg-accent-green' : 'bg-text-tertiary')} />
{mlStatus?.ml_logging_enabled ? 'Logging Enabled' : 'Enable Logging'}
</button>
</div>
{/* Decision files */}
{(dataStatus?.decisionFiles?.length ?? 0) > 0 ? (
<div className="space-y-1">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Decision Files</h4>
<div className="max-h-36 overflow-y-auto space-y-1">
{dataStatus!.decisionFiles.map(f => (
<div key={f.name} className="flex items-center justify-between px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs">
<span className="text-text-secondary font-mono truncate">{f.name}</span>
<div className="flex items-center gap-3 text-text-tertiary">
<span>{f.decisions.toLocaleString()} decisions</span>
<span>{(f.size / 1024).toFixed(0)} KB</span>
</div>
</div>
))}
</div>
</div>
) : (
<div className="text-xs text-text-tertiary italic">No decision files yet. Run batch sims with logging enabled.</div>
)}
</Section>
{/* ═══ Phase 2: Dataset & Checkpoints ═══ */}
<Section title="Phase 2 — Dataset & Model Artifacts" icon={<Database className="w-4 h-4" />} color="text-accent-purple">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Dataset splits */}
<div className="space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Dataset Splits</h4>
{Object.keys(dataStatus?.datasets ?? {}).length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{(['train', 'val', 'test'] as const).map(split => {
const ds = dataStatus?.datasets?.[split]
return (
<div key={split} className="px-3 py-2 bg-bg-tertiary rounded-lg">
<p className="text-[10px] text-text-tertiary uppercase">{split}</p>
{ds ? (
<>
<p className="text-sm font-bold text-text-primary">{ds.samples.toLocaleString()}</p>
<p className="text-[10px] text-text-tertiary">{ds.features} features</p>
</>
) : (
<p className="text-xs text-text-tertiary italic">Not built</p>
)}
</div>
)
})}
</div>
) : (
<p className="text-xs text-text-tertiary italic">No dataset built yet. Training will build it automatically.</p>
)}
</div>
{/* Checkpoints */}
<div className="space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Checkpoints</h4>
{(dataStatus?.checkpoints?.length ?? 0) > 0 ? (
<div className="max-h-28 overflow-y-auto space-y-1">
{dataStatus!.checkpoints.map(ckpt => (
<div key={ckpt.name} className="flex items-center justify-between px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs">
<span className="text-text-secondary font-mono truncate">{ckpt.name}</span>
<span className="text-text-tertiary">{(ckpt.size / 1024 / 1024).toFixed(1)} MB</span>
</div>
))}
</div>
) : (
<p className="text-xs text-text-tertiary italic">No checkpoints yet. Train a model to generate them.</p>
)}
</div>
</div>
{/* Model info + reload */}
<div className="flex items-center justify-between pt-2 border-t border-border-primary">
<div className="flex items-center gap-3">
<Cpu className="w-4 h-4 text-text-tertiary" />
<div className="text-xs">
{hasModel ? (
<span className="text-accent-green font-medium">
Model loaded on {modelInfo?.device} | {modelInfo?.input_dim} features | {modelInfo?.num_actions} actions
</span>
) : (
<span className="text-text-tertiary">No model loaded{modelInfo?.error ? ` — ${modelInfo.error}` : ''}</span>
)}
</div>
</div>
<button
onClick={handleReloadModel}
disabled={reloading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50"
>
{reloading ? <Spinner size="sm" /> : <RotateCw className="w-3 h-3" />}
Reload Model
</button>
</div>
{/* Eval results */}
{dataStatus?.evalResults && (
<div className="pt-2 border-t border-border-primary space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex items-center gap-2">
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
Latest Evaluation
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(dataStatus.evalResults).map(([k, v]) => (
<MetricCard
key={k}
label={k.replace(/_/g, ' ')}
value={typeof v === 'number' ? (k.includes('accuracy') || k.includes('rate') ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)) : String(v)}
accent={k.includes('accuracy') ? 'text-accent-green' : undefined}
/>
))}
</div>
</div>
)}
</Section>
{/* ═══ Phase 3: Supervised Training ═══ */}
<Section title="Phase 3 — Supervised Training" icon={<Activity className="w-4 h-4" />}>
{/* Config */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-tertiary block mb-1">Epochs</label>
<input type="number" min={1} max={1000} value={epochs} onChange={e => setEpochs(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Learning Rate</label>
<input type="number" min={0.00001} max={1} step={0.0001} value={lr} onChange={e => setLr(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
</div>
{/* Advanced toggle */}
<button onClick={() => setShowSupAdvanced(!showSupAdvanced)} className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
<Settings className="w-3 h-3" />
{showSupAdvanced ? 'Hide' : 'Show'} advanced options
</button>
{showSupAdvanced && (
<div className="grid grid-cols-3 gap-3 animate-fade-in">
<div>
<label className="text-xs text-text-tertiary block mb-1">Batch Size</label>
<input type="number" min={16} max={2048} value={batchSize} onChange={e => setBatchSize(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Early Stop Patience</label>
<input type="number" min={1} max={100} value={patience} onChange={e => setPatience(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer select-none">
<input type="checkbox" checked={rebuildDataset} onChange={e => setRebuildDataset(e.target.checked)}
disabled={isTraining}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5" />
Rebuild dataset
</label>
</div>
</div>
)}
</div>
{/* Training status / start button */}
{isTraining || (trainStatus?.phase && trainStatus.phase !== 'idle') ? (
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<PhaseBadge phase={trainStatus!.phase} />
<span className="text-xs text-text-secondary">{trainStatus!.message}</span>
</div>
{trainStatus!.started_at && (
<span className="text-[10px] text-text-tertiary">Started: {new Date(trainStatus!.started_at).toLocaleTimeString()}</span>
)}
</div>
{/* Progress bar — only in training phase */}
{(trainStatus!.phase === 'training' || trainStatus!.phase === 'done') && trainStatus!.total_epochs > 0 && (
<ProgressBar
value={trainStatus!.current_epoch}
max={trainStatus!.total_epochs}
label="Epoch progress"
/>
)}
{/* Live metrics during training */}
{trainStatus!.metrics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(trainStatus!.metrics).map(([k, v]) => {
if (typeof v !== 'number') return null
const isAcc = k.includes('accuracy') || k.includes('acc')
const isLoss = k.includes('loss')
return (
<MetricCard
key={k}
label={k.replace(/_/g, ' ')}
value={isAcc ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)}
accent={isAcc ? 'text-accent-green' : isLoss ? 'text-accent-amber' : undefined}
/>
)
})}
</div>
)}
{/* Final result */}
{trainStatus!.phase === 'done' && trainStatus!.result && (
<div className="pt-2 border-t border-border-primary space-y-2">
<h4 className="text-xs font-semibold text-accent-green flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5" /> Training Complete
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<MetricCard label="Device" value={trainStatus!.result.device} />
<MetricCard label="Checkpoint" value={trainStatus!.result.checkpoint.split('/').pop() || '—'} />
{trainStatus!.result.evaluation && Object.entries(trainStatus!.result.evaluation).map(([k, v]) => (
typeof v === 'number' ? (
<MetricCard key={k} label={k.replace(/_/g, ' ')}
value={k.includes('accuracy') ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)}
accent={k.includes('accuracy') ? 'text-accent-green' : undefined} />
) : null
))}
</div>
</div>
)}
{/* Error state */}
{trainStatus!.phase === 'error' && trainStatus!.error && (
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
<span className="text-xs text-accent-red">{trainStatus!.error}</span>
</div>
)}
</div>
) : (
<div className="flex items-center gap-3">
<button
onClick={handleStartTrain}
disabled={startingTrain || !hasDecisions || isPpo}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue/15 text-accent-blue text-sm font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{startingTrain ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Start Training
</button>
{!hasDecisions && (
<span className="text-xs text-text-tertiary italic">Enable logging and run batch sims first to generate decision data.</span>
)}
</div>
)}
</Section>
{/* ═══ Phase 4: PPO Reinforcement Learning ═══ */}
<Section title="Phase 4 — PPO Reinforcement Learning" icon={<Zap className="w-4 h-4" />} color="text-accent-purple" defaultOpen={false}>
<p className="text-xs text-text-secondary">
Fine-tune the supervised model using PPO self-play against an opponent policy.
</p>
{/* Config */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-tertiary block mb-1">Iterations</label>
<input type="number" min={1} max={1000} value={ppoIterations} onChange={e => setPpoIterations(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Opponent</label>
<select value={ppoOpponent} onChange={e => setPpoOpponent(e.target.value)}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50">
<option value="heuristic">Heuristic</option>
<option value="random">Random</option>
<option value="self">Self-Play</option>
</select>
</div>
</div>
<button onClick={() => setShowPpoAdvanced(!showPpoAdvanced)} className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
<Settings className="w-3 h-3" />
{showPpoAdvanced ? 'Hide' : 'Show'} advanced options
</button>
{showPpoAdvanced && (
<div className="grid grid-cols-3 gap-3 animate-fade-in">
<div>
<label className="text-xs text-text-tertiary block mb-1">Episodes / Iter</label>
<input type="number" min={1} max={512} value={ppoEpisodes} onChange={e => setPpoEpisodes(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">PPO Epochs</label>
<input type="number" min={1} max={20} value={ppoEpochs} onChange={e => setPpoEpochs(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Batch Size</label>
<input type="number" min={16} max={2048} value={ppoBatchSize} onChange={e => setPpoBatchSize(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Learning Rate</label>
<input type="number" min={0.000001} max={0.01} step={0.0001} value={ppoLr} onChange={e => setPpoLr(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Clip Epsilon</label>
<input type="number" min={0.05} max={0.5} step={0.05} value={ppoClipEps} onChange={e => setPpoClipEps(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Entropy Coeff</label>
<input type="number" min={0} max={0.1} step={0.005} value={ppoEntropy} onChange={e => setPpoEntropy(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
</div>
)}
{/* PPO running status */}
{isPpo || (ppoStatus?.phase && ppoStatus.phase !== 'idle') ? (
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
<div className="flex items-center gap-2">
<PhaseBadge phase={ppoStatus!.phase} />
<span className="text-xs text-text-secondary">{ppoStatus!.message}</span>
</div>
{(ppoStatus!.phase === 'training' || ppoStatus!.phase === 'done') && ppoStatus!.total_iterations > 0 && (
<ProgressBar
value={ppoStatus!.iteration}
max={ppoStatus!.total_iterations}
label="PPO iteration"
color="bg-accent-purple"
/>
)}
{ppoStatus!.metrics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{ppoStatus!.metrics.win_rate != null && (
<MetricCard label="Win Rate" value={`${(ppoStatus!.metrics.win_rate * 100).toFixed(1)}%`} accent="text-accent-green" />
)}
{ppoStatus!.metrics.avg_reward != null && (
<MetricCard label="Avg Reward" value={ppoStatus!.metrics.avg_reward.toFixed(3)} accent="text-accent-amber" />
)}
{ppoStatus!.metrics.policy_loss != null && (
<MetricCard label="Policy Loss" value={ppoStatus!.metrics.policy_loss.toFixed(4)} />
)}
{ppoStatus!.metrics.entropy != null && (
<MetricCard label="Entropy" value={ppoStatus!.metrics.entropy.toFixed(4)} />
)}
</div>
)}
{ppoStatus!.phase === 'done' && ppoStatus!.result && (
<div className="flex items-center gap-2 pt-2 border-t border-border-primary">
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
<span className="text-xs text-accent-green font-medium">
PPO Training Complete — Best Win Rate: {
typeof ppoStatus!.result.best_win_rate === 'number'
? `${(ppoStatus!.result.best_win_rate as number * 100).toFixed(1)}%`
: '—'
}
</span>
</div>
)}
{ppoStatus!.phase === 'error' && ppoStatus!.error && (
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
<span className="text-xs text-accent-red">{ppoStatus!.error}</span>
</div>
)}
</div>
) : (
<button
onClick={handleStartPpo}
disabled={startingPpo || isTraining || !hasModel}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-medium rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{startingPpo ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Start PPO Training
</button>
)}
{!hasModel && !(isPpo || (ppoStatus?.phase && ppoStatus.phase !== 'idle')) && (
<p className="text-xs text-text-tertiary italic">Train a supervised model first (Phase 3) before running PPO.</p>
)}
</Section>
{/* ═══ Phase 5: Tournament Evaluation ═══ */}
<Section title="Phase 5 — Tournament Evaluation" icon={<Swords className="w-4 h-4" />} color="text-accent-amber" defaultOpen={false}>
<p className="text-xs text-text-secondary">
Pit the trained model against heuristic, random, and other policy variants.
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-tertiary block mb-1">Episodes per Matchup</label>
<input type="number" min={10} max={1000} value={tournEpisodes} onChange={e => setTournEpisodes(Number(e.target.value))}
disabled={isTourn}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Playstyle</label>
<select value={tournPlaystyle} onChange={e => setTournPlaystyle(e.target.value)}
disabled={isTourn}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50">
<option value="midrange">Midrange</option>
<option value="aggro">Aggro</option>
<option value="control">Control</option>
<option value="combo">Combo</option>
</select>
</div>
</div>
{/* Tournament status */}
{isTourn || (tournStatus?.phase && tournStatus.phase !== 'idle') ? (
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
<div className="flex items-center gap-2">
<PhaseBadge phase={tournStatus!.phase} />
<span className="text-xs text-text-secondary">{tournStatus!.message}</span>
</div>
{tournStatus!.phase === 'error' && tournStatus!.error && (
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
<span className="text-xs text-accent-red">{tournStatus!.error}</span>
</div>
)}
</div>
) : (
<button
onClick={handleStartTournament}
disabled={startingTourn || isTraining || isPpo}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-amber/15 text-accent-amber text-sm font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{startingTourn ? <Spinner size="sm" /> : <Trophy className="w-4 h-4" />}
Start Tournament
</button>
)}
{/* Tournament results */}
{(tournStatus?.result?.players?.length ?? 0) > 0 && (
<div className="pt-2 border-t border-border-primary space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex items-center gap-2">
<Trophy className="w-3.5 h-3.5 text-accent-amber" />
Leaderboard
</h4>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary">
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium w-8">#</th>
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium">Policy</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Wins</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Games</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Win Rate</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{tournStatus!.result!.players
.sort((a, b) => b.win_rate - a.win_rate)
.map((p, i) => (
<tr key={p.name} className="hover:bg-bg-hover transition-colors">
<td className="px-3 py-2 text-text-tertiary text-xs">
{i === 0 ? <Trophy className="w-3.5 h-3.5 text-accent-amber" /> : i + 1}
</td>
<td className="px-3 py-2">
<span className="text-text-primary font-medium capitalize">{p.name}</span>
</td>
<td className="px-3 py-2 text-right text-text-secondary">{p.wins}</td>
<td className="px-3 py-2 text-right text-text-secondary">{p.games}</td>
<td className={'px-3 py-2 text-right font-semibold ' + (i === 0 ? 'text-accent-green' : 'text-accent-blue')}>
{(p.win_rate * 100).toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
</div>
)
}
@@ -0,0 +1,563 @@
// ═══════════════════════════════════════════════════════════
// Commander AI Lab — TypeScript Type Definitions
// Derived from the FastAPI backend's Pydantic models + SQLite schema
// ═══════════════════════════════════════════════════════════
// ── Cards & Collection ──────────────────────────────────────
export interface CollectionCard {
id: number
name: string
scryfall_id: string
set_code: string
set_name: string
collector_number: string
rarity: string
type_line: string
mana_cost: string
cmc: number
oracle_text: string
color_identity: string[]
quantity: number
foil_quantity: number
condition: string
tcg_price: number | null
foil_price: number | null
image_url: string
edhrec_rank: number | null
keywords: string
added_date: string
updated_date: string
}
export interface CollectionFilters {
q?: string
type_line?: string
color?: string
rarity?: string
set_code?: string
cmc_min?: number
cmc_max?: number
price_min?: number
price_max?: number
owned_only?: boolean
sort_by?: string
sort_dir?: 'asc' | 'desc'
page?: number
page_size?: number
}
export interface CollectionStats {
total_cards: number
total_unique: number
total_value: number
sets_count: number
}
export interface SetInfo {
code: string
name: string
card_count?: number
}
// ── Scan ─────────────────────────────────────────────────────
export interface ScanResult {
matches: ScanMatch[]
image_url?: string
}
export interface ScanMatch {
name: string
set_code: string
set_name: string
collector_number: string
scryfall_id: string
confidence: number
image_url: string
type_line: string
oracle_text: string
mana_cost: string
tcg_price: number | null
}
// ── Decks ────────────────────────────────────────────────────
export interface Deck {
id: number
name: string
commander: string
commander_scryfall_id?: string
commander_image_url?: string
color_identity: string[]
format: string
card_count: number
total_price: number
created_date: string
updated_date: string
}
export interface DeckCard {
id: number
deck_id: number
name: string
scryfall_id: string
quantity: number
is_commander: boolean
category: string
type_line: string
mana_cost: string
cmc: number
oracle_text: string
tcg_price: number | null
image_url: string
owned_qty: number
set_code: string
}
export interface DeckAnalysis {
card_count: number
land_count: number
creature_count: number
noncreature_count: number
avg_cmc: number
mana_curve: Record<number, number>
color_distribution: Record<string, number>
type_distribution: Record<string, number>
total_price: number
owned_count: number
missing_count: number
}
export interface DeckRecommendation {
name: string
scryfall_id: string
type_line: string
mana_cost: string
oracle_text: string
image_url: string
tcg_price: number | null
reason: string
score: number
owned: boolean
}
// ── EDHREC Recommendations ──────────────────────────────────
export interface EdhRecCard {
name: string
type_line: string
role: string
roles: string[]
inclusion_pct: number | null
synergy_score: number | null
owned: boolean
owned_qty: number
scryfall_id: string
image_url: string | null
}
export interface EdhRecsResponse {
commander: string
source: string
total: number
recommendations: EdhRecCard[]
}
// ── Collection Recommendations ──────────────────────────────
export interface CollectionRecCard {
id: number
scryfall_id: string
name: string
type_line: string
card_type: string
cmc: number
color_identity: string[]
owned_qty: number
roles: string[]
score: number
image_url: string | null
}
export interface CollectionRecsResponse {
shortfall_types: string[]
role_filter: string[]
grouped: Record<string, CollectionRecCard[]>
total: number
}
// ── V3 Deck Generator ────────────────────────────────────────
export interface DeckGenV3Request {
commander_name: string
strategy?: string
target_bracket: number
budget_usd?: number
budget_mode?: 'total' | 'per_card'
omit_cards?: string[]
use_collection?: boolean
run_substitution?: boolean
model?: string
deck_name?: string
}
export interface DeckGenV3Status {
initialized: boolean
pplx_configured: boolean
model: string | null
embeddings_loaded: boolean
embedding_cards: number
error: string | null
}
export interface DeckGenV3Card {
name: string
quantity: number
category: string
type_line: string
mana_cost: string
cmc: number
role: string
reason: string
tcg_price: number | null
image_url: string
owned: boolean
owned_qty: number
scryfall_id: string
is_substitute: boolean
original_name: string | null
substitution_reason: string | null
similarity_score: number | null
}
export interface DeckGenV3Result {
commander: {
name: string
image_url: string
type_line: string
color_identity: string[]
mana_cost: string
oracle_text: string
}
cards: DeckGenV3Card[]
stats: DeckAnalysis
strategy: string
bracket: number
model: string
substitution?: {
owned: number
substituted: number
missing: number
}
}
// ── Simulation ───────────────────────────────────────────────
export interface SimConfig {
deck_names: string[]
num_games: number
threads: number
use_deepseek?: boolean
}
export interface SimStatus {
running: boolean
sim_id: string
progress?: number
games_completed?: number
total_games?: number
}
export interface SimResult {
sim_id: string
decks: SimDeckResult[]
total_games: number
duration_ms: number
timestamp: string
}
export interface SimDeckResult {
name: string
wins: number
losses: number
win_rate: number
avg_turn: number
}
// ── Batch Sim (Lab) ──────────────────────────────────────────
export interface LabDeck {
name: string
source: string
path: string
card_count: number
}
export interface LabStartRequest {
decks: string[]
games: number
threads?: number
use_deepseek?: boolean
deepseek_deck?: string
}
export interface LabStatus {
running: boolean
run_id: string
games_completed: number
total_games: number
current_decks: string[]
error: string | null
}
export interface LabResult {
run_id: string
decks: LabDeckResult[]
total_games: number
timestamp: string
}
export interface LabDeckResult {
name: string
wins: number
games: number
win_rate: number
avg_turns: number
avg_life_remaining: number
}
export interface LabHistoryEntry {
run_id: string
timestamp: string
decks: string[]
total_games: number
}
export interface PreconDeck {
name: string
filename: string
release_date?: string
set_name?: string
installed: boolean
}
// ── Coach ────────────────────────────────────────────────────
export interface CoachStatus {
llmConnected: boolean
llmModel: string | null
llmModels: string[]
embeddingsLoaded: boolean
embeddingCards: number
deckReportsAvailable: number
error: string | null
}
export interface CoachDeck {
deck_id: number
deck_name: string
commander: string
card_count: number
has_report: boolean
report_count: number
last_report_date: string | null
}
export interface CoachGoals {
targetPowerLevel?: number | null
metaFocus?: string | null
budget?: string | null
focusAreas?: string[]
}
export interface CoachMessage {
role: 'user' | 'assistant'
content: string
}
export interface CoachChatResponse {
content: string
model: string
prompt_tokens: number
completion_tokens: number
}
export interface CoachSession {
sessionId: string
deckId: string
timestamp: string
summary: string
suggestedCuts: CoachSuggestedCut[]
suggestedAdds: CoachSuggestedAdd[]
heuristicHints: string[]
manaBaseAdvice: string | null
rawTextExplanation: string
modelUsed: string
promptTokens: number
completionTokens: number
goals: CoachGoals | null
}
export interface CoachSuggestedCut {
cardName: string
reason: string
replacementOptions: string[]
currentImpactScore: number
}
export interface CoachSuggestedAdd {
cardName: string
role: string
reason: string
synergyWith: string[]
estimatedManaValue: number | null
}
export interface CoachSessionSummary {
sessionId: string
deckId: string
timestamp: string
summary: string
cutsCount: number
addsCount: number
}
export interface CoachApplyResult {
cuts: { name: string; status: string }[]
adds: { name: string; scryfall_id?: string; status: string }[]
errors: { name: string; error: string }[]
total_cuts: number
total_adds: number
}
export interface CoachCardLikeResult {
name: string
similarity: number
types: string
mana_value: number
mana_cost: string
text: string
owned_qty: number
image_url: string | null
tcg_price: number | null
}
// ── ML Training ──────────────────────────────────────────────
/** GET /api/ml/status */
export interface MLStatus {
ml_logging_enabled: boolean
training_files: { file: string; decisions: number; size_kb: number }[]
total_decisions: number
total_files: number
}
/** GET /api/ml/data/status */
export interface MLDataStatus {
decisionFiles: { name: string; size: number; decisions: number }[]
totalDecisions: number
datasets: Record<string, { samples: number; features: number; size: number }>
checkpoints: { name: string; size: number; modified: string }[]
evalResults: Record<string, unknown> | null
policyLoaded: boolean
}
/** GET /api/ml/train/status */
export interface MLTrainStatus {
running: boolean
progress: number
total_epochs: number
current_epoch: number
phase: 'idle' | 'starting' | 'building' | 'training' | 'evaluating' | 'done' | 'error'
message: string
metrics: Record<string, unknown> | null
result: {
training: Record<string, unknown>
evaluation: Record<string, unknown> | null
checkpoint: string
device: string
} | null
error: string | null
started_at: string | null
}
/** GET /api/ml/train/ppo/status */
export interface PPOTrainStatus {
running: boolean
iteration: number
total_iterations: number
phase: 'idle' | 'starting' | 'training' | 'done' | 'error'
message: string
metrics: {
avg_reward?: number
win_rate?: number
policy_loss?: number
value_loss?: number
entropy?: number
[key: string]: unknown
} | null
result: Record<string, unknown> | null
error: string | null
}
/** GET /api/ml/tournament/status */
export interface TournamentStatus {
running: boolean
phase: 'idle' | 'starting' | 'running' | 'done' | 'error'
message: string
result: TournamentResults | null
error: string | null
}
export interface TournamentResults {
players: TournamentPlayer[]
total_matches: number
matchups?: Record<string, unknown>
}
export interface TournamentPlayer {
name: string
wins: number
games: number
win_rate: number
}
/** GET /api/ml/model */
export interface MLModelInfo {
loaded: boolean
device?: string
input_dim?: number
num_actions?: number
checkpoint_path?: string
error?: string
torch_available?: boolean
}
// ── DeepSeek ─────────────────────────────────────────────────
export interface DeepSeekStatus {
connected: boolean
model: string | null
base_url: string
}
// ── Commander Search ─────────────────────────────────────────
export interface CommanderSearchResult {
name: string
type_line: string
color_identity: string[]
mana_cost: string
image_url: string
oracle_text: string
scryfall_id: string
in_collection: boolean
}
// ── Perplexity ───────────────────────────────────────────────
export interface PplxStatus {
configured: boolean
key_prefix: string
}
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
recharts: ['recharts'],
react: ['react', 'react-dom', 'react-router-dom'],
},
},
},
},
})
+1944 -163
View File
File diff suppressed because it is too large. Load diff
+955 -285
View File
File diff suppressed because it is too large. Load diff
+8872
View File
File diff suppressed because it is too large. Load diff
+4487
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-34
View File
@@ -1,34 +0,0 @@
<!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
-495
View File
@@ -1,495 +0,0 @@
<!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>&#x1F4E4;</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>&#x1F3AE;</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
-245
View File
@@ -1,245 +0,0 @@
<!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">&times;</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 &amp; 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">&#9889;</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">&#9889;</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">&#8635; Regenerate</button>
<button class="dg-btn dg-btn-primary" id="dg-save-btn">&#128190; 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">&#128722; 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>
-795
View File
@@ -1,795 +0,0 @@
/**
* 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 + '">&times;</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">&#x1F3AE;</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, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
return { init };
})();
document.addEventListener('DOMContentLoaded', () => {
DeckGenerator.init();
});
-25735
View File
File diff suppressed because it is too large. Load diff
-955
View File
@@ -1,955 +0,0 @@
/* ═══════════════════════════════════════════════════════════
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);
}
-944
View File
@@ -1,944 +0,0 @@
<!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>&#x21A9;</button>
<button class="btn-util" id="btn-redo" onclick="redo()" title="Redo (Ctrl+Y)" disabled>&#x21AA;</button>
<button class="btn-util" id="btn-triggers" onclick="toggleTriggerStack()" title="Trigger Stack">&#x2747;</button>
<button class="btn-util" onclick="openDiceModal()" title="Dice &amp; Coins (G)">&#x1F3B2;</button>
<button class="btn-util" onclick="checkStateBasedActions()" title="Check State-Based Actions">SBA</button>
<button class="btn-util" onclick="toggleSettingsPanel()" title="Settings">&#x2699;</button>
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">&#x1F50D;</button>
<button class="btn-util" id="btn-deckbuilder-toggle" onclick="toggleDeckBuilderPanel()" title="Deck Builder">&#x1F0CF;</button>
<button class="btn-util btn-util-exit" onclick="location.reload()" title="Return to Start Screen">&#x2715;</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">&#x1F4DA;</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">&#x2B21;</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">&#x2620;</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">&#x1F0CF;</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">&#x1F451;</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">&#x1F4DA;</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">&#x2B21;</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">&#x2620;</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">&#x1F0CF;</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">&#x1F451;</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">&#x1F451;</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">&#x1F4DA;</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">&#x2B21;</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">&#x2620;</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">&#x1F0CF;</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">&#x1F451;</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">&#x1F4DA;</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">&#x2B21;</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">&#x2620;</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">&#x1F0CF;</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">&#x2212;</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">&#8212;</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">&#x2212;</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">&#8212;</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">&#x2212;</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">&#8212;</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">&#x2212;</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">&#8212;</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">&#x2715;</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">&#x2039;</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">&#x2715;</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">&#x2728; Create Token</button>
<button class="left-panel-btn" onclick="autoArrangeBattlefield()" title="Auto-arrange battlefield">&#x21C4; 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">&#x203A;</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()">&#x2715;</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 &amp; Coins</span>
<button class="btn-close" onclick="closeDiceModal()">&#x2715;</button>
</div>
<div class="dice-result-display" id="dice-result-display">&#x2014;</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()">&#x2715;</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()">&#x2715;</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()">&#x2715;</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()">&#x2715;</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()">&#x1F3B2; 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()">&#x2715;</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('&#x1F451; <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">&times;</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">&times;</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">&times;</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,'&lt;');
var snip = (t.oracleSnippet || '').replace(/</g,'&lt;');
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();">&#x2715;</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 || '&#8212;';
}
}
// 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>
-275
View File
@@ -1,275 +0,0 @@
// 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';
}
}
-426
View File
@@ -1,426 +0,0 @@
/**
* 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));
}
-167
View File
@@ -1,167 +0,0 @@
// 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}
];
-3963
View File
File diff suppressed because it is too large. Load diff
-2515
View File
File diff suppressed because it is too large. Load diff
+87 -208
View File
@@ -4,15 +4,10 @@ Commander AI Lab — Action Labeler
Maps raw Forge action records (from DecisionSnapshot JSONL)
to one of 8 macro-action labels for supervised policy learning.
Classification logic (updated):
1. Check action.type for unambiguous types (attack, land, pass, hold,
cast_commander)
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
Classification logic:
1. Check action.type (cast, attack, land, cast_commander, pass)
2. For "cast" actions, classify by card oracle text / card type keywords
3. For ambiguous cases, use the raw log line and card name heuristics
Macro-actions (from ml.config.scope):
0: CAST_CREATURE — Play a creature
@@ -46,6 +41,11 @@ logger = logging.getLogger("ml.labeler")
# 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 = {
"swords to plowshares", "path to exile", "beast within", "chaos warp",
"counterspell", "swan song", "negate", "arcane denial", "doomblade",
@@ -62,52 +62,45 @@ REMOVAL_CARD_NAMES = {
"decree of pain", "in garruk's wake", "plague wind",
"fire covenant", "imprisoned in the moon", "darksteel mutation",
"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 = {
"rhystic study", "mystic remora", "phyrexian arena", "sylvan library",
"harmonize", "blue sun's zenith", "pull from tomorrow",
"windfall", "wheel of fortune", "timetwister", "brainstorm",
"ponder", "preordain", "opt", "consider", "treasure cruise",
"dig through time", "fact or fiction", "night's whisper",
"sign in blood", "read the bones", "painful truths",
"ambition's cost", "promise of power", "necropotence",
"dark confidant", "toski bearer of secrets", "beast whisperer",
"guardian project", "the great henge", "skullclamp",
"esper sentinel", "archivist of oghma", "mind's eye",
"consecrated sphinx", "ancient craving", "faithless looting",
"cathartic reunion", "thrill of possibility", "jeska's will",
"valakut awakening", "stinging study", "notion thief",
"tymna the weaver", "shamanic revelation", "rishkar's expertise",
"return of the wildspeaker", "garruk's uprising", "elemental bond",
"kindred discovery", "coastal piracy", "reconnaissance mission",
"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",
"dig through time", "fact or fiction", "nights whisper",
"nights whisper", "sign in blood", "read the bones",
"painful truths", "ambition's cost", "promise of power",
"necropotence", "dark confidant", "toski bearer of secrets",
"beast whisperer", "guardian project", "the great henge",
"skullclamp", "esper sentinel", "archivist of oghma",
"mind's eye", "consecrated sphinx", "ancient craving",
"faithless looting", "cathartic reunion", "thrill of possibility",
"jeska's will", "valakut awakening", "stinging study",
"night's whisper", "notion thief", "tymna the weaver",
"shamanic revelation", "rishkar's expertise", "return of the wildspeaker",
"garruk's uprising", "elemental bond", "kindred discovery",
"coastal piracy", "reconnaissance mission", "bident of thassa",
}
# Known ramp cards
RAMP_KEYWORDS = {
"add {", "add one mana", "mana of any color",
"land.*onto the battlefield", "search.*basic land.*battlefield",
}
RAMP_CARD_NAMES = {
"sol ring", "mana crypt", "mana vault", "arcane signet",
"commander's sphere", "chromatic lantern", "fellwar stone",
"mind stone", "thought vessel", "thran dynamo", "worn powerstone",
"gilded lotus", "hedron archive", "everflowing chalice",
"coalition relic", "prismatic lens",
"coalition relic", "prismatic lens", "signets", # catch-all partial
"talisman", # catch-all for talisman cycle
"cultivate", "kodama's reach", "rampant growth", "farseek",
"nature's lore", "three visits", "skyshroud claim",
"explosive vegetation", "migration path", "circuitous route",
@@ -119,144 +112,13 @@ RAMP_CARD_NAMES = {
"birds of paradise", "llanowar elves", "elvish mystic",
"fyndhorn elves", "avacyn's pilgrim", "elves of deep shadow",
"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",
}
# Partial-match prefixes (for cycles like Signets, Talismans)
RAMP_PARTIAL_PREFIXES = {
"talisman", "signet", "locket", "banner", "obelisk",
"keyrune", "cluestone", "monolith", "medallion",
# Known creature-type indicators
CREATURE_KEYWORDS = {
"creature", "token", "enters the battlefield",
}
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:
"""
@@ -271,52 +133,74 @@ def label_action(decision: dict) -> int:
action = decision.get("action", {})
action_type = action.get("type", "").lower()
card_name = (action.get("card", "") or "").lower().strip()
raw_line = (action.get("raw", "") or "").lower()
players = decision.get("players", [])
active_seat = decision.get("active_seat", 0)
# ── Unambiguous direct-type matches ──────────────────────
# ── Direct type matching ────────────────────────────
# Commander cast is unambiguous
if action_type == "cast_commander":
return ACTION_TO_IDX[MacroAction.CAST_COMMANDER]
# Attack is unambiguous
if action_type == "attack":
return ACTION_TO_IDX[MacroAction.ATTACK_OPPONENT]
# Land play → developing mana base = ramp
# Land play → classify as CAST_RAMP (developing mana is ramp)
if action_type == "land":
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
# Pass / hold mana — check available mana to distinguish
# Pass / hold mana
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):
mana = players[active_seat].get("mana", 0)
if mana >= 2:
return ACTION_TO_IDX[MacroAction.HOLD_MANA]
return ACTION_TO_IDX[MacroAction.PASS]
# ── Cast actions ──────────────────────────────────────
# ── Cast actions: classify by card identity ─────────
if action_type == "cast":
# Path A: action.card is present — classify it directly
if card_name:
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]
# 1. Check known card name databases
if card_name in REMOVAL_CARD_NAMES or _partial_match(card_name, REMOVAL_CARD_NAMES):
return ACTION_TO_IDX[MacroAction.CAST_REMOVAL]
# Path B: action.card absent (Forge omits it) — infer from hand
inferred = _classify_from_hand(players, active_seat)
return ACTION_TO_IDX[inferred]
if card_name in DRAW_CARD_NAMES or _partial_match(card_name, DRAW_CARD_NAMES):
return ACTION_TO_IDX[MacroAction.CAST_DRAW]
# ── Fallback ──────────────────────────────────────────
if card_name in RAMP_CARD_NAMES or _partial_match(card_name, RAMP_CARD_NAMES):
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]
# ══════════════════════════════════════════════════════════
# File-level helpers
# ══════════════════════════════════════════════════════════
def _partial_match(name: str, name_set: set) -> bool:
"""Check if card name partially matches any name in the set."""
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]]:
"""
@@ -330,7 +214,7 @@ def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
labels = []
raw = []
with open(jsonl_path, "r", encoding="utf-8") as f:
with open(jsonl_path, "r") as f:
for i, line in enumerate(f):
line = line.strip()
if not line:
@@ -348,11 +232,7 @@ def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
"""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.
"""
"""Print and return the distribution of action labels."""
dist = {}
for action in MacroAction:
idx = ACTION_TO_IDX[action]
@@ -360,13 +240,12 @@ def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
dist[action.value] = count
total = len(labels)
logger.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
logger.info("─" * 43)
# Sort descending by count for readability
log.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
log.info("─" * 43)
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
pct = (count / total * 100) if total > 0 else 0
logger.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
logger.info("─" * 43)
logger.info(f" {'TOTAL':<23} {total:>8}")
log.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
log.info(f"{'─' * 43}")
log.info(f" {'TOTAL':<23} {total:>8}")
return dist
+1 -101
View File
@@ -155,25 +155,15 @@ class StateDimensions:
2 players = 28 global features
+ turn_number = 1
Total global = 29
Zone embedding boundary (architectural decision — Phase 5 follow-up):
Zone dims are **always** 768 per zone slot, zero-padded when a zone
is empty (no cards). The fixed 6144-dim zone block is the contract
between ForgeEpisodeGenerator (upstream) and the policy network
(downstream). All downstream consumers (PolicyNetwork, PPOTrainer,
dataset builder) depend on this shape. If a future change adds
attention pooling or variable-length zone representations, the
boundary must be updated here *and* in StateEncoder._encode_zones.
"""
card_embedding_dim: int = 768 # From mtg-embeddings
zone_pool_dim: int = 768 # Mean-pooled per zone (zero-vec when empty)
zone_pool_dim: int = 768 # Mean-pooled per zone
# Zones per player: hand, battlefield, graveyard, command_zone
zones_per_player: int = 4
num_players: int = 2 # 1v1
# Zone vectors: 4 zones × 2 players × 768 = 6144
# This is a fixed-size block; empty zones produce zero vectors.
total_zone_dim: int = 4 * 2 * 768 # 6144
# Global scalar features
@@ -266,93 +256,3 @@ class TrainingConfig:
log_every: int = 100 # Log every N batches
TRAINING_CONFIG = TrainingConfig()
# ══════════════════════════════════════════════════════════
# Distillation Loop Defaults (Phase 4)
# ══════════════════════════════════════════════════════════
@dataclass
class DistillationDefaults:
"""Default hyperparameters for the closed-loop distillation pipeline.
These are consumed by ml.training.distillation_loop.DistillationConfig
when no explicit override is provided.
Default weights: Forge-only (forge_weight=1.0, ppo_weight=0.0).
The baseline trains exclusively on Forge simulation data. Use
``MIXED_MODE_PRESETS`` below for blended Forge+PPO configurations.
"""
max_iterations: int = 10
convergence_window: int = 3
convergence_threshold: float = 0.01
# PPO self-play per generation
ppo_iterations: int = 50
ppo_episodes_per_iter: int = 64
ppo_lr: float = 3e-4
ppo_clip_epsilon: float = 0.2
# 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
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
forge_weight: float = 1.0
ppo_weight: float = 0.0
min_reward_threshold: float = 0.0
# Quality gate thresholds
min_forge_accuracy: float = 0.35
max_accuracy_drop: float = 0.03
min_ppo_win_rate: float = 0.30
# Retry behaviour on gate failure
max_retries_per_generation: int = 2
retry_lr_factor: float = 0.5
retry_entropy_boost: float = 0.005
# Output paths
results_dir: str = "data/results"
history_dir: str = "data/results/distillation-history"
DISTILLATION_DEFAULTS = DistillationDefaults()
# ══════════════════════════════════════════════════════════
# Mixed-Mode Presets (Phase 5 follow-up)
# ══════════════════════════════════════════════════════════
# Pre-defined Forge/PPO weight combinations for common scenarios.
# Select a preset by name when starting a distillation run to
# override the Forge-only defaults without specifying raw weights.
#
# "forge_only" — baseline: 100% Forge data, ignore PPO
# "forge_90_10" — light PPO blending: 90% Forge, 10% PPO
# "forge_80_20" — heavier PPO blending: 80% Forge, 20% PPO
MIXED_MODE_PRESETS: Dict[str, Dict[str, float]] = {
"forge_only": {"forge_weight": 1.0, "ppo_weight": 0.0},
"forge_90_10": {"forge_weight": 1.0, "ppo_weight": 0.11}, # ~10% PPO
"forge_80_20": {"forge_weight": 1.0, "ppo_weight": 0.25}, # ~20% PPO
}
def get_preset_weights(preset_name: str) -> Dict[str, float]:
"""Look up a mixed-mode preset by name.
Returns a dict with ``forge_weight`` and ``ppo_weight`` keys.
Raises ``KeyError`` if the preset name is unknown.
"""
if preset_name not in MIXED_MODE_PRESETS:
raise KeyError(
f"Unknown preset '{preset_name}'. "
f"Available: {list(MIXED_MODE_PRESETS.keys())}"
)
return dict(MIXED_MODE_PRESETS[preset_name])
+61 -315
View File
@@ -1,18 +1,9 @@
"""
Commander AI Lab — Training Dataset Builder
════════════════════════════════════════════
Reads ML decision JSONL files, encodes states, labels actions,
and produces a training-ready dataset saved as NPZ.
Phase 2: Source Awareness (Issue #66)
- Scans Forge sim files (ml-decisions-sim-*.jsonl) → source "forge"
- 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
- min_reward_threshold filter for PPO data quality
- Each sample tagged with source for downstream analysis
Usage:
python -m ml.data.dataset_builder --results-dir results/ --output ml/models/dataset.npz
@@ -22,7 +13,6 @@ Output NPZ structure:
game_ids: object (N,) — game ID for each sample
outcomes: object (N,) — game outcome per sample
playstyles: object (N,) — deck archetype per sample
sources: object (N,) — data source ("forge", "ppo", or "ds")
"""
import argparse
@@ -30,9 +20,8 @@ import json
import logging
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Tuple
import numpy as np
@@ -51,176 +40,22 @@ logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
logger = logging.getLogger("ml.dataset")
# ---------------------------------------------------------------------------
# Configuration (Phase 2)
# ---------------------------------------------------------------------------
@dataclass
class DatasetConfig:
"""Configuration for dataset building with source awareness."""
results_dir: str = "results"
embeddings_dir: Optional[str] = None
max_samples: Optional[int] = None
# Source weights control sampling ratios between Forge, PPO, and DS data.
# ds=4.0 oversamples the 8 DeepSeek files from ~5.8% → ~20% of effective
# training tokens, matching their higher signal quality relative to sim data.
source_weights: Dict[str, float] = field(
default_factory=lambda: {"forge": 1.0, "ppo": 0.0, "ds": 4.0}
)
# Minimum reward threshold for PPO data — only include decisions
# from games where the episode return exceeds this value.
# Set to None to disable filtering.
min_reward_threshold: Optional[float] = 0.0
# ---------------------------------------------------------------------------
# Source-aware file discovery (Phase 2 — Task 1)
# ---------------------------------------------------------------------------
def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
"""
Scan results directory for Forge sim, PPO, and DeepSeek decision files.
Returns:
Dict mapping source name to list of JSONL file paths.
Keys: "forge" for ml-decisions-sim-*.jsonl
"ppo" for ml-decisions-ppo-*.jsonl
"ds" for ml-decisions-ds-*.jsonl
"""
results_path = Path(results_dir)
sources = {}
# Forge batch sim files
forge_files = sorted(results_path.glob("ml-decisions-sim-*.jsonl"))
if forge_files:
sources["forge"] = forge_files
# PPO self-play files (from Phase 1 DecisionExporter)
ppo_files = sorted(results_path.glob("ml-decisions-ppo-*.jsonl"))
if ppo_files:
sources["ppo"] = ppo_files
# 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(
f for f in results_path.glob("ml-decisions-*.jsonl")
if f not in known
)
if legacy_files:
sources.setdefault("forge", []).extend(legacy_files)
return sources
# ---------------------------------------------------------------------------
# PPO reward filter (Phase 2 — Task 3)
# ---------------------------------------------------------------------------
def _passes_reward_filter(
decision: dict,
source: str,
min_reward_threshold: Optional[float],
) -> bool:
"""
Filter PPO decisions by reward threshold.
Forge and DS data always passes. PPO data must meet the threshold.
"""
if source != "ppo":
return True
if min_reward_threshold is None:
return True
episode_return = decision.get("episode_return", 0.0)
return episode_return >= min_reward_threshold
# ---------------------------------------------------------------------------
# Source-aware sampling (Phase 2 — Task 2)
# ---------------------------------------------------------------------------
def _apply_source_weights(
samples_by_source: Dict[str, List[dict]],
source_weights: Dict[str, float],
rng: np.random.Generator,
) -> List[dict]:
"""
Apply source weights to control the mixing ratio.
A weight of 1.0 keeps all samples from that source.
A weight of 0.5 randomly keeps ~50% of samples.
A weight > 1.0 oversamples (with replacement).
"""
combined = []
for source, samples in samples_by_source.items():
weight = source_weights.get(source, 1.0)
if weight <= 0:
logger.info(" Skipping %d %s samples (weight=0)", len(samples), source)
continue
if weight == 1.0:
combined.extend(samples)
elif weight < 1.0:
# Subsample
n_keep = max(1, int(len(samples) * weight))
indices = rng.choice(len(samples), size=n_keep, replace=False)
combined.extend(samples[i] for i in indices)
logger.info(
" Subsampled %s: %d → %d (weight=%.2f)",
source, len(samples), n_keep, weight,
)
else:
# Oversample (weight > 1.0)
n_target = int(len(samples) * weight)
indices = rng.choice(len(samples), size=n_target, replace=True)
combined.extend(samples[i] for i in indices)
logger.info(
" Oversampled %s: %d → %d (weight=%.2f)",
source, len(samples), n_target, weight,
)
return combined
# ---------------------------------------------------------------------------
# Main dataset builder (updated for Phase 2)
# ---------------------------------------------------------------------------
def build_dataset(
results_dir: str,
embeddings_dir: str = None,
max_samples: int = None,
source_weights: Dict[str, float] = None,
min_reward_threshold: float = 0.0,
) -> Dict[str, np.ndarray]:
"""
Build complete training dataset from all ML decision files.
Phase 2 additions:
- Scans Forge, PPO, and DeepSeek (ds) JSONL files
- Applies source_weights for configurable mixing
(default: ds=4.0 to oversample high-quality DeepSeek decisions)
- Filters PPO data by min_reward_threshold
- Tags each sample with its source
Args:
results_dir: Path to results/ directory containing JSONL files
results_dir: Path to results/ directory containing ml-decisions-*.jsonl
embeddings_dir: Path to embeddings/ directory (default: auto-detect)
max_samples: Cap total samples (None = unlimited)
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)
Returns:
Dict with keys: states, labels, game_ids, outcomes, playstyles, sources
Dict with keys: states, labels, game_ids, outcomes, playstyles
"""
if source_weights is None:
source_weights = {"forge": 1.0, "ppo": 0.0, "ds": 4.0}
# Load card embeddings
card_index = CardEmbeddingIndex(embeddings_dir)
if not card_index.load():
@@ -235,9 +70,10 @@ def build_dataset(
encoder = StateEncoder(card_index)
# --- Phase 2 Task 1: Discover files from all sources ---
source_files = _discover_jsonl_files(results_dir)
if not source_files:
# Find all JSONL files
results_path = Path(results_dir)
jsonl_files = sorted(results_path.glob("ml-decisions-*.jsonl"))
if not jsonl_files:
logger.error("No ML decision files found in %s", results_dir)
logger.error("Run batch simulations with --ml-log or enable ML logging in the UI first.")
raise RuntimeError(
@@ -245,114 +81,60 @@ def build_dataset(
"Run batch simulations with ML logging enabled first."
)
total_files = sum(len(files) for files in source_files.values())
logger.info(
"Found %d ML decision files across %d sources: %s",
total_files,
len(source_files),
{src: len(files) for src, files in source_files.items()},
)
logger.info("Found %d ML decision files", len(jsonl_files))
# --- Read and filter all decisions, grouped by source ---
rng = np.random.default_rng(42)
samples_by_source: Dict[str, List[dict]] = {}
total_skipped = 0
total_filtered = 0
for source, jsonl_files in source_files.items():
source_samples = []
for jsonl_file in jsonl_files:
logger.info("Processing %s [%s] ...", jsonl_file.name, source)
file_count = 0
with open(jsonl_file, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f):
line = line.strip()
if not line:
continue
try:
decision = json.loads(line)
# --- Phase 2 Task 3: PPO reward filter ---
if not _passes_reward_filter(
decision, source, min_reward_threshold
):
total_filtered += 1
continue
# --- Phase 2 Task 4: Tag with source ---
decision["_source"] = source
source_samples.append(decision)
file_count += 1
except Exception as e:
total_skipped += 1
if total_skipped <= 10:
logger.warning(
"Skipping %s line %d: %s", jsonl_file.name, line_num, e
)
logger.info(" → %d samples from %s", file_count, jsonl_file.name)
if source_samples:
samples_by_source[source] = source_samples
if not samples_by_source:
logger.error("No valid samples found.")
return {}
# Log per-source counts before weighting
for src, samples in samples_by_source.items():
logger.info("Source '%s': %d raw samples", src, len(samples))
if total_filtered > 0:
logger.info("PPO reward filter removed %d samples (threshold=%.2f)",
total_filtered, min_reward_threshold or 0.0)
# --- Phase 2 Task 2: Apply source weights ---
combined_decisions = _apply_source_weights(
samples_by_source, source_weights, rng
)
# Apply max_samples cap after weighting
if max_samples and len(combined_decisions) > max_samples:
rng.shuffle(combined_decisions)
combined_decisions = combined_decisions[:max_samples]
logger.info("Capped to %d samples", max_samples)
# --- Encode all decisions ---
all_states = []
all_labels = []
all_game_ids = []
all_outcomes = []
all_playstyles = []
all_sources = []
encode_errors = 0
total_skipped = 0
for decision in combined_decisions:
try:
playstyle = decision.get("archetype", "midrange")
state_vec = encoder.encode(decision, playstyle)
action_idx = label_action(decision)
game_id = decision.get("game_id", "unknown")
outcome = decision.get("game_outcome", "unknown")
source = decision.get("_source", "forge")
for jsonl_file in jsonl_files:
logger.info("Processing %s ...", jsonl_file.name)
file_count = 0
all_states.append(state_vec)
all_labels.append(action_idx)
all_game_ids.append(game_id)
all_outcomes.append(outcome)
all_playstyles.append(playstyle)
all_sources.append(source)
with open(jsonl_file, "r") as f:
for line_num, line in enumerate(f):
if max_samples and len(all_states) >= max_samples:
break
except Exception as e:
encode_errors += 1
if encode_errors <= 5:
logger.warning("Encode error: %s", e)
line = line.strip()
if not line:
continue
try:
decision = json.loads(line)
# Encode state
playstyle = decision.get("archetype", "midrange")
state_vec = encoder.encode(decision, playstyle)
# Label action
action_idx = label_action(decision)
# Metadata
game_id = decision.get("game_id", f"unknown-{line_num}")
outcome = decision.get("game_outcome", "unknown")
all_states.append(state_vec)
all_labels.append(action_idx)
all_game_ids.append(game_id)
all_outcomes.append(outcome)
all_playstyles.append(playstyle)
file_count += 1
except Exception as e:
total_skipped += 1
if total_skipped <= 10:
logger.warning(
"Skipping %s line %d: %s", jsonl_file.name, line_num, e
)
logger.info(" → %d samples from %s", file_count, jsonl_file.name)
if not all_states:
logger.error("No valid samples after encoding.")
logger.error("No valid samples found.")
return {}
# Convert to arrays
@@ -361,11 +143,10 @@ def build_dataset(
game_ids = np.array(all_game_ids, dtype=object)
outcomes = np.array(all_outcomes, dtype=object)
playstyles = np.array(all_playstyles, dtype=object)
sources = np.array(all_sources, dtype=object)
logger.info(
"Dataset built: %d samples, %d features, %d actions, %d skipped, %d encode errors",
len(states), states.shape[1], NUM_ACTIONS, total_skipped, encode_errors,
"Dataset built: %d samples, %d features, %d actions, %d skipped",
len(states), states.shape[1], NUM_ACTIONS, total_skipped,
)
# Print label distribution
@@ -373,25 +154,17 @@ def build_dataset(
# Print game outcome distribution
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
logger.info(f"\n{'Outcome':<25} {'Count':>8}")
logger.info("─" * 35)
log.info(f"\n{'Outcome':<25} {'Count':>8}")
log.info("─" * 35)
for o, c in sorted(zip(unique_outcomes, outcome_counts), key=lambda x: -x[1]):
logger.info(f" {str(o):<23} {c:>8}")
log.info(f" {str(o):<23} {c:>8}")
# Print playstyle distribution
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
logger.info(f"\n{'Playstyle':<25} {'Count':>8}")
logger.info("─" * 35)
log.info(f"\n{'Playstyle':<25} {'Count':>8}")
log.info("─" * 35)
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
logger.info(f" {str(s):<23} {c:>8}")
# --- Phase 2 Task 4: Print source distribution ---
unique_sources, source_counts = np.unique(sources, return_counts=True)
logger.info(f"\n{'Source':<25} {'Count':>8} {'%':>7}")
logger.info("─" * 42)
for s, c in sorted(zip(unique_sources, source_counts), key=lambda x: -x[1]):
pct = 100.0 * c / len(sources)
logger.info(f" {str(s):<23} {c:>8} {pct:>6.1f}%")
log.info(f" {str(s):<23} {c:>8}")
return {
"states": states,
@@ -399,7 +172,6 @@ def build_dataset(
"game_ids": game_ids,
"outcomes": outcomes,
"playstyles": playstyles,
"sources": sources,
}
@@ -468,6 +240,7 @@ def split_dataset(
len(train["states"]), len(val["states"]), len(test["states"]),
split_by_game,
)
return train, val, test
@@ -506,43 +279,16 @@ def main():
"--no-split", action="store_true",
help="Don't split into train/val/test (save single file)",
)
# Phase 2 CLI arguments
parser.add_argument(
"--forge-weight", type=float, default=1.0,
help="Sampling weight for Forge sim data (default: 1.0)",
)
parser.add_argument(
"--ppo-weight", type=float, default=0.0,
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(
"--min-reward", type=float, default=0.0,
help="Minimum episode_return for PPO data (default: 0.0)",
)
args = parser.parse_args()
source_weights = {
"forge": args.forge_weight,
"ppo": args.ppo_weight,
"ds": args.ds_weight,
}
# Build dataset
dataset = build_dataset(
results_dir=args.results_dir,
embeddings_dir=args.embeddings_dir,
max_samples=args.max_samples,
source_weights=source_weights,
min_reward_threshold=args.min_reward,
)
if not dataset:
logger.info("\nNo data to save. Run simulations with --ml-log first.")
log.info("\nNo data to save. Run simulations with --ml-log first.")
sys.exit(1)
if args.no_split:
@@ -554,7 +300,7 @@ def main():
save_dataset(val, f"{base}-val.npz")
save_dataset(test, f"{base}-test.npz")
logger.info("\nDataset build complete.")
log.info("\nDataset build complete.")
if __name__ == "__main__":
-587
View File
@@ -1,587 +0,0 @@
"""
Commander AI Lab — Quality Gate & Validation
═════════════════════════════════════════════
Phase 3 (Issue #67): Prevents bad PPO data from degrading the
supervised model by enforcing quality gates at multiple levels:
1. Forge Validation Holdout — fixed eval set from Forge-only data
2. Accuracy Gate — reject PPO batch if model accuracy drops on Forge eval
3. Win Rate Gate — only export PPO decisions above a win rate threshold
4. Data Quality Metrics — entropy, reward distribution logging
Usage:
from ml.data.quality_gate import QualityGate, QualityGateConfig
gate = QualityGate(QualityGateConfig(
min_forge_accuracy=0.40,
min_ppo_win_rate=0.30,
))
# Check if PPO batch should be accepted
verdict = gate.evaluate_ppo_batch(
forge_eval_accuracy=0.42,
ppo_win_rate=0.35,
ppo_decisions=decisions_list,
baseline_accuracy=0.41,
)
if verdict.accepted:
# merge PPO data into training set
...
Ref: docs/CLOSED-LOOP-DISTILLATION.md — Phase 3
Issue: #67
"""
import json
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
logger = logging.getLogger("ml.quality_gate")
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@dataclass
class QualityGateConfig:
"""Configuration for the quality gate system."""
# --- Forge validation holdout (Task 1) ---
forge_holdout_ratio: float = 0.12 # 12% of Forge data held out
forge_holdout_seed: int = 99 # Fixed seed for reproducibility
# --- Accuracy gate (Task 2) ---
# If model accuracy on Forge eval drops below this absolute floor,
# the PPO batch is rejected regardless.
min_forge_accuracy: float = 0.35
# If model accuracy drops by more than this relative amount compared
# to the baseline (pre-PPO model), reject the batch.
max_accuracy_drop: float = 0.03 # e.g., 41% → 38% = 3% drop → reject
# --- Win rate gate (Task 3) ---
# PPO agent must exceed this win rate before its decisions are exported.
# In a 4-player pod, random baseline is 25%.
min_ppo_win_rate: float = 0.30
# --- Quality metrics (Task 4) ---
# Directory to write quality reports
reports_dir: str = "data/results/quality-reports"
# ---------------------------------------------------------------------------
# Verdict dataclass
# ---------------------------------------------------------------------------
@dataclass
class GateVerdict:
"""Result of a quality gate evaluation."""
accepted: bool
reason: str
metrics: Dict = field(default_factory=dict)
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def to_dict(self) -> dict:
return {
"accepted": self.accepted,
"reason": self.reason,
"metrics": self.metrics,
"timestamp": self.timestamp,
}
# ---------------------------------------------------------------------------
# Forge Validation Holdout (Task 1)
# ---------------------------------------------------------------------------
def create_forge_holdout(
states: np.ndarray,
labels: np.ndarray,
sources: np.ndarray,
game_ids: np.ndarray,
holdout_ratio: float = 0.12,
seed: int = 99,
) -> Tuple[Dict[str, np.ndarray], Dict[str, np.ndarray]]:
"""
Split Forge-only data into training and holdout validation sets.
The holdout set is a fixed eval set used to detect accuracy regression
when PPO data is mixed in. Split is done by game_id to prevent leakage.
Args:
states, labels, sources, game_ids: Dataset arrays
holdout_ratio: Fraction of Forge games to hold out
seed: Fixed random seed for reproducibility
Returns:
(train_data, holdout_data) — each a dict with states, labels, sources, game_ids
"""
# Identify Forge-only samples
forge_mask = np.array([s == "forge" for s in sources])
ppo_mask = ~forge_mask
forge_game_ids = game_ids[forge_mask]
unique_forge_games = np.unique(forge_game_ids)
# Deterministic split by game_id
rng = np.random.default_rng(seed)
rng.shuffle(unique_forge_games)
n_holdout = max(1, int(len(unique_forge_games) * holdout_ratio))
holdout_games = set(unique_forge_games[:n_holdout])
train_games = set(unique_forge_games[n_holdout:])
# Build masks
forge_holdout_mask = np.array([
(s == "forge" and gid in holdout_games)
for s, gid in zip(sources, game_ids)
])
forge_train_mask = np.array([
(s == "forge" and gid in train_games)
for s, gid in zip(sources, game_ids)
])
# Training set = Forge train + all PPO data
train_mask = forge_train_mask | ppo_mask
def _subset(mask):
return {
"states": states[mask],
"labels": labels[mask],
"sources": sources[mask],
"game_ids": game_ids[mask],
}
holdout = _subset(forge_holdout_mask)
train = _subset(train_mask)
logger.info(
"Forge holdout: %d samples (%d games) held out, %d train samples remaining",
forge_holdout_mask.sum(), n_holdout, train_mask.sum(),
)
return train, holdout
# ---------------------------------------------------------------------------
# Accuracy Gate (Task 2)
# ---------------------------------------------------------------------------
def check_accuracy_gate(
current_accuracy: float,
baseline_accuracy: float,
config: QualityGateConfig,
) -> GateVerdict:
"""
Check if the current model accuracy on the Forge holdout set
is acceptable after training with PPO data mixed in.
Args:
current_accuracy: Accuracy on Forge holdout after training with PPO data
baseline_accuracy: Accuracy on Forge holdout from previous model (no PPO)
config: Quality gate configuration
Returns:
GateVerdict — accepted=True if accuracy is acceptable
"""
metrics = {
"current_accuracy": round(current_accuracy, 4),
"baseline_accuracy": round(baseline_accuracy, 4),
"accuracy_drop": round(baseline_accuracy - current_accuracy, 4),
"min_forge_accuracy": config.min_forge_accuracy,
"max_accuracy_drop": config.max_accuracy_drop,
}
# Check absolute floor
if current_accuracy < config.min_forge_accuracy:
return GateVerdict(
accepted=False,
reason=(
f"Forge eval accuracy ({current_accuracy:.1%}) below absolute "
f"minimum ({config.min_forge_accuracy:.1%}). PPO batch rejected."
),
metrics=metrics,
)
# Check relative drop
drop = baseline_accuracy - current_accuracy
if drop > config.max_accuracy_drop:
return GateVerdict(
accepted=False,
reason=(
f"Forge eval accuracy dropped {drop:.1%} "
f"(from {baseline_accuracy:.1%} to {current_accuracy:.1%}), "
f"exceeding max allowed drop of {config.max_accuracy_drop:.1%}. "
f"PPO batch rejected."
),
metrics=metrics,
)
return GateVerdict(
accepted=True,
reason=(
f"Forge eval accuracy {current_accuracy:.1%} is acceptable "
f"(baseline={baseline_accuracy:.1%}, drop={drop:.1%}). "
f"PPO batch accepted."
),
metrics=metrics,
)
# ---------------------------------------------------------------------------
# Win Rate Gate (Task 3)
# ---------------------------------------------------------------------------
def check_win_rate_gate(
ppo_win_rate: float,
config: QualityGateConfig,
total_episodes: int = 0,
min_episodes: int = 20,
) -> GateVerdict:
"""
Check if the PPO agent's win rate exceeds the minimum threshold
before allowing its decisions to be exported.
Args:
ppo_win_rate: Win rate of PPO agent (0.0 to 1.0)
config: Quality gate configuration
total_episodes: Number of episodes played
min_episodes: Minimum episodes required for statistical significance
Returns:
GateVerdict — accepted=True if win rate is sufficient
"""
metrics = {
"ppo_win_rate": round(ppo_win_rate, 4),
"min_ppo_win_rate": config.min_ppo_win_rate,
"total_episodes": total_episodes,
"min_episodes": min_episodes,
}
# Check minimum episode count
if total_episodes < min_episodes:
return GateVerdict(
accepted=False,
reason=(
f"Insufficient episodes ({total_episodes}/{min_episodes}). "
f"Need at least {min_episodes} episodes for reliable win rate."
),
metrics=metrics,
)
# Check win rate threshold
if ppo_win_rate < config.min_ppo_win_rate:
return GateVerdict(
accepted=False,
reason=(
f"PPO win rate ({ppo_win_rate:.1%}) below minimum "
f"threshold ({config.min_ppo_win_rate:.1%}). "
f"Agent not strong enough — decisions not exported."
),
metrics=metrics,
)
return GateVerdict(
accepted=True,
reason=(
f"PPO win rate ({ppo_win_rate:.1%}) exceeds minimum "
f"({config.min_ppo_win_rate:.1%}) over {total_episodes} episodes. "
f"Decisions approved for export."
),
metrics=metrics,
)
# ---------------------------------------------------------------------------
# Data Quality Metrics (Task 4)
# ---------------------------------------------------------------------------
def compute_action_entropy(action_indices: np.ndarray, num_actions: int = 8) -> float:
"""
Compute Shannon entropy of the action distribution.
High entropy = diverse actions (good).
Low entropy = collapsed to few actions (bad — possible mode collapse).
Max entropy for 8 actions = log2(8) = 3.0 bits.
"""
counts = np.bincount(action_indices, minlength=num_actions).astype(np.float64)
probs = counts / max(counts.sum(), 1)
# Filter zero probabilities for log
probs = probs[probs > 0]
entropy = -np.sum(probs * np.log2(probs))
return float(entropy)
def compute_reward_stats(rewards: np.ndarray) -> Dict[str, float]:
"""Compute summary statistics for reward/episode_return distribution."""
if len(rewards) == 0:
return {"mean": 0.0, "std": 0.0, "min": 0.0, "max": 0.0,
"median": 0.0, "p25": 0.0, "p75": 0.0}
return {
"mean": float(np.mean(rewards)),
"std": float(np.std(rewards)),
"min": float(np.min(rewards)),
"max": float(np.max(rewards)),
"median": float(np.median(rewards)),
"p25": float(np.percentile(rewards, 25)),
"p75": float(np.percentile(rewards, 75)),
}
def compute_quality_metrics(
decisions: List[dict],
num_actions: int = 8,
) -> Dict:
"""
Compute comprehensive data quality metrics for a batch of decisions.
Metrics:
- action_entropy: Shannon entropy of action distribution
- action_distribution: count per action
- reward_stats: mean, std, min, max, median, p25, p75
- source_breakdown: count per source
- total_decisions: number of decisions
- unique_games: number of unique game IDs
Args:
decisions: List of decision dicts (from JSONL records)
num_actions: Total number of possible actions
Returns:
Dict of quality metrics
"""
if not decisions:
return {"total_decisions": 0, "error": "empty batch"}
# Extract action indices
action_indices = np.array([
d.get("action", {}).get("action_idx", d.get("action_idx", 0))
for d in decisions
])
# Extract rewards/episode returns
rewards = np.array([
d.get("episode_return", d.get("reward", 0.0))
for d in decisions
])
# Action distribution
action_counts = np.bincount(action_indices, minlength=num_actions)
action_distribution = {
str(i): int(c) for i, c in enumerate(action_counts)
}
# Source breakdown
sources = [d.get("source", d.get("_source", "unknown")) for d in decisions]
source_counts = {}
for s in sources:
source_counts[s] = source_counts.get(s, 0) + 1
# Unique games
game_ids = set(d.get("game_id", "?") for d in decisions)
# Entropy
entropy = compute_action_entropy(action_indices, num_actions)
max_entropy = np.log2(num_actions)
return {
"total_decisions": len(decisions),
"unique_games": len(game_ids),
"action_entropy": round(entropy, 4),
"max_entropy": round(max_entropy, 4),
"entropy_ratio": round(entropy / max(max_entropy, 1e-8), 4),
"action_distribution": action_distribution,
"reward_stats": compute_reward_stats(rewards),
"source_breakdown": source_counts,
}
def log_quality_metrics(metrics: Dict):
"""Pretty-print quality metrics to the logger."""
logger.info("")
logger.info("=" * 55)
logger.info(" Data Quality Report")
logger.info("=" * 55)
logger.info(" Total decisions: %d", metrics.get("total_decisions", 0))
logger.info(" Unique games: %d", metrics.get("unique_games", 0))
logger.info(" Action entropy: %.3f / %.3f (%.0f%%)",
metrics.get("action_entropy", 0),
metrics.get("max_entropy", 0),
metrics.get("entropy_ratio", 0) * 100)
logger.info("")
# Action distribution
dist = metrics.get("action_distribution", {})
if dist:
total = sum(dist.values())
logger.info(" %-20s %8s %7s", "Action", "Count", "%")
logger.info(" " + "-" * 37)
for action_idx, count in sorted(dist.items(), key=lambda x: -x[1]):
pct = 100.0 * count / max(total, 1)
logger.info(" %-20s %8d %6.1f%%", f"action_{action_idx}", count, pct)
# Reward stats
rstats = metrics.get("reward_stats", {})
if rstats:
logger.info("")
logger.info(" Reward distribution:")
logger.info(" mean=%.3f std=%.3f min=%.3f max=%.3f",
rstats.get("mean", 0), rstats.get("std", 0),
rstats.get("min", 0), rstats.get("max", 0))
logger.info(" p25=%.3f median=%.3f p75=%.3f",
rstats.get("p25", 0), rstats.get("median", 0),
rstats.get("p75", 0))
# Source breakdown
sources = metrics.get("source_breakdown", {})
if sources:
logger.info("")
logger.info(" Source breakdown:")
for src, cnt in sorted(sources.items(), key=lambda x: -x[1]):
logger.info(" %-12s %d", src, cnt)
logger.info("=" * 55)
# ---------------------------------------------------------------------------
# Orchestrator — QualityGate class
# ---------------------------------------------------------------------------
class QualityGate:
"""
Orchestrates all quality checks for the closed-loop distillation pipeline.
Usage:
gate = QualityGate(config)
# Before exporting PPO decisions:
win_verdict = gate.check_win_rate(ppo_win_rate=0.35, total_episodes=100)
# After training with mixed data:
acc_verdict = gate.check_accuracy(
current_accuracy=0.42,
baseline_accuracy=0.41,
)
# Log quality metrics for a batch:
gate.report_batch_quality(decisions)
"""
def __init__(self, config: QualityGateConfig = None):
self.config = config or QualityGateConfig()
self.verdicts: List[GateVerdict] = []
def check_win_rate(
self,
ppo_win_rate: float,
total_episodes: int = 0,
min_episodes: int = 20,
) -> GateVerdict:
"""Check if PPO agent meets win rate threshold for export."""
verdict = check_win_rate_gate(
ppo_win_rate, self.config, total_episodes, min_episodes
)
self.verdicts.append(verdict)
if verdict.accepted:
logger.info("WIN RATE GATE: PASS — %s", verdict.reason)
else:
logger.warning("WIN RATE GATE: FAIL — %s", verdict.reason)
return verdict
def check_accuracy(
self,
current_accuracy: float,
baseline_accuracy: float,
) -> GateVerdict:
"""Check if model accuracy on Forge holdout is acceptable."""
verdict = check_accuracy_gate(
current_accuracy, baseline_accuracy, self.config
)
self.verdicts.append(verdict)
if verdict.accepted:
logger.info("ACCURACY GATE: PASS — %s", verdict.reason)
else:
logger.warning("ACCURACY GATE: FAIL — %s", verdict.reason)
return verdict
def report_batch_quality(self, decisions: List[dict]) -> Dict:
"""Compute and log quality metrics for a decision batch."""
metrics = compute_quality_metrics(decisions)
log_quality_metrics(metrics)
# Save report to disk
os.makedirs(self.config.reports_dir, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
report_path = os.path.join(
self.config.reports_dir, f"quality_{timestamp}.json"
)
with open(report_path, "w", encoding="utf-8") as f:
json.dump(metrics, f, indent=2)
logger.info("Quality report saved: %s", report_path)
return metrics
def evaluate_ppo_batch(
self,
forge_eval_accuracy: float,
ppo_win_rate: float,
ppo_decisions: List[dict],
baseline_accuracy: float,
total_episodes: int = 0,
) -> GateVerdict:
"""
Run all quality gates on a PPO batch. Returns a combined verdict.
This is the main entry point for the distillation pipeline to decide
whether to accept or reject a batch of PPO-generated training data.
"""
# 1. Win rate gate
win_verdict = self.check_win_rate(ppo_win_rate, total_episodes)
if not win_verdict.accepted:
return win_verdict
# 2. Accuracy gate
acc_verdict = self.check_accuracy(forge_eval_accuracy, baseline_accuracy)
if not acc_verdict.accepted:
return acc_verdict
# 3. Log quality metrics (informational, doesn't block)
quality_metrics = self.report_batch_quality(ppo_decisions)
# 4. Check for extreme entropy collapse (soft warning)
entropy_ratio = quality_metrics.get("entropy_ratio", 1.0)
if entropy_ratio < 0.3:
logger.warning(
"LOW ENTROPY WARNING: action entropy ratio %.2f — "
"possible mode collapse in PPO agent",
entropy_ratio,
)
return GateVerdict(
accepted=True,
reason="All quality gates passed.",
metrics={
"win_rate": win_verdict.metrics,
"accuracy": acc_verdict.metrics,
"quality": quality_metrics,
},
)
def save_verdict_history(self, path: str = None):
"""Save all verdict history to a JSON file."""
if path is None:
os.makedirs(self.config.reports_dir, exist_ok=True)
path = os.path.join(self.config.reports_dir, "verdict_history.json")
history = [v.to_dict() for v in self.verdicts]
with open(path, "w", encoding="utf-8") as f:
json.dump(history, f, indent=2)
logger.info("Verdict history saved: %s (%d entries)", path, len(history))
+36 -113
View File
@@ -141,17 +141,8 @@ class CardEmbeddingIndex:
logger.error("Auto-download failed: %s", e)
return None
def get_embedding(self, card_name) -> np.ndarray:
"""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
def get_embedding(self, card_name: str) -> np.ndarray:
"""Get embedding vector for a card name. Returns zero vector if not found."""
if not self._loaded:
return self._zero_vec
idx = self._name_to_idx.get(card_name.lower())
@@ -164,11 +155,8 @@ class CardEmbeddingIndex:
return self._embeddings[idx]
return self._zero_vec
def mean_pool_zone(self, card_names: list) -> np.ndarray:
"""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.
"""
def mean_pool_zone(self, card_names: List[str]) -> np.ndarray:
"""Mean-pool embeddings for a list of card names (a game zone)."""
if not card_names:
return self._zero_vec
vectors = [self.get_embedding(name) for name in card_names]
@@ -179,24 +167,6 @@ class CardEmbeddingIndex:
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:
"""
Encodes a decision snapshot JSON dict into a fixed-size numpy vector.
@@ -208,23 +178,6 @@ class StateEncoder:
[0:29] - Global features (life, mana, turn, phase, etc.)
[29:6173] - Zone embeddings (4 zones × 2 players × 768)
[6173:6177] - Playstyle one-hot (4 dims)
Global feature layout per player block (14 features × 2 players + 1 turn = 29):
idx 0 life (normalised /40)
idx 1 cmdr_dmg (normalised /21)
idx 2 mana (normalised /20, clamped)
idx 3 cmdr_tax (normalised /10, clamped)
idx 4 hand_size (normalised /15, clamped)
idx 5 graveyard_size (normalised /100, clamped)
idx 6 creatures (normalised /30, clamped)
idx 7 total_power (normalised /100, clamped)
└─ real Forge value when present;
falls back to creatures*3 heuristic
idx 8 is_active (binary 0/1)
idx 9-12 phase one-hot (4 dims: main_1/combat/main_2/end)
idx 13 lands (normalised /15, clamped)
--- repeat for player 1 at idx 14-27 ---
idx 28 turn (normalised /max_turns)
"""
def __init__(self, card_index: CardEmbeddingIndex):
@@ -244,6 +197,7 @@ class StateEncoder:
"""
players = decision.get("players", [])
if len(players) < 2:
# Pad to 2 players for 1v1
while len(players) < 2:
players.append(self._empty_player(len(players)))
@@ -264,76 +218,55 @@ class StateEncoder:
return state.astype(np.float32)
def _encode_global(self, decision: dict, players: list) -> np.ndarray:
"""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.
"""
"""Encode global scalar features. Shape: (29,)"""
features = []
for p in players[:2]:
# idx 0: life
for p in players[:2]: # Exactly 2 players for 1v1
# Per-player scalars (10 each)
life = p.get("life", 40)
features.append(life / 40.0)
features.append(life / 40.0) # Normalized life
# idx 1: commander damage taken
# _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)
cmdr_dmg = p.get("cmdr_dmg", 0)
features.append(cmdr_dmg / 21.0) # Normalized commander damage
# idx 2: mana available
mana = p.get("mana", 0)
features.append(min(mana / 20.0, 1.0))
features.append(min(mana / 20.0, 1.0)) # Normalized mana
# idx 3: commander tax
cmdr_tax = p.get("cmdr_tax", 0)
features.append(min(cmdr_tax / 10.0, 1.0))
features.append(min(cmdr_tax / 10.0, 1.0)) # Normalized tax
# idx 4: cards in hand
hand_size = len(p.get("hand", []))
features.append(min(hand_size / 15.0, 1.0))
# idx 5: graveyard size
grave_size = len(p.get("graveyard", []))
features.append(min(grave_size / 100.0, 1.0))
# idx 6: creatures on battlefield
creatures = p.get("creatures", 0)
features.append(min(creatures / 30.0, 1.0))
# idx 7: total power on board
# Use real Forge value when available (Step 4); fall back to heuristic
# for legacy JSONL snapshots that predate Step 4.
raw_power = p.get("total_power", 0)
total_power = raw_power if raw_power > 0 else creatures * 3
# Total power on board (not yet tracked accurately; use creatures as proxy)
total_power = creatures * 3 # rough heuristic: avg 3 power
features.append(min(total_power / 100.0, 1.0))
# idx 8: is active player
# Is active player
is_active = 1.0 if p.get("seat") == decision.get("active_seat") else 0.0
features.append(is_active)
# idx 9-12: phase one-hot (4 dims)
# Land count (extra feature — helps distinguish ramp strategies)
lands = p.get("lands", 0)
features.append(min(lands / 15.0, 1.0))
# Phase one-hot (4 dims per player)
phase_str = decision.get("phase", "main_1")
phase_oh = [0.0] * 4
try:
phase_enum = GamePhase(phase_str)
phase_oh[PHASE_TO_IDX[phase_enum]] = 1.0
except (ValueError, KeyError):
phase_oh[0] = 1.0 # default to main_1
phase_oh[0] = 1.0 # Default to main_1
features.extend(phase_oh)
# 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 number (1 feature, shared)
turn = decision.get("turn", 1)
features.append(min(turn / GAME_SCOPE.max_turns, 1.0))
@@ -343,13 +276,6 @@ class StateEncoder:
"""
Encode zone contents as mean-pooled card embeddings.
Shape: (4 zones × 2 players × 768) = (6144,)
Zone embedding boundary: each zone slot is exactly ``zone_pool_dim``
(768) floats. Empty zones produce a zero vector of that size. This
fixed-size contract is relied upon by every downstream consumer
(PolicyNetwork, PPOTrainer, dataset builder). See
``StateDimensions`` in ``ml/config/scope.py`` for the canonical
definition.
"""
zone_keys = ["hand", "battlefield", "graveyard", "command_zone"]
vectors = []
@@ -369,27 +295,24 @@ class StateEncoder:
style = Playstyle(playstyle_str.lower())
vec[PLAYSTYLE_TO_IDX[style]] = 1.0
except (ValueError, KeyError):
# Default: midrange
vec[PLAYSTYLE_TO_IDX[Playstyle.MIDRANGE]] = 1.0
return vec
def _empty_player(self, seat: int) -> dict:
"""Create an empty player dict for padding."""
return {
"seat": seat,
"life": 40,
"cmdr_dmg": 0,
"mana": 0,
"cmdr_tax": 0,
"creatures": 0,
"total_power": 0,
"total_toughness": 0,
"artifacts": 0,
"enchantments": 0,
"lands": 0,
"hand": [],
"battlefield": [],
"graveyard": [],
"command_zone": [],
"seat": seat,
"life": 40,
"cmdr_dmg": 0,
"mana": 0,
"cmdr_tax": 0,
"creatures": 0,
"lands": 0,
"hand": [],
"battlefield": [],
"graveyard": [],
"command_zone": [],
}
@@ -415,7 +338,7 @@ def encode_decisions_file(
states = []
raw = []
with open(jsonl_path, "r", encoding="utf-8") as f:
with open(jsonl_path, "r") as f:
for i, line in enumerate(f):
if max_samples and i >= max_samples:
break
-431
View File
@@ -1,431 +0,0 @@
"""
Commander AI Lab — ELO Tracker & Cross-Generation Tournament
═══════════════════════════════════════════════════════════════
Phase 6 (Issue #70): Quantify improvement across distillation
generations using ELO ratings.
- Runs round-robin tournaments between generation checkpoints
and baselines (heuristic, random)
- Computes ELO ratings using the standard formula
- Persists ELO history to `data/elo_history.json`
- Provides ELO delta for convergence detection
Reuses `run_match`, `LearnedPolicy`, `HeuristicPolicy`,
`RandomPolicy` from `ml/eval/tournament.py`.
Usage:
python -m ml.eval.elo_tracker
python -m ml.eval.elo_tracker --episodes 50 --output data/elo_history.json
python -m ml.eval.elo_tracker --top-n 10
"""
import json
import logging
import math
import os
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from itertools import combinations
from pathlib import Path
from typing import Dict, List, Optional, Tuple
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from ml.eval.tournament import (
run_match, LearnedPolicy, HeuristicPolicy, RandomPolicy,
)
logger = logging.getLogger("ml.eval.elo")
# ═══════════════════════════════════════════════════════════
# ELO Rating Engine
# ═══════════════════════════════════════════════════════════
DEFAULT_K = 32
DEFAULT_START_ELO = 1200.0
class EloTracker:
"""Standard ELO rating tracker."""
def __init__(self, k_factor: float = DEFAULT_K, start_elo: float = DEFAULT_START_ELO):
self.k = k_factor
self.start_elo = start_elo
self.ratings: Dict[str, float] = {}
def ensure_player(self, name: str):
if name not in self.ratings:
self.ratings[name] = self.start_elo
def expected_score(self, rating_a: float, rating_b: float) -> float:
return 1.0 / (1.0 + math.pow(10, (rating_b - rating_a) / 400.0))
def update(self, winner: str, loser: str):
"""Update ratings after a decisive match."""
self.ensure_player(winner)
self.ensure_player(loser)
e_win = self.expected_score(self.ratings[winner], self.ratings[loser])
e_lose = self.expected_score(self.ratings[loser], self.ratings[winner])
self.ratings[winner] += self.k * (1.0 - e_win)
self.ratings[loser] += self.k * (0.0 - e_lose)
def update_draw(self, player_a: str, player_b: str):
"""Update ratings after a draw."""
self.ensure_player(player_a)
self.ensure_player(player_b)
e_a = self.expected_score(self.ratings[player_a], self.ratings[player_b])
e_b = self.expected_score(self.ratings[player_b], self.ratings[player_a])
self.ratings[player_a] += self.k * (0.5 - e_a)
self.ratings[player_b] += self.k * (0.5 - e_b)
def get_ratings(self) -> Dict[str, float]:
"""Return a copy of current ratings, rounded to 1 decimal."""
return {k: round(v, 1) for k, v in self.ratings.items()}
# ═══════════════════════════════════════════════════════════
# ELO Result
# ═══════════════════════════════════════════════════════════
@dataclass
class EloResult:
"""Result of an ELO-rated tournament."""
ratings: Dict[str, float] = field(default_factory=dict)
match_count: int = 0
total_time_s: float = 0.0
policies: List[str] = field(default_factory=list)
rating_progression: List[Dict[str, float]] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"ratings": self.ratings,
"match_count": self.match_count,
"total_time_s": round(self.total_time_s, 1),
"policies": self.policies,
"rating_progression": self.rating_progression,
}
# ═══════════════════════════════════════════════════════════
# ELO History (persistence)
# ═══════════════════════════════════════════════════════════
class EloHistory:
"""Persists ELO ratings across tournament runs."""
def __init__(self, path: str = "data/elo_history.json"):
self.path = path
self.entries: List[dict] = []
self._load()
def _load(self):
if os.path.exists(self.path):
try:
with open(self.path, "r", encoding="utf-8") as f:
data = json.load(f)
self.entries = data.get("entries", [])
except Exception:
self.entries = []
def save(self):
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
with open(self.path, "w", encoding="utf-8") as f:
json.dump({"entries": self.entries}, f, indent=2)
def append(self, generation: int, ratings: Dict[str, float]):
"""Add a new ELO snapshot after a tournament."""
self.entries.append({
"timestamp": datetime.now(timezone.utc).isoformat(),
"generation": generation,
"ratings": {k: round(v, 1) for k, v in ratings.items()},
})
self.save()
def get_elo_delta(self, window: int = 2) -> Optional[float]:
"""
Compute average ELO gain of the latest generation model
over the last `window` entries.
Returns None if insufficient data.
"""
if len(self.entries) < window:
return None
recent = self.entries[-window:]
deltas = []
for i in range(1, len(recent)):
prev_ratings = recent[i - 1]["ratings"]
curr_ratings = recent[i]["ratings"]
# Find the latest generation model name in current entry
gen_models = [k for k in curr_ratings if k.startswith("gen-")]
if not gen_models:
continue
latest_gen = sorted(gen_models)[-1]
curr_elo = curr_ratings.get(latest_gen, DEFAULT_START_ELO)
# Compare to previous entry's latest gen model, or heuristic
prev_gen_models = [k for k in prev_ratings if k.startswith("gen-")]
if prev_gen_models:
prev_latest = sorted(prev_gen_models)[-1]
prev_elo = prev_ratings.get(prev_latest, DEFAULT_START_ELO)
else:
prev_elo = prev_ratings.get("heuristic", DEFAULT_START_ELO)
deltas.append(curr_elo - prev_elo)
if not deltas:
return None
return sum(deltas) / len(deltas)
def to_dict(self) -> dict:
return {"entries": self.entries}
@classmethod
def from_dict(cls, data: dict, path: str = "data/elo_history.json") -> "EloHistory":
h = cls.__new__(cls)
h.path = path
h.entries = data.get("entries", [])
return h
# ═══════════════════════════════════════════════════════════
# Tournament Functions
# ═══════════════════════════════════════════════════════════
def run_elo_tournament(
policies: Dict[str, object],
episodes_per_matchup: int = 50,
playstyle: str = "midrange",
k_factor: float = DEFAULT_K,
) -> EloResult:
"""
Run a round-robin ELO-rated tournament.
Uses `run_match` from tournament.py for match execution.
Args:
policies: Dict mapping name → policy object (with select_action method)
episodes_per_matchup: Games per pair (played both sides)
playstyle: Game playstyle
k_factor: ELO K-factor
Returns:
EloResult with final ratings, match count, and progression
"""
t_start = time.time()
names = list(policies.keys())
tracker = EloTracker(k_factor=k_factor)
# Initialize all players
for name in names:
tracker.ensure_player(name)
progression = [tracker.get_ratings().copy()]
total_matches = 0
logger.info("ELO Tournament: %d policies, %d games/matchup", len(names), episodes_per_matchup)
for name_a, name_b in combinations(names, 2):
policy_a = policies[name_a]
policy_b = policies[name_b]
for ep in range(episodes_per_matchup):
# Game 1: A as seat 0
m1 = run_match(policy_a, name_a, policy_b, name_b, playstyle=playstyle)
total_matches += 1
if m1.winner == name_a:
tracker.update(name_a, name_b)
elif m1.winner == name_b:
tracker.update(name_b, name_a)
else:
tracker.update_draw(name_a, name_b)
# Game 2: B as seat 0
m2 = run_match(policy_b, name_b, policy_a, name_a, playstyle=playstyle)
total_matches += 1
if m2.winner == name_a:
tracker.update(name_a, name_b)
elif m2.winner == name_b:
tracker.update(name_b, name_a)
else:
tracker.update_draw(name_a, name_b)
# Record progression after each matchup
progression.append(tracker.get_ratings().copy())
logger.info(
" %s (%.0f) vs %s (%.0f)",
name_a, tracker.ratings[name_a],
name_b, tracker.ratings[name_b],
)
return EloResult(
ratings=tracker.get_ratings(),
match_count=total_matches,
total_time_s=time.time() - t_start,
policies=names,
rating_progression=progression,
)
def run_generation_tournament(
checkpoint_dir: str = "ml/models/checkpoints",
episodes_per_matchup: int = 50,
playstyle: str = "midrange",
k_factor: float = DEFAULT_K,
top_n: int = 10,
) -> EloResult:
"""
Discover generation checkpoints and run an ELO tournament.
Looks for `gen-*/best_policy.pt` under checkpoint_dir.
Also includes heuristic and random baselines.
Args:
top_n: Only include the N most recent gen-* checkpoints.
0 means include all (unbounded). Default is 10.
"""
policies: Dict[str, object] = {
"heuristic": HeuristicPolicy(),
"random": RandomPolicy(),
}
ckpt_path = Path(checkpoint_dir)
if ckpt_path.exists():
gen_dirs = sorted(ckpt_path.glob("gen-*"))
# Apply top_n cutoff — keep only the latest N generation dirs
if top_n > 0 and len(gen_dirs) > top_n:
skipped = len(gen_dirs) - top_n
logger.info(
"top_n=%d: skipping %d older generation(s), using %s … %s",
top_n, skipped,
gen_dirs[-top_n].name,
gen_dirs[-1].name,
)
gen_dirs = gen_dirs[-top_n:]
for gen_dir in gen_dirs:
model_path = gen_dir / "best_policy.pt"
if model_path.exists():
name = gen_dir.name # e.g., "gen-001"
policies[name] = LearnedPolicy(str(model_path))
logger.info("Loaded %s: %s", name, model_path)
# Also check standard checkpoints
sup_path = ckpt_path / "best_policy.pt"
if sup_path.exists() and "supervised" not in policies:
policies["supervised"] = LearnedPolicy(str(sup_path))
ppo_path = ckpt_path / "best_ppo.pt"
if ppo_path.exists() and "ppo" not in policies:
policies["ppo"] = LearnedPolicy(str(ppo_path))
if len(policies) < 2:
logger.warning("Need at least 2 policies for ELO tournament")
return EloResult(
ratings={k: DEFAULT_START_ELO for k in policies},
policies=list(policies.keys()),
)
logger.info("Running ELO tournament with %d policies: %s", len(policies), list(policies.keys()))
return run_elo_tournament(
policies=policies,
episodes_per_matchup=episodes_per_matchup,
playstyle=playstyle,
k_factor=k_factor,
)
# ═══════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════
def main():
"""CLI entry point for ELO tournament."""
import argparse
parser = argparse.ArgumentParser(
description="Commander AI Lab — ELO Tournament"
)
parser.add_argument(
"--episodes", type=int, default=50,
help="Episodes per matchup (default: 50)",
)
parser.add_argument(
"--checkpoint-dir", default="ml/models/checkpoints",
help="Checkpoint directory (default: ml/models/checkpoints)",
)
parser.add_argument(
"--playstyle", default="midrange",
help="Playstyle (default: midrange)",
)
parser.add_argument(
"--output", default="data/elo_history.json",
help="Output path for ELO history (default: data/elo_history.json)",
)
parser.add_argument(
"--k-factor", type=float, default=32,
help="ELO K-factor (default: 32)",
)
parser.add_argument(
"--top-n", type=int, default=10,
help="Only include the N most recent gen checkpoints (0 = all, default: 10)",
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s")
result = run_generation_tournament(
checkpoint_dir=args.checkpoint_dir,
episodes_per_matchup=args.episodes,
playstyle=args.playstyle,
k_factor=args.k_factor,
top_n=args.top_n,
)
# Print results
logger.info("")
logger.info("=" * 50)
logger.info(" ELO RATINGS")
logger.info("=" * 50)
sorted_ratings = sorted(result.ratings.items(), key=lambda x: x[1], reverse=True)
for name, elo in sorted_ratings:
logger.info(" %-20s %7.1f", name, elo)
logger.info("")
logger.info(" %d matches in %.1f s", result.match_count, result.total_time_s)
logger.info("=" * 50)
# Save to history
history = EloHistory(path=args.output)
# Use the latest generation number, or 0 if no gen models
gen_models = [k for k in result.ratings if k.startswith("gen-")]
gen_num = 0
if gen_models:
try:
gen_num = max(int(g.split("-")[1]) for g in gen_models)
except (ValueError, IndexError):
pass
history.append(gen_num, result.ratings)
logger.info("ELO history saved: %s", args.output)
print(json.dumps(result.to_dict(), indent=2))
if __name__ == "__main__":
main()
-548
View File
@@ -1,548 +0,0 @@
"""
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)
-548
View File
@@ -1,548 +0,0 @@
"""
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)
+1 -1
View File
@@ -417,7 +417,7 @@ def main():
# Save results
output_path = args.output or os.path.join(ckpt_dir, "tournament_results.json")
with open(output_path, "w", encoding="utf-8") as f:
with open(output_path, "w") as f:
json.dump(result.to_dict(), f, indent=2)
logger.info("Results saved: %s", output_path)
Loaded 100 of 521 files, more files were not shown because too many files have changed in this diff. Show more