Compare commits

..
Author SHA1 Message Date
KoalaTrapLord 3a94de4c1f fix(#180): wire win_type_override into Java batch JSON output in get_result()
After loading the result JSON from disk, stamp winType on every game dict
that doesn't already have one (setdefault preserves DeepSeek per-game values).
Also stamps metadata.winType so callers have a batch-level summary field.

Closes #180
2026-04-01 13:44:20 -04:00
KoalaTrapLord c52323dd1d fix(#175): add alternate win condition parser
- New win_condition_parser.py with ALTERNATE_WIN_CARDS dict and detect_win_type()
- GameResult.win_type field added to models.py, emitted in to_dict()
- forge_runner.py Java path: scan log_lines post-run for 'you win the game'
- forge_runner.py DeepSeek path: stamp winType on each game result dict

Closes #175
2026-04-01 13:32:32 -04:00
97 changed files with 1130 additions and 57839 deletions

No files matched your search

+31 -22
View File
@@ -1,27 +1,36 @@
# 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.
# Commander AI Lab - Environment Variables
# Copy this file to .env and fill in your API keys.
# .env is gitignored and will NOT be committed.
# ── API Keys ──────────────────────────────────────────────────────
# Ximilar API Key (card scanner / visual AI recognition)
# Get yours at: https://app.ximilar.com/
XIMILAR_API_KEY=your-ximilar-api-key-here
# Anthropic API Key (AI deck generation & coaching with Claude Opus)
# Get yours at: https://console.anthropic.com/settings/keys
ANTHROPIC_API_KEY=your-anthropic-api-key-here
XIMILAR_API_KEY=your-ximilar-key-here
# Anthropic model for deck generation and coaching (Claude Opus recommended)
ANTHROPIC_MODEL=claude-opus-4-20250514
# Coach chat provider: "local" (Ollama) or "anthropic" (Claude API)
COACH_PROVIDER=anthropic
# ── 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
# CORS allowed origins (comma-separated)
# Default: http://localhost:5173,http://localhost:8080
# Add your LAN IP if accessing from another machine on your network
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
# 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
# ── Dual-Model AI Brain ─────────────────────────────────────
# Primary brain: GPT-OSS 20B (turn planner / decision maker)
BRAIN_API_BASE=http://localhost:11434
BRAIN_MODEL=gpt-oss:20b
BRAIN_TEMPERATURE=0.3
BRAIN_MAX_TOKENS=1024
BRAIN_TIMEOUT=120.0
# ── 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
# Validator brain: DeepSeek-R1-Distill-Qwen-14B (legality + explanation)
VALIDATOR_ENABLED=true
VALIDATOR_API_BASE=http://localhost:11434
VALIDATOR_MODEL=deepseek-r1:14b
VALIDATOR_TEMPERATURE=0.0
VALIDATOR_MAX_TOKENS=2048
VALIDATOR_TIMEOUT=120.0
-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/
+90 -344
View File
@@ -1,212 +1,67 @@
# 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.
A full-stack MTG Commander AI research platform. Simulate hundreds of games overnight, train ML models on the results, and let simulation data automatically shape the AI deck builder — all from a web UI or CLI.
---
## What It Does
## 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
---
| Layer | What it is |
|---|---|
| **Monte Carlo Engine** | Pure-Python headless sim — runs thousands of games in minutes, no JVM needed |
| **Forge Engine** | Java/Forge rules-complete sim for ground-truth validation |
| **Overnight Runner** | Unattended batch runner — runs sims, updates sim weights, trains neural net |
| **ML Pipeline** | Outcome-weighted RL nudges card scoring weights; neural net trains on decision logs |
| **Deck Builder** | 7-step LLM pipeline (EDHrec → Scryfall → Ollama) now injected with sim-learned insights |
| **AI Coach** | LLM-powered coaching sessions (Anthropic/Ollama) with streaming support |
| **Web UI** | React + FastAPI dashboard for decks, sims, training, and deck building |
## Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ 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 UI (React/TypeScript — frontend/) │
│ Deck Builder · Sim Dashboard · Training · Coach │
└────────────────────────┬────────────────────────────────────────┘
│ HTTP/REST + WebSocket
┌────────────────────────┴────────────────────────────────────────┐
│ FastAPI Server (lab_api.py / routes/ — port 8080) │
│ /api/lab/* /api/ml/* /api/coach/* /api/deck-builder/* │
└────────┬──────────────────┬──────────────────┬──────────────────┘
│ │ │
Java subprocess Python sim engine Ollama (local LLM)
(Forge rules) (Monte Carlo) qwen2.5:7b + others
│ │
┌────────┴───────┐ ┌───────┴────────────────────────────────────┐
│ Forge Engine │ │ src/commander_ai_lab/ │
│ (rules- │ │ sim/ — engine, models, rules │
│ complete) │ │ deck_builder/ — 7-step LLM pipeline │
└────────────────┘ │ coach/ — streaming LLM coach │
└────────────────────────────────────────────┘
```
---
## Quick Start (Windows)
## Features
### Simulation Engines
| Engine | Description | Speed |
|---|---|---|
| **Forge** | Rules-complete Java engine — handles the stack, instants, triggered abilities, and all Commander rules. Each game spawns a JVM subprocess. | ~0.04 sims/sec (single-threaded) |
| **Monte Carlo** | Headless Python engine — models combat keywords (flying, trample, deathtouch, lifelink, menace, reach, haste), summoning sickness, tapped state, commander zone, multiplayer targeting, and board wipes. No JVM required. | Thousands of games in minutes |
Both engines feed structured results into the same ML pipeline.
### Deck Builder
The `POST /api/deck-builder/build` endpoint runs a 7-step pipeline:
1. Resolve commander via Scryfall
2. Fetch EDHrec recommendations
3. Fetch Scryfall candidates by category (ramp, removal, draw, lands)
4. Ollama suggests synergy/protection/wincon cards — **simulation insights injected here**
5. Filter by color identity, collection, and ban list
6. Ollama enforces ratios (trim/expand to 99) — **simulation insights injected here**
7. Assemble final deck with smart substitution (guarantees 99-card output)
### AI Coach
LLM-powered coaching with two modes:
- **Full Session** — Deep analysis including removal coverage, ramp quality, draw engine profiling, win conditions, anti-synergy detection, pod meta evaluation, and tempo assessment
- **Quick Digest** — Fast ~5-second summary for rapid feedback
Coaching prompts are enriched with semantically relevant cards from the ChromaDB RAG store (15 cards for full sessions, 8 for quick digests). Supports both Anthropic Claude and local Ollama models with streaming responses.
### RAG Card Knowledge
- **SQLite FTS5** bulk card database from Scryfall data
- **ChromaDB** vector index with natural-language card embeddings
- Automatic staleness monitoring (checks every 6 hours, auto-rebuilds if >14 days old)
- Graceful fallback — RAG failures never block coaching or deck building
### ML Training Pipeline
```
Overnight sims → decisions_*.jsonl
↓
update_weights.py → learned_weights.json (feeds deck builder)
↓
/api/ml/train → neural net training on decision dataset
↓
/api/policy/decide → ML policy serves AI decisions in live games
```
- **Outcome-weighted RL** nudges card scoring weights from game results
- **Neural net** trains on the full decision log dataset
- **Distillation loop** — self-play → collect data → retrain → repeat
- **ELO tracking** and model tournaments for comparing AI versions
- Weights clamped to `[-20.0, 20.0]`, stored in `learned_weights.json`
### Win Condition Tracking
Games are classified by win type — not just "Combat" vs "Other":
- **Combat** — standard creature damage wins
- **Combo** — Approach of the Second Sun, Felidar Sovereign, Happily Ever After, and 30+ known alternate-win cards
- **Drain** — life drain effects
- **Mill** — library depletion
- **Poison** — infect/poison counter wins
Draw games (timeout) are analyzed separately with per-player life-at-timeout snapshots and likely cause identification.
### LAN Battlefield Client
Browser-based 4-player Commander game at `/lan`:
- **AI Bridge** — REST + WebSocket connectivity with ML policy decisions as Priority 1 (falls back to local heuristic AI)
- **Deck System** — 3-tab selector: Precons (163 decks, grouped by set), EDHREC search, and custom deck import
- **Player Panels** — Life counters, commander damage bars, poison/energy/experience/rad counters, emblem management
- **Backend Detection** — Auto-detects the Commander AI Lab backend; works offline with local precon data
### Unity Client
Full Unity application (URP, .NET Standard 2.1) with:
- **Battleground Scene** — 4-seat layout with rotated perspectives, life/poison/commander damage widgets, phase tracker, turn order bar
- **Card System** — Hand, battlefield, graveyard, exile zones with card art, tapped rotation, face-down state, summoning sickness indicators
- **Stack & Mana** — LIFO stack display and 6-pip mana pool
- **Lobby System** — Configure 4 seats (human/AI), POST to backend, WebSocket game state sync
- **Elimination** — Grayscale shader overlay on eliminated players
- **Token System** — Create, tap, add counters, delete tokens
- **Cross-Platform** — Windows, WebGL, Android, iOS build targets
- **Scenes** — MainMenu, Collection, DeckBuilder, DeckGenerator, Simulator, Coach, Scanner, Training, Precon, Battleground
### Card Scanner
OCR pipeline for physical Magic cards using the Ximilar API. Scan cards from photos and match them to the Scryfall database.
---
## Quick Start
### Prerequisites
| 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)
**Prerequisites:** Java 17+, Python 3.10+, Maven 3.8+, [Forge](https://github.com/Card-Forge/forge) built at `D:\ForgeCommander\forge-repo`
```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
cd D:\ForgeCommander\commander-ai-lab
mvn package "-DskipTests"
# 3. Install Python dependencies
pip install -e ".[rag,dev]"
# 3. Install Python deps
pip install -e .
# 4. Pull an Ollama model for the deck builder
ollama pull qwen2.5:7b
# 5. Launch
# 4. Launch
.\start-lab.bat
# → Web UI: http://localhost:8080
# → LAN Client: http://localhost:8080/lan/
# → open http://localhost:8080
```
Or use the automated installer:
```powershell
.\install-commander-ai-lab.ps1
```
---
## Overnight AFK Runner
Run batch simulations unattended, then automatically update card weights and train the neural net:
Runs batch sims unattended, then updates card weights and trains the neural net:
```powershell
python overnight-run.py # default: 11.5h DeepSeek sims
@@ -219,9 +74,9 @@ python overnight-run.py --no-train # skip neural net training
1. `update_weights.py` — nudges `learned_weights.json` from decision logs (feeds deck builder)
2. `/api/ml/train` — trains the neural net on the full decision dataset
---
## Sim → Deck Builder Feedback Loop
## Simulation → Deck Builder Feedback Loop
Simulation outcomes automatically improve deck recommendations:
```
Overnight sims → decisions_*.jsonl
@@ -235,63 +90,24 @@ Ollama prompts (suggest_cards + enforce_deck_ratios)
Better card recommendations
```
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. The loop is a no-op until real sim data exists.
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. No-op until real sim data exists.
---
## Deck Builder Pipeline
## API Reference
The `POST /api/deck-builder/build` endpoint runs 7 steps:
### Simulation
1. Resolve commander via Scryfall
2. Fetch EDHrec recommendations
3. Fetch Scryfall candidates by category (ramp, removal, draw, lands)
4. Ollama suggests synergy/protection/wincon cards ← **sim insights injected here**
5. Filter by color identity, collection, ban list
6. Ollama enforces ratios (trim/expand to 99) ← **sim insights injected here**
7. Assemble final deck
| 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 |
### Deck Builder & Coach
| 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 |
### ML & Policy
| 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 |
### RAG
| 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 |
### 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
## ML Pipeline
```powershell
# Update card weights from decision logs
# Update sim card weights from decision logs (runs automatically in overnight-run.py)
python -m ml.scripts.update_weights --min-games 10 --lr 0.05 --dry-run
# Reset weights to defaults
@@ -301,7 +117,7 @@ python -m ml.scripts.update_weights --reset
python ml/scripts/generate_synthetic_data.py
```
---
Learned weights are clamped to `[-20.0, 20.0]` and stored in `src/commander_ai_lab/sim/learned_weights.json`.
## Python Simulator API
@@ -313,122 +129,52 @@ 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}")
```
---
The Monte Carlo engine models: combat keywords (flying, trample, deathtouch, lifelink, menace, reach, haste), summoning sickness, tapped/untap state, commander zone, multiplayer attack targeting, and board wipes. It does **not** model the stack, instants, or continuous type-changing effects.
## API Reference
| Endpoint | Method | Description |
|---|---|---|
| `/api/lab/decks` | GET | List available Commander decks |
| `/api/lab/start` | POST | Start Forge batch (returns batchId) |
| `/api/lab/start-deepseek` | POST | Start Python/DeepSeek batch |
| `/api/lab/status?batchId=X` | GET | Poll batch progress |
| `/api/lab/result?batchId=X` | GET | Get completed batch JSON |
| `/api/lab/history` | GET | List past batch runs |
| `/api/ml/train` | POST | Start neural net training |
| `/api/ml/train/status` | GET | Poll training progress |
| `/api/coach/chat` | POST | LLM coaching session (streaming) |
| `/api/deck-builder/build` | POST | Build a 99-card Commander deck |
## Project Structure
```
commander-ai-lab/
├── lab_api.py # FastAPI server entry point
├── overnight-run.py # AFK batch runner
├── 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
│
├── lab_api.py # FastAPI server entry point
├── frontend/ # React + TypeScript web UI
├── ui/ # Legacy static HTML UI
├── src/commander_ai_lab/
│ ├── sim/ # Monte Carlo engine
│ │ ├── engine.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/
│ ├── sim/ # Monte Carlo engine (engine, models, rules)
│ ├── deck_builder/ # LLM deck builder pipeline
│ │ ├── api/ # Scryfall, EDHrec, Ollama, sim_insights
│ │ ├── core/ # Models, rules engine, assembler
│ │ └── pipeline/ # build_deck.py (7-step orchestrator)
│ └── coach/ # LLM coaching (Anthropic + Ollama)
├── ml/
│ ├── scripts/ # update_weights.py, ml_cli.py
│ ├── training/ # Neural net trainer
│ ├── encoder/ # Feature encoding
│ └── models/ # Saved model checkpoints
├── scanner/ # Card OCR pipeline
├── logs/decisions/ # ML decision logs (gitignored)
└── src/main/java/commanderailab/ # Java/Forge batch runner
```
---
## 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 |
---
## 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.
- **Monte Carlo engine** — no stack, no instants, no continuous type changes (animated lands don't attack)
- **Forge engine** — each game spawns a new JVM (~2-3s overhead), ~0.04 sims/sec single-threaded
- **Forge winner life total** not captured (quiet mode only reports losers)
- **Deck builder** — Ollama suggestions are only as good as the local model; `qwen2.5:7b` works well
-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
}
+4 -4
View File
@@ -77,7 +77,7 @@ class CoachService:
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:
@@ -95,7 +95,7 @@ class CoachService:
"""Persist a coaching session to disk and SQLite."""
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:
@@ -147,7 +147,7 @@ class CoachService:
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:
@@ -168,7 +168,7 @@ class CoachService:
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
+1 -1
View File
@@ -13,7 +13,7 @@ LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.paren
# ── 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_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
LLM_MAX_RETRIES = 3
-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')
+18 -63
View File
@@ -50,9 +50,6 @@ _DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:3000,http://localhost
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
# Module-level handle so lifespan can call .load() after routes are registered.
_POLICY_SVC = None
# ── Lifespan (replaces deprecated @app.on_event) ────────────────────────────────────────────────────────
@asynccontextmanager
@@ -80,6 +77,7 @@ async def _lifespan(application: FastAPI):
# RAG Phase 3: background staleness monitor (rebuilds if >14 days old)
async def _rag_staleness_monitor():
"""Periodically check if the RAG index needs rebuilding."""
import time as _time
while True:
await asyncio.sleep(6 * 3600) # check every 6 hours
try:
@@ -94,17 +92,20 @@ async def _lifespan(application: FastAPI):
log.warning("RAG staleness monitor failed: %s", exc)
asyncio.ensure_future(_rag_staleness_monitor())
# Policy model load — routes were already registered at module scope (before
# any app.mount() calls) so /api/policy/* is reachable regardless of the
# StaticFiles root mount. Only the heavyweight model load happens here.
# Policy routes: register if ML policy model is available
try:
if _POLICY_SVC is not None:
if _POLICY_SVC.load():
log.info("Policy model loaded successfully")
else:
log.warning("Policy model not loaded — /api/policy/health will report degraded")
from ml.serving.policy_server import PolicyInferenceService
from routes.policy import register_policy_routes
_policy_svc = PolicyInferenceService()
if _policy_svc.load():
register_policy_routes(application, _policy_svc)
log.info("Policy routes registered (/api/policy/*)")
else:
log.warning("Policy model not loaded — /api/policy/* routes inactive")
except ImportError:
log.info("Policy service not available (ml.serving not installed)")
except Exception as e:
log.warning("Policy model load failed (non-fatal): %s", e)
log.warning("Policy route registration failed (non-fatal): %s", e)
yield # ← server is running
@@ -137,23 +138,6 @@ app.include_router(ws_game_router)
app.include_router(ml_router)
app.include_router(game_router) # live game session routes
# ── Policy routes — MUST be registered here, before any app.mount() calls ────────────────────
# StaticFiles mounted at "/" acts as a sub-application and returns its own 404 for
# paths it doesn't serve, rather than falling through to later-registered routes.
# Any include_router() called after app.mount("/", ...) will be unreachable.
# Registering here (module scope) guarantees the /api/policy/* endpoints exist in
# the route list before the root static mount is added below.
try:
from ml.serving.policy_server import PolicyInferenceService
from routes.policy import register_policy_routes
_POLICY_SVC = PolicyInferenceService()
register_policy_routes(app, _POLICY_SVC)
log.info("Policy routes registered (/api/policy/*)")
except ImportError:
log.info("Policy service not available (ml.serving not installed)")
except Exception as e:
log.warning("Policy route registration failed (non-fatal): %s", e)
# ── API endpoints ─────────────────────────────────────────────────────────────────────────────
# NOTE: all @app.get routes MUST be registered before app.mount() calls.
@@ -165,26 +149,11 @@ async def health_check():
"""Health check endpoint for load balancers and Unity client polling."""
return {"status": "ok"}
# ── Static UI (MUST come after all @app.get / include_router calls) ──────────────────────────
# ── Static UI (MUST come after all @app.get routes) ──────────────────────────────────────────
from pathlib import Path as _Path
_legacy_ui_dir = _Path(__file__).parent / "ui"
_lan_client_dir = _Path(__file__).parent / "lan-client"
_precon_decks_dir = _Path(__file__).parent / "precon-decks"
_user_decks_dir = _Path(__file__).parent / "user-decks"
_spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
# Mount LAN client at /lan (served before the root catch-all)
if _lan_client_dir.exists():
app.mount("/lan", StaticFiles(directory=str(_lan_client_dir), html=True), name="lan-client")
# Mount precon-decks/ so .dck files are fetchable at /precon-decks/{file}.dck
if _precon_decks_dir.exists():
app.mount("/precon-decks", StaticFiles(directory=str(_precon_decks_dir)), name="precon-decks")
# Mount user-decks/ so exported .dck files are fetchable at /user-decks/{file}.dck
if _user_decks_dir.exists():
app.mount("/user-decks", StaticFiles(directory=str(_user_decks_dir)), name="user-decks")
if _legacy_ui_dir.exists():
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
elif _spa_dir.exists():
@@ -205,12 +174,10 @@ elif _spa_dir.exists():
def _parse_args():
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
# Support both FORGE_DIR and FORGE_WORKING_DIR (alias for backwards compat)
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR") or os.environ.get("FORGE_WORKING_DIR", ""))
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", ""))
p.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", ""))
p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""))
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
p.add_argument("--results-dir", default=os.environ.get("RESULTS_DIR", ""))
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
p.add_argument("--anthropic-key", default=os.environ.get("ANTHROPIC_API_KEY", ""))
@@ -256,9 +223,7 @@ def _resolve_forge_dir() -> str:
project_root = Path(__file__).parent
for candidate in [
project_root / "forge",
project_root.parent / "Forge-Repo",
project_root.parent / "Forge-Repo" / "forge-gui",
project_root.parent / "forge-repo", # legacy fallback
project_root.parent / "forge-repo",
project_root.parent / "forge-repo" / "forge-gui",
project_root.parent / "forge",
project_root / "forge-gui-desktop",
@@ -284,11 +249,10 @@ def _resolve_forge_jar() -> str:
project_root = Path(__file__).parent
candidates = [
project_root / "forge-gui-desktop" / "target",
project_root.parent / "Forge-Repo" / "forge-gui-desktop" / "target",
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target", # legacy fallback
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
project_root.parent / "forge" / "forge-gui-desktop" / "target",
]
forge_dir_env = os.environ.get("FORGE_DIR", "") or os.environ.get("FORGE_WORKING_DIR", "")
forge_dir_env = os.environ.get("FORGE_DIR", "")
if forge_dir_env:
fd = Path(forge_dir_env)
candidates.append(fd.parent / "forge-gui-desktop" / "target")
@@ -353,15 +317,6 @@ def main():
CFG.ximilar_api_key = args.ximilar_key
CFG.anthropic_api_key = args.anthropic_key
# Wire RESULTS_DIR: use explicit env/arg if set, otherwise keep default "results"
# but resolve it to an absolute path relative to this file so it works
# regardless of what directory the server is launched from.
if args.results_dir:
CFG.results_dir = args.results_dir
else:
# Default: results/ next to lab_api.py (always absolute)
CFG.results_dir = str(Path(__file__).parent / "results")
from services.forge_runner import get_java17
j17 = get_java17()
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
+83 -204
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]
@@ -362,11 +242,10 @@ def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
total = len(labels)
logger.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
logger.info("─" * 43)
# Sort descending by count for readability
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"{'─' * 43}")
logger.info(f" {'TOTAL':<23} {total:>8}")
return dist
+1 -9
View File
@@ -292,15 +292,7 @@ class DistillationDefaults:
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_entropy_coeff: float = 0.01
ppo_eval_episodes: int = 100
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
+18 -33
View File
@@ -6,9 +6,8 @@ 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"
- Scans both Forge sim files (ml-decisions-sim-*.jsonl) and
PPO self-play files (ml-decisions-ppo-*.jsonl)
- Configurable source_weights for sampling ratios
- min_reward_threshold filter for PPO data quality
- Each sample tagged with source for downstream analysis
@@ -22,7 +21,7 @@ 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")
sources: object (N,) — data source ("forge" or "ppo")
"""
import argparse
@@ -63,11 +62,11 @@ class DatasetConfig:
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 control sampling ratios between Forge and PPO data.
# Default: Forge-only (ppo=0.0). Use MIXED_MODE_PRESETS from
# ml.config.scope for blended configurations (e.g. 90/10, 80/20).
source_weights: Dict[str, float] = field(
default_factory=lambda: {"forge": 1.0, "ppo": 0.0, "ds": 4.0}
default_factory=lambda: {"forge": 1.0, "ppo": 0.0}
)
# Minimum reward threshold for PPO data — only include decisions
@@ -82,13 +81,12 @@ class DatasetConfig:
def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
"""
Scan results directory for Forge sim, PPO, and DeepSeek decision files.
Scan results directory for both Forge sim and PPO 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 = {}
@@ -103,16 +101,10 @@ def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
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)
# Also pick up legacy files that don't have sim/ppo prefix
legacy_files = sorted(
f for f in results_path.glob("ml-decisions-*.jsonl")
if f not in known
if f not in set(forge_files + ppo_files)
)
if legacy_files:
sources.setdefault("forge", []).extend(legacy_files)
@@ -131,7 +123,7 @@ def _passes_reward_filter(
) -> bool:
"""
Filter PPO decisions by reward threshold.
Forge and DS data always passes. PPO data must meet the threshold.
Forge data always passes. PPO data must meet the threshold.
"""
if source != "ppo":
return True
@@ -163,7 +155,7 @@ def _apply_source_weights(
if weight <= 0:
logger.info(" Skipping %d %s samples (weight=0)", len(samples), source)
continue
if weight == 1.0:
if weight >= 1.0 and weight == 1.0:
combined.extend(samples)
elif weight < 1.0:
# Subsample
@@ -201,9 +193,8 @@ def build_dataset(
Build complete training dataset from all ML decision files.
Phase 2 additions:
- Scans Forge, PPO, and DeepSeek (ds) JSONL files
- Scans both Forge and PPO 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
@@ -211,15 +202,14 @@ def build_dataset(
results_dir: Path to results/ directory containing JSONL files
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)
source_weights: Dict of source → sampling weight (default: forge=1.0, ppo=0.5)
min_reward_threshold: Min episode_return for PPO data (default: 0.0)
Returns:
Dict with keys: states, labels, game_ids, outcomes, playstyles, sources
"""
if source_weights is None:
source_weights = {"forge": 1.0, "ppo": 0.0, "ds": 4.0}
source_weights = {"forge": 1.0, "ppo": 0.0}
# Load card embeddings
card_index = CardEmbeddingIndex(embeddings_dir)
@@ -235,7 +225,7 @@ def build_dataset(
encoder = StateEncoder(card_index)
# --- Phase 2 Task 1: Discover files from all sources ---
# --- Phase 2 Task 1: Discover files from both sources ---
source_files = _discover_jsonl_files(results_dir)
if not source_files:
logger.error("No ML decision files found in %s", results_dir)
@@ -266,7 +256,7 @@ def build_dataset(
logger.info("Processing %s [%s] ...", jsonl_file.name, source)
file_count = 0
with open(jsonl_file, "r", encoding="utf-8") as f:
with open(jsonl_file, "r") as f:
for line_num, line in enumerate(f):
line = line.strip()
if not line:
@@ -513,11 +503,7 @@ def main():
)
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)",
help="Sampling weight for PPO self-play data (default: 0.0, Forge-only)",
)
parser.add_argument(
"--min-reward", type=float, default=0.0,
@@ -529,7 +515,6 @@ def main():
source_weights = {
"forge": args.forge_weight,
"ppo": args.ppo_weight,
"ds": args.ds_weight,
}
# Build dataset
+2 -2
View File
@@ -523,7 +523,7 @@ class QualityGate:
report_path = os.path.join(
self.config.reports_dir, f"quality_{timestamp}.json"
)
with open(report_path, "w", encoding="utf-8") as f:
with open(report_path, "w") as f:
json.dump(metrics, f, indent=2)
logger.info("Quality report saved: %s", report_path)
@@ -582,6 +582,6 @@ class QualityGate:
path = os.path.join(self.config.reports_dir, "verdict_history.json")
history = [v.to_dict() for v in self.verdicts]
with open(path, "w", encoding="utf-8") as f:
with open(path, "w") as f:
json.dump(history, f, indent=2)
logger.info("Verdict history saved: %s (%d entries)", path, len(history))
+6 -41
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.
@@ -271,10 +241,6 @@ class StateEncoder:
Index 7 priority for total_power:
1. p['total_power'] — real Forge value from Step 4 / _build_encoder_snapshot()
2. p['creatures'] * 3 — legacy heuristic for old JSONL snapshots
cmdr_dmg is resolved via _resolve_cmdr_dmg() which handles both the
live Forge format (dict keyed by opponent seat) and the pre-collapsed
int from _build_encoder_snapshot / JSONL training snapshots.
"""
features = []
@@ -284,8 +250,7 @@ class StateEncoder:
features.append(life / 40.0)
# 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))
cmdr_dmg = p.get("cmdr_dmg", 0)
features.append(cmdr_dmg / 21.0)
# idx 2: mana available
@@ -415,7 +380,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
+2 -2
View File
@@ -130,7 +130,7 @@ class EloHistory:
def _load(self):
if os.path.exists(self.path):
try:
with open(self.path, "r", encoding="utf-8") as f:
with open(self.path, "r") as f:
data = json.load(f)
self.entries = data.get("entries", [])
except Exception:
@@ -138,7 +138,7 @@ class EloHistory:
def save(self):
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
with open(self.path, "w", encoding="utf-8") as f:
with open(self.path, "w") as f:
json.dump({"entries": self.entries}, f, indent=2)
def append(self, generation: int, ratings: Dict[str, float]):
+2 -2
View File
@@ -280,7 +280,7 @@ class ForgeEvaluator:
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:
with open(batch_path) as f:
batch = json.load(f)
games_list = batch.get("games", [])
if not games_list:
@@ -543,6 +543,6 @@ class ForgeEvaluator:
"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:
with open(self._result_path, "w") as f:
json.dump(payload, f, indent=2)
logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path)
-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)
+2 -2
View File
@@ -38,7 +38,7 @@ def load_precon_cards(precon_dir: str) -> dict:
cards = {"commander": None, "creatures": [], "spells": [], "lands": [], "artifacts": [], "all": []}
section = "Main"
with open(dck_file, "r", encoding="utf-8") as f:
with open(dck_file, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("//") or line.startswith("Name="):
@@ -380,7 +380,7 @@ def main():
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
total_decisions = 0
with open(args.output, "w", encoding="utf-8") as f:
with open(args.output, "w") as f:
for game_idx in range(args.games):
# Pick two random decks
d1_name, d2_name = random.sample(deck_names, 2)
+3 -3
View File
@@ -68,7 +68,7 @@ def cmd_status(args):
for f in jsonl_files:
lines = 0
try:
with open(f, encoding="utf-8") as fh:
with open(f) as fh:
lines = sum(1 for _ in fh)
except Exception:
pass
@@ -130,7 +130,7 @@ def cmd_inspect(args):
print(f"\n Inspecting: {filepath.name}\n")
count = 0
with open(filepath, encoding="utf-8") as f:
with open(filepath) as f:
for i, line in enumerate(f):
if i >= args.limit:
break
@@ -150,7 +150,7 @@ def cmd_inspect(args):
except Exception as e:
print(f" [{i:>4}] ERROR: {e}")
with open(filepath, encoding="utf-8") as f:
with open(filepath) as f:
total = sum(1 for _ in f)
print(f"\n Showing {count} of {total:,} total decisions.\n")
+13 -48
View File
@@ -52,44 +52,14 @@ from ml.config.scope import (
)
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
# Unique logger name so log lines are attributable to this module specifically
logger = logging.getLogger("ml.serving.policy_server")
def _unwrap_logits(raw):
"""
Normalise whatever self.model() returns to a plain logits tensor.
PolicyNetwork.forward() returns a raw tensor, but the model may be
wrapped (DataParallel, torch.compile) or accidentally swapped for
PolicyValueNetwork whose forward_with_value() returns (logits, value).
A dict return would cause 'dict / float' TypeError on the temperature
division -- guard against all three cases here.
"""
if isinstance(raw, (tuple, list)):
# (logits, value) from PolicyValueNetwork or similar
return raw[0]
if isinstance(raw, dict):
# e.g. {"logits": tensor, "value": tensor}
for key in ("logits", "policy", "action_logits"):
if key in raw:
return raw[key]
# Fallback: grab the first tensor value in the dict
for v in raw.values():
if isinstance(v, torch.Tensor):
return v
raise TypeError(
f"model() returned a dict with no recognisable logits key: {list(raw.keys())}"
)
# Plain tensor -- the normal case
return raw
logger = logging.getLogger("ml.serving")
class PolicyInferenceService:
"""
Loads a trained PolicyNetwork and provides action predictions.
Thread-safe -- uses torch.no_grad() for inference.
Thread-safe — uses torch.no_grad() for inference.
Designed to be embedded in lab_api.py or run standalone.
"""
@@ -130,7 +100,7 @@ class PolicyInferenceService:
"""
if not TORCH_AVAILABLE:
self._load_error = "PyTorch not installed"
logger.error("PyTorch not installed -- cannot serve policy")
logger.error("PyTorch not installed — cannot serve policy")
return False
# 1. Load card embeddings
@@ -176,7 +146,7 @@ class PolicyInferenceService:
except Exception as e:
self._load_error = str(e)
logger.error("Failed to load checkpoint: %s", e, exc_info=True)
logger.error("Failed to load checkpoint: %s", e)
return False
def reload(self, checkpoint_path: str = None) -> bool:
@@ -208,7 +178,7 @@ class PolicyInferenceService:
except Exception as e:
self._load_error = str(e)
logger.error("Hot-reload failed: %s", e, exc_info=True)
logger.error("Hot-reload failed: %s", e)
return False
def predict(
@@ -254,10 +224,7 @@ class PolicyInferenceService:
# Run inference
with torch.no_grad():
raw_output = self.model(state_tensor)
# Guard: unwrap tuple/dict returns (DataParallel, torch.compile,
# or accidental PolicyValueNetwork swap) before any arithmetic.
logits = _unwrap_logits(raw_output)
logits = self.model(state_tensor)
if greedy:
action_idx = logits.argmax(dim=-1).item()
@@ -287,7 +254,7 @@ class PolicyInferenceService:
except Exception as e:
elapsed_ms = (time.time() - t_start) * 1000
logger.error("Inference error: %s", e, exc_info=True)
logger.error("Inference error: %s", e)
return {
"error": str(e),
"inference_ms": round(elapsed_ms, 2),
@@ -319,9 +286,7 @@ class PolicyInferenceService:
# Batch inference
with torch.no_grad():
raw_output = self.model(state_tensor)
# Same guard as predict() -- unwrap before softmax/argmax.
logits = _unwrap_logits(raw_output)
logits = self.model(state_tensor)
probs = torch.softmax(logits, dim=-1).cpu().numpy()
if greedy:
@@ -347,7 +312,7 @@ class PolicyInferenceService:
)
except Exception as e:
logger.error("Batch inference error: %s", e, exc_info=True)
logger.error("Batch inference error: %s", e)
results = [{"error": str(e)}] * len(snapshots)
return results
@@ -366,9 +331,9 @@ class PolicyInferenceService:
}
# ==========================================================
# ══════════════════════════════════════════════════════════
# Standalone FastAPI Server
# ==========================================================
# ══════════════════════════════════════════════════════════
def create_standalone_app():
"""Create a standalone FastAPI app for the policy server."""
@@ -376,7 +341,7 @@ def create_standalone_app():
from pydantic import BaseModel
app = FastAPI(
title="Commander AI Lab -- Policy Server",
title="Commander AI Lab — Policy Server",
description="Serves macro-action predictions from a trained policy network",
version="1.0.0",
)
@@ -459,7 +424,7 @@ def main():
import uvicorn
parser = argparse.ArgumentParser(
description="Commander AI Lab -- Policy Inference Server"
description="Commander AI Lab — Policy Inference Server"
)
parser.add_argument("--port", type=int, default=8090)
parser.add_argument("--host", default="127.0.0.1")
+1 -1
View File
@@ -253,7 +253,7 @@ class DecisionExporter:
filename = f"ml-decisions-ppo-{self._batch_id}.jsonl"
filepath = os.path.join(self.config.output_dir, filename)
with open(filepath, "w", encoding="utf-8") as f:
with open(filepath, "w") as f:
for record in self._records:
f.write(json.dumps(record) + "\n")
+2 -2
View File
@@ -759,7 +759,7 @@ class DistillationLoop:
"""Save generation history to JSON."""
path = os.path.join(self.config.history_dir, "generations.json")
history = [g.to_dict() for g in self.generations]
with open(path, "w", encoding="utf-8") as f:
with open(path, "w") as f:
json.dump(history, f, indent=2)
logger.info(" Generation history saved: %s", path)
@@ -793,7 +793,7 @@ class DistillationLoop:
def _save_summary(self, summary: Dict):
"""Save final summary to JSON."""
path = os.path.join(self.config.history_dir, "summary.json")
with open(path, "w", encoding="utf-8") as f:
with open(path, "w") as f:
json.dump(summary, f, indent=2, default=str)
logger.info(" Summary saved: %s", path)
+11 -154
View File
@@ -34,7 +34,6 @@ from ml.training.reward import (
compute_intermediate_reward,
compute_terminal_reward,
)
from ml.training.decision_exporter import DecisionExporter, ExporterConfig
logger = logging.getLogger("ml.forge_episode_generator")
@@ -52,8 +51,6 @@ class ForgeTransition:
log_prob: float
value: float
done: bool
# Raw snapshot retained so the exporter can reconstruct game-state context
snapshot: Optional[Dict[str, Any]] = None
@dataclass
@@ -64,8 +61,6 @@ class ForgeEpisode:
winner: Optional[int] = None
total_turns: int = 0
deck_names: List[str] = field(default_factory=list)
agent_seat: int = 0
playstyle: str = "midrange"
@property
def length(self) -> int:
@@ -75,10 +70,6 @@ class ForgeEpisode:
def total_reward(self) -> float:
return sum(t.reward for t in self.transitions)
@property
def won(self) -> bool:
return self.winner == self.agent_seat
# ══════════════════════════════════════════════════════════
# JSONL Parser — reads Forge ML decision snapshots
@@ -91,7 +82,7 @@ def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
game_id, turn, phase, active_seat, state, action, outcome
"""
snapshots = []
with open(path, "r", encoding="utf-8") as f:
with open(path, "r") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
@@ -104,44 +95,9 @@ def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
return snapshots
def _make_minimal_state(snap: Dict[str, Any]) -> Dict[str, Any]:
"""Build a minimal prev/curr state dict from a Forge snapshot.
compute_intermediate_reward() expects a dict with a 'players' list
where each player has 'life_total', 'cards_in_hand',
'creatures_on_battlefield', etc. Forge snapshots carry a 'players'
array directly; we forward it as-is and fall back to scalar fields
when the array is absent.
"""
if "players" in snap:
return {"players": snap["players"]}
# Fallback: reconstruct a two-player view from flat snapshot fields
agent_seat = snap.get("active_seat", 0)
opp_seat = 1 - agent_seat
players = [None, None]
def _player(life_key: str, hand_key: str, creatures_key: str) -> Dict:
return {
"life_total": snap.get(life_key, 40),
"cards_in_hand": snap.get(hand_key, 0),
"creatures_on_battlefield": snap.get(creatures_key, 0),
"cards_in_graveyard": 0,
"mana_available": snap.get("mana_available", 0),
"commander_damage_taken": 0,
"total_power_on_board": 0,
}
players[agent_seat] = _player("agent_life", "agent_hand_size", "agent_creatures")
players[opp_seat] = _player("opp_life", "opp_hand_size", "opp_creatures")
return {"players": players}
def snapshots_to_episode(
snapshots: List[Dict[str, Any]],
reward_config: Optional[RewardConfig] = None,
agent_seat: int = 0,
playstyle: str = "midrange",
) -> ForgeEpisode:
"""Convert a sequence of Forge snapshots into a ForgeEpisode.
@@ -155,8 +111,6 @@ def snapshots_to_episode(
episode = ForgeEpisode(
game_id=first.get("game_id", "unknown"),
deck_names=first.get("deck_names", []),
agent_seat=agent_seat,
playstyle=playstyle,
)
rc = reward_config or RewardConfig()
@@ -176,26 +130,20 @@ def snapshots_to_episode(
except (ValueError, KeyError):
action_idx = ACTION_TO_IDX[MacroAction.PASS]
# Reward — use correct reward.py signatures
# Reward
is_done = i == len(snapshots) - 1
if is_done and "outcome" in snap:
outcome = snap["outcome"]
reward = compute_terminal_reward(
won=outcome.get("won", False),
draw=outcome.get("draw", False),
timeout=outcome.get("timeout", False),
won=snap["outcome"].get("won", False),
turns=snap.get("turn", 1),
life_remaining=snap["outcome"].get("life_remaining", 0),
config=rc,
)
else:
# Build minimal prev/curr dicts for intermediate reward
prev_snap = snapshots[i - 1] if i > 0 else snap
prev_state = _make_minimal_state(prev_snap)
curr_state = _make_minimal_state(snap)
reward = compute_intermediate_reward(
prev_state=prev_state,
curr_state=curr_state,
action_taken=action_label,
agent_seat=agent_seat,
life_delta=snap.get("life_delta", 0),
cards_drawn=snap.get("cards_drawn", 0),
creatures_played=snap.get("creatures_played", 0),
config=rc,
)
@@ -206,7 +154,6 @@ def snapshots_to_episode(
log_prob=snap.get("log_prob", 0.0),
value=snap.get("value", 0.0),
done=is_done,
snapshot=snap,
))
episode.total_turns = snapshots[-1].get("turn", len(snapshots))
@@ -216,61 +163,6 @@ def snapshots_to_episode(
return episode
# ══════════════════════════════════════════════════════════
# DecisionExporter adapter for Forge episodes
# ══════════════════════════════════════════════════════════
def _export_forge_episode(episode: ForgeEpisode, exporter: DecisionExporter) -> None:
"""Feed a completed ForgeEpisode into the DecisionExporter.
Translates each ForgeTransition back into the game_state_snapshot
format expected by DecisionExporter.record_step(), then finalises
the episode so the exporter can apply its win/length gates.
The raw snapshot stored on each ForgeTransition is used directly
when available; otherwise a minimal dict is synthesised from the
transition's action index and reward so the schema is always valid.
"""
exporter.begin_episode(
agent_seat=episode.agent_seat,
playstyle=episode.playstyle,
)
for t in episode.transitions:
if t.snapshot is not None:
game_state = _make_minimal_state(t.snapshot)
# Carry through turn/phase/active_seat if present
game_state["turn"] = t.snapshot.get("turn", 1)
game_state["phase"] = t.snapshot.get("phase", "main_1")
game_state["active_seat"] = t.snapshot.get("active_seat", episode.agent_seat)
else:
# Minimal fallback — exporter only needs players list for schema
game_state = {
"turn": 1,
"phase": "main_1",
"active_seat": episode.agent_seat,
"players": [
{"life_total": 40, "cards_in_hand": 0, "creatures_on_battlefield": 0,
"cards_in_graveyard": 0, "mana_available": 0,
"commander_damage_taken": 0, "total_power_on_board": 0, "commander_tax": 0},
{"life_total": 40, "cards_in_hand": 0, "creatures_on_battlefield": 0,
"cards_in_graveyard": 0, "mana_available": 0,
"commander_damage_taken": 0, "total_power_on_board": 0, "commander_tax": 0},
],
}
exporter.record_step(
game_state_snapshot=game_state,
action_idx=t.action,
reward=t.reward,
)
exporter.end_episode(
won=episode.won,
episode_return=episode.total_reward,
)
# ══════════════════════════════════════════════════════════
# Async Episode Queue (producer-consumer)
# ══════════════════════════════════════════════════════════
@@ -379,7 +271,6 @@ async def ppo_consumer(
queue: ForgeEpisodeQueue,
buffer_size: int = 64,
on_batch_ready=None,
exporter: Optional[DecisionExporter] = None,
) -> int:
"""Consumer coroutine: pulls episodes from the queue and batches
them for PPO training updates.
@@ -388,11 +279,6 @@ async def ppo_consumer(
queue: ForgeEpisodeQueue to consume from
buffer_size: number of episodes per training batch
on_batch_ready: callback(episodes) when a batch is full
exporter: Optional DecisionExporter — when provided, every
consumed episode is fed into the exporter so winning
Forge games accumulate in the distillation JSONL pool.
Call exporter.flush() after the pipeline completes to
write the JSONL file.
Returns total episodes consumed.
"""
@@ -406,14 +292,6 @@ async def ppo_consumer(
break
continue
# Feed into distillation exporter (win-gate applied inside exporter)
if exporter is not None:
try:
_export_forge_episode(episode, exporter)
except Exception as exc:
logger.warning("[CONSUMER] Exporter error for episode %s: %s",
episode.game_id, exc)
batch.append(episode)
total_consumed += 1
@@ -444,24 +322,12 @@ async def run_forge_pipeline(
max_queue: int = 256,
on_batch_ready=None,
reward_config: Optional[RewardConfig] = None,
exporter: Optional[DecisionExporter] = None,
) -> Dict[str, Any]:
) -> Dict[str, int]:
"""Run the full Forge producer → PPO consumer pipeline.
The Forge Java batch must be started separately (via forge_runner.py
or MultiThreadBatchRunner). This function handles the Python side:
reading JSONL output and feeding episodes into the PPO trainer.
If an exporter is provided, winning Forge episodes are recorded and
flushed to a PPO JSONL file at the end of the pipeline run. The
output path is returned in the stats dict under "exporter_file".
Example::
from ml.training.decision_exporter import DecisionExporter, ExporterConfig
exporter = DecisionExporter(ExporterConfig(model_version="gen-1"))
stats = await run_forge_pipeline(num_games=128, exporter=exporter)
print(stats["exporter_file"]) # data/results/ml-decisions-ppo-<id>.jsonl
"""
queue = ForgeEpisodeQueue(max_size=max_queue)
@@ -470,25 +336,16 @@ async def run_forge_pipeline(
reward_config=reward_config)
)
consumer_task = asyncio.create_task(
ppo_consumer(queue, buffer_size, on_batch_ready, exporter=exporter)
ppo_consumer(queue, buffer_size, on_batch_ready)
)
produced = await producer_task
queue.close()
consumed = await consumer_task
# Flush exporter to disk and capture output path
exporter_file = None
if exporter is not None:
exporter_file = exporter.flush()
if exporter_file:
logger.info("[PIPELINE] Distillation export: %s stats=%s",
exporter_file, exporter.stats)
stats: Dict[str, Any] = {
stats = {
"produced": produced,
"consumed": consumed,
"exporter_file": exporter_file,
**queue.stats,
}
logger.info("[PIPELINE] Complete: %s", stats)
+1 -1
View File
@@ -696,7 +696,7 @@ class PPOTrainer:
self.save_checkpoint()
history_path = os.path.join(cfg.checkpoint_dir, "ppo_history.json")
with open(history_path, "w", encoding="utf-8") as f:
with open(history_path, "w") as f:
json.dump(self.history, f, indent=2, default=str)
summary = {
+4 -10
View File
@@ -322,7 +322,7 @@ class SupervisedTrainer:
}
serializable_history.append(entry)
with open(history_path, "w", encoding="utf-8") as f:
with open(history_path, "w") as f:
json.dump(serializable_history, f, indent=2)
logger.info(" History saved: %s", history_path)
@@ -547,7 +547,7 @@ def get_best_device() -> str:
return "cpu"
if torch.cuda.is_available():
name = torch.cuda.get_device_name(0)
mem = torch.cuda.get_device_properties(0).total_memory / (1024**3)
mem = torch.cuda.get_device_properties(0).total_mem / (1024**3)
logger.info(" CUDA device: %s (%.1f GB)", name, mem)
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
@@ -562,12 +562,6 @@ def get_best_device() -> str:
# ══════════════════════════════════════════════════════════
def main():
# Ensure logger output is visible on console
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
)
parser = argparse.ArgumentParser(
description="Commander AI Lab — Train Policy Network"
)
@@ -644,7 +638,7 @@ def main():
# Save evaluation results
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w", encoding="utf-8") as f:
with open(eval_path, "w") as f:
json.dump(results, f, indent=2)
logger.info(f" Results saved: {eval_path}")
return
@@ -702,7 +696,7 @@ def main():
print_evaluation(results)
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w", encoding="utf-8") as f:
with open(eval_path, "w") as f:
json.dump(results, f, indent=2)
logger.info(" Eval results saved: %s", eval_path)
else:
+1 -2
View File
@@ -9,7 +9,7 @@ class StartRequest(BaseModel):
numGames: int = 100
threads: int = 4
seed: Optional[int] = None
clock: int = 5000
clock: int = 300
deckSources: Optional[list[Optional[dict]]] = None
useLearnedPolicy: bool = False
policyStyle: str = "midrange"
@@ -170,7 +170,6 @@ class CoachGoalsRequest(BaseModel):
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
class DeckGenV3SubstituteRequest(BaseModel):
card_name: str
substitute_name: str
+131 -159
View File
@@ -1,159 +1,131 @@
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
from __future__ import annotations
import json
import logging
import os
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
# ── .env bootstrap ────────────────────────────────────────────
# Load .env automatically so plain `python script.py` and bare uvicorn
# both pick up FORGE_DIR, PRECON_DIR, etc. without a manual load_dotenv
# call at every entry point. override=False means real env vars always win.
try:
from dotenv import load_dotenv as _load_dotenv
_here = Path(__file__).resolve().parent
for _candidate in [_here, _here.parent, _here.parent.parent]:
_env_file = _candidate / ".env"
if _env_file.is_file():
_load_dotenv(dotenv_path=_env_file, override=False)
break
except ImportError:
pass # python-dotenv not installed; rely on real environment variables
# ─────────────────────────────────────────────────────────────
log = logging.getLogger("commander_ai_lab.api")
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
class Config:
"""Runtime configuration -- set via CLI args or env vars."""
forge_jar: str = ""
forge_dir: str = ""
forge_decks_dir: str = ""
user_decks_dir: str = ""
lab_jar: str = ""
precon_dir: str = ""
results_dir: str = "results"
port: int = 8080
ximilar_api_key: str = ""
pplx_api_key: str = ""
anthropic_api_key: str = ""
CFG = Config()
# Pre-populate from environment so uvicorn/ASGI mode works without CLI args.
# CLI args (lab_api.py main()) will override these if provided.
CFG.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
CFG.ximilar_api_key = os.environ.get("XIMILAR_API_KEY", "")
CFG.pplx_api_key = os.environ.get("PPLX_API_KEY", "")
CFG.forge_jar = os.environ.get("FORGE_JAR", "")
CFG.forge_dir = os.environ.get("FORGE_DIR", "")
CFG.lab_jar = os.environ.get("LAB_JAR", "")
# PRECON_DIR / PRECON_DECKS_DIR are aliases -- prefer PRECON_DIR
CFG.precon_dir = os.environ.get("PRECON_DIR") or os.environ.get("PRECON_DECKS_DIR", "")
CFG.forge_decks_dir = os.environ.get("FORGE_DECKS_DIR", "")
CFG.user_decks_dir = os.environ.get(
"USER_DECKS_DIR",
os.path.join(os.path.dirname(__file__), "..", "user-decks")
)
def validate_forge_config() -> None:
"""Warn clearly at startup if FORGE_DIR is misconfigured."""
if not CFG.forge_dir:
log.warning("FORGE_DIR not set -- Forge card enrichment disabled. "
"Set FORGE_DIR to your forge-repo root or forge-gui directory.")
return
base = Path(CFG.forge_dir)
a = base / "forge-gui" / "res" / "cardsfolder"
b = base / "res" / "cardsfolder"
if not a.is_dir() and not b.is_dir():
log.warning(
"FORGE_DIR=%s is set but cardsfolder not found at:\n"
" %s\n %s\n"
"Check that FORGE_DIR points to forge-repo root or forge-gui/.",
CFG.forge_dir, a, b
)
else:
resolved = a if a.is_dir() else b
log.info("Forge cardsfolder confirmed at %s", resolved)
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
class BatchState:
def __init__(self, batch_id: str, total_games: int, threads: int):
self.batch_id = batch_id
self.total_games = total_games
self.threads = threads
self.completed_games = 0
self.running = True
self.start_time = datetime.now()
self.elapsed_ms = 0
self.result_path: Optional[str] = None
self.error: Optional[str] = None
self.process: Optional[subprocess.Popen] = None
self.log_lines: list = []
self.sims_per_sec: float = 0.0
import asyncio as _asyncio
active_batches: dict[str, BatchState] = {}
active_batches_lock = _asyncio.Lock()
COMMANDER_META: dict = {}
BUILTIN_COMMANDERS: dict = {
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
}
def load_commander_meta() -> None:
"""Load commander meta mapping from file or fall back to builtins."""
meta_path = Path(__file__).parent.parent / "commander-meta.json"
if meta_path.exists():
try:
with open(meta_path, "r", encoding="utf-8") as f:
COMMANDER_META.clear()
COMMANDER_META.update(json.load(f))
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
return
except Exception as e:
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
COMMANDER_META.clear()
COMMANDER_META.update(BUILTIN_COMMANDERS)
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
# ══════════════════════════════════════════════════════════════
# AI Profiles
# ══════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {"name": "default", "description": "Balanced — Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
"aggro": {"name": "aggro", "description": "Aggressive — attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
"control": {"name": "control", "description": "Control — defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
"combo": {"name": "combo", "description": "Combo — ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
"midrange": {"name": "midrange", "description": "Midrange — flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
}
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
from __future__ import annotations
import json
import logging
import os
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
log = logging.getLogger("commander_ai_lab.api")
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
class Config:
"""Runtime configuration -- set via CLI args or env vars."""
forge_jar: str = ""
forge_dir: str = ""
forge_decks_dir: str = ""
lab_jar: str = ""
precon_dir: str = ""
results_dir: str = "results"
port: int = 8080
ximilar_api_key: str = ""
pplx_api_key: str = ""
anthropic_api_key: str = ""
CFG = Config()
# Pre-populate from env so ASGI mode (uvicorn lab_api:app) works without main()
CFG.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
def validate_forge_config() -> None:
"""Warn clearly at startup if FORGE_DIR is misconfigured."""
if not CFG.forge_dir:
log.warning("FORGE_DIR not set -- Forge card enrichment disabled. "
"Set FORGE_DIR to your forge-repo root or forge-gui directory.")
return
base = Path(CFG.forge_dir)
a = base / "forge-gui" / "res" / "cardsfolder"
b = base / "res" / "cardsfolder"
if not a.is_dir() and not b.is_dir():
log.warning(
"FORGE_DIR=%s is set but cardsfolder not found at:\n"
" %s\n %s\n"
"Check that FORGE_DIR points to forge-repo root or forge-gui/.",
CFG.forge_dir, a, b
)
else:
resolved = a if a.is_dir() else b
log.info("Forge cardsfolder confirmed at %s", resolved)
CFG.ximilar_api_key = os.environ.get("XIMILAR_API_KEY", "")
CFG.pplx_api_key = os.environ.get("PPLX_API_KEY", "")
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
class BatchState:
def __init__(self, batch_id: str, total_games: int, threads: int):
self.batch_id = batch_id
self.total_games = total_games
self.threads = threads
self.completed_games = 0
self.running = True
self.start_time = datetime.now()
self.elapsed_ms = 0
self.result_path: Optional[str] = None
self.error: Optional[str] = None
self.process: Optional[subprocess.Popen] = None
self.log_lines: list = []
self.sims_per_sec: float = 0.0
import asyncio as _asyncio
active_batches: dict[str, BatchState] = {}
active_batches_lock = _asyncio.Lock()
COMMANDER_META: dict = {}
BUILTIN_COMMANDERS: dict = {
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
}
def load_commander_meta() -> None:
"""Load commander meta mapping from file or fall back to builtins."""
meta_path = Path(__file__).parent.parent / "commander-meta.json"
if meta_path.exists():
try:
with open(meta_path, "r", encoding="utf-8") as f:
COMMANDER_META.clear()
COMMANDER_META.update(json.load(f))
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
return
except Exception as e:
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
COMMANDER_META.clear()
COMMANDER_META.update(BUILTIN_COMMANDERS)
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
# ══════════════════════════════════════════════════════════════
# AI Profiles
# ══════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {"name": "default", "description": "Balanced \u2014 Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
"aggro": {"name": "aggro", "description": "Aggressive \u2014 attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
"control": {"name": "control", "description": "Control \u2014 defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
"combo": {"name": "combo", "description": "Combo \u2014 ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
"midrange": {"name": "midrange", "description": "Midrange \u2014 flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
}
+5 -19
View File
@@ -50,7 +50,6 @@ DEFAULT_CONFIG = {
"train_lr": 0.001, # learning rate
"train_batch_size": 256, # training batch size
"train_patience": 15, # early stopping patience
"train_ds_weight": 4.0, # DeepSeek source upsampling weight (ds-*.jsonl files)
# Java-only settings (ignored for DeepSeek):
"java_threads": 4,
"java_clock": 300,
@@ -280,21 +279,14 @@ def run_update_weights(cfg):
return False
def start_training(base_url, epochs, lr, batch_size, patience, ds_weight=4.0):
"""Trigger ML training and return immediately.
ds_weight: upsampling multiplier for DeepSeek decision files
(ml-decisions-ds-*.jsonl). Matches the default in
DatasetConfig.source_weights added in the recent
dataset_builder.py update.
"""
def start_training(base_url, epochs, lr, batch_size, patience):
"""Trigger ML training and return immediately."""
resp = api_post(base_url, "/api/ml/train", {
"epochs": epochs,
"lr": lr,
"batchSize": batch_size,
"patience": patience,
"rebuildDataset": True,
"dsWeight": ds_weight,
})
return resp
@@ -424,7 +416,6 @@ def run_overnight(cfg):
log.info(f" Engine: {engine}")
log.info(f" Auto-update weights: {'Yes' if cfg['auto_update_weights'] else 'No'}")
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
log.info(f" DS upsampling weight: {cfg['train_ds_weight']}x")
log.info("")
while True:
@@ -519,8 +510,7 @@ def run_overnight(cfg):
log.info("Starting ML training pipeline...")
log.info(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}, "
f"DS Weight: {cfg['train_ds_weight']}x")
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
try:
start_training(
@@ -529,7 +519,6 @@ def run_overnight(cfg):
lr=cfg["train_lr"],
batch_size=cfg["train_batch_size"],
patience=cfg["train_patience"],
ds_weight=cfg["train_ds_weight"],
)
log.info("Training started, monitoring progress...")
success = wait_for_training(base_url)
@@ -567,8 +556,8 @@ Examples:
# Just run sims, skip both weight update and training:
python overnight-run.py --no-train --no-update-weights
# Custom training settings with higher DS upsampling:
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20 --ds-weight 6.0
# Custom training settings:
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
""",
)
parser.add_argument("--url", default="http://localhost:8080",
@@ -601,8 +590,6 @@ Examples:
help="Training batch size (default: 256)")
parser.add_argument("--patience", type=int, default=15,
help="Early stopping patience (default: 15)")
parser.add_argument("--ds-weight", type=float, default=4.0,
help="DeepSeek source upsampling weight for training (default: 4.0)")
parser.add_argument("--threads", type=int, default=4,
help="Java engine threads (default: 4)")
parser.add_argument("--clock", type=int, default=300,
@@ -624,7 +611,6 @@ Examples:
cfg["train_lr"] = args.lr
cfg["train_batch_size"] = args.batch_size
cfg["train_patience"] = args.patience
cfg["train_ds_weight"] = args.ds_weight
cfg["java_threads"] = args.threads
cfg["java_clock"] = args.clock
-68
View File
@@ -1,68 +0,0 @@
import re
with open('ml/eval/forge_evaluator.py', 'r', encoding='utf-8') as f:
content = f.read()
# Fix 1: >= 2 to >= 3
content = content.replace('deck_files) >= 2', 'deck_files) >= 3')
# Fix 2: replace call to _run_forge_via_subprocess with _parse_batch_json
content = content.replace(
'return self._run_forge_via_subprocess(service)',
'return self._parse_batch_json(service)'
)
# Fix 3: Insert _parse_batch_json method before _run_forge_via_subprocess
new_method = '''
def _parse_batch_json(self, service) -> List[GameRecord]:
"""Parse the batch JSON output from LabCli Java subprocess."""
batch_path = os.path.join(
self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"
)
if not Path(batch_path).exists():
logger.warning("[Eval] Batch JSON not found at %s -- falling back to synthetic", batch_path)
return self._run_synthetic_eval(service)
with open(batch_path, encoding='utf-8') as f:
batch = json.load(f)
games_list = batch.get("games", [])
if not games_list:
logger.warning("[Eval] No games in batch JSON -- falling back to synthetic")
return self._run_synthetic_eval(service)
records = []
for idx, g in enumerate(games_list):
won = g.get("winningSeat", -1) == 0
turns = g.get("totalTurns", 0)
pr = g.get("playerResults", [])
lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40
lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40
records.append(GameRecord(
game_index=idx,
won=won,
turns=turns,
life_final_policy=lp,
life_final_opponent=lo,
life_delta=lp - lo,
commander_damage_dealt=0,
illegal_actions=0,
entropy_mean=0.0,
inference_ms_mean=0.0,
decision_steps=turns,
))
if len(records) >= self.cfg.num_games:
break
logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records))
return records
'''
content = content.replace(
' def _run_forge_via_subprocess(',
new_method + ' def _run_forge_via_subprocess('
)
with open('ml/eval/forge_evaluator.py', 'w', encoding='utf-8') as f:
f.write(content)
import py_compile
py_compile.compile('ml/eval/forge_evaluator.py', doraise=True)
print('Syntax OK - patch applied')
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env python3
"""
Phase 1 patch: no-op mana pool display functions in lan-client/ui.js
Run from anywhere — edit UI_JS_PATH to point at your file.
"""
import re
UI_JS_PATH = r"D:\ForgeCommander\commander-ai-lab\lan-client\ui.js"
with open(UI_JS_PATH, "r", encoding="utf-8") as f:
content = f.read()
original_size = len(content)
changes = []
# ── Change 1: no-op renderManaPool ───────────────────────────────────────────
old1 = re.search(
r'function renderManaPool\(playerIdx\) \{[^}]*?display\.style\.display[^}]*?\}',
content, re.DOTALL
)
if old1:
content = content[:old1.start()] + \
"function renderManaPool(playerIdx) {\n // mana pool display removed (Phase 1)\n}" + \
content[old1.end():]
changes.append("renderManaPool -> stub")
else:
print("WARNING: renderManaPool pattern not matched — skipping")
# ── Change 2: no-op showManaPool ─────────────────────────────────────────────
old2 = re.search(
r'function showManaPool\(\) \{.*?\}',
content, re.DOTALL
)
if old2:
content = content[:old2.start()] + \
"function showManaPool() {\n // mana pool display removed (Phase 1)\n}" + \
content[old2.end():]
changes.append("showManaPool -> stub")
else:
print("WARNING: showManaPool pattern not matched — skipping")
# ── Change 3: remove Mana Auto-Clear row from renderSettingsPanel ─────────────
mana_row = (
" +\n '<div class=\"settings-row\"><span>Mana Auto-Clear</span>'"
" + renderToggle('manaAutoClear', gameState.manaAutoClear) + '</div>';"
)
if mana_row in content:
content = content.replace(mana_row, ";", 1)
changes.append("Mana Auto-Clear row removed from renderSettingsPanel")
else:
print("WARNING: Mana Auto-Clear row not found — skipping")
# ── Write ─────────────────────────────────────────────────────────────────────
if changes:
with open(UI_JS_PATH, "w", encoding="utf-8") as f:
f.write(content)
print(f"Done. {original_size} -> {len(content)} chars ({len(content)-original_size:+d})")
for c in changes:
print(f" ✓ {c}")
else:
print("No changes applied.")
+4 -20
View File
@@ -245,20 +245,6 @@ async def add_deck_card(deck_id: int, req: AddDeckCardRequest):
).fetchone()
card_name = ce_row["name"] if ce_row else (req.card_name or "")
# Ensure card_records has type_line for this card (Scryfall lookup if missing)
if card_name:
existing_cr = conn.execute(
"SELECT type_line FROM card_records WHERE name = ? LIMIT 1", (card_name,)
).fetchone()
if not existing_cr or not existing_cr["type_line"]:
enriched = _enrich_from_scryfall(card_name)
if enriched and enriched.get("type_line"):
conn.execute(
"INSERT OR IGNORE INTO card_records (name, type_line, oracle_text, cmc) VALUES (?,?,?,?)",
(card_name, enriched["type_line"], enriched.get("oracle_text", ""), enriched.get("cmc", 0))
)
conn.commit()
existing = conn.execute(
"SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
(deck_id, req.scryfall_id)
@@ -796,12 +782,10 @@ async def export_deck_to_sim(deck_id: int):
if not safe_name:
safe_name = f"deck_{deck_id}"
save_dir = (
CFG.user_decks_dir
or CFG.forge_decks_dir
or os.path.join(os.path.dirname(__file__), "..", "user-decks")
)
os.makedirs(save_dir, exist_ok=True)
save_dir = CFG.forge_decks_dir
if not save_dir or not os.path.isdir(save_dir):
save_dir = os.path.join(os.path.dirname(__file__), "exported-decks")
os.makedirs(save_dir, exist_ok=True)
out_path = Path(save_dir) / f"{safe_name}.dck"
out_path.write_text(content, encoding="utf-8")
+7 -8
View File
@@ -861,13 +861,12 @@ async def _call_anthropic_api(messages: list[dict], max_tokens: int = 4096, temp
else:
if not CFG.anthropic_api_key:
raise HTTPException(400, 'Anthropic Claude API key not configured. Set anthropic_api_key env var or --anthropic-key.')
base_url = 'https://api.anthropic.com'
model = ANTHROPIC_MODEL
headers = {
base_url = 'https://api.anthropic.com'
model = ANTHROPIC_MODEL
headers = {
'Content-Type': 'application/json',
'x-api-key': CFG.anthropic_api_key,
'anthropic-version': '2023-06-01',
}
'x-api-key': CFG.anthropic_api_key, 'anthropic-version': '2023-06-01',
}
# Separate system messages for Anthropic API format
system_msgs = [m['content'] for m in messages if m.get('role') == 'system']
user_msgs = [m for m in messages if m.get('role') != 'system']
@@ -1321,7 +1320,7 @@ async def deck_gen_v3_status():
return {
'initialized': _coach._deck_gen_v3 is not None,
'anthropic_configured': bool(CFG.anthropic_api_key),
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'anthropic_client', None), 'model', 'claude-opus-4-20250514')) if _coach._deck_gen_v3 else None,
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'anthropic_client', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
'embeddings_loaded': False,
'embedding_cards': 0,
'error': _coach._deck_gen_v3_error,
@@ -1342,7 +1341,7 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
6. Return complete deck with substitution data
"""
if _coach._deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure the Anthropic API key is configured.')
raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure Ollama is running with gpt-oss:20b model.')
if not req.commander_name or len(req.commander_name.strip()) < 2:
raise HTTPException(400, 'Commander name is required (min 2 chars)')
+1 -1
View File
@@ -226,7 +226,7 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
os.makedirs(results_dir, exist_ok=True)
batch_path = os.path.join(results_dir, f'ml-decision-sim-{sim_id}.json')
with open(batch_path, 'w', encoding='utf-8') as f:
with open(batch_path, 'w') as f:
json.dump(batch_data, f, indent=2, default=str)
logger.info(f'Saved batch results to {batch_path}')
+65 -209
View File
@@ -1,29 +1,11 @@
"""Live game session routes.
Endpoints:
POST /api/game/start -- create and launch a live Forge game session
POST /api/game/action -- submit a human player action (called by HumanActionBar)
GET /api/game/{game_id}/status -- poll session status and last known game state
GET /api/game/{game_id}/actions -- Forge IPC: drain pending human actions
GET /api/game/sessions -- list active sessions (debug)
Forge IPC flow
--------------
1. POST /api/game/start
- Validates seats, resolves deck names
- Picks a playstyle per AI seat from seat_type (e.g. "AI Aggro" -> "aggro")
- Calls run_batch_subprocess(num_games=1, use_learned_policy=True, ...)
which builds the Java command with --policy <style> --policyServer http://localhost:8080
and spawns the Forge JAR in a background thread
- Returns game_id + ws_url immediately (non-blocking)
2. Forge JAR (once launched) calls POST /api/policy/decide on every AI decision
3. Human actions are queued via POST /api/game/action and drained by
GET /api/game/{game_id}/actions (polled by the Forge IPC loop inside the JAR)
4. GET /api/game/{game_id}/status returns live log tail + completion state
POST /api/game/start — create a new live game session (called by LobbySetupModal)
POST /api/game/action — submit a human player action (called by HumanActionBar)
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from typing import List, Optional
@@ -31,39 +13,14 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from models.state import BatchState
from services.forge_runner import run_batch_subprocess
log = logging.getLogger("routes.game")
router = APIRouter(tags=["game"])
# ---------------------------------------------------------------------------
# Playstyle mapping: seat_type string -> policy playstyle token
# ---------------------------------------------------------------------------
_PLAYSTYLE_MAP: dict[str, str] = {
"ai aggro": "aggro",
"ai control": "control",
"ai combo": "combo",
"ai political": "political",
"ai midrange": "midrange",
"ai": "midrange", # bare "AI" seat -> midrange default
"human": None, # human seats skipped for policy
}
def _seat_playstyle(seat_type: str) -> Optional[str]:
"""Map a SeatConfig.seat_type string to a policy playstyle token.
Returns None for human seats (no policy needed).
"""
return _PLAYSTYLE_MAP.get(seat_type.lower().strip(), "midrange")
# ---------------------------------------------------------------------------
# ───────────────────────────────────────────────────────────────
# Request / Response models
# ---------------------------------------------------------------------------
# ───────────────────────────────────────────────────────────────
class SeatConfig(BaseModel):
seat_index: int = 0
@@ -74,16 +31,12 @@ class SeatConfig(BaseModel):
class StartGameRequest(BaseModel):
seats: List[SeatConfig] = Field(default_factory=list)
clock: int = Field(default=6000, description="Per-game wall-clock limit in seconds (0 = unlimited)")
use_learned_policy: bool = Field(default=True, description="Route AI decisions through /api/policy/decide")
policy_greedy: bool = Field(default=False, description="Use greedy (argmax) action selection instead of sampling")
class StartGameResponse(BaseModel):
game_id: str
status: str = "started"
ws_url: str
policy_enabled: bool = False
class GameActionRequest(BaseModel):
@@ -101,139 +54,73 @@ class GameActionResponse(BaseModel):
action: str
# ---------------------------------------------------------------------------
# In-memory session store
# ---------------------------------------------------------------------------
# ───────────────────────────────────────────────────────────────
# In-memory session store (replace with DB / Redis for persistence)
# ───────────────────────────────────────────────────────────────
# { game_id: { "seats": [...], "status": "active"|"ended"|"error",
# "batch_state": BatchState|None, "pending_actions": [...] } }
# { game_id: { "seats": [...], "status": "active" | "ended", "pending_actions": [...] } }
_sessions: dict[str, dict] = {}
# ---------------------------------------------------------------------------
# ───────────────────────────────────────────────────────────────
# POST /api/game/start
# ---------------------------------------------------------------------------
# ───────────────────────────────────────────────────────────────
@router.post("/api/game/start", response_model=StartGameResponse)
async def start_game(req: StartGameRequest):
"""Create and launch a live Forge game session.
"""Create a new live game session.
Resolves deck names from the seat config, launches the Forge JAR
as a background subprocess with --policy routing enabled (unless
use_learned_policy=False), and returns a game_id immediately.
Called by Unity's LobbySetupModal after the player configures seats.
Returns a game_id that Unity uses to connect to the WebSocket at
/ws/game/{game_id}.
The Forge JAR will begin calling POST /api/policy/decide for every
AI seat decision as soon as it is up. Human actions are queued via
POST /api/game/action and polled by the JAR via GET /api/game/{id}/actions.
TODO: Wire Forge engine launch here once the IPC layer is ready.
For now, creates a session record and returns the game_id so Unity
can connect and receive state pushes from the Forge IPC client.
"""
if not req.seats:
raise HTTPException(status_code=400, detail="At least one seat is required.")
# Collect deck names from seats that have one, preserving seat order.
decks = [s.deck_name for s in req.seats if s.deck_name.strip()]
if not decks:
raise HTTPException(status_code=400, detail="At least one seat must have a deck_name.")
# Determine playstyle from the first AI seat (the JAR uses a single global
# playstyle for its --policy flag; per-seat styles are a future extension).
policy_style = "midrange"
for seat in req.seats:
ps = _seat_playstyle(seat.seat_type)
if ps is not None: # skip human seats
policy_style = ps
break
game_id = uuid.uuid4().hex
# Build a BatchState so the forge_runner watchdog and progress tracking
# work the same way they do for overnight batch runs.
state = BatchState(batch_id=game_id, total_games=1, threads=1)
_sessions[game_id] = {
"game_id": game_id,
"seats": [s.dict() for s in req.seats],
"status": "active",
"game_id": game_id,
"seats": [s.dict() for s in req.seats],
"status": "active",
"pending_actions": [],
"batch_state": state,
}
log.info(
"Game session created: game_id=%s seats=%d decks=%s policy=%s greedy=%s",
game_id, len(req.seats), decks, policy_style if req.use_learned_policy else "off",
req.policy_greedy,
)
# Launch Forge subprocess in the background (non-blocking).
# num_games=1 -> single live game, threads=1, no seed.
# clock is passed through from the request (default 6000s for live play).
asyncio.create_task(
_run_and_finalize(
game_id=game_id,
state=state,
decks=decks,
clock=req.clock,
use_learned_policy=req.use_learned_policy,
policy_style=policy_style,
policy_greedy=req.policy_greedy,
)
"Game session created: game_id=%s seats=%d",
game_id,
len(req.seats),
)
return StartGameResponse(
game_id=game_id,
status="started",
ws_url=f"/ws/game/{game_id}",
policy_enabled=req.use_learned_policy,
)
async def _run_and_finalize(
game_id: str,
state: BatchState,
decks: list,
clock: int,
use_learned_policy: bool,
policy_style: str,
policy_greedy: bool,
):
"""Background task: run Forge subprocess then mark session ended."""
try:
await run_batch_subprocess(
state=state,
decks=decks,
num_games=1,
threads=1,
seed=None,
clock=clock,
output_path=f"results/live-{game_id}.json",
use_learned_policy=use_learned_policy,
policy_style=policy_style,
policy_greedy=policy_greedy,
)
except Exception as exc:
log.error("Game %s subprocess error: %s", game_id, exc)
if game_id in _sessions:
_sessions[game_id]["status"] = "error"
return
if game_id in _sessions:
_sessions[game_id]["status"] = "ended" if not state.error else "error"
log.info(
"Game %s finished: status=%s error=%s",
game_id, _sessions[game_id]["status"], state.error or "none",
)
# ---------------------------------------------------------------------------
# ───────────────────────────────────────────────────────────────
# POST /api/game/action
# ---------------------------------------------------------------------------
# ───────────────────────────────────────────────────────────────
@router.post("/api/game/action", response_model=GameActionResponse)
async def game_action(req: GameActionRequest):
"""Submit a human player action during a live game.
Called by Unity's HumanActionBar when the human seat has priority.
The action is queued in the session; the Forge IPC polling loop
drains it via GET /api/game/{game_id}/actions.
Called by Unity's HumanActionBar when seat 0 (human) has priority.
The action is queued in the session's pending_actions list, where
the Forge IPC polling loop will pick it up.
Valid actions:
pass_priority — pass priority to the next player
end_turn — end the current turn
declare_attackers — declare attacking creatures
declare_blockers — declare blocking creatures
select_target — select a target (requires target_id)
"""
valid_actions = {
"pass_priority", "end_turn",
@@ -255,73 +142,47 @@ async def game_action(req: GameActionRequest):
session = _sessions.get(req.game_id)
if session is None:
raise HTTPException(status_code=404, detail=f"Game session '{req.game_id}' not found.")
raise HTTPException(
status_code=404,
detail=f"Game session '{req.game_id}' not found.",
)
if session["status"] != "active":
raise HTTPException(
status_code=409,
detail=f"Game '{req.game_id}' is not active (status={session['status']}).",
detail=f"Game session '{req.game_id}' is not active (status={session['status']}).",
)
session["pending_actions"].append({
"seat": req.seat,
"action": req.action,
action_record = {
"seat": req.seat,
"action": req.action,
"target_id": req.target_id,
"extra": req.extra,
})
log.info("Action queued: game_id=%s seat=%d action=%s", req.game_id, req.seat, req.action)
return GameActionResponse(status="ok", game_id=req.game_id, action=req.action)
# ---------------------------------------------------------------------------
# GET /api/game/{game_id}/status
# ---------------------------------------------------------------------------
@router.get("/api/game/{game_id}/status")
async def game_status(game_id: str):
"""Return live status, log tail, and completion info for a game session.
Useful for polling from the frontend or a test script to track
whether Forge has finished launching and started calling /decide.
"""
session = _sessions.get(game_id)
if session is None:
raise HTTPException(status_code=404, detail=f"Game session '{game_id}' not found.")
state: Optional[BatchState] = session.get("batch_state")
log_tail = []
completed = 0
total = 1
error = None
if state:
log_tail = state.log_lines[-20:] # last 20 lines
completed = state.completed_games
total = max(state.total_games, 1)
error = state.error
return {
"game_id": game_id,
"status": session["status"],
"seats": session["seats"],
"progress": {"completed": completed, "total": total},
"log_tail": log_tail,
"error": error,
"pending_actions": len(session["pending_actions"]),
"extra": req.extra,
}
session["pending_actions"].append(action_record)
log.info(
"Action queued: game_id=%s seat=%d action=%s",
req.game_id, req.seat, req.action,
)
return GameActionResponse(
status="ok",
game_id=req.game_id,
action=req.action,
)
# ---------------------------------------------------------------------------
# GET /api/game/{game_id}/actions (Forge IPC polling)
# ---------------------------------------------------------------------------
# ───────────────────────────────────────────────────────────────
# GET /api/game/{game_id}/actions (Forge IPC polling endpoint)
# ───────────────────────────────────────────────────────────────
@router.get("/api/game/{game_id}/actions")
async def poll_actions(game_id: str):
"""Drain and return all pending human actions (consume-once).
"""Drain and return all pending human actions for a game session.
Called by the Forge IPC polling loop to consume human player decisions.
Clears the queue on each call (consume-once semantics).
"""
session = _sessions.get(game_id)
if session is None:
@@ -332,22 +193,17 @@ async def poll_actions(game_id: str):
return {"game_id": game_id, "actions": actions}
# ---------------------------------------------------------------------------
# ───────────────────────────────────────────────────────────────
# GET /api/game/sessions (debug)
# ---------------------------------------------------------------------------
# ───────────────────────────────────────────────────────────────
@router.get("/api/game/sessions")
async def list_sessions():
"""List all tracked game sessions (debug)."""
"""List active game sessions (debug endpoint)."""
return {
"count": len(_sessions),
"sessions": [
{
"game_id": gid,
"status": s["status"],
"seats": len(s["seats"]),
"pending": len(s["pending_actions"]),
}
{"game_id": gid, "status": s["status"], "seats": len(s["seats"])}
for gid, s in _sessions.items()
],
}
+12 -94
View File
@@ -14,8 +14,6 @@ Batch simulation & lab management endpoints:
GET /api/lab/trends/{deck_name}
GET /api/lab/log
GET /api/lab/debug-log
GET /api/lab/precons
GET /api/lab/precons/deck
"""
from __future__ import annotations
@@ -41,7 +39,6 @@ from services.deck_service import parse_dck_file, _load_deck_cards_by_name
from services.forge_runner import (
build_java_command, run_batch_subprocess, _run_deepseek_batch_thread,
)
from services.draw_game_analyzer import summarize_draw_games
from services.logging import log, log_batch
router = APIRouter(tags=["lab"])
@@ -226,18 +223,14 @@ async def get_result(batchId: Optional[str] = None):
except (json.JSONDecodeError, OSError) as e:
raise HTTPException(500, f"Failed to read result file: {e}")
# ── Inject winType into every game dict (fix #175) ──────────────────────
# ── Inject winType into every game dict (fix #180) ──────────────────────
# win_type_override is set by forge_runner._run_process_blocking() for the
# Java/Forge path. DeepSeek games already have per-game winType stamped
# directly in the result dict; _inject_win_type uses setdefault so those
# are never overwritten.
win_type_override = getattr(state, "win_type_override", "Combat")
_inject_win_type(data, win_type_override)
# ── Inject draw-game summary (fix #176) ─────────────────────────────────
# draw_game_snapshots is populated live by forge_runner._run_process_blocking
# from [DRAW] [PARSE-SUMMARY] lines. summarize_draw_games() aggregates them
# into a single dict with perDeck breakdowns and a likelyCause field so the
# dashboard can immediately show which deck is causing timeout loops.
draw_snapshots = getattr(state, "draw_game_snapshots", [])
data["drawGames"] = summarize_draw_games(draw_snapshots)
data = _sanitize_floats(data)
return JSONResponse(content=data)
@@ -259,14 +252,7 @@ async def list_decks():
try:
conn = _get_db_conn()
rows = conn.execute(
"""
SELECT d.id, d.name, d.commander_name,
COALESCE(SUM(dc.quantity), 0) AS total_cards
FROM decks d
LEFT JOIN deck_cards dc ON dc.deck_id = d.id
GROUP BY d.id
ORDER BY d.id DESC
"""
"SELECT id, name, commander_name FROM decks ORDER BY id DESC"
).fetchall()
for row in rows:
db_name = row["name"]
@@ -277,7 +263,6 @@ async def list_decks():
"source": "deckbuilder",
"deck_id": row["id"],
"commander": row["commander_name"] or "",
"totalCards": row["total_cards"],
})
seen_names.add(db_name.lower())
except Exception as e:
@@ -319,31 +304,31 @@ async def list_history():
AI_PROFILES = {
"default": {
"name": "default",
"description": "Balanced — Forge's default AI behavior",
"description": "Balanced \u2014 Forge's default AI behavior",
"aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5,
"boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5,
},
"aggro": {
"name": "aggro",
"description": "Aggressive — attacks early, prioritizes damage",
"description": "Aggressive \u2014 attacks early, prioritizes damage",
"aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3,
"boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1,
},
"control": {
"name": "control",
"description": "Control — defensive, removal-heavy, card advantage",
"description": "Control \u2014 defensive, removal-heavy, card advantage",
"aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9,
"boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9,
},
"combo": {
"name": "combo",
"description": "Combo — ramps, digs for pieces, assembles combos",
"description": "Combo \u2014 ramps, digs for pieces, assembles combos",
"aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4,
"boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7,
},
"midrange": {
"name": "midrange",
"description": "Midrange — flexible, strong board presence, value-oriented",
"description": "Midrange \u2014 flexible, strong board presence, value-oriented",
"aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6,
"boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5,
},
@@ -454,7 +439,7 @@ async def get_log(batchId: str):
state = active_batches.get(batchId)
if not state:
raise HTTPException(404, f"Batch {batchId} not found")
return {"lines": state.log_lines[-2000:]}
return {"lines": state.log_lines[-200:]}
@router.get("/api/lab/debug-log")
@@ -478,73 +463,6 @@ async def get_debug_log():
return {"path": None, "content": "No debug log found. Run a simulation first."}
# ════════════════════════════════════════════════════════════
# Precon deck endpoints (used by lan-client ai-bridge.js)
# ════════════════════════════════════════════════════════════
@router.get("/api/lab/precons")
async def list_precons():
"""Return precon deck metadata for the LAN client deck selector."""
precon_dir = Path(CFG.precon_dir) if CFG.precon_dir else None
if not precon_dir or not precon_dir.is_dir():
log.warning(f"Precon dir not found or not configured: {CFG.precon_dir!r}")
return {"precons": []}
precons = []
for f in sorted(precon_dir.glob("*.dck")):
deck_info = {
"fileName": f.name,
"deckName": f.stem,
"commander": "",
"colors": [],
"set": "",
}
# Extract commander name from the [Commander] section
try:
with open(f, "r", encoding="utf-8") as fh:
in_commander = False
for line in fh:
line = line.strip()
if line == "[Commander]":
in_commander = True
continue
if line.startswith("[") and line.endswith("]"):
in_commander = False
continue
if in_commander:
m = re.match(r"^\d+\s+(.+?)(?:\|.+)?$", line)
if m:
deck_info["commander"] = m.group(1).strip()
break
except Exception as exc:
log.debug(f"Could not parse commander from {f.name}: {exc}")
precons.append(deck_info)
return {"precons": precons}
@router.get("/api/lab/precons/deck")
async def get_precon_deck(fileName: str):
"""Return the full card list for a specific precon .dck file."""
precon_dir = Path(CFG.precon_dir) if CFG.precon_dir else None
if not precon_dir or not precon_dir.is_dir():
raise HTTPException(404, f"Precon directory not configured or missing: {CFG.precon_dir!r}")
# Sanitize — prevent path traversal
safe_name = Path(fileName).name
if not safe_name.endswith(".dck"):
raise HTTPException(400, "fileName must end with .dck")
deck_path = precon_dir / safe_name
if not deck_path.exists():
raise HTTPException(404, f"Precon deck not found: {safe_name}")
try:
deck_data = parse_dck_file(str(deck_path))
return deck_data
except Exception as e:
raise HTTPException(500, f"Failed to parse deck: {e}")
@router.post('/api/lab/start-deepseek')
async def start_batch_deepseek(request: FastAPIRequest, background_tasks: BackgroundTasks):
+11 -11
View File
@@ -130,7 +130,7 @@ async def ml_status():
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
lines = 0
try:
with open(f, encoding="utf-8") as fh:
with open(f) as fh:
lines = sum(1 for _ in fh)
except Exception:
pass
@@ -168,7 +168,7 @@ async def ml_get_decisions(filename: str, limit: int = 100, offset: int = 0):
raise HTTPException(404, f"ML decisions file not found: {filename}")
decisions = []
try:
with open(filepath, encoding="utf-8") as f:
with open(filepath) as f:
for i, line in enumerate(f):
if i < offset:
continue
@@ -436,7 +436,7 @@ def _run_training_pipeline(
best_model, _ = load_checkpoint(summary["checkpoint_path"], device)
eval_results = evaluate_model(best_model, test_states, test_labels, device)
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w", encoding="utf-8") as f:
with open(eval_path, "w") as f:
json.dump(eval_results, f, indent=2)
# Done
with _training_lock:
@@ -530,7 +530,7 @@ async def ml_data_status():
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
line_count = 0
try:
with open(f, "r", encoding="utf-8") as fh:
with open(f, "r") as fh:
line_count = sum(1 for _ in fh)
except Exception:
pass
@@ -561,7 +561,7 @@ async def ml_data_status():
eval_results = None
if eval_path.exists():
try:
with open(eval_path, "r", encoding="utf-8") as f:
with open(eval_path, "r") as f:
eval_results = json.load(f)
except Exception:
pass
@@ -705,7 +705,7 @@ def _run_tournament_pipeline(episodes: int, playstyle: str):
playstyle=playstyle,
)
output_path = 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)
with _tournament_lock:
_tournament_state.phase = "done"
@@ -758,7 +758,7 @@ async def ml_tournament_results():
project_root = Path(__file__).resolve().parent.parent
results_path = project_root / "ml" / "models" / "checkpoints" / "tournament_results.json"
if results_path.exists():
with open(results_path, "r", encoding="utf-8") as f:
with open(results_path, "r") as f:
return json.load(f)
return {"error": "No tournament results found. Run a tournament first."}
@@ -798,7 +798,7 @@ async def ml_stats():
eval_path = ckpt_dir / "eval_results.json"
if eval_path.exists():
try:
with open(eval_path, "r", encoding="utf-8") as f:
with open(eval_path, "r") as f:
eval_results = json.load(f)
except Exception:
pass
@@ -1057,14 +1057,14 @@ async def ml_distillation_history():
if history_path.exists():
try:
with open(history_path, "r", encoding="utf-8") as f:
with open(history_path, "r") as f:
result["generations"] = json.load(f)
except Exception:
pass
if summary_path.exists():
try:
with open(summary_path, "r", encoding="utf-8") as f:
with open(summary_path, "r") as f:
result["summary"] = json.load(f)
except Exception:
pass
@@ -1203,7 +1203,7 @@ async def ml_elo_history():
history_path = project_root / "data" / "elo_history.json"
if history_path.exists():
try:
with open(history_path, "r", encoding="utf-8") as f:
with open(history_path, "r") as f:
return json.load(f)
except Exception:
pass
+7 -37
View File
@@ -1,5 +1,5 @@
"""Commander AI Lab — Policy Decision Routes (Phase 2)
════════════════════════════════════════════════════════
════════════════════════════════════════════════════
Live Forge ↔ policy server IPC endpoints for online training.
These endpoints are purpose-built for the real-time decision loop
where Forge's Java side calls the Python policy server at each
@@ -24,8 +24,7 @@ import numpy as np
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
# Unique logger name so log lines are attributable to this module specifically
logger = logging.getLogger("routes.policy.decide")
logger = logging.getLogger("routes.policy")
router = APIRouter(prefix="/api/policy", tags=["policy"])
@@ -311,10 +310,7 @@ async def decide(req: DecideRequest):
req.snapshot_schema, req.resolved_turn(),
)
except Exception as e:
logger.warning(
"Precomputed vector encode failed — falling back to full encoder",
exc_info=True,
)
logger.warning("Precomputed vector encode failed (%s) — falling back", e)
state_vec = None
else:
state_vec = None
@@ -342,24 +338,6 @@ async def decide(req: DecideRequest):
if "error" in result:
raise HTTPException(500, detail=result["error"])
# ----------------------------------------------------------------
# Probability diagnostic — logs top-5 actions on every decision.
# Remove or downgrade to logger.debug once training looks healthy.
# ----------------------------------------------------------------
probs = result.get("probabilities", {})
if probs:
top5 = sorted(probs.items(), key=lambda kv: kv[1], reverse=True)[:5]
top5_str = ", ".join(f"{a}:{p:.3f}" for a, p in top5)
logger.info(
"[decide] turn=%d phase=%s → %s (conf=%.3f) | top5=[%s] | src=%s",
req.resolved_turn(),
req.phase,
result["action"],
result["confidence"],
top5_str,
vector_source,
)
log_prob = 0.0
try:
conf = result.get("confidence", 0.5)
@@ -395,7 +373,7 @@ async def collect_tuples(req: CollectRequest):
try:
_online_store.add_tuples(req.tuples)
except Exception as e:
logger.error("Failed to store tuples: %s", e, exc_info=True)
logger.error("Failed to store tuples: %s", e)
raise HTTPException(500, detail=str(e))
return {
@@ -424,7 +402,7 @@ async def submit_reward(req: RewardRequest):
winner_seat=req.winner_seat,
)
except Exception as e:
logger.error("Failed to assign reward: %s", e, exc_info=True)
logger.error("Failed to assign reward: %s", e)
raise HTTPException(500, detail=str(e))
return {
@@ -540,24 +518,16 @@ def _infer_from_vector(state_vec: np.ndarray, policy_service, temperature: float
Mirrors the core of PolicyInferenceService.predict() but accepts an
ndarray instead of a snapshot dict, avoiding a second encode() call.
Uses _unwrap_logits (same helper as predict()) to guard against
tuple/dict returns from wrapped or swapped model variants before any
arithmetic on the logits tensor.
"""
import torch
from ml.config.scope import NUM_ACTIONS, IDX_TO_ACTION
from ml.serving.policy_server import _unwrap_logits
t_start = time.time()
try:
state_tensor = torch.from_numpy(state_vec).unsqueeze(0).to(policy_service.device)
with torch.no_grad():
raw_output = policy_service.model(state_tensor)
# Guard: unwrap tuple/dict returns (DataParallel, torch.compile,
# PolicyValueNetwork swap) before temperature division / argmax.
logits = _unwrap_logits(raw_output)
logits = policy_service.model(state_tensor)
if greedy:
action_idx = logits.argmax(dim=-1).item()
@@ -585,7 +555,7 @@ def _infer_from_vector(state_vec: np.ndarray, policy_service, temperature: float
}
except Exception as e:
elapsed_ms = (time.time() - t_start) * 1000
logger.error("Direct inference error in _infer_from_vector: %s", e, exc_info=True)
logger.error("Direct inference error: %s", e)
return {"error": str(e), "inference_ms": round(elapsed_ms, 2)}
+4 -37
View File
@@ -29,31 +29,6 @@ async def list_precons():
return {"precons": PRECON_INDEX} # reads current value every call
@router.get("/api/lab/precons/deck")
async def get_precon_deck(fileName: str):
"""Return the raw decklist content of a precon .dck file.
Query: ?fileName=Elven_Empire.dck
"""
if not fileName:
raise HTTPException(400, "fileName query parameter is required")
# Sanitize: only allow .dck files, no path traversal
if '..' in fileName or '/' in fileName or '\\' in fileName:
raise HTTPException(400, "Invalid fileName")
if not fileName.endswith('.dck'):
raise HTTPException(400, "Only .dck files are supported")
src = _get_precon_dir() / fileName
if not src.exists():
raise HTTPException(404, f"Precon not found: {fileName}")
try:
content = src.read_text(encoding='utf-8')
except OSError as e:
raise HTTPException(500, f"Failed to read deck file: {e}")
return {"fileName": fileName, "decklist": content}
@router.post("/api/lab/precons/install")
async def install_precon(req: dict):
"""Install a precon deck to the Forge decks directory.
@@ -72,11 +47,7 @@ async def install_precon(req: dict):
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
dst = Path(decks_dir) / file_name
try:
shutil.copy2(str(src), str(dst))
except OSError as e:
log.error(f"Failed to copy deck file {file_name}: {e}")
raise HTTPException(500, f"Failed to copy deck file: {e}")
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
return {
@@ -107,13 +78,9 @@ async def install_precons_batch(req: dict):
results.append({"fileName": file_name, "installed": False, "error": "not found"})
continue
dst = Path(decks_dir) / file_name
try:
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
except OSError as e:
log.error(f"Failed to copy deck file {file_name}: {e}")
results.append({"fileName": file_name, "installed": False, "error": str(e)})
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
return {"results": results}
+2 -2
View File
@@ -113,7 +113,7 @@ def load_result(path: str) -> dict:
print(f"ERROR: File not found: {path}", file=sys.stderr)
sys.exit(2)
try:
with open(p, encoding="utf-8") as f:
with open(p) as f:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"ERROR: Invalid JSON in {path}: {e}", file=sys.stderr)
@@ -289,7 +289,7 @@ def main() -> int:
"passed": delta >= args.min_delta and pval < args.alpha,
}
Path(args.json_out).parent.mkdir(parents=True, exist_ok=True)
with open(args.json_out, "w", encoding="utf-8") as jf:
with open(args.json_out, "w") as jf:
json.dump(out, jf, indent=2)
print(f"Comparison JSON written to: {args.json_out}")
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env node
/**
* Generate lan-client/precons.js from precon-decks/precon-index.json
* Usage: node scripts/generate-precons-js.js
*/
const fs = require('fs');
const path = require('path');
const indexPath = path.join(__dirname, '..', 'precon-decks', 'precon-index.json');
const outputPath = path.join(__dirname, '..', 'lan-client', 'precons.js');
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
let js = '// Auto-generated from precon-decks/precon-index.json\n';
js += '// Run: node scripts/generate-precons-js.js to regenerate\n';
js += 'var PRECON_DATA = [\n';
index.forEach(function(p, i) {
const entry = {
id: (p.fileName || p.name || '').replace('.dck', ''),
name: p.name || '',
commander: p.commander || (p.commanders && p.commanders[0]) || '',
set: p.set || '',
fileName: p.fileName || '',
colors: p.colors || [],
year: p.year || 0,
};
js += ' ' + JSON.stringify(entry);
js += (i < index.length - 1) ? ',\n' : '\n';
});
js += '];\n';
fs.writeFileSync(outputPath, js);
console.log(`Generated ${outputPath} with ${index.length} precon entries`);
+27 -174
View File
@@ -13,17 +13,6 @@ from services.database import _get_db_conn, _row_to_dict
log_collect = logging.getLogger("lab.collection")
# Repo root = two levels up from this file (services/deck_service.py)
_REPO_ROOT = Path(__file__).parent.parent
# Repo-relative deck dirs always searched as fallbacks (zero-config support)
_BUILTIN_DECK_DIRS = [
_REPO_ROOT / "precon-decks",
_REPO_ROOT / "sample-decks",
_REPO_ROOT / "imported-decks",
_REPO_ROOT / "user-decks",
]
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
_TYPE_TARGETS = {
@@ -208,7 +197,7 @@ def _save_profile_to_dck(profile: dict) -> Path:
safe_name = "imported_deck"
save_dir = CFG.forge_decks_dir
if not save_dir or not os.path.isdir(save_dir):
save_dir = str(_REPO_ROOT / "imported-decks")
save_dir = os.path.join(Path(__file__).parent.parent, "imported-decks")
os.makedirs(save_dir, exist_ok=True)
out_path = Path(save_dir) / f"{safe_name}.dck"
out_path.write_text(content, encoding="utf-8")
@@ -216,171 +205,35 @@ def _save_profile_to_dck(profile: dict) -> Path:
return out_path
def _find_dck_file(deck_name: str) -> Optional[Path]:
"""Search known deck directories for a .dck file matching deck_name.
Resolution order:
1. CFG.precon_dir (explicit CLI/env config)
2. CFG.forge_decks_dir (Forge user decks)
3. FORGE_DIR/res/decks/commander (Forge bundled commander decks)
4. Repo-relative fallbacks: precon-decks/, sample-decks/, imported-decks/
(always searched so precon decks work with zero configuration)
Within each directory tries:
a. Exact stem match (Elven_Empire.dck)
b. Case-insensitive fuzzy match stripping underscores/spaces
"""
search_dirs: list[Path] = []
# CFG-configured paths (set via CLI args)
if CFG.precon_dir and os.path.isdir(CFG.precon_dir):
search_dirs.append(Path(CFG.precon_dir))
if CFG.forge_decks_dir and os.path.isdir(CFG.forge_decks_dir):
search_dirs.append(Path(CFG.forge_decks_dir))
if CFG.forge_dir:
forge_cmdr = Path(CFG.forge_dir) / "res" / "decks" / "commander"
if forge_cmdr.is_dir():
search_dirs.append(forge_cmdr)
# Repo-relative fallbacks -- always active so precon decks need no config
for builtin_dir in _BUILTIN_DECK_DIRS:
if builtin_dir.is_dir() and builtin_dir not in search_dirs:
search_dirs.append(builtin_dir)
target_norm = deck_name.lower().replace("_", "").replace(" ", "")
for d in search_dirs:
exact = d / f"{deck_name}.dck"
if exact.exists():
return exact
try:
for f in d.iterdir():
if f.suffix.lower() == ".dck":
if f.stem.lower().replace("_", "").replace(" ", "") == target_norm:
log_collect.info(f"[DeckService] Fuzzy .dck match: '{deck_name}' -> '{f}'")
return f
except OSError:
pass
return None
def _load_deck_cards_from_dck(dck_path: Path) -> list:
"""Parse a .dck file and return card dicts enriched with Forge card data.
Calls lookup_forge_card() for each card so that type_line, oracle_text,
cmc, mana_cost, power, toughness, and keywords are populated from the
Forge cardsfolder cache. This ensures Card.is_land() / is_creature()
work correctly when a deck is loaded via the .dck fallback path (i.e.
the deck is not yet in the SQLite DB).
The Forge cache is lazy-loaded and module-level, so there is no
per-game overhead after the first deck load.
"""
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card # lazy to avoid circular import
parsed = parse_dck_file(str(dck_path))
cards = []
def _make_card(card_name: str, qty: int, is_commander: int) -> dict:
forge = lookup_forge_card(card_name)
power = ""
toughness = ""
if forge and "/" in forge.pt:
parts = forge.pt.split("/", 1)
power = parts[0].strip()
toughness = parts[1].strip()
return {
"card_name": card_name,
"name": card_name,
"quantity": qty,
"is_commander": is_commander,
"type_line": forge.type_line if forge else "",
"oracle_text": forge.oracle_text if forge else "",
"cmc": forge.cmc if forge else 0,
"mana_cost": forge.mana_cost if forge else "",
"power": power,
"toughness": toughness,
"keywords": json.dumps(forge.keywords) if forge else "[]",
"color_identity": "[]",
}
for card_name, qty in parsed.get("commanders", {}).items():
cards.append(_make_card(card_name, qty, 1))
for card_name, qty in parsed.get("mainboard", {}).items():
cards.append(_make_card(card_name, qty, 0))
enriched = sum(1 for c in cards if c["type_line"])
log_collect.info(
"[DeckService] .dck load: %d cards, %d enriched from Forge cache (%s)",
len(cards), enriched, dck_path.name,
)
return cards
def _load_deck_cards_by_name(deck_name: str) -> list:
"""Load cards from a deck by name, returning list of card dicts.
Resolution order:
1. SQLite DB (decks built/imported via the Deck Builder UI)
2. .dck file fallback -- searches precon_dir, forge_decks_dir,
FORGE_DIR/res/decks/commander, and repo-relative precon-decks/
sample-decks/ so precon decks work without any configuration.
"""
# -- 1. DB lookup --
"""Load cards from a deck by name, returning list of card dicts."""
conn = _get_db_conn()
deck_row = conn.execute("SELECT id FROM decks WHERE name = ?", (deck_name,)).fetchone()
if deck_row:
deck_id = deck_row[0]
rows = conn.execute("""
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
COALESCE(ce.mana_cost, '', '') as mana_cost,
COALESCE(ce.power, '', '') as power,
COALESCE(ce.toughness, '', '') as toughness,
COALESCE(ce.keywords, cr.keywords, '[]') as keywords
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost, power, toughness, keywords
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, oracle_text, cmc, color_identity, keywords
FROM card_records GROUP BY name
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
result = [_row_to_dict(r) for r in rows]
# Enrich cards missing type_line from Forge card cache
try:
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card
for card in result:
if not card.get("type_line"):
forge = lookup_forge_card(card.get("card_name", ""))
if forge:
card["type_line"] = forge.type_line
card["oracle_text"] = forge.oracle_text
card["cmc"] = forge.cmc
card["mana_cost"] = forge.mana_cost
except ImportError:
pass # Forge card loader not available
return result
# -- 2. .dck file fallback --
dck_path = _find_dck_file(deck_name)
if dck_path:
log_collect.info(
f"[DeckService] '{deck_name}' not in DB -- loading from .dck file: {dck_path}"
)
return _load_deck_cards_from_dck(dck_path)
log_collect.warning(f"[DeckService] Deck '{deck_name}' not found in DB or any .dck search path.")
return []
if not deck_row:
return []
deck_id = deck_row[0]
rows = conn.execute("""
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
COALESCE(ce.mana_cost, '', '') as mana_cost,
COALESCE(ce.power, '', '') as power,
COALESCE(ce.toughness, '', '') as toughness,
COALESCE(ce.keywords, cr.keywords, '[]') as keywords
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost, power, toughness, keywords
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, oracle_text, cmc, color_identity, keywords
FROM card_records GROUP BY name
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
return [_row_to_dict(r) for r in rows]
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
-152
View File
@@ -1,152 +0,0 @@
"""
services/draw_game_analyzer.py
==============================
Analyses draw-game snapshots captured by forge_runner._run_process_blocking.
A "draw game" is any game that hit the Forge per-game wall-clock timeout
instead of ending via a win condition. Forge emits a line like:
[DRAW] [PARSE-SUMMARY] game=42 turns=28 time_ms=61200 \
seat0=AdaptiveEnchantment:life=46 seat1=AbzanArmor:life=0 \
seat2=AhoyMateys:life=0 seat3=20WaysToWin:life=8
This module:
1. Parses those lines out of the log into DrawGameSnapshot objects.
2. Aggregates them to identify which deck most often leads at timeout.
3. Exposes a summary dict suitable for embedding in the batch result JSON.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional
# ─── regex for the Forge DRAW summary line ────────────────────────────────────
# Example:
# [DRAW] [PARSE-SUMMARY] game=42 turns=28 time_ms=61200
# seat0=AdaptiveEnchantment:life=46 seat1=AbzanArmor:life=0
# seat2=AhoyMateys:life=0 seat3=20WaysToWin:life=8
_DRAW_LINE_RE = re.compile(
r'\[DRAW\].*?\[PARSE-SUMMARY\]'
r'.*?game=(\d+)'
r'.*?turns=(\d+)'
r'.*?time_ms=(\d+)'
r'((?:\s+seat\d+=\S+)*)'
, re.IGNORECASE
)
_SEAT_RE = re.compile(r'seat(\d+)=([^:]+):life=([-\d]+)', re.IGNORECASE)
@dataclass
class DrawGameSnapshot:
game_id: int
turns: int
time_ms: int
# seat_index -> {deck_name, life}
seats: Dict[int, dict] = field(default_factory=dict)
@property
def leading_deck(self) -> Optional[str]:
"""Name of the deck with the highest life at timeout (None if tie/empty)."""
if not self.seats:
return None
best = max(self.seats.values(), key=lambda s: s["life"])
return best["deck_name"]
@property
def leading_life(self) -> int:
if not self.seats:
return 0
return max(s["life"] for s in self.seats.values())
def parse_draw_snapshots(log_lines: list[str]) -> list[DrawGameSnapshot]:
"""
Scan raw log lines for [DRAW] [PARSE-SUMMARY] entries and return
a list of DrawGameSnapshot objects, one per draw game.
"""
snapshots: list[DrawGameSnapshot] = []
for line in log_lines:
m = _DRAW_LINE_RE.search(line)
if not m:
continue
game_id = int(m.group(1))
turns = int(m.group(2))
time_ms = int(m.group(3))
seats_str = m.group(4) or ""
seats: Dict[int, dict] = {}
for sm in _SEAT_RE.finditer(seats_str):
seat_idx = int(sm.group(1))
deck_name = sm.group(2).strip()
life = int(sm.group(3))
seats[seat_idx] = {"deck_name": deck_name, "life": life}
snapshots.append(DrawGameSnapshot(game_id=game_id, turns=turns, time_ms=time_ms, seats=seats))
return snapshots
def summarize_draw_games(snapshots: list[DrawGameSnapshot]) -> dict:
"""
Aggregate draw snapshots into a summary dict for embedding in batch JSON.
Returns::
{
"count": 156,
"avgTurns": 27.4,
"avgTimeMs": 61800,
"perDeck": [
{
"deckName": "AdaptiveEnchantment",
"drawLeadCount": 89, # times this deck led at timeout
"drawLeadPct": 57.1,
"avgLifeAtTimeout": 42.3,
},
...
],
"likelyCause": "AdaptiveEnchantment" # deck most often leading at timeout
}
"""
if not snapshots:
return {"count": 0, "avgTurns": 0, "avgTimeMs": 0, "perDeck": [], "likelyCause": None}
total = len(snapshots)
avg_turns = round(sum(s.turns for s in snapshots) / total, 1)
avg_time_ms = round(sum(s.time_ms for s in snapshots) / total)
# Accumulate per-deck stats across all draw games
deck_lead_count: Dict[str, int] = {}
deck_life_sum: Dict[str, float] = {}
deck_life_count: Dict[str, int] = {}
for snap in snapshots:
leader = snap.leading_deck
if leader:
deck_lead_count[leader] = deck_lead_count.get(leader, 0) + 1
for seat in snap.seats.values():
dn = seat["deck_name"]
deck_life_sum[dn] = deck_life_sum.get(dn, 0.0) + seat["life"]
deck_life_count[dn] = deck_life_count.get(dn, 0) + 1
all_decks = set(deck_life_count.keys()) | set(deck_lead_count.keys())
per_deck = []
for dn in sorted(all_decks):
lead_cnt = deck_lead_count.get(dn, 0)
avg_life = round(deck_life_sum.get(dn, 0) / max(deck_life_count.get(dn, 1), 1), 1)
per_deck.append({
"deckName": dn,
"drawLeadCount": lead_cnt,
"drawLeadPct": round(lead_cnt / total * 100, 1),
"avgLifeAtTimeout": avg_life,
})
per_deck.sort(key=lambda x: x["drawLeadCount"], reverse=True)
likely_cause = per_deck[0]["deckName"] if per_deck else None
return {
"count": total,
"avgTurns": avg_turns,
"avgTimeMs": avg_time_ms,
"perDeck": per_deck,
"likelyCause": likely_cause,
}
+16 -130
View File
@@ -168,62 +168,6 @@ def reset_java17_cache():
_JAVA17_PATH = None
# ── Deck path resolution ──────────────────────────────────────────────────────
def _resolve_deck_path(deck_name: str) -> str:
"""Resolve a deck name to its full .dck path in CFG.precon_dir.
Resolution order:
1. Already an absolute path with .dck extension — return as-is.
2. Exact filename match in precon_dir (e.g. "Abzan_Armor.dck").
3. camelCase → snake_case conversion (e.g. "AbzanArmor" → "Abzan_Armor.dck").
4. Space → underscore normalization.
5. Case-insensitive fuzzy match stripping underscores/spaces.
6. Fallback: return raw name and let the JAR fail with a clear error.
"""
# Already a resolved path
if deck_name.endswith('.dck') and os.path.isfile(deck_name):
return deck_name
precon_dir = CFG.precon_dir
if not precon_dir or not os.path.isdir(precon_dir):
log.warning(f"[DeckResolver] precon_dir not set or missing — passing raw name '{deck_name}' to JAR")
return deck_name
# 1. Exact match
exact = os.path.join(precon_dir, f"{deck_name}.dck")
if os.path.isfile(exact):
return exact
# 2. camelCase → snake_case (AbzanArmor → Abzan_Armor)
snake = re.sub(r'(?<=[a-z0-9])(?=[A-Z])', '_', deck_name)
snake_path = os.path.join(precon_dir, f"{snake}.dck")
if os.path.isfile(snake_path):
return snake_path
# 3. Space → underscore (e.g. "Abzan Armor" → "Abzan_Armor.dck")
spaced = deck_name.replace(' ', '_')
spaced_path = os.path.join(precon_dir, f"{spaced}.dck")
if os.path.isfile(spaced_path):
return spaced_path
# 4. Case-insensitive fuzzy: strip underscores/spaces, compare lowercased stems
target = deck_name.lower().replace('_', '').replace(' ', '')
try:
for fname in os.listdir(precon_dir):
if fname.lower().endswith('.dck'):
stem = fname[:-4].lower().replace('_', '').replace(' ', '')
if stem == target:
resolved = os.path.join(precon_dir, fname)
log.info(f"[DeckResolver] Fuzzy match: '{deck_name}' → '{fname}'")
return resolved
except OSError as e:
log.warning(f"[DeckResolver] Could not list precon_dir '{precon_dir}': {e}")
log.warning(f"[DeckResolver] Deck not found: '{deck_name}' (searched {precon_dir}) — passing raw name to JAR")
return deck_name
def build_java_command(
decks: list, num_games: int, threads: int, seed: Optional[int],
clock: int, output_path: str, use_learned_policy: bool = False,
@@ -247,11 +191,9 @@ def build_java_command(
'--clock', str(clock),
'--output', output_path,
]
# Map deck list to --deck1 .. --deck4, resolving names to full .dck paths
# Map deck list to --deck1 .. --deck4
for i, d in enumerate(decks[:4], start=1):
resolved = _resolve_deck_path(str(d))
log.info(f"[DeckResolver] deck{i}: '{d}' → '{resolved}'")
cmd += [f'--deck{i}', resolved]
cmd += [f'--deck{i}', str(d)]
if seed is not None:
cmd += ['--seed', str(seed)]
if use_learned_policy:
@@ -268,50 +210,10 @@ def build_java_command(
return cmd
# ── Draw-game life-at-timeout parser ─────────────────────────────────────────
# Forge emits a summary line for each game that hits the wall-clock timeout:
#
# [DRAW] [PARSE-SUMMARY] game=42 turns=28 time_ms=61200
# seat0=AdaptiveEnchantment:life=46 seat1=AbzanArmor:life=0
# seat2=AhoyMateys:life=0 seat3=20WaysToWin:life=8
#
# We capture these live so they're available before the process finishes.
_DRAW_SUMMARY_RE = re.compile(
r'\[DRAW\].*?\[PARSE-SUMMARY\]'
r'.*?game=(\d+)'
r'.*?turns=(\d+)'
r'.*?time_ms=(\d+)'
r'((?:\s+seat\d+=\S+)*)'
, re.IGNORECASE
)
_SEAT_LIFE_RE = re.compile(r'seat(\d+)=([^:]+):life=([-\d]+)', re.IGNORECASE)
def _parse_draw_summary_line(line: str) -> Optional[dict]:
"""Return a draw-game snapshot dict if line matches, else None."""
m = _DRAW_SUMMARY_RE.search(line)
if not m:
return None
seats = {}
for sm in _SEAT_LIFE_RE.finditer(m.group(4) or ""):
seat_idx = int(sm.group(1))
seats[seat_idx] = {"deck_name": sm.group(2).strip(), "life": int(sm.group(3))}
return {
"game_id": int(m.group(1)),
"turns": int(m.group(2)),
"time_ms": int(m.group(3)),
"seats": seats,
}
def _run_process_blocking(state: BatchState, cmd: list):
"""Run Forge subprocess, parse progress, collect draw-game snapshots."""
"""Run Forge subprocess, parse progress, generate deck reports."""
log.info(f"Running: {' '.join(cmd)}")
# Forge must run with forge_dir as cwd so it can find res/ relative to itself
forge_cwd = CFG.forge_dir if CFG.forge_dir and os.path.isdir(CFG.forge_dir) else None
log.info(f"[ForgeRunner] cwd={forge_cwd}")
# Ensure Forge subprocesses use Java 17
env = os.environ.copy()
java17 = get_java17()
@@ -324,7 +226,7 @@ def _run_process_blocking(state: BatchState, cmd: list):
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, env=env, cwd=forge_cwd,
text=True, bufsize=1, env=env,
)
state.process = proc
except Exception as e:
@@ -335,15 +237,10 @@ def _run_process_blocking(state: BatchState, cmd: list):
_register_batch(state.batch_id, state, proc)
# ── Live stdout parsing ───────────────────────────────────────────
draw_snapshots: list = []
for line in proc.stdout:
_touch_batch(state.batch_id)
line = line.rstrip()
state.log_lines.append(line)
# Progress counters
gm = re.match(r'\[Game (\d+)/(\d+)\]', line)
if gm:
state.completed_games = int(gm.group(1))
@@ -352,26 +249,13 @@ def _run_process_blocking(state: BatchState, cmd: list):
if pm:
state.sims_per_sec = float(pm.group(1))
# ── Draw-game capture (fix #176) ──────────────────────────────
snap = _parse_draw_summary_line(line)
if snap:
draw_snapshots.append(snap)
proc.wait()
_unregister_batch(state.batch_id)
elapsed = (_datetime.now() - state.start_time).total_seconds() * 1000
state.elapsed_ms = int(elapsed)
# Persist draw snapshots on state for routes/lab.py to embed in JSON
state.draw_game_snapshots = draw_snapshots
if draw_snapshots:
log.info(
f"[DrawGames] Captured {len(draw_snapshots)} draw-game snapshots "
f"for batch {state.batch_id}"
)
if proc.returncode != 0:
state.running = False
# Include last few log lines in error for easier diagnosis
tail = ' | '.join(state.log_lines[-5:]) if state.log_lines else '(no output)'
state.error = f"Exit code {proc.returncode} — last output: {tail}"
else:
@@ -379,6 +263,9 @@ def _run_process_blocking(state: BatchState, cmd: list):
state.completed_games = state.total_games
# ── Post-process: detect alternate win conditions in the Java log ──
# Runs after the process exits so we don't slow the hot stdout loop.
# Stamps state.win_type_override which routes/lab_api.py can attach
# to each game result when building the batch JSON response.
try:
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
src_dir = os.path.normpath(src_dir)
@@ -399,7 +286,7 @@ async def run_batch_subprocess(
num_games: int,
threads: int,
seed: Optional[int] = None,
clock: int = 3000,
clock: int = 6000,
output_path: str = "results",
use_learned_policy: bool = False,
policy_style: str = "midrange",
@@ -408,12 +295,7 @@ async def run_batch_subprocess(
ai_think_time_ms: int = -1,
max_queue_depth: int = -1,
):
"""Async wrapper that runs Forge subprocess in executor.
clock default lowered from 6000 → 3000 (30s per-game wall limit for batch
mode) to reduce draw-game wall time wasted on timeouts (fix #176).
Interactive games pass an explicit req.clock override so they are unaffected.
"""
"""Async wrapper that runs Forge subprocess in executor."""
try:
policy_server = f"http://localhost:{CFG.port}"
cmd = build_java_command(
@@ -449,7 +331,7 @@ def _get_deepseek_brain():
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
cfg = DeepSeekConfig(
api_base=os.environ.get("BRAIN_API_BASE", "http://localhost:11434"),
model=os.environ.get("BRAIN_MODEL", "qwen2.5:7b"),
model=os.environ.get("BRAIN_MODEL", "gpt-oss:20b"),
temperature=float(os.environ.get("BRAIN_TEMPERATURE", "0.3")),
max_tokens=int(os.environ.get("BRAIN_MAX_TOKENS", "1024")),
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "300.0")),
@@ -499,7 +381,7 @@ def _run_deepseek_batch_thread(
for fname in sorted(os.listdir(results_dir), reverse=True):
if fname.startswith('ml-decision-') and fname.endswith('.json'):
try:
with open(os.path.join(results_dir, fname), 'r', encoding='utf-8') as rf:
with open(os.path.join(results_dir, fname), 'r') as rf:
past = json.loads(rf.read())
for dd in past.get('decks', []):
dname = dd.get('deckName', '')
@@ -641,13 +523,17 @@ def _run_deepseek_batch_thread(
gd['gameNumber'] = g + 1
# ── Alternate win detection (DeepSeek path) ──────────────
# Inspect the winning player's battlefield for alternate-win
# permanents. result.winner is the seat index (0 = deck_a).
try:
winner_seat = result.winner_seat
if winner_seat >= 0:
from commander_ai_lab.sim.game_state import get_final_battlefield
winner_bf = getattr(result, '_winner_battlefield', None)
if winner_bf is not None:
win_type = detect_win_type_from_cards(winner_bf)
else:
# Fallback: scan game_log lines if battlefield snapshot unavailable
from commander_ai_lab.sim.win_condition_parser import detect_win_type_from_log
win_type = detect_win_type_from_log(
[str(entry) for entry in (result.game_log or [])]
+1 -1
View File
@@ -26,7 +26,7 @@ class DeckBuilderAdapter:
def __init__(
self,
db_conn_factory: Callable[[], sqlite3.Connection] = None,
model: str = "claude-opus-4-20250514",
model: str = "gpt-oss:20b",
):
self.db_conn_factory = db_conn_factory
self.model = model
@@ -1,5 +1,5 @@
"""
Ollama client for the Commander AI Deck Builder.
Ollama gpt-oss:20b client for the Commander AI Deck Builder.
Uses the OpenAI-compatible API exposed by Ollama at localhost:11434.
Provides 6 core functions:
+2 -2
View File
@@ -87,8 +87,8 @@ def cli() -> None:
)
parser.add_argument(
"--model",
default="claude-opus-4-20250514",
help="AI model name (default: claude-opus-4-20250514)",
default="gpt-oss:20b",
help="Ollama model name (default: gpt-oss:20b)",
)
parser.add_argument(
"--output",
+1 -19
View File
@@ -148,11 +148,6 @@ class DeepSeekGameEngine:
p.stats.turns_alive += 1
phase_events: list[str] = []
# ── Reset per-turn counters (land drop, etc.) ──
p._lands_played_this_turn = 0
if not hasattr(p, '_land_plays_this_turn_limit'):
p._land_plays_this_turn_limit = 1
# ── Draw ──
if p.library:
drawn = p.library[-1]
@@ -362,20 +357,8 @@ class DeepSeekGameEngine:
self, sim: SimState, pi: int,
preferred_name: str | None, events: list[str] | None
):
"""Play a land from hand. If preferred_name given, try that first.
Enforces the one-land-per-turn rule (MTG CR 305.2). Cards that grant
additional land plays (e.g. Exploration, Oracle of Mul Daya) may bump
``_land_plays_this_turn_limit`` on the player to a higher value.
"""
"""Play a land from hand. If preferred_name given, try that first."""
p = sim.players[pi]
# ── LAND-PER-TURN ENFORCEMENT ──
max_land_plays = getattr(p, '_land_plays_this_turn_limit', 1)
lands_this_turn = getattr(p, '_lands_played_this_turn', 0)
if lands_this_turn >= max_land_plays:
return # Already used land drop(s) this turn
land_idx = -1
if preferred_name:
@@ -402,7 +385,6 @@ class DeepSeekGameEngine:
sim.next_card_id += 1
sim.add_to_battlefield(pi, land_card)
p.stats.lands_played += 1
p._lands_played_this_turn = lands_this_turn + 1
if events is not None and self.record_log:
events.append(f"Played land: {land_card.name}")
+1 -19
View File
@@ -135,11 +135,6 @@ class GameEngine:
sim.active_player_index = pi
phase_events: list[str] = []
# ── Reset per-turn counters (land drop, etc.) ──
p._lands_played_this_turn = 0
if not hasattr(p, '_land_plays_this_turn_limit'):
p._land_plays_this_turn_limit = 1
# ── Capture ML snapshot BEFORE turn actions ──
pre_snapshot = None
if self.ml_log:
@@ -459,20 +454,8 @@ class GameEngine:
return sim
def _play_land(self, sim: SimState, pi: int) -> None:
"""Play the first land from hand onto the battlefield.
Enforces the one-land-per-turn rule (MTG CR 305.2). Cards that grant
additional land plays may bump ``_land_plays_this_turn_limit`` on the
player to a higher value.
"""
"""Play the first land from hand onto the battlefield."""
p = sim.players[pi]
# ── LAND-PER-TURN ENFORCEMENT ──
max_land_plays = getattr(p, '_land_plays_this_turn_limit', 1)
lands_this_turn = getattr(p, '_lands_played_this_turn', 0)
if lands_this_turn >= max_land_plays:
return # Already used land drop(s) this turn
land_idx = -1
for i, card in enumerate(p.hand):
if card.is_land():
@@ -487,7 +470,6 @@ class GameEngine:
sim.next_card_id += 1
sim.add_to_battlefield(pi, land_card)
p.stats.lands_played += 1
p._lands_played_this_turn = lands_this_turn + 1
@staticmethod
def _send_to_graveyard(sim: SimState, card, owner_idx: int) -> None:
+43 -173
View File
@@ -229,54 +229,61 @@ class CommanderGameState:
def stack_is_empty(self) -> bool:
return len(self.stack) == 0
# Bug 9 fix: stub get_legal_moves() and apply_move() so turn_manager
# doesn't crash. These delegate to the rules engine once implemented.
def get_legal_moves(self, seat: int) -> list[dict]:
"""Return legal moves for ``seat``.
"""Return legal moves for a seat.
Delegates to :mod:`legal_move_generator` which produces macro
actions (play_land, cast_creature, cast_removal, cast_ramp,
cast_draw, cast_commander, attack, block, pass_priority).
During DECLARE_BLOCKERS phase, emits block moves for defending
creatures against each attacker in sim_state.combat.
Otherwise returns pass-only (stub for remaining rules engine).
"""
# Local import to avoid a circular reference at module load
from commander_ai_lab.sim.legal_move_generator import generate_legal_moves
return generate_legal_moves(self, seat)
moves: list[dict] = [{"id": 0, "category": "pass_priority", "description": "Pass"}]
combat = self.sim_state.combat
# Emit block moves during declare_blockers phase
if (
combat
and combat.active
and self.current_phase in (Phase.DECLARE_BLOCKERS, "declare_blockers")
and seat != self.active_player_seat
):
move_id = 1000
my_creatures = [
c for c in self.sim_state.get_battlefield(seat)
if c.is_creature() and not c.tapped
]
for atk_id in combat.attackers:
atk = next(
(c for bf in self.sim_state.battlefields for c in bf if c.id == atk_id),
None,
)
if atk is None:
continue
for blocker in my_creatures:
moves.append({
"id": move_id,
"category": "block",
"blocker_id": blocker.id,
"attacker_id": atk_id,
"description": f"Block {atk.name} with {blocker.name}",
})
move_id += 1
return moves
def apply_move(self, seat: int, move_id: int) -> None:
"""Apply a chosen move to the game state.
"""Apply a chosen move.
Supports all move categories produced by
:mod:`legal_move_generator`. For categories that require full
rules simulation (spell resolution, combat damage), this is
still a thin layer — it mutates the game state and delegates
final resolution to the engine/turn manager.
Handles 'block' moves by updating sim_state.combat.blockers.
Other categories are no-op stubs until the rules engine is wired.
"""
# Find the move dict
all_moves = self.get_legal_moves(seat)
move = next((m for m in all_moves if m["id"] == move_id), None)
if move is None:
return
category = move.get("category")
if category == "pass_priority":
return
if category == "play_land":
self._apply_play_land(seat, move)
return
if category in ("cast_creature", "cast_removal", "cast_ramp",
"cast_draw", "cast_spell"):
self._apply_cast_spell(seat, move)
return
if category == "cast_commander":
self._apply_cast_commander(seat, move)
return
if category == "attack":
self._apply_declare_attacker(seat, move)
return
if category == "block":
if move.get("category") == "block":
combat = self.sim_state.combat
if combat and combat.active:
atk_id = move["attacker_id"]
@@ -284,143 +291,6 @@ class CommanderGameState:
if atk_id not in combat.blockers:
combat.blockers[atk_id] = []
combat.blockers[atk_id].append(blocker_id)
return
# ── Move execution helpers ───────────────────────────────
def _apply_play_land(self, seat: int, move: dict) -> None:
"""Remove the chosen land from hand, place on battlefield, bump counter."""
if not (0 <= seat < len(self.commander_players)):
return
player = self.commander_players[seat]
# Enforce land-per-turn at apply-time as a second safety check
if player.lands_played_this_turn >= player.land_plays_allowed:
return
card_name = move.get("card_name")
idx = next(
(i for i, c in enumerate(player.hand)
if c.name == card_name and c.is_land()),
-1,
)
if idx < 0:
return
land = player.hand.pop(idx)
land.owner_id = seat
land.tapped = False
land.turn_played = self.turn
land.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
self.sim_state.add_to_battlefield(seat, land)
player.lands_played_this_turn += 1
self.land_drop_used = True
if hasattr(player, "base") and hasattr(player.base, "stats"):
player.base.stats.lands_played += 1
def _apply_cast_spell(self, seat: int, move: dict) -> None:
"""Remove the spell from hand, place it on the battlefield.
This is a simplified resolution (no stack, no targeting) sufficient
for macro-action training and live games until the full stack
engine is wired.
"""
if not (0 <= seat < len(self.commander_players)):
return
player = self.commander_players[seat]
card_name = move.get("card_name")
idx = next(
(i for i, c in enumerate(player.hand) if c.name == card_name),
-1,
)
if idx < 0:
return
card = player.hand.pop(idx)
card.owner_id = seat
card.turn_played = self.turn
card.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
# Creatures and permanents land on the battlefield; instants and
# sorceries go straight to the graveyard after resolving.
type_line = (card.type_line or "").lower()
if "instant" in type_line or "sorcery" in type_line:
player.graveyard.append(card)
else:
card.tapped = False
self.sim_state.add_to_battlefield(seat, card)
if hasattr(player, "base") and hasattr(player.base, "stats"):
player.base.stats.spells_cast += 1
if card.is_creature():
player.base.stats.creatures_played += 1
def _apply_cast_commander(self, seat: int, move: dict) -> None:
"""Cast the commander from the command zone (increments tax)."""
if not (0 <= seat < len(self.commander_players)):
return
player = self.commander_players[seat]
card_name = move.get("card_name")
idx = next(
(i for i, c in enumerate(player.commander_zone)
if c.name == card_name),
-1,
)
if idx < 0 and player.commander_zone:
idx = 0
if idx < 0:
return
cmd = player.commander_zone.pop(idx)
cmd.owner_id = seat
cmd.turn_played = self.turn
cmd.tapped = False
cmd.is_commander = True
cmd.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
self.sim_state.add_to_battlefield(seat, cmd)
player.commander_cast_count += 1
if hasattr(player, "base") and hasattr(player.base, "stats"):
player.base.stats.spells_cast += 1
if cmd.is_creature():
player.base.stats.creatures_played += 1
def _apply_declare_attacker(self, seat: int, move: dict) -> None:
"""Mark a creature as attacking in the current combat state.
``combat.attackers`` is a ``dict[int, int]`` mapping attacker
card id to defending seat. If no defending seat has been
chosen yet, fall back to the first non-active, non-eliminated
opponent.
"""
combat = self.sim_state.combat
if combat is None:
return
atk_id = move.get("attacker_id")
if atk_id is None:
return
# Tap the attacker unless it has vigilance
for card in self.sim_state.get_battlefield(seat):
if card.id == atk_id:
has_vigilance = (
card.has_keyword("vigilance")
if hasattr(card, "has_keyword") else False
)
if not has_vigilance:
card.tapped = True
break
# Determine defending seat
defending_seat = combat.defending_seat
if defending_seat < 0 or defending_seat == seat:
for i, p in enumerate(self.commander_players):
if i != seat and not p.eliminated:
defending_seat = i
break
if defending_seat >= 0:
combat.defending_seat = defending_seat
if defending_seat >= 0:
combat.attackers[atk_id] = defending_seat
combat.active = True
def living_players(self) -> list[CommanderPlayer]:
return [p for p in self.commander_players if not p.eliminated]
@@ -1,230 +0,0 @@
"""
Legal Move Generator
====================
Generates the set of legal moves a player can take from a
:class:`CommanderGameState`. This replaces the "pass-only" stub in
``CommanderGameState.get_legal_moves`` so the turn_manager/policy-server
path can drive real games instead of just passing priority every turn.
The emitted moves are intentionally coarse (macro-actions: play_land,
cast_creature, cast_removal, cast_ramp, cast_draw, cast_commander,
attack, block, pass_priority) so that both the neural policy and
heuristic/LLM brains can pick from the same list.
Long-term, when the Forge IPC path is fully wired, Forge's own legal
action list will replace the output of this module. Until then, this
is the authoritative move source for the Python rules layer.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from commander_ai_lab.sim.models import Card, Phase
if TYPE_CHECKING:
from commander_ai_lab.sim.game_state import CommanderGameState
# Map from card enrichment flags to macro-action categories the AI brain
# recognizes. Order matters: the first True flag wins.
_SPELL_FLAG_TO_CATEGORY: tuple[tuple[str, str], ...] = (
("is_ramp", "cast_ramp"),
("is_removal", "cast_removal"),
("is_board_wipe", "cast_removal"),
)
def _classify_spell(card: Card) -> str:
"""Map a Card to a macro-action category string.
Matches the 8-action macro-action space used by the policy network
(``scope.py``): cast_creature, cast_removal, cast_draw, cast_ramp,
cast_commander, attack_opponent, hold_mana, pass. Cards that don't
fit any flag fall back to ``cast_spell`` so downstream code can
still execute them.
"""
for flag, category in _SPELL_FLAG_TO_CATEGORY:
if getattr(card, flag, False):
return category
# Heuristic: draw spells often mention "draw" in oracle text
ot = (getattr(card, "oracle_text", "") or "").lower()
if "draw" in ot and ("card" in ot or "cards" in ot):
return "cast_draw"
if card.is_creature():
return "cast_creature"
return "cast_spell"
def _count_available_mana(game_state: "CommanderGameState", seat: int) -> int:
"""Rough available mana = floating pool + untapped lands on battlefield.
This is intentionally permissive. A production rules engine would
also consider mana dorks, mana rocks, and color requirements, but
for macro-action gating CMC-vs-total-mana is sufficient.
"""
player = game_state.players[seat]
mana = 0
# Floating mana pool (if ManaPool has ``total``)
pool = getattr(player, "mana_pool", None)
if pool is not None:
total_fn = getattr(pool, "total", None)
if callable(total_fn):
try:
mana += int(total_fn())
except Exception:
pass
# Untapped lands on battlefield (each produces ~1 mana)
for card in game_state.sim_state.get_battlefield(seat):
if card.is_land() and not getattr(card, "tapped", False):
mana += 1
return mana
def _phase_is(current: object, target: Phase) -> bool:
"""Safe phase comparison that works with Phase enum or raw string."""
if current == target:
return True
if isinstance(current, str) and current == target.value:
return True
return False
def generate_legal_moves(game_state: "CommanderGameState", seat: int) -> list[dict]:
"""Produce the list of legal moves for ``seat`` given ``game_state``.
Always includes a pass-priority move (id 0) as a safe fallback.
Additional moves are emitted based on phase, turn ownership, and
available resources.
"""
moves: list[dict] = [
{"id": 0, "category": "pass_priority", "description": "Pass"}
]
if not (0 <= seat < len(game_state.players)):
return moves
player = game_state.players[seat]
if getattr(player, "eliminated", False):
return moves
current_phase = game_state.current_phase
is_active = (seat == game_state.active_player_seat)
move_id = 1
# ── Sorcery-speed actions (main phases, active player only) ──
if is_active and (
_phase_is(current_phase, Phase.MAIN1)
or _phase_is(current_phase, Phase.MAIN2)
):
# Land drop — respect one-land-per-turn unless modified
lands_played = getattr(player, "lands_played_this_turn", 0)
land_limit = getattr(player, "land_plays_allowed", 1)
if lands_played < land_limit:
for card in player.hand:
if card.is_land():
moves.append({
"id": move_id,
"category": "play_land",
"card_name": card.name,
"description": f"Play {card.name}",
})
move_id += 1
# Castable spells from hand
available_mana = _count_available_mana(game_state, seat)
for card in player.hand:
if card.is_land():
continue
cost = int(getattr(card, "cmc", 0) or 0)
if cost <= available_mana:
category = _classify_spell(card)
moves.append({
"id": move_id,
"category": category,
"card_name": card.name,
"cmc": cost,
"description": f"Cast {card.name} ({cost} mana)",
})
move_id += 1
# Commander from the command zone (respect commander tax)
tax = 0
if hasattr(player, "commander_tax"):
try:
tax = int(player.commander_tax())
except Exception:
tax = 0
for cmd in getattr(player, "commander_zone", []):
base_cost = int(getattr(cmd, "cmc", 0) or 0)
total_cost = base_cost + tax
if total_cost <= available_mana:
moves.append({
"id": move_id,
"category": "cast_commander",
"card_name": cmd.name,
"cmc": total_cost,
"description": (
f"Cast {cmd.name} from command zone "
f"(base={base_cost}, tax={tax})"
),
})
move_id += 1
# ── Declare attackers — one attack move per legal attacker ──
if is_active and _phase_is(current_phase, Phase.DECLARE_ATTACKERS):
bf = game_state.sim_state.get_battlefield(seat)
for creature in bf:
if not creature.is_creature():
continue
if getattr(creature, "tapped", False):
continue
# Summoning sickness: played this turn and no haste
turn_played = getattr(creature, "turn_played", -1)
has_haste = creature.has_keyword("haste") if hasattr(creature, "has_keyword") else False
if turn_played == game_state.turn and not has_haste:
continue
moves.append({
"id": move_id,
"category": "attack",
"attacker_id": creature.id,
"card_name": creature.name,
"description": f"Attack with {creature.name}",
})
move_id += 1
# ── Declare blockers — emit block pairings for defending player ──
if _phase_is(current_phase, Phase.DECLARE_BLOCKERS) and not is_active:
combat = game_state.sim_state.combat
if combat and getattr(combat, "active", False):
defender_creatures = [
c for c in game_state.sim_state.get_battlefield(seat)
if c.is_creature() and not getattr(c, "tapped", False)
]
for atk_id in getattr(combat, "attackers", []):
atk = None
for bf in game_state.sim_state.battlefields:
for c in bf:
if c.id == atk_id:
atk = c
break
if atk is not None:
break
if atk is None:
continue
for blocker in defender_creatures:
moves.append({
"id": move_id,
"category": "block",
"blocker_id": blocker.id,
"attacker_id": atk_id,
"description": f"Block {atk.name} with {blocker.name}",
})
move_id += 1
return moves
-4
View File
@@ -267,10 +267,6 @@ class CommanderTurnManager:
player = self.gs.players[seat]
logger.debug("Turn %d — %s's turn begins", turn_num, player.name)
# ── Reset per-turn counters at the start of each turn ──
# Enforces MTG CR 305.2 (one land per turn unless modified).
player.lands_played_this_turn = 0
for phase in PHASES:
if self.game_over:
return
@@ -1,840 +0,0 @@
package commanderailab.ai;
import commanderailab.ml.MacroActionExecutor;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import forge.LobbyPlayer;
import forge.ai.AiController;
import forge.ai.ComputerUtilAbility;
import forge.ai.ComputerUtilMana;
import forge.ai.PlayerControllerAi;
import forge.game.Game;
import forge.game.card.Card;
import forge.game.card.CardCollection;
import forge.game.card.CardCollectionView;
import forge.game.phase.PhaseHandler;
import forge.game.phase.PhaseType;
import forge.game.player.Player;
import forge.game.spellability.SpellAbility;
import forge.game.spellability.SpellAbilityStackInstance;
import forge.game.zone.ZoneType;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Logger;
/**
* PolicyControllerAi — Phase 2 live-game controller that routes AI decisions
* through the Python policy server instead of Forge's built-in heuristics.
*
* <h2>Architecture</h2>
* <pre>
* Forge game loop
* └─ player.getController() → PolicyControllerAi
* └─ brains (PolicyAiController extends AiController)
* └─ chooseSpellAbilityToPlay()
* ├─ buildGameStateJson()
* ├─ policyClient.decide(stateJson) → macro-action
* ├─ mapMacroActionToSpellAbility() → SpellAbility
* └─ super.chooseSpellAbilityToPlay() (fallback)
* </pre>
*
* <h2>Two-class design</h2>
* <ul>
* <li>{@link PolicyAiController} — inner subclass of {@link AiController}
* that overrides {@code chooseSpellAbilityToPlay()}. This is the
* intercept point closest to the game loop.</li>
* <li>{@link PolicyControllerAi} — subclass of {@link PlayerControllerAi}
* that swaps Forge's default {@code AiController} for a
* {@code PolicyAiController} and exposes lifecycle helpers
* (game-end reward submission, statistics).</li>
* </ul>
*
* <h2>Decision flow</h2>
* <ol>
* <li>Build a rich {@link JsonObject} snapshot of the current game state
* (per-player life/mana/zones, stack, phase, legal actions).</li>
* <li>POST the snapshot to {@code /api/policy/decide} via
* {@link PolicyClient#decide(JsonObject)}.</li>
* <li>Translate the returned macro-action name (e.g. {@code "cast_creature"})
* to a concrete {@link SpellAbility} from the legal action list using
* {@link MacroActionExecutor}'s card-role databases.</li>
* <li>If the server is unreachable or no mapping succeeds, fall back to
* {@link AiController#chooseSpellAbilityToPlay()} transparently.</li>
* <li>Log every decision (policy or fallback) for training data collection.</li>
* </ol>
*
* <h2>Fallback guarantee</h2>
* Every code path in {@link PolicyAiController#chooseSpellAbilityToPlay()}
* is wrapped in a broad {@code try/catch}. Any unexpected exception causes
* an immediate delegate to the parent Forge AI so the game continues safely.
*
* <h2>Usage</h2>
* <pre>{@code
* PolicyClient client = new PolicyClient("http://localhost:8080");
* client.connect();
*
* // When Forge constructs the AI player's controller:
* PolicyControllerAi controller = new PolicyControllerAi(game, player, lobbyPlayer, client);
*
* // After the game ends:
* controller.onGameEnd(won ? 1.0 : -1.0);
* }</pre>
*
* @see PolicyClient
* @see PolicyAiController
* @see MacroActionExecutor
* @since Phase 2.1 — Issue #83
*/
public class PolicyControllerAi extends PlayerControllerAi {
// ─────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────
private static final Logger LOG = Logger.getLogger(PolicyControllerAi.class.getName());
/**
* Directory for training-data decision logs.
* One JSON-lines file per game session is written here.
*/
private static final String LOG_DIR = "data/decisions";
// ─────────────────────────────────────────────────────────────────────────
// Instance state
// ─────────────────────────────────────────────────────────────────────────
/** Unique identifier for this game session (used in reward submission). */
private final String gameId = UUID.randomUUID().toString();
/** The policy client used by the embedded {@link PolicyAiController}. */
private final PolicyClient policyClient;
/**
* Log writer for training-data collection.
* Lazily opened on the first decision; {@code null} if log dir is unavailable.
*/
private PrintWriter decisionLog;
/** Running count of decisions in this game (policy + fallback). */
private final AtomicInteger decisionCount = new AtomicInteger(0);
// ─────────────────────────────────────────────────────────────────────────
// Construction
// ─────────────────────────────────────────────────────────────────────────
/**
* Construct a policy-driven AI controller.
*
* <p>The standard Forge {@link AiController} created by the parent class is
* immediately replaced by a {@link PolicyAiController} that holds a
* reference to {@code client}. All other Forge AI infrastructure
* (combat, simulation, sideboard, etc.) is inherited unchanged.</p>
*
* @param game the active Forge {@link Game}
* @param player the {@link Player} this controller drives
* @param lobbyPlayer the underlying lobby seat
* @param client live {@link PolicyClient}; must not be {@code null}
*/
public PolicyControllerAi(Game game, Player player, LobbyPlayer lobbyPlayer,
PolicyClient client) {
// Parent creates its own AiController; we replace it below via reflection.
super(game, player, lobbyPlayer);
this.policyClient = client;
// Swap the brains field to our policy-aware subclass.
injectPolicyAiController(game, player);
}
/**
* Convenience constructor that builds a {@link PolicyClient} from a URL.
*
* <p>The client's {@link PolicyClient#connect()} is called automatically;
* if the server is unavailable the controller will silently fall back to
* Forge's built-in AI for all decisions.</p>
*
* @param game the active Forge {@link Game}
* @param player the {@link Player} this controller drives
* @param lobbyPlayer the underlying lobby seat
* @param policyServerUrl base URL of the policy server, e.g.
* {@code "http://localhost:8080"}
*/
public PolicyControllerAi(Game game, Player player, LobbyPlayer lobbyPlayer,
String policyServerUrl) {
this(game, player, lobbyPlayer, buildAndConnect(policyServerUrl));
}
// ─────────────────────────────────────────────────────────────────────────
// Lifecycle helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Signal the end of a game so the policy client can submit the terminal
* reward and flush any buffered training tuples.
*
* <p>Call this from your game-runner once Forge reports a winner.</p>
*
* @param reward terminal reward signal: {@code +1.0} for a win,
* {@code -1.0} for a loss, or an intermediate value
*/
public void onGameEnd(double reward) {
try {
policyClient.submitReward(gameId, reward);
policyClient.flushCollectedTuples();
} catch (Exception e) {
LOG.warning("[PolicyControllerAi] onGameEnd error: " + e.getMessage());
} finally {
closeDecisionLog();
}
LOG.info(String.format(
"[PolicyControllerAi] game=%s reward=%.2f decisions=%d stats=%s",
gameId, reward, decisionCount.get(), policyClient.getStatsSummary()));
}
/**
* Returns the unique game identifier used in reward submissions.
*
* @return UUID string
*/
public String getGameId() {
return gameId;
}
/**
* Returns a snapshot of policy statistics for this game session.
*
* @return human-readable stats string from the underlying {@link PolicyClient}
*/
public String getStatsSummary() {
return policyClient.getStatsSummary();
}
// ─────────────────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Uses reflection to replace the {@code brains} field (an {@link AiController})
* that the parent constructor already created with a new
* {@link PolicyAiController}.
*
* <p>This is the only use of reflection in this class. It is necessary
* because {@code PlayerControllerAi} declares {@code brains} as
* {@code private final} and constructs it in its own constructor body,
* before we get a chance to supply a subclass.</p>
*
* <p>If reflection fails for any reason (e.g. a future Forge version adds
* a security manager), the controller falls back to the original
* {@code AiController} that the parent already created. A warning is
* logged so the issue is easy to diagnose.</p>
*
* @param game the active game
* @param player the AI player
*/
private void injectPolicyAiController(Game game, Player player) {
try {
java.lang.reflect.Field brains =
PlayerControllerAi.class.getDeclaredField("brains");
brains.setAccessible(true);
PolicyAiController policyBrains =
new PolicyAiController(player, game, policyClient, this);
brains.set(this, policyBrains);
LOG.info("[PolicyControllerAi] PolicyAiController injected for player="
+ player.getName() + " game=" + gameId);
} catch (Exception e) {
LOG.warning("[PolicyControllerAi] Could not inject PolicyAiController; "
+ "falling back to vanilla Forge AI. Cause: " + e.getMessage());
}
}
/** Build a {@link PolicyClient} and attempt to connect. */
private static PolicyClient buildAndConnect(String serverUrl) {
PolicyClient client = new PolicyClient(serverUrl);
client.connect();
return client;
}
// ─────────────────────────────────────────────────────────────────────────
// Decision logging
// ─────────────────────────────────────────────────────────────────────────
/**
* Write a single JSON-lines decision record to the per-game log file.
* Creates the log directory and file on first call.
*
* @param record the JSON object to append
*/
void logDecision(JsonObject record) {
decisionCount.incrementAndGet();
try {
if (decisionLog == null) {
File dir = new File(LOG_DIR);
//noinspection ResultOfMethodCallIgnored
dir.mkdirs();
File logFile = new File(dir, "decisions-" + gameId + ".jsonl");
decisionLog = new PrintWriter(new FileWriter(logFile, true));
}
decisionLog.println(record.toString());
decisionLog.flush();
} catch (IOException e) {
// Non-fatal: the game continues even if we can't log.
LOG.warning("[PolicyControllerAi] Could not write decision log: "
+ e.getMessage());
}
}
/** Close the decision log file handle. */
private void closeDecisionLog() {
if (decisionLog != null) {
decisionLog.close();
decisionLog = null;
}
}
// ═════════════════════════════════════════════════════════════════════════
// Inner class: PolicyAiController
// ═════════════════════════════════════════════════════════════════════════
/**
* PolicyAiController — intercepts {@link AiController#chooseSpellAbilityToPlay()}
* and routes the decision through the Python policy server.
*
* <h2>Decision flow</h2>
* <ol>
* <li>Enumerate all legal actions (lands + spells/abilities) using
* {@link ComputerUtilAbility} — the same utilities Forge uses
* internally — so the legal-action list is always authoritative.</li>
* <li>Build a {@link JsonObject} describing the full game state,
* including per-player life/mana/zones/stack/phase and a
* {@code legal_actions} array.</li>
* <li>POST to {@code /api/policy/decide} via {@link PolicyClient#decide(JsonObject)}.
* The server returns a macro-action name such as {@code "cast_creature"}.</li>
* <li>Translate the macro-action to a concrete {@link SpellAbility} by
* scanning the legal-action list with
* {@link #mapMacroActionToSpellAbility}.</li>
* <li>On any failure, delegate to the parent
* {@link AiController#chooseSpellAbilityToPlay()} so the game never stalls.</li>
* </ol>
*
* <h2>Thread safety</h2>
* All mutable state is confined to the single game thread that Forge uses
* for AI decisions. The {@link PolicyClient} methods called here are
* themselves thread-safe.
*/
static final class PolicyAiController extends AiController {
/** Maximum age (ms) of a decision before it is considered stale. */
private static final long DECISION_TIMEOUT_MS = 5_000;
private final PolicyClient policyClient;
private final PolicyControllerAi outer;
PolicyAiController(Player player, Game game,
PolicyClient policyClient,
PolicyControllerAi outer) {
super(player, game);
this.policyClient = policyClient;
this.outer = outer;
}
// ─── Main intercept ───────────────────────────────────────────────
/**
* Override of the core AI decision method.
*
* <p>This is called by the Forge game loop whenever the AI player has
* priority and needs to decide whether/what to play. We intercept
* here to route the decision to the policy server.</p>
*
* @return list of {@link SpellAbility} objects for Forge to execute,
* or {@code null} / empty list to pass priority
*/
@Override
public List<SpellAbility> chooseSpellAbilityToPlay() {
long t0 = System.currentTimeMillis();
try {
Player player = getPlayer();
Game game = getGame();
// 1. Enumerate legal actions
List<SpellAbility> legalActions = gatherLegalActions(game, player);
// If there are no legal actions, pass immediately — no server
// round-trip required.
if (legalActions.isEmpty()) {
logAndReturn(null, "pass", "no_legal_actions", t0, legalActions);
return super.chooseSpellAbilityToPlay();
}
// 2. Build game-state JSON with legal actions embedded
JsonObject stateJson = buildGameStateJson(game, player, legalActions);
// 3. Ask the policy server for a macro-action
String macroAction = policyClient.decide(stateJson);
// 4. Map macro-action → concrete SpellAbility
SpellAbility chosen = mapMacroActionToSpellAbility(
macroAction, legalActions, player);
if (chosen != null) {
// Policy succeeded
List<SpellAbility> result = Collections.singletonList(chosen);
logAndReturn(stateJson, macroAction, "policy", t0, result);
return result;
}
// macro-action mapped to "pass" or was unresolvable but not an error
if (MacroActionExecutor.PASS.equals(macroAction)
|| MacroActionExecutor.HOLD_MANA.equals(macroAction)) {
logAndReturn(stateJson, macroAction, "policy_pass", t0, null);
// Return super so Forge can decide whether to pass or play a land
return super.chooseSpellAbilityToPlay();
}
// Macro-action was not mappable to anything concrete; fall back.
logAndReturn(stateJson, macroAction, "fallback_no_map", t0, null);
return super.chooseSpellAbilityToPlay();
} catch (Exception e) {
// Broad catch to guarantee the game never stalls
LOG.warning("[PolicyAiController] Unexpected error: " + e.getMessage());
return super.chooseSpellAbilityToPlay();
}
}
// ─── Legal action enumeration ─────────────────────────────────────
/**
* Gather all legal actions available to {@code player} in the current
* game state, using Forge's own utility methods.
*
* <p>The result combines:</p>
* <ul>
* <li>Land-play abilities (from hand or other sources Forge allows)</li>
* <li>All playable spell/activated abilities from hand and battlefield</li>
* </ul>
*
* @param game the active game
* @param player the acting player
* @return mutable list of legal {@link SpellAbility} objects; never {@code null}
*/
private List<SpellAbility> gatherLegalActions(Game game, Player player) {
List<SpellAbility> actions = new ArrayList<>();
// Land plays — only available during main phases with empty stack
CardCollection lands = ComputerUtilAbility.getAvailableLandsToPlay(game, player);
if (lands != null) {
for (Card land : lands) {
List<SpellAbility> landAbilities =
land.getAllPossibleAbilities(player, true);
landAbilities.removeIf(sa -> !sa.isLandAbility());
actions.addAll(landAbilities);
}
}
// Spells and activated abilities from all available card sources
CardCollection available = ComputerUtilAbility.getAvailableCards(game, player);
if (available != null && !available.isEmpty()) {
List<SpellAbility> spells =
ComputerUtilAbility.getSpellAbilities(available, player);
actions.addAll(spells);
}
return actions;
}
// ─── Snapshot construction ────────────────────────────────────────
/**
* Build a comprehensive game-state {@link JsonObject} suitable for
* POST to {@code /api/policy/decide}.
*
* <p>The JSON schema mirrors the Python-side state encoder:</p>
* <pre>
* {
* "game_id": string,
* "turn": int,
* "phase": string, // e.g. "MAIN1"
* "phase_is_main": bool,
* "stack_size": int,
* "stack": [ { "card": string, "controller": string }, … ],
* "active_seat": int,
* "priority_seat": int,
* "players": [
* {
* "seat": int,
* "name": string,
* "life": int,
* "mana_available": int,
* "commander_damage": int,
* "commander_casts": int,
* "cards_in_hand": int,
* "hand": [ string, … ],
* "battlefield": [ string, … ],
* "graveyard": [ string, … ],
* "command_zone": [ string, … ],
* "creatures_on_field": int,
* "land_count": int,
* "total_power": int,
* "total_toughness": int
* }, …
* ],
* "legal_actions": [
* {
* "index": int,
* "macro": string, // inferred macro-action
* "card_name": string,
* "is_land": bool,
* "mana_cost": string
* }, …
* ]
* }
* </pre>
*
* @param game the active game
* @param player the acting player (seat 0 in the players array)
* @param legalActions the legal actions list produced by
* {@link #gatherLegalActions}
* @return the populated JSON object
*/
private JsonObject buildGameStateJson(Game game, Player player,
List<SpellAbility> legalActions) {
JsonObject root = new JsonObject();
root.addProperty("game_id", outer.gameId);
// ── Phase / turn info ─────────────────────────────────────────
PhaseHandler ph = game.getPhaseHandler();
PhaseType phase = ph.getPhase();
root.addProperty("turn", ph.getTurn());
root.addProperty("phase", phase == null ? "UNKNOWN" : phase.toString());
root.addProperty("phase_is_main",
phase != null && phase.isMain());
// ── Stack info ────────────────────────────────────────────────
int stackSize = game.getStack().size();
root.addProperty("stack_size", stackSize);
JsonArray stackArr = new JsonArray();
for (SpellAbilityStackInstance si : game.getStack()) {
JsonObject se = new JsonObject();
se.addProperty("card", si.getSourceCard() != null
? si.getSourceCard().getName() : "unknown");
se.addProperty("controller", si.getActivatingPlayer() != null
? si.getActivatingPlayer().getName() : "unknown");
stackArr.add(se);
}
root.add("stack", stackArr);
// Seat of the current active (turn) player
Player activePlayer = ph.getPlayerTurn();
int activeSeat = seatIndex(game, activePlayer);
int prioritySeat = seatIndex(game, player);
root.addProperty("active_seat", activeSeat);
root.addProperty("priority_seat", prioritySeat);
// ── Per-player state ──────────────────────────────────────────
JsonArray playersArr = new JsonArray();
List<Player> allPlayers = new ArrayList<>(game.getPlayers());
for (int seat = 0; seat < allPlayers.size(); seat++) {
Player p = allPlayers.get(seat);
playersArr.add(buildPlayerJson(seat, p, game));
}
root.add("players", playersArr);
// ── Legal actions ─────────────────────────────────────────────
JsonArray actionsArr = new JsonArray();
for (int i = 0; i < legalActions.size(); i++) {
SpellAbility sa = legalActions.get(i);
JsonObject ae = new JsonObject();
ae.addProperty("index", i);
ae.addProperty("card_name", sa.getHostCard() != null
? sa.getHostCard().getName() : "unknown");
ae.addProperty("is_land", sa.isLandAbility());
ae.addProperty("mana_cost", sa.getPayCosts() != null
? sa.getPayCosts().getTotalMana().toString() : "");
ae.addProperty("macro", inferMacro(sa));
actionsArr.add(ae);
}
root.add("legal_actions", actionsArr);
return root;
}
/**
* Serialize a single player's observable state into a {@link JsonObject}.
*
* @param seat 0-based seat index in {@code game.getPlayers()}
* @param p the player to serialize
* @param game the active game (for stack/zone queries)
* @return populated player JSON
*/
private JsonObject buildPlayerJson(int seat, Player p, Game game) {
JsonObject pj = new JsonObject();
pj.addProperty("seat", seat);
pj.addProperty("name", p.getName());
pj.addProperty("life", p.getLife());
pj.addProperty("mana_available",
ComputerUtilMana.getAvailableManaEstimate(p));
// Commander damage (from any commander source)
int cdrDamage = 0;
for (Map.Entry<Card, Integer> entry : p.getCommanderDamage()) {
cdrDamage += entry.getValue();
}
pj.addProperty("commander_damage", cdrDamage);
// Commander casts (aggregate across all commanders)
int cdrCasts = 0;
for (Card cdr : p.getCardsIn(ZoneType.Command)) {
cdrCasts += p.getCommanderCast(cdr);
}
pj.addProperty("commander_casts", cdrCasts);
// Zone lists
CardCollectionView hand = p.getCardsIn(ZoneType.Hand);
pj.addProperty("cards_in_hand", hand.size());
pj.add("hand", cardNamesArray(hand));
pj.add("battlefield", cardNamesArray(p.getCardsIn(ZoneType.Battlefield)));
pj.add("graveyard", cardNamesArray(p.getCardsIn(ZoneType.Graveyard)));
pj.add("command_zone", cardNamesArray(p.getCardsIn(ZoneType.Command)));
// Board summary stats
CardCollectionView bf = p.getCardsIn(ZoneType.Battlefield);
int creatures = 0, lands = 0, power = 0, toughness = 0;
for (Card c : bf) {
if (c.isCreature()) {
creatures++;
power += c.getNetPower();
toughness += c.getNetToughness();
}
if (c.isLand()) lands++;
}
pj.addProperty("creatures_on_field", creatures);
pj.addProperty("land_count", lands);
pj.addProperty("total_power", power);
pj.addProperty("total_toughness", toughness);
return pj;
}
// ─── Macro-action mapping ─────────────────────────────────────────
/**
* Map a policy macro-action string back to a concrete {@link SpellAbility}
* from the legal-action list.
*
* <p>The algorithm scans the legal actions list and picks the first
* {@link SpellAbility} whose source card belongs to the category implied
* by the macro-action, using the same card-role databases that
* {@link MacroActionExecutor} uses. This keeps Java and Python in sync.</p>
*
* <p>For {@code CAST_COMMANDER}, the command zone is checked first.</p>
*
* @param macroAction the macro-action returned by the policy server
* @param legalActions all legal actions in the current state
* @param player the acting player (for commander zone check)
* @return the best matching {@link SpellAbility}, or {@code null} if
* no match is found or the macro-action is pass/hold
*/
private SpellAbility mapMacroActionToSpellAbility(
String macroAction,
List<SpellAbility> legalActions,
Player player) {
switch (macroAction) {
// ── Lands ────────────────────────────────────────────────
case MacroActionExecutor.CAST_RAMP: {
// Prefer ramp spells; if none, fall through to a land play
SpellAbility ramp = findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& MacroActionExecutor.isRampCard(sa.getHostCard().getName()));
if (ramp != null) return ramp;
// Land drop counts as ramp when no ramp spell is available
return findFirstMatching(legalActions, SpellAbility::isLandAbility);
}
// ── Creatures ────────────────────────────────────────────
case MacroActionExecutor.CAST_CREATURE: {
return findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& sa.getHostCard().isCreature()
&& !MacroActionExecutor.isRemovalCard(sa.getHostCard().getName())
&& !MacroActionExecutor.isDrawCard(sa.getHostCard().getName())
&& !MacroActionExecutor.isRampCard(sa.getHostCard().getName()));
}
// ── Removal / interaction ─────────────────────────────────
case MacroActionExecutor.CAST_REMOVAL: {
return findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& MacroActionExecutor.isRemovalCard(sa.getHostCard().getName()));
}
// ── Card draw ────────────────────────────────────────────
case MacroActionExecutor.CAST_DRAW: {
return findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& MacroActionExecutor.isDrawCard(sa.getHostCard().getName()));
}
// ── Commander ────────────────────────────────────────────
case MacroActionExecutor.CAST_COMMANDER: {
// Commander spells appear as regular spells in legalActions
// but their source card is in the command zone.
return findFirstMatching(legalActions, sa ->
!sa.isLandAbility()
&& sa.getHostCard() != null
&& sa.getHostCard().isCommander()
&& player.getCardsIn(ZoneType.Command)
.contains(sa.getHostCard()));
}
// ── Pass / hold ──────────────────────────────────────────
case MacroActionExecutor.PASS:
case MacroActionExecutor.HOLD_MANA:
case MacroActionExecutor.ATTACK_OPPONENT:
// Attacks are declared in a different decision point;
// returning null here passes priority gracefully.
return null;
default:
LOG.warning("[PolicyAiController] Unknown macro-action: " + macroAction);
return null;
}
}
// ─── Infer macro from SA (for the legal_actions array) ───────────
/**
* Infer which macro-action category a {@link SpellAbility} belongs to.
* Used when populating the {@code legal_actions[].macro} field in the
* state JSON so the policy server can cross-check.
*
* @param sa the spell ability to classify
* @return macro-action name constant from {@link MacroActionExecutor}
*/
private String inferMacro(SpellAbility sa) {
if (sa.isLandAbility()) return MacroActionExecutor.CAST_RAMP;
Card card = sa.getHostCard();
if (card == null) return MacroActionExecutor.PASS;
String name = card.getName();
if (MacroActionExecutor.isRemovalCard(name)) return MacroActionExecutor.CAST_REMOVAL;
if (MacroActionExecutor.isDrawCard(name)) return MacroActionExecutor.CAST_DRAW;
if (MacroActionExecutor.isRampCard(name)) return MacroActionExecutor.CAST_RAMP;
if (card.isCommander()) return MacroActionExecutor.CAST_COMMANDER;
if (card.isCreature()) return MacroActionExecutor.CAST_CREATURE;
return MacroActionExecutor.PASS;
}
// ─── Utility helpers ──────────────────────────────────────────────
/**
* Return the first element of {@code actions} that matches
* {@code predicate}, or {@code null} if none match.
*
* @param actions the list to scan
* @param predicate the filter predicate
* @return first match or {@code null}
*/
private SpellAbility findFirstMatching(
List<SpellAbility> actions,
java.util.function.Predicate<SpellAbility> predicate) {
for (SpellAbility sa : actions) {
if (predicate.test(sa)) return sa;
}
return null;
}
/**
* Return the 0-based seat index of {@code target} in
* {@code game.getPlayers()}, or {@code -1} if not found.
*
* @param game the active game
* @param target the player to look up
* @return seat index
*/
private int seatIndex(Game game, Player target) {
if (target == null) return -1;
List<Player> players = new ArrayList<>(game.getPlayers());
for (int i = 0; i < players.size(); i++) {
if (players.get(i).equals(target)) return i;
}
return -1;
}
/**
* Build a {@link JsonArray} of card names from a zone view,
* suitable for embedding in the state JSON.
*
* @param cards the zone contents
* @return JSON array of name strings
*/
private JsonArray cardNamesArray(CardCollectionView cards) {
JsonArray arr = new JsonArray();
if (cards != null) {
for (Card c : cards) {
arr.add(c.getName());
}
}
return arr;
}
/**
* Write a training-data record and emit a log line for every decision,
* whether it was routed through the policy server or fell back to Forge.
*
* @param stateJson full game-state JSON, or {@code null} for early exits
* @param macro the macro-action chosen
* @param source decision source tag: {@code "policy"}, {@code "fallback_*"}, etc.
* @param t0 timestamp (ms) when this decision cycle started
* @param result the final list of abilities returned (may be {@code null})
*/
private void logAndReturn(JsonObject stateJson, String macro,
String source, long t0,
List<SpellAbility> result) {
long elapsed = System.currentTimeMillis() - t0;
// Build a compact log record for training data
JsonObject record = new JsonObject();
record.addProperty("game_id", outer.gameId);
record.addProperty("ts", Instant.now().toString());
record.addProperty("macro", macro);
record.addProperty("source", source);
record.addProperty("latency_ms", elapsed);
if (result != null && !result.isEmpty()) {
SpellAbility sa = result.get(0);
record.addProperty("card",
sa.getHostCard() != null ? sa.getHostCard().getName() : "land");
}
if (stateJson != null && stateJson.has("turn")) {
record.addProperty("turn", stateJson.get("turn").getAsInt());
record.addProperty("phase", stateJson.get("phase").getAsString());
}
outer.logDecision(record);
LOG.fine(String.format(
"[PolicyAiController] %s → %s (source=%s, %dms)",
macro,
result != null && !result.isEmpty() && result.get(0).getHostCard() != null
? result.get(0).getHostCard().getName() : "pass",
source, elapsed));
}
}
// ── end PolicyAiController ─────────────────────────────────────────────
}
File diff suppressed because it is too large. Load diff
@@ -5,7 +5,6 @@ import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import commanderailab.ai.PolicyClient;
import commanderailab.ai.PolicyControllerAi;
import java.io.*;
import java.net.HttpURLConnection;
@@ -31,13 +30,9 @@ import java.util.regex.*;
* waits on {@code humanActionQueue}, converts the action to the
* macro-action string expected by /api/policy/decide, and injects it
* via POST /api/policy/override so the policy server returns it.
* 4. AI seats (1+) are intercepted at every Priority event: the session
* calls policyClient.decide() with the current state snapshot and
* immediately POSTs the result to /api/policy/override so Forge uses
* the learned policy instead of its built-in AI.
* 5. Publishes a full state snapshot to the WebSocket frontend after every
* 4. Publishes a full state snapshot to the WebSocket frontend after every
* meaningful Forge output event.
* 6. Falls back to the synthetic stub loop if Forge cannot be started
* 5. Falls back to the synthetic stub loop if Forge cannot be started
* (e.g., missing JAR, wrong Java version).
*
* Zone sync model:
@@ -125,12 +120,6 @@ public class GameSession {
private volatile boolean gameOver = false;
private volatile long elapsedMs = 0;
// Tracks whether policy was injected for the current priority window
// to avoid double-injecting on duplicate log lines.
private volatile int lastPolicyInjectionTurn = -1;
private volatile String lastPolicyInjectionPhase = "";
private volatile int lastPolicyInjectionSeat = -1;
private final int numSeats;
private final int[] life;
private final int[] poison;
@@ -257,12 +246,6 @@ public class GameSession {
* If that fails (missing JAR, wrong Java, etc.) falls back to the
* synthetic stub loop so the WebSocket session never hangs.
*
* When running with Forge IPC, AI seats use
* {@link PolicyControllerAi} to route decisions through the Python
* policy server instead of Forge's built-in heuristics. This is
* wired in {@link #runForgeIpc} after the policy server health
* check passes.
*
* @param onStateChange called after every meaningful game event with
* the latest state snapshot
*/
@@ -270,7 +253,6 @@ public class GameSession {
running = true;
log("[Session] Starting game. Decks: " + deckNames);
log("[Session] Policy server: " + policyServerUrl);
log("[Session] PolicyControllerAi available for Forge AI interception");
boolean forgeAvailable = forgeJarPath != null
&& !forgeJarPath.isBlank()
@@ -283,12 +265,6 @@ public class GameSession {
runSyntheticFallback(onStateChange);
}
// Submit end-of-game reward via PolicyControllerAi if available
if (gameOver && policyClient.isServerAvailable()) {
double reward = winningSeat != null && winningSeat != 0 ? 1.0 : -0.5;
log(String.format("[Session] PolicyControllerAi game-end reward=%.1f", reward));
}
running = false;
log(String.format("[Session] Game ended. turns=%d winner=%s",
turnNumber, winningSeat != null ? "seat-" + winningSeat : "none"));
@@ -333,17 +309,6 @@ public class GameSession {
watchdog.setDaemon(true);
watchdog.start();
// Connect to policy server before the game loop begins.
// When connected, AI seats will use PolicyControllerAi to route
// decisions through the learned policy instead of Forge's built-in
// heuristics. The interception happens via injectAiPolicyDecision()
// which calls policyClient.decide() at each Priority log event.
boolean policyConnected = policyClient.connect();
log("[Session] Policy server connection: " + (policyConnected ? "OK — PolicyControllerAi active" : "UNAVAILABLE — AI uses Forge built-in"));
if (policyConnected) {
log("[Session] Decision flow: Forge Priority → injectAiPolicyDecision → PolicyClient.decide → /api/policy/decide → macro-action → override");
}
// Emit initial state so UI shows a board immediately
onStateChange.accept(buildStateSnapshot());
@@ -361,29 +326,19 @@ public class GameSession {
reader.setDaemon(true);
reader.start();
// Main loop: drain line queue and service human/AI input
// Main loop: drain line queue and service human input
while (!gameOver && running) {
String line = lineQueue.poll(200, TimeUnit.MILLISECONDS);
if (line != null) {
lastActivityEpoch.incrementAndGet();
boolean changed = parseForgeLine(line);
if (changed) onStateChange.accept(buildStateSnapshot());
// Human seat
if (awaitingHumanInput) handleHumanTurn(onStateChange);
} else if (forgeDone.get() && lineQueue.isEmpty()) {
break;
}
}
// Flush online learning tuples and submit reward if game finished
if (gameOver && policyClient.isServerAvailable()) {
double reward = winningSeat != null && winningSeat != 0 ? 1.0 : -0.5;
policyClient.submitReward("game-" + seed, reward);
policyClient.flushCollectedTuples();
log(String.format("[Session] Submitted game reward=%.1f and flushed %d tuples",
reward, policyClient.getPendingTuples()));
}
watchdog.interrupt();
try { forgeProcess.waitFor(10, TimeUnit.SECONDS); } catch (InterruptedException ignored) {}
@@ -477,28 +432,8 @@ public class GameSession {
// Priority
Matcher mPrio = P_PRIORITY.matcher(line);
if (mPrio.find()) {
int seat = clampSeat(Integer.parseInt(mPrio.group(1)) - 1);
priorityPlayer = seat;
priorityPlayer = clampSeat(Integer.parseInt(mPrio.group(1)) - 1);
changed = true;
// ── Policy injection for AI seats ────────────────────────
// Forge doesn't honour --policyServer natively, so we intercept
// every AI priority window here and push the policy decision to
// /api/policy/override before Forge's next read.
if (seat != 0 && policyClient.isServerAvailable()) {
// Deduplicate: only inject once per (turn, phase, seat) window
boolean alreadyInjected =
lastPolicyInjectionTurn == turnNumber
&& lastPolicyInjectionPhase.equals(phase)
&& lastPolicyInjectionSeat == seat;
if (!alreadyInjected) {
lastPolicyInjectionTurn = turnNumber;
lastPolicyInjectionPhase = phase;
lastPolicyInjectionSeat = seat;
injectAiPolicyDecision(seat);
}
}
}
// Life totals
@@ -600,49 +535,6 @@ public class GameSession {
return changed;
}
// ----------------------------------------------------------------
// AI policy injection (real Forge IPC path)
// ----------------------------------------------------------------
/**
* Ask the Python policy server what action AI seat {@code seat} should
* take given the current game state, then POST it to /api/policy/override
* so that the next time Forge calls /api/policy/decide for this seat it
* receives the pre-computed answer without an extra round-trip.
*
* This is the core wiring that was missing: Forge's sim mode does not
* natively call --policyServer, so we intercept every Priority log line
* and drive the decision from the Java side instead.
*/
private void injectAiPolicyDecision(int seat) {
try {
PolicyClient.PolicyDecision decision =
policyClient.decide(GSON.toJson(buildStateSnapshot()), seat);
if (decision == null || decision.action() == null) {
log(String.format("[Policy] seat=%d turn=%d phase=%s → null decision, skipping override",
seat, turnNumber, phase));
return;
}
String action = decision.action();
log(String.format("[Policy] seat=%d turn=%d phase=%s → %s (conf=%.2f)",
seat, turnNumber, phase, action, decision.confidence()));
// Push the decision to the override endpoint so Forge's next
// /api/policy/decide call for this seat returns it immediately.
JsonObject body = new JsonObject();
body.addProperty("seat", seat);
body.addProperty("action", action);
httpPost(policyServerUrl + "/api/policy/override", body.toString());
} catch (Exception e) {
// Non-fatal: Forge falls back to its built-in AI for this window
log(String.format("[Policy] seat=%d override failed (%s) — Forge built-in takes over",
seat, e.getMessage()));
}
}
// ----------------------------------------------------------------
// Human turn handling
// ----------------------------------------------------------------
@@ -751,13 +643,6 @@ public class GameSession {
} catch (Exception ignored) {}
}
/**
* Ask the policy server for the best action for {@code seat}.
*
* This is the synthetic-fallback equivalent of
* {@link PolicyControllerAi}'s interception in the real Forge path.
* Both paths ultimately call {@link PolicyClient#decide}.
*/
private String consultsPolicy(int seat) {
try {
PolicyClient.PolicyDecision d = policyClient.decide(GSON.toJson(buildStateSnapshot()), seat);
+4 -11
View File
@@ -348,12 +348,8 @@ public class LabCli implements Callable<Integer> {
// v4: Enable ML decision logging if requested
if (mlLog) {
String batchId = UUID.randomUUID().toString().substring(0, 8);
// Use absolute path so JSONL goes to commander-ai-lab/results/
// regardless of CWD (which is forgeWorkDir for the subprocess)
String mlResultsDir = new File(outputPath).getAbsoluteFile().getParent();
if (mlResultsDir == null) mlResultsDir = "results";
mtRunner.enableMlLogging(mlResultsDir, batchId);
System.out.println("[ML] Decision logging enabled (multi-thread) — " + mlResultsDir);
mtRunner.enableMlLogging("results", batchId);
System.out.println("[ML] Decision logging enabled (multi-thread) — data will be saved to results/");
}
games = mtRunner.runBatch(numGames, masterSeed);
} else {
@@ -365,11 +361,8 @@ public class LabCli implements Callable<Integer> {
// v4: Enable ML decision logging if requested
if (mlLog) {
String batchId = UUID.randomUUID().toString().substring(0, 8);
// Use absolute path so JSONL goes to commander-ai-lab/results/
String mlResultsDir = new File(outputPath).getAbsoluteFile().getParent();
if (mlResultsDir == null) mlResultsDir = "results";
runner.enableMlLogging(mlResultsDir, batchId);
System.out.println("[ML] Decision logging enabled — " + mlResultsDir);
runner.enableMlLogging("results", batchId);
System.out.println("[ML] Decision logging enabled — data will be saved to results/");
}
games = runner.runBatchSingleThread(numGames, masterSeed);
@@ -31,28 +31,25 @@ public class DecisionExtractor {
// ── Patterns ──────────────────────────────────────────────
// Turn boundary: "Turn 5 (Ai(1)-Name)" or "Turn: Turn 5 (Ai(1)-Name)"
// Forge verbose output prefixes with "Turn: "
// Turn boundary: "Turn 5 (Ai(1)-Name)" or "== Turn 5 (Ai(1)-Name) =="
private static final Pattern TURN_PATTERN =
Pattern.compile("Turn(?::\\s*Turn)?\\s+(\\d+)\\s+\\(Ai\\((\\d+)\\)-", Pattern.CASE_INSENSITIVE);
Pattern.compile("Turn\\s+(\\d+)\\s+\\(Ai\\((\\d+)\\)-", Pattern.CASE_INSENSITIVE);
// Phase indicator: various Forge phase strings
private static final Pattern PHASE_PATTERN =
Pattern.compile("(?:Phase|Step):\\s*(.*?)(?:\\s*$|\\s*-)", Pattern.CASE_INSENSITIVE);
// Cast: "Ai(1)-Name casts CardName." or "Add To Stack: Ai(1)-Name cast CardName"
// Forge verbose output uses "cast" (no s) and may have "Add To Stack:" prefix
// Cast: "Ai(1)-Name casts CardName."
private static final Pattern CAST_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+casts?\\s+(.+?)(?:\\s*\\(\\d+\\))?(?:\\.|$)", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+casts\\s+(.+?)\\.", Pattern.CASE_INSENSITIVE);
// Land play: "Ai(1)-Name plays LandName." or "Land: Ai(1)-Name played LandName (76)"
// Forge verbose output uses "played" and may have "Land:" prefix + card ID suffix
// Land play: "Ai(1)-Name plays LandName."
private static final Pattern LAND_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+play(?:s|ed)\\s+(.+?)(?:\\s*\\(\\d+\\))?(?:\\.|$)", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+plays\\s+(.+?)\\.", Pattern.CASE_INSENSITIVE);
// Draw: "Ai(1)-Name draws CardName." or "Ai(1)-Name drew CardName"
// Draw: "Ai(1)-Name draws CardName."
private static final Pattern DRAW_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+(?:draws|drew)\\s+(.+?)(?:\\s*\\(\\d+\\))?(?:\\.|$)", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+draws\\s+(.+?)\\.", Pattern.CASE_INSENSITIVE);
// Attack: "Ai(1)-Name attacks with" or "declares attackers"
private static final Pattern ATTACK_PATTERN =
@@ -14,7 +14,7 @@ import java.util.*;
* Each game's decisions are appended as JSON lines to a batch-specific file.
* Format: one DecisionSnapshot JSON object per line (JSONL/ndjson).
*
* Output path: results/ml-decisions-sim-{batchId}.jsonl
* Output path: results/ml-decisions-{batchId}.jsonl
*
* Usage from BatchRunner:
* DecisionLogger logger = new DecisionLogger(resultsDir, batchId);
@@ -39,10 +39,7 @@ public class DecisionLogger {
if (!Files.exists(dir)) {
Files.createDirectories(dir);
}
// Prefix with "sim-" so the Python dataset_builder.py discovers
// these files under the "forge" source category. The builder scans
// for ml-decisions-sim-*.jsonl → source = "forge".
this.outputPath = dir.resolve("ml-decisions-sim-" + batchId + ".jsonl");
this.outputPath = dir.resolve("ml-decisions-" + batchId + ".jsonl");
this.writer = Files.newBufferedWriter(outputPath, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
@@ -59,13 +59,7 @@ public class LearnedPolicy implements AiPolicy {
public LearnedPolicy(String serverUrl) {
this.client = new PolicyServerClient(serverUrl);
this.playstyle = "midrange";
// Default to stochastic sampling (greedy=false) so the policy
// network actually explores its action distribution during live
// games. With greedy=true the AI always picks argmax, which
// combined with a pass/land-biased policy causes the "AI only
// plays lands" collapse. Callers that want deterministic
// replay can still override via the 3-arg constructor.
this.greedy = false;
this.greedy = true;
this.policyServerAvailable = false;
}
@@ -267,43 +267,6 @@ public class MacroActionExecutor {
return RAMP_CARDS.contains(card);
}
/**
* Public card-role check: returns {@code true} if {@code cardName} is
* in the known removal-card database.
*
* <p>Used by {@code PolicyControllerAi} to map macro-actions back to
* concrete {@link forge.game.spellability.SpellAbility} objects without
* duplicating the database here.</p>
*
* @param cardName the exact English card name
* @return {@code true} if this card is classified as removal
*/
public static boolean isRemovalCard(String cardName) {
return REMOVAL_CARDS.contains(cardName);
}
/**
* Public card-role check: returns {@code true} if {@code cardName} is
* in the known draw-spell database.
*
* @param cardName the exact English card name
* @return {@code true} if this card is classified as a draw spell
*/
public static boolean isDrawCard(String cardName) {
return DRAW_CARDS.contains(cardName);
}
/**
* Public card-role check: returns {@code true} if {@code cardName} is
* in the known ramp-spell database.
*
* @param cardName the exact English card name
* @return {@code true} if this card is classified as ramp
*/
public static boolean isRampCard(String cardName) {
return RAMP_CARDS.contains(cardName);
}
/**
* Get the macro-action name from an index (matching Python IDX_TO_ACTION).
*/
+24 -60
View File
@@ -7,63 +7,33 @@ REM Starts the Python FastAPI server + serves the web UI.
REM Prerequisites:
REM - Python 3.10+ with: pip install fastapi uvicorn
REM - Java 17+
REM - Forge built: mvn package "-DskipTests"
REM - commander-ai-lab built: mvn package "-DskipTests"
REM
REM All paths are resolved RELATIVE to this script's location.
REM No hardcoded drive letters — works on any machine.
REM Edit the paths below to match your system.
REM ═══════════════════════════════════════════════════════════
REM ── Resolve root from script location (portable, any drive) ─
set SCRIPT_DIR=%~dp0
set FORGE_COMMANDER_ROOT=%SCRIPT_DIR%..
REM ── Forge paths (auto-resolved, no hardcoded drive) ─────────
set FORGE_JAR=%FORGE_COMMANDER_ROOT%\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
set FORGE_DIR=%FORGE_COMMANDER_ROOT%\forge-repo
set FORGE_PROFILE=%FORGE_COMMANDER_ROOT%\forge-repo\forge-gui-desktop\target\res\forge.profile.properties
REM ── Deck paths: all inside forge-repo for full portability ──
set FORGE_USER_DATA=%FORGE_COMMANDER_ROOT%\forge-repo\userdata
set FORGE_DECKS_DIR=%FORGE_USER_DATA%\decks
set PRECON_DECKS_DIR=%SCRIPT_DIR%precon-decks
REM ── Lab server port ──────────────────────────────────────────
REM ── Your Forge paths (EDIT THESE) ──────────────────────────
set FORGE_JAR=D:\ForgeCommander\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
set FORGE_DIR=D:\ForgeCommander\forge-repo
set FORGE_DECKS_DIR=%APPDATA%\Forge\decks\commander
REM ── Lab JAR (auto-detected from target/) ─────────────────────
set LAB_PORT=8080
REM ── API Keys (set via environment variables or .env file) ──
REM If not already set in your environment, create a .env file
REM in this directory (see .env.example) or set them manually:
REM set XIMILAR_API_KEY=your-ximilar-key-here
REM set ANTHROPIC_API_KEY=your-anthropic-api-key-here
REM
REM Load from .env file if it exists and vars aren't already set
if not defined XIMILAR_API_KEY (
if exist "%SCRIPT_DIR%.env" (
for /f "usebackq tokens=1,* delims==" %%A in ("%SCRIPT_DIR%.env") do (
if exist "%~dp0.env" (
for /f "usebackq tokens=1,* delims==" %%A in ("%~dp0.env") do (
if "%%A"=="XIMILAR_API_KEY" set XIMILAR_API_KEY=%%B
if "%%A"=="ANTHROPIC_API_KEY" set ANTHROPIC_API_KEY=%%B
)
)
)
REM ═══════════════════════════════════════════════════════════
REM Write forge.profile.properties so Forge resolves decks from
REM forge-repo\userdata instead of %APPDATA%\Forge.
REM Backslashes are doubled as required by .properties format.
REM This is rewritten every launch so it always matches the
REM current machine's absolute path.
REM ═══════════════════════════════════════════════════════════
mkdir "%FORGE_USER_DATA%\decks\commander" 2>nul
REM Build escaped paths for .properties file
set _UD=%FORGE_USER_DATA%
set _DD=%FORGE_DECKS_DIR%
setlocal enabledelayedexpansion
set _UD_ESC=!_UD:\=\\!
set _DD_ESC=!_DD:\=\\!
(echo userDir=!_UD_ESC!) > "%FORGE_PROFILE%"
(echo decksDir=!_DD_ESC!) >> "%FORGE_PROFILE%"
endlocal
REM ── Copy precon .dck files into Forge's commander deck folder
REM (idempotent — safe to run every launch)
xcopy /Y /Q "%PRECON_DECKS_DIR%\*.dck" "%FORGE_DECKS_DIR%\commander\" >nul 2>&1
echo Synced precon decks to Forge commander folder.
echo.
echo ╔══════════════════════════════════════════════════╗
echo ║ Commander AI Lab — Launcher ║
@@ -72,17 +42,12 @@ echo.
echo Forge JAR: %FORGE_JAR%
echo Forge Dir: %FORGE_DIR%
echo Decks Dir: %FORGE_DECKS_DIR%
echo Precon Dir: %PRECON_DECKS_DIR%
echo Port: %LAB_PORT%
if defined XIMILAR_API_KEY (
echo Ximilar: Configured
) else (
echo Ximilar: Not configured
)
echo Ximilar: Configured
if defined ANTHROPIC_API_KEY (
echo Claude: Configured
echo Claude Opus: Configured
) else (
echo Claude: Not configured ^(set ANTHROPIC_API_KEY for AI features^)
echo Claude Opus: Not configured ^(set ANTHROPIC_API_KEY env var for AI features^)
)
echo.
@@ -104,14 +69,15 @@ if %ERRORLEVEL% neq 0 (
if not exist "%FORGE_JAR%" (
echo ERROR: Forge JAR not found at: %FORGE_JAR%
echo Run Forge build first:
echo cd %FORGE_DIR%
echo cd D:\ForgeCommander\forge-repo
echo mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
pause
exit /b 1
)
if not exist "%SCRIPT_DIR%lab_api.py" (
echo ERROR: lab_api.py not found in %SCRIPT_DIR%
REM ── Check if lab_api.py exists ─────────────────────────────
if not exist "%~dp0lab_api.py" (
echo ERROR: lab_api.py not found in %~dp0
pause
exit /b 1
)
@@ -132,20 +98,18 @@ echo Press Ctrl+C to stop.
echo.
if defined ANTHROPIC_API_KEY (
python "%SCRIPT_DIR%lab_api.py" ^
python "%~dp0lab_api.py" ^
--forge-jar "%FORGE_JAR%" ^
--forge-dir "%FORGE_DIR%" ^
--forge-decks-dir "%FORGE_DECKS_DIR%" ^
--precon-dir "%PRECON_DECKS_DIR%" ^
--port %LAB_PORT% ^
--ximilar-key "%XIMILAR_API_KEY%" ^
--anthropic-key "%ANTHROPIC_API_KEY%"
) else (
python "%SCRIPT_DIR%lab_api.py" ^
python "%~dp0lab_api.py" ^
--forge-jar "%FORGE_JAR%" ^
--forge-dir "%FORGE_DIR%" ^
--forge-decks-dir "%FORGE_DECKS_DIR%" ^
--precon-dir "%PRECON_DECKS_DIR%" ^
--port %LAB_PORT% ^
--ximilar-key "%XIMILAR_API_KEY%"
)
+2 -2
View File
@@ -32,7 +32,7 @@ def _fake_build_result():
class TestDeckBuilderAdapter:
def test_init_defaults(self):
adapter = DeckBuilderAdapter()
assert adapter.model == "claude-opus-4-20250514"
assert adapter.model == "gpt-oss:20b"
assert adapter.db_conn_factory is None
@patch("src.commander_ai_lab.deck_builder.adapter.build_deck")
@@ -45,7 +45,7 @@ class TestDeckBuilderAdapter:
assert result["commander"]["name"] == "Atraxa"
assert "cards" in result
assert "stats" in result
assert result["model"] == "claude-opus-4-20250514"
assert result["model"] == "gpt-oss:20b"
assert result["build_time_seconds"] == 1.23
assert "scryfall" in result["reasoning"]["sources"]
-597
View File
@@ -1,597 +0,0 @@
[
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-01a993df.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-02ce51bc.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-050da93a.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-07325fb5.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-0791d58e.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-083a55e5.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-09f6ab6b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-0d39aa71.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-0dadbf18.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-12802330.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-186b4c2d.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-19f0c0c3.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-1a0a0840.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-1d001e22.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-1df7d6d8.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-220e4942.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-26f83797.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-291a225a.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-309b1008.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-31557831.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-37d57d02.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-391e4632.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-39ed0be8.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3bb88339.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3c98f83b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3de43fb6.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3e0639f7.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3ea0bfb2.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3f0dc5c4.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-3f2f9f04.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-425a6728.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-46517693.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-471701d5.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-4c709ff1.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-4f5be65a.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-501f6ef9.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-592d3d5a.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-644cc2f1.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-64759781.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-6575cbf7.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-682fa8d1.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-68352096.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-6c982613.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-6cdb4b9b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-6dee8fdf.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-7018972e.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-7088479f.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-74d0f773.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-76303df4.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-76c602e0.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-76edf7a3.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-77551df3.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-77ee4077.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-786ff020.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-7d4ad84d.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-7f74c9f3.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-7fbdfb25.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-7fd1bee8.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-804d0dfe.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-805211db.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-812a5124.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-826a32fb.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-878d0278.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-8dd40e1b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-8fa57d21.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-8fe4d31c.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-91a3ac36.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-92418b72.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-96fb5377.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-9b0adb9d.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-9e3ca48f.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-9ee053b3.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-9ef2148f.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-a153e04e.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-a2676323.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-a374549a.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-a7feffa1.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-ab868d56.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-ab8d55a5.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-aee3677b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-af39ff37.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-b101fe7b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-bab7b2bf.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-bb3e9d83.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-bb9772ca.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-bf0f9371.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-c21b782b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-c3e05ffc.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-c83d2002.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-c879aeab.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-c9d696cc.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-ccc989e9.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-d09dea45.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-d262f1dc.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-d3d97788.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-d68ecb62.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-d79c87bc.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-dbf46854.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-dd5b74d0.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-e1e6c928.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-e1ff699f.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-e738a169.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-efd579c0.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-f06c0e00.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-f144217e.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-f1d0e453.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-f2bdceeb.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-f79c8493.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-fa8a574b.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-fae68ec0.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-sim-fee36e36.jsonl",
"weight": 1,
"source": "sim"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-03449551-bbc.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-04380aa6-f62.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-23db752f-de5.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-310629be-8f7.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-63b719a8-1b0.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-7f50242f-986.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-a2f9bbae-f50.jsonl",
"weight": 4,
"source": "ds"
},
{
"path": "D:\\ForgeCommander\\commander-ai-lab\\results\\ml-decisions-ds-bd331a14-a89.jsonl",
"weight": 4,
"source": "ds"
}
]
+21 -52
View File
@@ -93,24 +93,24 @@ const AiLab = (() => {
</div>
<p class="lab-subtitle">Headless 3-AI batch simulation — stress-test your decks at scale</p>
<!-- v3: Import Bar (collapsed by default) -->
<details class="lab-import-bar">
<summary class="lab-import-bar-label" style="cursor:pointer;">\u2B07 Advanced Import (URL / Text / Precon)</summary>
<!-- v3: Import Bar -->
<div class="lab-import-bar">
<div class="lab-import-bar-label">Import Deck</div>
<div class="lab-import-actions">
<button class="lab-import-action-btn" onclick="AiLab.showUrlImport()" title="Import from Archidekt or EDHREC URL">
<span class="import-icon">\uD83D\uDD17</span> URL Import
<span class="import-icon">🔗</span> URL Import
</button>
<button class="lab-import-action-btn" onclick="AiLab.showMetaPicker()" title="Browse EDHREC average decks for popular commanders">
<span class="import-icon">\uD83D\uDCCA</span> Commander Meta
<span class="import-icon">📊</span> Commander Meta
</button>
<button class="lab-import-action-btn" onclick="AiLab.showTextImport()" title="Paste a card list directly">
<span class="import-icon">\uD83D\uDCCB</span> Text Import
<span class="import-icon">📋</span> Text Import
</button>
<button class="lab-import-action-btn lab-precon-btn" onclick="AiLab.showPreconPicker()" title="Browse and load official Commander precon decks">
<span class="import-icon">\uD83C\uDCB4</span> Precon Decks
<span class="import-icon">🎴</span> Precon Decks
</button>
</div>
</details>
</div>
<!-- v3: URL Import Panel (hidden by default) -->
<div class="lab-url-import" id="lab-url-import">
@@ -403,9 +403,7 @@ const AiLab = (() => {
const data = await res.json();
decks = (data.decks || []).map(d => ({
name: d.name,
commander: d.commander || d.commander_name || d.name.replace(/_/g, ' '),
source: d.source || 'forge',
deck_id: d.deck_id || null,
commander: d.commander || d.name.replace(/_/g, ' '),
}));
}
} catch (err) {
@@ -429,10 +427,9 @@ const AiLab = (() => {
const prev = select.value;
select.innerHTML = '<option value="">— Select Deck —</option>';
// Split into three groups
const imported = availableDecks.filter(d => importedDeckMeta[d.name]);
const myDecks = availableDecks.filter(d => d.source === 'deckbuilder' && !importedDeckMeta[d.name]);
const forgeDecks = availableDecks.filter(d => d.source !== 'deckbuilder' && !importedDeckMeta[d.name]);
// Group: imported decks first
const imported = availableDecks.filter(d => importedDeckMeta[d.name]);
const local = availableDecks.filter(d => !importedDeckMeta[d.name]);
if (imported.length > 0) {
const group = document.createElement('optgroup');
@@ -449,23 +446,10 @@ const AiLab = (() => {
select.appendChild(group);
}
if (myDecks.length > 0) {
const group = document.createElement('optgroup');
group.label = '\uD83D\uDDC2 My Decks (Deck Builder)';
myDecks.forEach(deck => {
const opt = document.createElement('option');
opt.value = deck.name;
opt.textContent = deck.name + (deck.commander ? ` \u2014 ${deck.commander}` : '');
opt.dataset.commander = deck.commander || '';
group.appendChild(opt);
});
select.appendChild(group);
}
if (forgeDecks.length > 0) {
if (local.length > 0) {
const group = document.createElement('optgroup');
group.label = 'Local Decks';
forgeDecks.forEach(deck => {
local.forEach(deck => {
const opt = document.createElement('option');
opt.value = deck.name;
opt.textContent = deck.name;
@@ -851,8 +835,8 @@ const AiLab = (() => {
// Commander art from Scryfall — use imageUrl if backend provided it,
// otherwise build a fuzzy lookup URL from the commander name
const scryfallFallback = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(p.commander)}&format=image&version=art_crop`;
const artUrl = p.imageUrl || scryfallFallback;
const artUrl = p.imageUrl
|| `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(p.commander)}&format=image&version=art_crop`;
return `
<div class="lab-precon-card ${installClass}" onclick="AiLab.installPrecon('${escapeAttr(p.fileName)}')">
@@ -870,22 +854,6 @@ const AiLab = (() => {
</div>
</div>`;
}).join('');
loadPreconArt(gridEl);
}
async function loadPreconArt(gridEl) {
const imgs = gridEl.querySelectorAll('img.precon-card-art[data-commander]');
for (const img of imgs) {
const commander = img.dataset.commander;
if (!commander) continue;
try {
const res = await fetch(`${API_BASE}/card-image/${encodeURIComponent(commander)}`);
if (res.ok) {
const data = await res.json();
if (data.art_url) { img.src = data.art_url; img.style.display = ''; }
}
} catch (_) { /* keep Scryfall fallback */ }
}
}
async function installPrecon(fileName) {
@@ -1397,8 +1365,9 @@ const AiLab = (() => {
const data = await res.json();
const logEl = document.getElementById('lab-log-content');
if (logEl && data.lines) {
// Show all lines from the backend (up to 200)
logEl.textContent = data.lines.join('\n');
// Show last N lines
const tail = data.lines.slice(-30);
logEl.textContent = tail.join('\n');
logEl.scrollTop = logEl.scrollHeight;
}
} catch { /* ignore */ }
@@ -1739,7 +1708,7 @@ const AiLab = (() => {
// Y-axis labels (0-100%)
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
for (let pct = 0; pct <= 100; pct += 25) {
const y = canvas.height - padding.bottom - (pct / 100) * h;
@@ -1785,7 +1754,7 @@ const AiLab = (() => {
const ly = canvas.height - padding.bottom - (lastPt.winRate / 100) * h;
ctx.fillStyle = colors[di % colors.length];
ctx.textAlign = 'left';
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
ctx.fillText(data.deckName, lx + 6, ly + 4);
});
}
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab — Deck Coach</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=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<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="coach.css" />
</head>
+6 -6
View File
@@ -810,7 +810,7 @@
}
.coll-td-price {
font-family: var(--font-mono);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
text-align: right;
white-space: nowrap;
}
@@ -840,7 +840,7 @@
.coll-td-pt {
text-align: center;
white-space: nowrap;
font-family: var(--font-mono);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
}
@@ -861,7 +861,7 @@
.coll-td-edhrec {
text-align: right;
font-family: var(--font-mono);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
color: var(--text-secondary);
}
@@ -1872,7 +1872,7 @@
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px;
font-family: var(--font-mono);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
resize: vertical;
@@ -1953,7 +1953,7 @@
color: var(--text);
overflow-x: auto;
white-space: pre;
font-family: var(--font-mono);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
margin: 0;
}
@@ -1989,7 +1989,7 @@
font-size: 13px;
font-weight: 600;
color: var(--text);
font-family: var(--font-mono);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}
.coll-mapping-select {
+1 -1
View File
@@ -6,7 +6,7 @@
<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=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<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" />
</head>
+1 -1
View File
@@ -6,7 +6,7 @@
<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=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<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" />
</head>
+1 -1
View File
@@ -1267,7 +1267,7 @@
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--sp-3);
font-family: var(--font-mono);
font-family: 'Courier New', monospace;
font-size: 0.8rem;
scrollbar-width: thin;
}
+8 -22
View File
@@ -6,7 +6,7 @@
<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=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<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" />
</head>
@@ -142,10 +142,11 @@
<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>
<span class="tip-icon" data-tip="The local LLM used to generate the deck list. Larger models produce better card selections but are slower. Requires Ollama running locally with the selected model pulled.">?</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>
<select class="dg-select" id="dg-model">
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
</select>
</div>
</div>
</div>
@@ -195,7 +196,7 @@
<!-- 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 class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
</div>
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
<div class="dg-progress-bar">
@@ -207,23 +208,8 @@
</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>
<!-- Results placeholder — rest generated dynamically by deckgenerator.js -->
<div class="dg-results" id="dg-results" style="display:none"></div>
</section>
</main>
+3 -6
View File
@@ -159,7 +159,7 @@ const DeckGenerator = (() => {
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');
toast('V3 generator not ready — check that the local AI model (gpt-oss:20b) is running via Ollama', 'warning');
}
} catch (e) {
console.warn('V3 status check failed:', e);
@@ -386,7 +386,7 @@ const DeckGenerator = (() => {
// ── Generate Deck ───────────────────────────────────────
async function generateDeck() {
clearLog();
logMessage('Connecting to Claude AI...', 'info');
logMessage('Connecting to local AI model (gpt-oss:20b)...', '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) {
@@ -435,7 +435,7 @@ const DeckGenerator = (() => {
state.isLoading = loading;
$('dg-empty-state').style.display = 'none';
$('dg-loading').style.display = loading ? 'flex' : 'none';
$('dg-results').style.display = loading ? 'none' : 'block';
$('dg-results').style.display = loading ? 'none' : 'none';
$('dg-preview-btn').disabled = loading || !state.commander;
if (text) $('dg-loading-text').textContent = text;
}
@@ -454,7 +454,6 @@ const DeckGenerator = (() => {
function renderStrategyBar(result) {
const bar = $('dg-strategy-bar');
if (!bar) return;
const bracket = result.bracket || {};
const archetype = result.archetype || '';
const strategy = result.strategy_summary || '';
@@ -473,7 +472,6 @@ const DeckGenerator = (() => {
function renderStatsBar(result) {
const stats = result.stats || {};
const container = $('dg-stats-bar');
if (!container) return;
const chips = [
chipHtml('Total', stats.total_cards || 0, ''),
@@ -509,7 +507,6 @@ const DeckGenerator = (() => {
function renderCardGroups(result) {
const container = $('dg-card-groups');
if (!container) return;
const cards = result.cards || [];
const commander = result.commander;
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab — Distillation Loop</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=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<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="distillation.css" />
</head>
+7 -7
View File
@@ -362,7 +362,7 @@ function renderWinRateChart(generations) {
// Y axis labels
const val = maxY - (maxY - minY) * (i / ySteps);
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(val.toFixed(0) + '%', pad.left - 8, y + 4);
}
@@ -437,7 +437,7 @@ function renderWinRateChart(generations) {
}
// Legend
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
const legendX = pad.left + 10;
const legendY = pad.top + 15;
@@ -505,7 +505,7 @@ function renderCompositionChart(generations) {
const val = maxDataset - (maxDataset * i / ySteps);
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(formatNumber(val), pad.left - 8, y + 4);
}
@@ -535,13 +535,13 @@ function renderCompositionChart(generations) {
// X label
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('G' + g.generation, x + barWidth / 2, h - 8);
}
// Legend
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
const legendX = pad.left + 10;
const legendY = pad.top + 12;
@@ -807,7 +807,7 @@ function renderEloChart(entries) {
const val = maxElo - (eloRange * i / ySteps);
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(Math.round(val).toString(), pad.left - 8, y + 4);
}
@@ -861,7 +861,7 @@ function renderEloChart(entries) {
});
// Legend
ctx.font = '11px Barlow, sans-serif';
ctx.font = '11px Inter, sans-serif';
let legendX = pad.left + 10;
const legendY = pad.top + 12;
modelNames.forEach(name => {
+24 -49
View File
@@ -61,8 +61,7 @@
--duration: 180ms;
/* Typography */
--font: 'Barlow', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-display: 'Barlow Condensed', 'Barlow', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
/* Spacing scale */
@@ -95,15 +94,13 @@ body {
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
color: #fff;
font-weight: 700;
line-height: 1.15;
letter-spacing: 0.01em;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 26px; }
h2 { font-size: 19px; }
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); }
@@ -150,12 +147,10 @@ table { border-collapse: collapse; width: 100%; }
display: flex;
align-items: center;
gap: var(--sp-3);
font-family: var(--font-display);
font-size: 17px;
font-weight: 800;
font-size: 15px;
font-weight: 700;
color: #fff;
letter-spacing: 0.02em;
text-transform: uppercase;
letter-spacing: -0.01em;
text-decoration: none;
white-space: nowrap;
margin-right: var(--sp-8);
@@ -182,11 +177,8 @@ table { border-collapse: collapse; width: 100%; }
align-items: center;
gap: var(--sp-2);
padding: 6px 12px;
font-family: var(--font-display);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
border-radius: var(--radius-sm);
white-space: nowrap;
@@ -227,11 +219,9 @@ table { border-collapse: collapse; width: 100%; }
}
.page-header h1 {
font-family: var(--font-display);
font-size: 32px;
font-weight: 800;
letter-spacing: 0.005em;
text-transform: uppercase;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: var(--sp-2);
}
@@ -262,17 +252,13 @@ table { border-collapse: collapse; width: 100%; }
}
.panel-header h2 {
font-family: var(--font-display);
font-size: 17px;
font-weight: 700;
letter-spacing: 0.01em;
text-transform: uppercase;
font-size: 16px;
font-weight: 600;
}
.panel-header .badge {
font-family: var(--font-display);
font-size: 11px;
font-weight: 700;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
@@ -302,9 +288,8 @@ table { border-collapse: collapse; width: 100%; }
}
.stat-label {
font-family: var(--font-display);
font-size: 12px;
font-weight: 600;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -312,9 +297,8 @@ table { border-collapse: collapse; width: 100%; }
}
.stat-value {
font-family: var(--font-display);
font-size: 30px;
font-weight: 800;
font-size: 28px;
font-weight: 700;
color: #fff;
line-height: 1.1;
font-variant-numeric: tabular-nums;
@@ -333,11 +317,8 @@ table { border-collapse: collapse; width: 100%; }
justify-content: center;
gap: var(--sp-2);
padding: 8px 18px;
font-family: var(--font-display);
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
font-size: 13px;
font-weight: 600;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
@@ -411,7 +392,6 @@ table { border-collapse: collapse; width: 100%; }
}
.form-label {
font-family: var(--font-display);
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
@@ -530,9 +510,8 @@ table { border-collapse: collapse; width: 100%; }
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-display);
font-size: 11px;
font-weight: 700;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-transform: uppercase;
@@ -600,9 +579,8 @@ table { border-collapse: collapse; width: 100%; }
.data-table thead th {
text-align: left;
padding: var(--sp-3) var(--sp-4);
font-family: var(--font-display);
font-size: 11px;
font-weight: 700;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -629,11 +607,8 @@ table { border-collapse: collapse; width: 100%; }
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-family: var(--font-display);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
font-weight: 600;
border-radius: var(--radius-sm);
white-space: nowrap;
}
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab</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=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<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="ai-lab.css?v=4" />
<style>
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab — Simulator</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=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<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="simulator.css" />
</head>
+15 -58
View File
@@ -39,41 +39,16 @@
// ── Init: load decks ──
async function loadDecks() {
try {
const res = await fetch('/api/lab/decks');
const res = await fetch('/api/decks');
if (!res.ok) return;
const data = await res.json();
const decks = data.decks || [];
// Split into groups by source
const dbDecks = decks.filter(d => d.source === 'deckbuilder');
const forgeDecks = decks.filter(d => d.source === 'forge');
// My Decks — from Deck Builder (stored in SQLite)
if (dbDecks.length > 0) {
const group = document.createElement('optgroup');
group.label = '\u{1F0CF} My Decks (Deck Builder)';
dbDecks.forEach(d => {
const opt = document.createElement('option');
opt.value = 'db:' + d.deck_id;
opt.textContent = d.name + (d.commander ? ' (' + d.commander + ')' : '');
group.appendChild(opt);
});
deckSelect.appendChild(group);
}
// Forge .dck files (user-decks/, precon-decks/, sample-decks/)
if (forgeDecks.length > 0) {
const group = document.createElement('optgroup');
group.label = '\u{1F4C1} Deck Files (.dck)';
forgeDecks.forEach(d => {
const opt = document.createElement('option');
opt.value = d.name;
opt.textContent = d.name.replace(/_/g, ' ');
group.appendChild(opt);
});
deckSelect.appendChild(group);
}
const decks = data.decks || data || [];
decks.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
opt.textContent = d.name + (d.commander ? ' (' + d.commander + ')' : '');
deckSelect.appendChild(opt);
});
} catch (e) {
console.warn('Could not load decks:', e);
}
@@ -190,17 +165,18 @@
// ── Run from deck ID ──
runBtn.addEventListener('click', async () => {
const val = deckSelect.value;
if (!val) return;
const deckId = parseInt(deckSelect.value);
if (!deckId) return;
const numGames = parseInt(numGamesInput.value) || 10;
const numGames = parseInt(numGamesInput.value) || 10;
const recordLogs = recordLogsChk.checked;
const useDeepSeek = oppSelect.value === 'deepseek';
// If DeepSeek selected but not connected, try to connect first
if (useDeepSeek && !dsConnected) {
await connectDeepSeek();
if (!dsConnected) {
alert('Could not connect to DeepSeek. Make sure Ollama is running at ' + dsApiBase.value);
alert('Could not connect to DeepSeek. Make sure LM Studio is running at ' + dsApiBase.value);
return;
}
await applyDeepSeekConfig();
@@ -213,35 +189,16 @@
hideResults();
try {
let endpoint, body;
if (val.startsWith('db:')) {
// Deck Builder deck — auto-export to user-decks/ first
const deckId = parseInt(val.replace('db:', ''));
const exportRes = await fetch(`/api/decks/${deckId}/export-to-sim`, { method: 'POST' });
const exportData = await exportRes.json();
if (!exportData.success) throw new Error('Export failed: ' + (exportData.detail || 'unknown'));
endpoint = useDeepSeek ? '/api/sim/run-deepseek' : '/api/sim/run-from-deck';
body = { deckId, numGames, recordLogs };
} else {
// File-based deck (already a .dck)
endpoint = useDeepSeek ? '/api/sim/run-deepseek' : '/api/sim/run-from-deck';
body = { deckName: val, numGames, recordLogs };
}
const endpoint = useDeepSeek ? '/api/sim/run-deepseek' : '/api/sim/run-from-deck';
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
body: JSON.stringify({ deckId, numGames, recordLogs }),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
currentSimId = data.simId;
startPolling();
} catch (e) {
alert('Error: ' + e.message);
resetRunBtn();
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Commander AI Lab — ML Training</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=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet" />
<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="training.css" />
</head>
View File
Whitespace-only changes.