Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c67222de1 | ||
|
|
d8238ec11f | ||
|
|
7aed64a660 | ||
|
|
e3497a8f74 | ||
|
|
6a655628c8 | ||
|
|
e3d4eb5785 | ||
|
|
abee551ab4 | ||
|
|
9fdb6d29fb | ||
|
|
2c8c6c26bd | ||
|
|
9ffefd463c | ||
|
|
4c99957e76 | ||
|
|
e5e25a7553 | ||
|
|
8d56feee80 | ||
|
|
837bb3d6f9 | ||
|
|
98b81ceab2 | ||
|
|
fae04a9336 | ||
|
|
8fd155a925 | ||
|
|
34ce9bfbf7 | ||
|
|
07010a71ed | ||
|
|
ac536999eb | ||
|
|
2e43511d87 | ||
|
|
1bebe38e66 | ||
|
|
eca1653024 | ||
|
|
08f1cb7581 | ||
|
|
9d9e8675a9 | ||
|
|
64526b7bee | ||
|
|
e9732d292f | ||
|
|
fa5349ea7c | ||
|
|
1c491bc2b0 | ||
|
|
81fe5108d2 | ||
|
|
61fa37dc48 | ||
|
|
0118950939 | ||
|
|
22cf23b24d | ||
|
|
0010b0f394 | ||
|
|
d0cdafa66a | ||
|
|
bcf558418a | ||
|
|
61ac349445 | ||
|
|
06ce5cc91a | ||
|
|
fc2e9d88d5 | ||
|
|
4b0cd34eda | ||
|
|
83502ce726 | ||
|
|
8df5900ff9 | ||
|
|
864cefb8d1 | ||
|
|
6c03e56794 | ||
|
|
4b92a5f364 | ||
|
|
da0193eb11 | ||
|
|
71e0db4985 | ||
|
|
825946ae75 | ||
|
|
bd4df0c263 | ||
|
|
113b5c63f0 | ||
|
|
3f6d71ecbc | ||
|
|
4246555228 | ||
|
|
8dd9623417 | ||
|
|
817b71f69d | ||
|
|
e71cac9dd1 | ||
|
|
cb38081a8b | ||
|
|
cd9d2eebd8 | ||
|
|
e6f18d4a61 | ||
|
|
4e90337f54 | ||
|
|
a01a70f725 | ||
|
|
4da58681a4 | ||
|
|
224b4b5d5b | ||
|
|
1a34f00574 | ||
|
|
8ec7db1612 | ||
|
|
e0a75e2d04 | ||
|
|
15bd6f42bb | ||
|
|
0aa57ab7d2 | ||
|
|
9f5eb4a99f | ||
|
|
a9f18f53da | ||
|
|
c9e932ef43 | ||
|
|
cb99388e3c | ||
|
|
2e316548e7 | ||
|
|
b46e1e0877 | ||
|
|
e17b877dcc | ||
|
|
6ce21908d3 | ||
|
|
15495483f3 | ||
|
|
540fbbf1b3 | ||
|
|
a5cc7736b4 | ||
|
|
8c6248cf84 | ||
|
|
22d63fd15e | ||
|
|
2dc0ba2d25 | ||
|
|
8f180820f9 | ||
|
|
b283418879 | ||
|
|
61be42ea6d | ||
|
|
3906b93c6a | ||
|
|
af2a71d1a5 | ||
|
|
cf7c872e12 | ||
|
|
c9227dac70 | ||
|
|
34b1ea2e30 | ||
|
|
1c6454dce5 | ||
|
|
464dd732d5 | ||
|
|
f481243c4e | ||
|
|
f49b6cbea5 | ||
|
|
71f316a220 | ||
|
|
9556f9d7d0 | ||
|
|
9de0965c78 | ||
|
|
38bfa51d85 | ||
|
|
c58f561df4 | ||
|
|
6cf1e58018 | ||
|
|
1382d25e46 | ||
|
|
d8f7105471 | ||
|
|
071088715f | ||
|
|
95429d9f35 | ||
|
|
55379a98ef | ||
|
|
173dee6ccc | ||
|
|
34dee24399 | ||
|
|
88f1022d0e | ||
|
|
737863c55d | ||
|
|
e710287c35 | ||
|
|
2e3c1ec0f1 | ||
|
|
21f032d32e | ||
|
|
2881309d41 | ||
|
|
e162d50608 | ||
|
|
49bab7fb0a |
No files matched your search
+7
-7
@@ -6,13 +6,13 @@
|
||||
# Get yours at: https://app.ximilar.com/
|
||||
XIMILAR_API_KEY=your-ximilar-api-key-here
|
||||
|
||||
# Perplexity API Key (AI deck research & generation)
|
||||
# Get yours at: https://www.perplexity.ai/settings/api
|
||||
PPLX_API_KEY=your-perplexity-api-key-here
|
||||
# Perplexity model for deck research & generation (sonar / sonar-pro / sonar-deep-research)
|
||||
PPLX_MODEL=sonar-pro
|
||||
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
|
||||
COACH_PROVIDER=local
|
||||
# Anthropic API Key (AI deck generation & coaching with Claude Opus)
|
||||
# Get yours at: https://console.anthropic.com/settings/keys
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||
# Anthropic model for deck generation and coaching (Claude Opus recommended)
|
||||
ANTHROPIC_MODEL=claude-opus-4-20250514
|
||||
# Coach chat provider: "local" (Ollama) or "anthropic" (Claude API)
|
||||
COACH_PROVIDER=anthropic
|
||||
|
||||
# CORS allowed origins (comma-separated)
|
||||
# Default: http://localhost:5173,http://localhost:8080
|
||||
|
||||
@@ -1,381 +1,180 @@
|
||||
# Commander AI Lab
|
||||
|
||||
Headless batch simulation environment for 3-AI MTG Commander pods.
|
||||
Run 100+ games, analyze win rates, and stress-test your decks — all from a web UI or CLI.
|
||||
A full-stack MTG Commander AI research platform. Simulate hundreds of games overnight, train ML models on the results, and let simulation data automatically shape the AI deck builder — all from a web UI or CLI.
|
||||
|
||||
## What It Does
|
||||
|
||||
| Layer | What it is |
|
||||
|---|---|
|
||||
| **Monte Carlo Engine** | Pure-Python headless sim — runs thousands of games in minutes, no JVM needed |
|
||||
| **Forge Engine** | Java/Forge rules-complete sim for ground-truth validation |
|
||||
| **Overnight Runner** | Unattended batch runner — runs sims, updates sim weights, trains neural net |
|
||||
| **ML Pipeline** | Outcome-weighted RL nudges card scoring weights; neural net trains on decision logs |
|
||||
| **Deck Builder** | 7-step LLM pipeline (EDHrec → Scryfall → Ollama) now injected with sim-learned insights |
|
||||
| **AI Coach** | LLM-powered coaching sessions (Anthropic/Ollama) with streaming support |
|
||||
| **Web UI** | React + FastAPI dashboard for decks, sims, training, and deck building |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Web Browser (ai-lab.js + ai-lab.css) │
|
||||
│ Select decks → Start batch → Poll progress → View stats │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
│ HTTP (REST)
|
||||
┌─────────────────────┴───────────────────────────────────────┐
|
||||
│ Python FastAPI Server (lab_api.py, port 8080) │
|
||||
│ /api/lab/start — spawn Java batch runner subprocess │
|
||||
│ /api/lab/status — poll progress (parses stdout) │
|
||||
│ /api/lab/result — return completed JSON results │
|
||||
│ /api/lab/decks — list decks from Forge user data │
|
||||
│ /api/lab/history— browse past batch runs │
|
||||
│ /api/lab/log — stream live console output │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
│ subprocess (java -jar)
|
||||
┌─────────────────────┴───────────────────────────────────────┐
|
||||
│ Java CLI (LabCli.java + BatchRunner + MultiThreadRunner) │
|
||||
│ Parses Forge sim output → GameResult objects → JSON │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
│ subprocess per game
|
||||
┌─────────────────────┴───────────────────────────────────────┐
|
||||
│ Card Forge Engine (forge-gui-desktop sim mode) │
|
||||
│ java -jar forge.jar sim -d "deck1" "deck2" "deck3" │
|
||||
│ -f commander -n 1 -q │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Web UI (React/TypeScript — frontend/) │
|
||||
│ Deck Builder · Sim Dashboard · Training · Coach │
|
||||
└────────────────────────┬────────────────────────────────────────┘
|
||||
│ HTTP/REST + WebSocket
|
||||
┌────────────────────────┴────────────────────────────────────────┐
|
||||
│ FastAPI Server (lab_api.py / routes/ — port 8080) │
|
||||
│ /api/lab/* /api/ml/* /api/coach/* /api/deck-builder/* │
|
||||
└────────┬──────────────────┬──────────────────┬──────────────────┘
|
||||
│ │ │
|
||||
Java subprocess Python sim engine Ollama (local LLM)
|
||||
(Forge rules) (Monte Carlo) qwen2.5:7b + others
|
||||
│ │
|
||||
┌────────┴───────┐ ┌───────┴────────────────────────────────────┐
|
||||
│ Forge Engine │ │ src/commander_ai_lab/ │
|
||||
│ (rules- │ │ sim/ — engine, models, rules │
|
||||
│ complete) │ │ deck_builder/ — 7-step LLM pipeline │
|
||||
└────────────────┘ │ coach/ — streaming LLM coach │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start (Windows)
|
||||
|
||||
### Prerequisites
|
||||
- **Java 17+** (`java -version`)
|
||||
- **Python 3.10+** (`python --version`)
|
||||
- **Maven 3.8+** (`mvn --version`)
|
||||
- **Forge repo** cloned and built at `D:\ForgeCommander\forge-repo`
|
||||
**Prerequisites:** Java 17+, Python 3.10+, Maven 3.8+, [Forge](https://github.com/Card-Forge/forge) built at `D:\ForgeCommander\forge-repo`
|
||||
|
||||
### 1. Build Forge (one-time)
|
||||
```powershell
|
||||
# 1. Build Forge (one-time)
|
||||
cd D:\ForgeCommander\forge-repo
|
||||
mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
||||
```
|
||||
|
||||
### 2. Build Commander AI Lab
|
||||
```powershell
|
||||
# 2. Build Commander AI Lab
|
||||
cd D:\ForgeCommander\commander-ai-lab
|
||||
mvn package "-DskipTests"
|
||||
```
|
||||
This produces `target/commander-ai-lab-1.0.0-SNAPSHOT.jar` (shaded fat JAR).
|
||||
|
||||
### 3. Install Python dependencies
|
||||
```powershell
|
||||
pip install fastapi uvicorn
|
||||
```
|
||||
# 3. Install Python deps
|
||||
pip install -e .
|
||||
|
||||
### 4. Launch the Web UI
|
||||
**Option A — One-click launcher:**
|
||||
```powershell
|
||||
# 4. Launch
|
||||
.\start-lab.bat
|
||||
# → open http://localhost:8080
|
||||
```
|
||||
Edit the paths at the top of `start-lab.bat` if needed.
|
||||
|
||||
**Option B — Manual launch:**
|
||||
## Overnight AFK Runner
|
||||
|
||||
Runs batch sims unattended, then updates card weights and trains the neural net:
|
||||
|
||||
```powershell
|
||||
python lab_api.py `
|
||||
--forge-jar "D:\ForgeCommander\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
|
||||
--forge-dir "D:\ForgeCommander\forge-repo\forge-gui" `
|
||||
--port 8080
|
||||
python overnight-run.py # default: 11.5h DeepSeek sims
|
||||
python overnight-run.py --batches 20 --games 50 # fixed batch count
|
||||
python overnight-run.py --no-update-weights # skip weight update
|
||||
python overnight-run.py --no-train # skip neural net training
|
||||
```
|
||||
|
||||
Open `http://localhost:8080` — select 3 decks, set game count, click **Run AI Sims**.
|
||||
**What happens after sims complete:**
|
||||
1. `update_weights.py` — nudges `learned_weights.json` from decision logs (feeds deck builder)
|
||||
2. `/api/ml/train` — trains the neural net on the full decision dataset
|
||||
|
||||
## Sim → Deck Builder Feedback Loop
|
||||
|
||||
Simulation outcomes automatically improve deck recommendations:
|
||||
|
||||
```
|
||||
Overnight sims → decisions_*.jsonl
|
||||
↓
|
||||
update_weights.py → learned_weights.json
|
||||
↓
|
||||
sim_insights.py → natural-language insight block
|
||||
↓
|
||||
Ollama prompts (suggest_cards + enforce_deck_ratios)
|
||||
↓
|
||||
Better card recommendations
|
||||
```
|
||||
|
||||
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. No-op until real sim data exists.
|
||||
|
||||
## Deck Builder Pipeline
|
||||
|
||||
The `POST /api/deck-builder/build` endpoint runs 7 steps:
|
||||
|
||||
1. Resolve commander via Scryfall
|
||||
2. Fetch EDHrec recommendations
|
||||
3. Fetch Scryfall candidates by category (ramp, removal, draw, lands)
|
||||
4. Ollama suggests synergy/protection/wincon cards ← **sim insights injected here**
|
||||
5. Filter by color identity, collection, ban list
|
||||
6. Ollama enforces ratios (trim/expand to 99) ← **sim insights injected here**
|
||||
7. Assemble final deck
|
||||
|
||||
## ML Pipeline
|
||||
|
||||
### 5. CLI-only usage (no web server)
|
||||
```powershell
|
||||
cd D:\ForgeCommander\forge-repo\forge-gui
|
||||
# Update sim card weights from decision logs (runs automatically in overnight-run.py)
|
||||
python -m ml.scripts.update_weights --min-games 10 --lr 0.05 --dry-run
|
||||
|
||||
java -jar "D:\ForgeCommander\commander-ai-lab\target\commander-ai-lab-1.0.0-SNAPSHOT.jar" `
|
||||
--forge-jar "..\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
|
||||
--forge-dir "." `
|
||||
--deck1 "Edgar Markov" `
|
||||
--deck2 "Grimgrin" `
|
||||
--deck3 "Xyris" `
|
||||
--games 100 `
|
||||
--threads 4 `
|
||||
--output results/batch-100.json
|
||||
# Reset weights to defaults
|
||||
python -m ml.scripts.update_weights --reset
|
||||
|
||||
# Generate synthetic training data
|
||||
python ml/scripts/generate_synthetic_data.py
|
||||
```
|
||||
|
||||
## Scaling Tests
|
||||
Learned weights are clamped to `[-20.0, 20.0]` and stored in `src/commander_ai_lab/sim/learned_weights.json`.
|
||||
|
||||
### Single-threaded baseline (10 games)
|
||||
```powershell
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
||||
--forge-jar "..." --forge-dir "..." `
|
||||
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
||||
--games 10 --threads 1 --output results/baseline-10.json
|
||||
## Python Simulator API
|
||||
|
||||
```python
|
||||
from commander_ai_lab.lab.experiments import build_deck, run_simulation
|
||||
|
||||
deck = build_deck(["Sol Ring", "Cultivate", "Lightning Bolt"] + ["Forest"] * 36)
|
||||
stats = run_simulation(deck, num_games=100, name_a="My Deck")
|
||||
print(f"Win rate: {stats['win_rate']:.1%}, Avg turns: {stats['avg_turns']:.1f}")
|
||||
```
|
||||
|
||||
### Multi-threaded (10 games, 2 threads)
|
||||
```powershell
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
||||
--forge-jar "..." --forge-dir "..." `
|
||||
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
||||
--games 10 --threads 2 --output results/mt-10-t2.json
|
||||
```
|
||||
|
||||
### Scale to 100 games (4 threads)
|
||||
```powershell
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
||||
--forge-jar "..." --forge-dir "..." `
|
||||
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
||||
--games 100 --threads 4 --output results/scale-100-t4.json
|
||||
```
|
||||
|
||||
### Maximum throughput test (100 games, 8 threads)
|
||||
```powershell
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
|
||||
--forge-jar "..." --forge-dir "..." `
|
||||
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
|
||||
--games 100 --threads 8 --seed 42 --output results/scale-100-t8-seed42.json
|
||||
```
|
||||
|
||||
### Performance comparison script
|
||||
Run different thread counts and compare sims/sec:
|
||||
```powershell
|
||||
# Baseline: 1 thread
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 1 --seed 42 --output results/perf-t1.json
|
||||
|
||||
# 2 threads
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 2 --seed 42 --output results/perf-t2.json
|
||||
|
||||
# 4 threads
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 4 --seed 42 --output results/perf-t4.json
|
||||
|
||||
# 8 threads
|
||||
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 8 --seed 42 --output results/perf-t8.json
|
||||
```
|
||||
|
||||
Then compare the `simsPerSecond` values in each output JSON to see scaling behavior.
|
||||
The Monte Carlo engine models: combat keywords (flying, trample, deathtouch, lifelink, menace, reach, haste), summoning sickness, tapped/untap state, commander zone, multiplayer attack targeting, and board wipes. It does **not** model the stack, instants, or continuous type-changing effects.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/lab/decks` | GET | List available Commander decks |
|
||||
| `/api/lab/start` | POST | Start a batch run (returns batchId) |
|
||||
| `/api/lab/status?batchId=X` | GET | Poll progress for a running batch |
|
||||
| `/api/lab/result?batchId=X` | GET | Get completed batch result JSON |
|
||||
| `/api/lab/log?batchId=X` | GET | Get live log lines from running batch |
|
||||
| `/api/lab/history` | GET | List past batch result files |
|
||||
|
||||
### POST /api/lab/start
|
||||
```json
|
||||
{
|
||||
"decks": ["Edgar Markov", "Grimgrin", "Xyris"],
|
||||
"numGames": 100,
|
||||
"threads": 4,
|
||||
"seed": null,
|
||||
"clock": 120
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/lab/status response
|
||||
```json
|
||||
{
|
||||
"batchId": "abc123",
|
||||
"running": true,
|
||||
"completed": 42,
|
||||
"total": 100,
|
||||
"threads": 4,
|
||||
"elapsedMs": 156000,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
## Python Simulator (Monte Carlo Engine)
|
||||
|
||||
A pure-Python deck simulator for rapid Monte Carlo analysis — no Forge JVM required.
|
||||
Ported from the [mtg-commander-lan](https://github.com/) Node.js simulator.
|
||||
|
||||
### Location
|
||||
|
||||
```
|
||||
src/commander_ai_lab/
|
||||
├── __init__.py
|
||||
├── sim/ # Core simulation engine
|
||||
│ ├── __init__.py # Public exports
|
||||
│ ├── models.py # Card, Player, SimState, GameResult, PlayerStats
|
||||
│ ├── engine.py # GameEngine — turn loop, combat (flying/trample/deathtouch/lifelink/menace/reach)
|
||||
│ └── rules.py # AI_DEFAULT_WEIGHTS, enrich_card(), score_card(), parse_decklist()
|
||||
└── lab/ # High-level experiment helpers
|
||||
├── __init__.py
|
||||
└── experiments.py # build_deck(), run_single_game(), run_simulation(), _cli_main()
|
||||
```
|
||||
|
||||
### Install as editable package
|
||||
|
||||
```powershell
|
||||
cd D:\ForgeCommander\commander-ai-lab
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
This installs `commander_ai_lab` as an importable package and registers the `commander-sim` CLI command.
|
||||
|
||||
### Quick smoke test
|
||||
|
||||
```powershell
|
||||
python -m commander_ai_lab.lab.experiments
|
||||
```
|
||||
|
||||
Expected output — a 10-game Monte Carlo run of a sample "Korvold" deck:
|
||||
|
||||
```
|
||||
══════════════════════════════════════════
|
||||
Commander AI Lab — Experiment Runner
|
||||
══════════════════════════════════════════
|
||||
|
||||
--- Demo: Korvold Aristocrats (10 games) ---
|
||||
|
||||
Win rate: XX.X%
|
||||
Avg turns: XX.X
|
||||
Avg damage dealt: XX.X
|
||||
Avg spells cast: XX.X
|
||||
Avg creatures: XX.X
|
||||
Elapsed: 0.02s
|
||||
|
||||
Done.
|
||||
```
|
||||
|
||||
### Python API
|
||||
|
||||
```python
|
||||
from commander_ai_lab.sim.models import Card
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
from commander_ai_lab.lab.experiments import build_deck, run_simulation
|
||||
|
||||
# Build a deck from card names
|
||||
deck = build_deck(["Sol Ring", "Cultivate", "Lightning Bolt", "Forest"] + ["Forest"] * 36)
|
||||
|
||||
# Monte Carlo simulation (100 games)
|
||||
stats = run_simulation(deck, num_games=100, name_a="My Deck")
|
||||
print(f"Win rate: {stats['win_rate']:.1%}")
|
||||
print(f"Avg turns: {stats['avg_turns']:.1f}")
|
||||
```
|
||||
|
||||
Key functions:
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `build_deck(card_names)` | Create enriched `Card` list from names |
|
||||
| `build_deck_from_text(decklist)` | Parse `"1 Sol Ring\n37 Forest"` format |
|
||||
| `run_single_game(deck_a, deck_b)` | One game, returns winner/turns/stats |
|
||||
| `run_simulation(deck_a, deck_b, n)` | Monte Carlo over `n` games, aggregated stats |
|
||||
|
||||
### CLI entry point
|
||||
|
||||
After `pip install -e .`:
|
||||
|
||||
```powershell
|
||||
commander-sim
|
||||
```
|
||||
|
||||
Runs the same demo as `python -m commander_ai_lab.lab.experiments`.
|
||||
|
||||
### How the engine works
|
||||
|
||||
1. **Deck enrichment** — `enrich_card()` infers type, CMC, keywords, and power/toughness from card name heuristics when Scryfall data is unavailable.
|
||||
2. **AI scoring** — `score_card()` uses weighted categories (removal, ramp, draw, aggro, etc.) to pick the best play each turn.
|
||||
3. **Game loop** — Each turn: untap → draw → play lands → cast spells (AI scored) → combat (attack/block decisions) → end step. Full keyword support for flying, trample, deathtouch, lifelink, menace, and reach.
|
||||
4. **Monte Carlo** — `run_simulation()` runs N independent games and aggregates win rate, avg turns, damage dealt/received, spells cast, creatures played, removal used, ramp played, cards drawn, and max board size.
|
||||
| `/api/lab/start` | POST | Start Forge batch (returns batchId) |
|
||||
| `/api/lab/start-deepseek` | POST | Start Python/DeepSeek batch |
|
||||
| `/api/lab/status?batchId=X` | GET | Poll batch progress |
|
||||
| `/api/lab/result?batchId=X` | GET | Get completed batch JSON |
|
||||
| `/api/lab/history` | GET | List past batch runs |
|
||||
| `/api/ml/train` | POST | Start neural net training |
|
||||
| `/api/ml/train/status` | GET | Poll training progress |
|
||||
| `/api/coach/chat` | POST | LLM coaching session (streaming) |
|
||||
| `/api/deck-builder/build` | POST | Build a 99-card Commander deck |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
commander-ai-lab/
|
||||
├── lab_api.py # Python FastAPI server (web UI backend)
|
||||
├── overnight-run.py # AFK batch runner
|
||||
├── start-lab.bat # Windows one-click launcher
|
||||
├── pom.xml # Maven build (Java 17, shaded JAR)
|
||||
├── pyproject.toml # Python package config (src/ layout)
|
||||
├── ui/
|
||||
│ ├── index.html # Web UI entry point
|
||||
│ ├── ai-lab.js # UI controller (deck selection, progress, results)
|
||||
│ ├── ai-lab.css # Styles (dark theme, responsive)
|
||||
│ ├── collection.html # Card collection manager
|
||||
│ ├── collection.js # Collection controller
|
||||
│ ├── collection.css # Collection styles
|
||||
│ ├── deckbuilder.html # Deck builder + EDH advisor
|
||||
│ ├── deckbuilder.js # Deck builder controller
|
||||
│ └── deckbuilder.css # Deck builder styles
|
||||
├── src/commander_ai_lab/ # Python simulator package
|
||||
│ ├── __init__.py
|
||||
│ ├── sim/ # Core engine (models, engine, rules)
|
||||
│ └── lab/ # Experiment helpers
|
||||
├── src/main/java/commanderailab/ # Java batch runner (Forge integration)
|
||||
│ ├── cli/LabCli.java # CLI entry point (picocli)
|
||||
│ ├── batch/
|
||||
│ │ ├── BatchRunner.java # Single-thread runner (parses Forge output)
|
||||
│ │ └── MultiThreadBatchRunner.java # Multi-thread runner (ExecutorService)
|
||||
│ ├── bridge/WebApiBridge.java # Java-side web API bridge
|
||||
│ ├── schema/
|
||||
│ │ ├── BatchResult.java # Data model POJOs
|
||||
│ │ └── JsonExporter.java # JSON serialization + validation
|
||||
│ ├── stats/StatsAggregator.java # Win rates, turn counts, breakdowns
|
||||
│ └── ai/
|
||||
│ ├── AiPolicy.java # Policy interface
|
||||
│ └── ForgeBuiltinPolicy.java # v1 Forge AI wrapper
|
||||
├── scanner/ # Card scanner (OCR pipeline)
|
||||
│ ├── __init__.py
|
||||
│ ├── ocr.py # Tesseract OCR wrapper
|
||||
│ ├── pipeline.py # Image preprocessing pipeline
|
||||
│ └── preprocess.py # Image preprocessing helpers
|
||||
├── docs/
|
||||
│ ├── PROJECT_VISION.md
|
||||
│ ├── batch-result-schema.json
|
||||
│ └── game-log-schema.json
|
||||
├── sample-decks/ # 3 sample .dck files
|
||||
├── precon-decks/ # Preconstructed decks + index
|
||||
├── results/ # Batch output JSON files (gitignored)
|
||||
└── src/test/java/ # Unit tests
|
||||
├── lab_api.py # FastAPI server entry point
|
||||
├── frontend/ # React + TypeScript web UI
|
||||
├── ui/ # Legacy static HTML UI
|
||||
├── src/commander_ai_lab/
|
||||
│ ├── sim/ # Monte Carlo engine (engine, models, rules)
|
||||
│ ├── deck_builder/ # LLM deck builder pipeline
|
||||
│ │ ├── api/ # Scryfall, EDHrec, Ollama, sim_insights
|
||||
│ │ ├── core/ # Models, rules engine, assembler
|
||||
│ │ └── pipeline/ # build_deck.py (7-step orchestrator)
|
||||
│ └── coach/ # LLM coaching (Anthropic + Ollama)
|
||||
├── ml/
|
||||
│ ├── scripts/ # update_weights.py, ml_cli.py
|
||||
│ ├── training/ # Neural net trainer
|
||||
│ ├── encoder/ # Feature encoding
|
||||
│ └── models/ # Saved model checkpoints
|
||||
├── scanner/ # Card OCR pipeline
|
||||
├── logs/decisions/ # ML decision logs (gitignored)
|
||||
└── src/main/java/commanderailab/ # Java/Forge batch runner
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"schemaVersion": "1.0.0",
|
||||
"batchId": "uuid",
|
||||
"timestamp": "2026-03-06T...",
|
||||
"totalGames": 100,
|
||||
"completedGames": 100,
|
||||
"format": "commander",
|
||||
"podSize": 3,
|
||||
"engineVersion": "forge-2.0.12-SNAPSHOT",
|
||||
"masterSeed": 42,
|
||||
"threads": 4,
|
||||
"elapsedMs": 260000
|
||||
},
|
||||
"decks": [...],
|
||||
"games": [...],
|
||||
"summary": {
|
||||
"perDeck": [
|
||||
{ "deckName": "Edgar Markov", "winRate": 0.60, "wins": 60, ... }
|
||||
],
|
||||
"avgGameTurns": 12.3,
|
||||
"simsPerSecond": 0.38
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
The UI is a React + TypeScript SPA in the `frontend/` directory.
|
||||
|
||||
### Dev server (with hot reload)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Vite proxies `/api` and `/ws` to `localhost:8000`, so start the FastAPI backend first.
|
||||
|
||||
### Production build
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
FastAPI serves the built assets from `frontend/dist/`.
|
||||
|
||||
## Known Limitations
|
||||
- Winner's life total not captured (Forge quiet mode only reports losers)
|
||||
- Win condition classification is heuristic-based on Forge loss reason strings
|
||||
- Each game spawns a new JVM process (~2-3s overhead per game)
|
||||
- Max throughput ~0.04 sims/sec single-threaded (Forge sim is CPU-heavy)
|
||||
- Multithreading spawns parallel subprocesses — throughput scales linearly with threads up to CPU core count
|
||||
|
||||
- **Monte Carlo engine** — no stack, no instants, no continuous type changes (animated lands don't attack)
|
||||
- **Forge engine** — each game spawns a new JVM (~2-3s overhead), ~0.04 sims/sec single-threaded
|
||||
- **Forge winner life total** not captured (quiet mode only reports losers)
|
||||
- **Deck builder** — Ollama suggestions are only as good as the local model; `qwen2.5:7b` works well
|
||||
@@ -1 +1 @@
|
||||
# Coach clients — Perplexity, LM Studio
|
||||
# Coach clients — Anthropic Claude, LM Studio
|
||||
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Commander AI Lab — Anthropic Claude Structured Output Client
|
||||
════════════════════════════════════════════════════════════
|
||||
Uses the anthropic SDK to call Claude's messages endpoint
|
||||
with structured JSON output enforced via system prompt.
|
||||
Supports both sync and native async operation.
|
||||
|
||||
All substantive calls use .messages.stream() to avoid the SDK's
|
||||
10-minute non-streaming timeout restriction on large max_tokens.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import anthropic
|
||||
|
||||
logger = logging.getLogger("coach.anthropic")
|
||||
|
||||
# ── Anthropic Models ───────────────────────────────────────────
|
||||
CLAUDE_OPUS = "claude-opus-4-0-20250514"
|
||||
CLAUDE_SONNET = "claude-sonnet-4-5"
|
||||
CLAUDE_HAIKU = "claude-haiku-4-5"
|
||||
|
||||
|
||||
class AnthropicResponse:
|
||||
"""Parsed response container from Anthropic API.
|
||||
|
||||
Drop-in replacement for PerplexityResponse — same attribute shape
|
||||
so all existing call-sites work without changes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
parsed_json: Optional[dict],
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
model: str,
|
||||
citations: list[str] = None,
|
||||
):
|
||||
self.content = content
|
||||
self.parsed_json = parsed_json
|
||||
self.prompt_tokens = prompt_tokens
|
||||
self.completion_tokens = completion_tokens
|
||||
self.model = model
|
||||
self.citations = citations or [] # Anthropic has no citations; kept for compat
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.parsed_json is not None
|
||||
|
||||
|
||||
class AnthropicClient:
|
||||
"""
|
||||
Anthropic Claude API client.
|
||||
|
||||
Drop-in replacement for PerplexityClient.
|
||||
All substantive calls use .messages.stream() so the SDK never
|
||||
enforces the 10-minute non-streaming timeout.
|
||||
|
||||
Usage:
|
||||
client = AnthropicClient(api_key="sk-ant-...")
|
||||
resp = client.chat_structured(
|
||||
system_prompt="You are a deck builder.",
|
||||
user_prompt="Build a deck for...",
|
||||
json_schema=deck_schema,
|
||||
schema_name="DeckList",
|
||||
)
|
||||
if resp.ok:
|
||||
deck = resp.parsed_json
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
model: str = CLAUDE_OPUS,
|
||||
timeout: int = 120,
|
||||
base_url: str = None,
|
||||
):
|
||||
if not api_key:
|
||||
raise ValueError("Anthropic API key is required")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self._client = anthropic.Anthropic(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
timeout=float(timeout),
|
||||
)
|
||||
self._async_client = anthropic.AsyncAnthropic(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
timeout=float(timeout),
|
||||
)
|
||||
|
||||
def chat_structured(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
json_schema: dict = None,
|
||||
schema_name: str = "response",
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 8192,
|
||||
) -> AnthropicResponse:
|
||||
"""
|
||||
Send a chat request expecting JSON output.
|
||||
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
|
||||
"""
|
||||
use_model = model or self.model
|
||||
effective_system = self._build_system(system_prompt, json_schema, schema_name)
|
||||
|
||||
try:
|
||||
with self._client.messages.stream(
|
||||
model=use_model,
|
||||
system=effective_system,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
) as stream:
|
||||
content = stream.get_final_text()
|
||||
final_msg = stream.get_final_message()
|
||||
return self._parse_streamed(content, final_msg, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] chat_structured failed: %s", e)
|
||||
raise
|
||||
|
||||
def chat_plain(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 4096,
|
||||
) -> AnthropicResponse:
|
||||
"""
|
||||
Send a plain chat request without strict structured output.
|
||||
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
|
||||
"""
|
||||
use_model = model or self.model
|
||||
|
||||
try:
|
||||
with self._client.messages.stream(
|
||||
model=use_model,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
) as stream:
|
||||
content = stream.get_final_text()
|
||||
final_msg = stream.get_final_message()
|
||||
return self._parse_streamed(content, final_msg, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] chat_plain failed: %s", e)
|
||||
raise
|
||||
|
||||
async def achat_structured(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
json_schema: dict = None,
|
||||
schema_name: str = "response",
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 8192,
|
||||
) -> AnthropicResponse:
|
||||
"""Native async structured chat using streaming."""
|
||||
use_model = model or self.model
|
||||
effective_system = self._build_system(system_prompt, json_schema, schema_name)
|
||||
|
||||
try:
|
||||
async with self._async_client.messages.stream(
|
||||
model=use_model,
|
||||
system=effective_system,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
) as stream:
|
||||
content = await stream.get_final_text()
|
||||
final_msg = await stream.get_final_message()
|
||||
return self._parse_streamed(content, final_msg, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] achat_structured failed: %s", e)
|
||||
raise
|
||||
|
||||
async def achat_plain(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 4096,
|
||||
) -> AnthropicResponse:
|
||||
"""Native async plain chat using streaming."""
|
||||
use_model = model or self.model
|
||||
|
||||
try:
|
||||
async with self._async_client.messages.stream(
|
||||
model=use_model,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
) as stream:
|
||||
content = await stream.get_final_text()
|
||||
final_msg = await stream.get_final_message()
|
||||
return self._parse_streamed(content, final_msg, use_model)
|
||||
except Exception as e:
|
||||
logger.error("[anthropic] achat_plain failed: %s", e)
|
||||
raise
|
||||
|
||||
def check_status(self) -> dict:
|
||||
"""
|
||||
Quick health check — tiny max_tokens=10 call, safe to use non-streaming
|
||||
since it will never approach the 10-minute threshold.
|
||||
"""
|
||||
try:
|
||||
response = self._client.messages.create(
|
||||
model=self.model,
|
||||
system="Reply with the word OK.",
|
||||
messages=[{"role": "user", "content": "Status check."}],
|
||||
max_tokens=10,
|
||||
)
|
||||
content = response.content[0].text if response.content else ""
|
||||
return {
|
||||
"connected": True,
|
||||
"model": self.model,
|
||||
"response": content,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"connected": False,
|
||||
"model": self.model,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
# ── Internal helpers ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _build_system(system_prompt: str, json_schema: dict, schema_name: str) -> str:
|
||||
"""Append JSON schema hint to system prompt when a schema is provided."""
|
||||
if not json_schema:
|
||||
return system_prompt
|
||||
schema_str = json.dumps(json_schema, indent=2)
|
||||
return (
|
||||
f"{system_prompt}\n\n"
|
||||
f"You MUST respond with valid JSON only — no markdown fences, no prose.\n"
|
||||
f"The response must conform to the following JSON schema ({schema_name}):\n"
|
||||
f"{schema_str}"
|
||||
)
|
||||
|
||||
def _parse_streamed(self, content: str, final_msg, use_model: str) -> AnthropicResponse:
|
||||
"""Build an AnthropicResponse from streamed text + final message metadata."""
|
||||
usage = getattr(final_msg, "usage", None)
|
||||
prompt_tokens = usage.input_tokens if usage else 0
|
||||
completion_tokens = usage.output_tokens if usage else 0
|
||||
|
||||
parsed = None
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed = self._extract_json(content)
|
||||
|
||||
logger.info(
|
||||
"[anthropic] model=%s tokens=(%d in, %d out) parsed=%s",
|
||||
use_model,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
"yes" if parsed else "no",
|
||||
)
|
||||
|
||||
return AnthropicResponse(
|
||||
content=content,
|
||||
parsed_json=parsed,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
model=use_model,
|
||||
citations=[],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_json(text: str) -> Optional[dict]:
|
||||
"""Best-effort JSON extraction from possibly noisy text."""
|
||||
try:
|
||||
return json.loads(text.strip())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Markdown fences
|
||||
fence_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text)
|
||||
if fence_match:
|
||||
try:
|
||||
return json.loads(fence_match.group(1).strip())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Brace counting
|
||||
start = text.find('{')
|
||||
if start == -1:
|
||||
return None
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
if ch == '\\' and in_string:
|
||||
escape_next = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
try:
|
||||
return json.loads(text[start:i + 1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
# Truncated JSON — attempt repair
|
||||
return AnthropicClient._repair_truncated_json(text[start:])
|
||||
|
||||
@staticmethod
|
||||
def _repair_truncated_json(text: str) -> Optional[dict]:
|
||||
"""
|
||||
Attempt to repair truncated JSON from max_tokens cutoff.
|
||||
Strategy: find the last complete nested object, then close
|
||||
all open brackets/braces.
|
||||
"""
|
||||
logger.info("Attempting truncated JSON repair (%d chars)...", len(text))
|
||||
last_good = -1
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
for i in range(len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
if ch == '\\' and in_string:
|
||||
escape_next = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = not in_string
|
||||
continue
|
||||
if in_string:
|
||||
continue
|
||||
if ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth >= 1:
|
||||
last_good = i
|
||||
|
||||
if last_good == -1:
|
||||
return None
|
||||
|
||||
truncated = text[:last_good + 1]
|
||||
close_depth = 0
|
||||
in_arr = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
for ch in truncated:
|
||||
if esc:
|
||||
esc = False
|
||||
continue
|
||||
if ch == '\\' and in_str:
|
||||
esc = True
|
||||
continue
|
||||
if ch == '"':
|
||||
in_str = not in_str
|
||||
continue
|
||||
if in_str:
|
||||
continue
|
||||
if ch == '{':
|
||||
close_depth += 1
|
||||
elif ch == '}':
|
||||
close_depth -= 1
|
||||
elif ch == '[':
|
||||
in_arr += 1
|
||||
elif ch == ']':
|
||||
in_arr -= 1
|
||||
|
||||
suffix = ']' * in_arr + '}' * close_depth
|
||||
repaired = truncated + suffix
|
||||
try:
|
||||
result = json.loads(repaired)
|
||||
card_count = len(result.get('cards', []))
|
||||
logger.info("JSON repair succeeded: recovered %d cards", card_count)
|
||||
return result
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning("JSON repair failed: %s", e)
|
||||
return None
|
||||
+264
-57
@@ -18,12 +18,16 @@ from typing import Optional, List, Dict
|
||||
from .config import (
|
||||
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
|
||||
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
|
||||
COACH_PROVIDER, PPLX_MODEL,
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD, ensure_dirs,
|
||||
COACH_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD, DEFAULT_MAX_TOKENS, ensure_dirs,
|
||||
)
|
||||
from .models import (
|
||||
DeckReport, CoachGoals, CoachSession,
|
||||
SuggestedCut, SuggestedAdd, CoachStatus,
|
||||
UpgradePriorityItem, CommanderDependency, MulliganAnalysis,
|
||||
RemovalCoverage, RampQuality, DrawEngineProfile,
|
||||
WinCondition, AntiSynergyFlag,
|
||||
PodPresence, TempoAssessment, MetaMatchup,
|
||||
)
|
||||
from .llm_client import LLMClient
|
||||
from .embeddings import MTGEmbeddingIndex
|
||||
@@ -61,23 +65,19 @@ class CoachService:
|
||||
self.embeddings = embeddings
|
||||
ensure_dirs()
|
||||
|
||||
# ── Deck Report I/O ────────────────────────────────────────
|
||||
# ── Deck Report I/O ────────────────────────────────────
|
||||
|
||||
def load_deck_report(self, deck_id: str) -> Optional[DeckReport]:
|
||||
"""Load a deck report from disk."""
|
||||
# Try exact filename first, then case-insensitive search
|
||||
report_path = DECK_REPORTS_DIR / f"{deck_id}.json"
|
||||
if not report_path.exists():
|
||||
# Try case-insensitive match
|
||||
for f in DECK_REPORTS_DIR.glob("*.json"):
|
||||
if re.sub(r'[^a-z0-9]', '', f.stem.lower()) == re.sub(r'[^a-z0-9]', '', deck_id.lower()):
|
||||
report_path = f
|
||||
break
|
||||
|
||||
if not report_path.exists():
|
||||
logger.warning("Deck report not found: %s", deck_id)
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(report_path, "r") as f:
|
||||
data = json.load(f)
|
||||
@@ -92,16 +92,14 @@ class CoachService:
|
||||
return []
|
||||
return [f.stem for f in DECK_REPORTS_DIR.glob("*.json")]
|
||||
|
||||
# ── Coach Session I/O ──────────────────────────────────────
|
||||
# ── Coach Session I/O ──────────────────────────────
|
||||
|
||||
def save_session(self, session: CoachSession) -> Path:
|
||||
"""Persist a coaching session to disk and SQLite."""
|
||||
# 1. Write JSON file (backward compat)
|
||||
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
|
||||
with open(path, "w") as f:
|
||||
f.write(session.model_dump_json(indent=2))
|
||||
# 2. Write to SQLite
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
_db_save_session({
|
||||
@@ -124,10 +122,8 @@ class CoachService:
|
||||
logger.warning("Failed to persist session to SQLite: %s", e)
|
||||
return path
|
||||
|
||||
|
||||
def load_session(self, session_id: str) -> Optional[CoachSession]:
|
||||
"""Load a coaching session from SQLite, falling back to disk."""
|
||||
# Try SQLite first
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
row = _db_load_session(session_id)
|
||||
@@ -150,7 +146,6 @@ class CoachService:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("SQLite load failed for %s: %s", session_id, e)
|
||||
# Fallback to disk
|
||||
path = COACH_SESSIONS_DIR / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -162,10 +157,8 @@ class CoachService:
|
||||
logger.error("Failed to load session %s: %s", session_id, e)
|
||||
return None
|
||||
|
||||
|
||||
def list_sessions(self, deck_id: str = None) -> List[dict]:
|
||||
"""List coaching sessions from SQLite, falling back to disk."""
|
||||
# Try SQLite first
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
rows = _db_list_sessions(deck_id)
|
||||
@@ -173,7 +166,6 @@ class CoachService:
|
||||
return rows
|
||||
except Exception as e:
|
||||
logger.warning("SQLite list_sessions failed: %s", e)
|
||||
# Fallback to disk
|
||||
if not COACH_SESSIONS_DIR.exists():
|
||||
return []
|
||||
sessions = []
|
||||
@@ -195,24 +187,24 @@ class CoachService:
|
||||
continue
|
||||
return sessions
|
||||
|
||||
# ── Main Coaching Pipeline ───────────────────────────────
|
||||
|
||||
# ── Main Coaching Pipeline ─────────────────────────────────
|
||||
|
||||
async def run_coaching_session(self, deck_id: str,
|
||||
goals: Optional[CoachGoals] = None,
|
||||
fallback_report: Optional[DeckReport] = None,
|
||||
) -> CoachSession:
|
||||
async def run_coaching_session(
|
||||
self,
|
||||
deck_id: str,
|
||||
goals: Optional[CoachGoals] = None,
|
||||
fallback_report: Optional[DeckReport] = None,
|
||||
) -> CoachSession:
|
||||
"""
|
||||
Full coaching pipeline:
|
||||
1. Load deck report (or use fallback from DB card list)
|
||||
2. Identify underperformers
|
||||
3. Find replacement candidates via embeddings
|
||||
4. Build prompt
|
||||
5. Call LLM
|
||||
5. Call LLM (streaming for Anthropic to avoid 10-min SDK timeout)
|
||||
6. Parse response
|
||||
7. Persist session
|
||||
"""
|
||||
# 0. Auto-load embeddings if not loaded
|
||||
if not self.embeddings.loaded:
|
||||
logger.info("Embeddings not loaded — attempting auto-load...")
|
||||
try:
|
||||
@@ -224,7 +216,6 @@ class CoachService:
|
||||
except Exception as e:
|
||||
logger.warning("Embeddings auto-load failed: %s", e)
|
||||
|
||||
# 1. Load deck report — try simulation report first, then fallback
|
||||
report = self.load_deck_report(deck_id)
|
||||
if report is None and fallback_report is not None:
|
||||
report = fallback_report
|
||||
@@ -232,25 +223,18 @@ class CoachService:
|
||||
if report is None:
|
||||
raise ValueError(f"Deck report not found for: {deck_id}")
|
||||
|
||||
# 2. Get deck card names (for exclusion from candidates)
|
||||
deck_card_names = [c.name for c in report.cards]
|
||||
|
||||
# 3. Find replacement candidates for underperformers
|
||||
candidates: Dict[str, List[dict]] = {}
|
||||
|
||||
underperformers = report.underperformers[:MAX_UNDERPERFORMERS]
|
||||
if not underperformers:
|
||||
# If no explicit underperformers, pick lowest impact cards
|
||||
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
|
||||
underperformers = [
|
||||
c.name for c in sorted_cards[:MAX_UNDERPERFORMERS]
|
||||
if c.impactScore < UNDERPERFORMER_IMPACT_THRESHOLD
|
||||
]
|
||||
|
||||
# If still no underperformers and we have cards, pick bottom N by impact
|
||||
if not underperformers and report.cards:
|
||||
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
|
||||
# Take the bottom 5 non-land cards as candidates for cuts
|
||||
underperformers = [
|
||||
c.name for c in sorted_cards[:5]
|
||||
if not any(t in (c.tags or []) for t in [])
|
||||
@@ -268,18 +252,61 @@ class CoachService:
|
||||
candidates[card_name] = [m.to_dict() for m in matches]
|
||||
else:
|
||||
logger.warning("Embeddings not loaded — skipping candidate search")
|
||||
# Phase 3: RAG vector search for additional card context
|
||||
rag_cards: List[dict] = []
|
||||
try:
|
||||
from services.rag_store import query_cards
|
||||
query_text = f"{report.commander} {' '.join(report.colorIdentity)} commander deck"
|
||||
rag_cards = query_cards(query_text, n_results=15, color_identity=report.colorIdentity)
|
||||
logger.info("RAG returned %d cards for '%s'", len(rag_cards), deck_id)
|
||||
except Exception as e:
|
||||
logger.warning("RAG query failed (non-fatal): %s", e)
|
||||
|
||||
|
||||
# 4. Build prompts
|
||||
system_prompt = build_system_prompt(report, goals)
|
||||
user_prompt = build_user_prompt(report, candidates)
|
||||
user_prompt = build_user_prompt(report, candidates, rag_cards=rag_cards)
|
||||
|
||||
# 5. Call LLM
|
||||
logger.info("Calling LLM for deck: %s", deck_id)
|
||||
llm_response = await self.llm.chat(system_prompt, user_prompt)
|
||||
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
|
||||
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
import anthropic as _anthropic
|
||||
anthropic_key = ANTHROPIC_API_KEY
|
||||
if not anthropic_key:
|
||||
raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.")
|
||||
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
|
||||
async with aclient.messages.stream(
|
||||
model=ANTHROPIC_MODEL,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
max_tokens=DEFAULT_MAX_TOKENS,
|
||||
temperature=0.7,
|
||||
) as stream:
|
||||
content = await stream.get_final_text()
|
||||
final_msg = await stream.get_final_message()
|
||||
usage = getattr(final_msg, "usage", None)
|
||||
from types import SimpleNamespace
|
||||
llm_response = SimpleNamespace(
|
||||
content=content,
|
||||
model=final_msg.model,
|
||||
prompt_tokens=usage.input_tokens if usage else 0,
|
||||
completion_tokens=usage.output_tokens if usage else 0,
|
||||
parsed_json=None,
|
||||
)
|
||||
try:
|
||||
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
|
||||
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
|
||||
llm_response.parsed_json = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
match = re.search(r'\{[\s\S]*\}', content)
|
||||
if match:
|
||||
try:
|
||||
llm_response.parsed_json = json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
llm_response = await self.llm.chat(system_prompt, user_prompt)
|
||||
|
||||
# 6. Parse response into CoachSession
|
||||
session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
||||
|
||||
session = CoachSession(
|
||||
sessionId=session_id,
|
||||
deckId=deck_id,
|
||||
@@ -293,27 +320,111 @@ class CoachService:
|
||||
if llm_response.parsed_json:
|
||||
self._populate_session_from_json(session, llm_response.parsed_json)
|
||||
else:
|
||||
# Fallback: store raw text
|
||||
session.rawTextExplanation = llm_response.content
|
||||
session.summary = "LLM returned non-JSON response. See raw explanation."
|
||||
|
||||
# 7. Persist
|
||||
self.save_session(session)
|
||||
logger.info("Coaching session saved: %s", session_id)
|
||||
|
||||
return session
|
||||
|
||||
# ── Quick Digest ──────────────────────────────────────────
|
||||
|
||||
async def run_quick_digest(
|
||||
self,
|
||||
deck_id: str,
|
||||
goals: Optional[CoachGoals] = None,
|
||||
fallback_report: Optional[DeckReport] = None,
|
||||
) -> CoachSession:
|
||||
"""Fast ~400-600 token call: summary + top 3 upgradePriority + commanderDependency."""
|
||||
report = self.load_deck_report(deck_id)
|
||||
if report is None and fallback_report is not None:
|
||||
report = fallback_report
|
||||
if report is None:
|
||||
raise ValueError(f"Deck report not found for: {deck_id}")
|
||||
|
||||
# Phase 3: light RAG query for quick digest too
|
||||
rag_cards: List[dict] = []
|
||||
try:
|
||||
from services.rag_store import query_cards
|
||||
query_text = f"{report.commander} {' '.join(report.colorIdentity)}"
|
||||
rag_cards = query_cards(query_text, n_results=8, color_identity=report.colorIdentity)
|
||||
except Exception:
|
||||
pass
|
||||
from .prompt_template import build_quick_system_prompt, build_user_prompt
|
||||
system_prompt = build_quick_system_prompt(report, goals)
|
||||
user_prompt = build_user_prompt(report, {}, rag_cards=rag_cards) # RAG-enriched quick digest # no candidates for speed
|
||||
|
||||
logger.info("Quick digest for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
|
||||
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
import anthropic as _anthropic
|
||||
anthropic_key = ANTHROPIC_API_KEY
|
||||
if not anthropic_key:
|
||||
raise ConnectionError("Anthropic API key not configured.")
|
||||
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
|
||||
async with aclient.messages.stream(
|
||||
model=ANTHROPIC_MODEL,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
max_tokens=1024,
|
||||
temperature=0.5,
|
||||
) as stream:
|
||||
content = await stream.get_final_text()
|
||||
final_msg = await stream.get_final_message()
|
||||
usage = getattr(final_msg, "usage", None)
|
||||
from types import SimpleNamespace
|
||||
llm_response = SimpleNamespace(
|
||||
content=content,
|
||||
model=final_msg.model,
|
||||
prompt_tokens=usage.input_tokens if usage else 0,
|
||||
completion_tokens=usage.output_tokens if usage else 0,
|
||||
parsed_json=None,
|
||||
)
|
||||
try:
|
||||
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
|
||||
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
|
||||
llm_response.parsed_json = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
match = re.search(r'\{[\s\S]*\}', content)
|
||||
if match:
|
||||
try:
|
||||
llm_response.parsed_json = json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
llm_response = await self.llm.chat(system_prompt, user_prompt)
|
||||
|
||||
session_id = f"quick-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
||||
session = CoachSession(
|
||||
sessionId=session_id,
|
||||
deckId=deck_id,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
modelUsed=llm_response.model,
|
||||
promptTokens=llm_response.prompt_tokens,
|
||||
completionTokens=llm_response.completion_tokens,
|
||||
goals=goals,
|
||||
isQuickDigest=True,
|
||||
)
|
||||
if llm_response.parsed_json:
|
||||
self._populate_session_from_json(session, llm_response.parsed_json)
|
||||
else:
|
||||
session.rawTextExplanation = llm_response.content
|
||||
session.summary = "Quick digest returned non-JSON. See raw explanation."
|
||||
|
||||
self.save_session(session)
|
||||
return session
|
||||
|
||||
# ── JSON Parser ───────────────────────────────────────────
|
||||
|
||||
def _populate_session_from_json(self, session: CoachSession, data: dict):
|
||||
"""Parse LLM JSON response into CoachSession fields."""
|
||||
session.summary = data.get("summary", "")
|
||||
session.rawTextExplanation = data.get("rawTextExplanation", "")
|
||||
session.manaBaseAdvice = data.get("manaBaseAdvice")
|
||||
|
||||
# Heuristic hints
|
||||
hints = data.get("heuristicHints", [])
|
||||
session.heuristicHints = hints if isinstance(hints, list) else [str(hints)]
|
||||
|
||||
# Suggested cuts
|
||||
for cut_data in data.get("suggestedCuts", []):
|
||||
if isinstance(cut_data, dict):
|
||||
session.suggestedCuts.append(SuggestedCut(
|
||||
@@ -322,7 +433,6 @@ class CoachService:
|
||||
replacementOptions=cut_data.get("replacementOptions", []),
|
||||
))
|
||||
|
||||
# Suggested adds
|
||||
for add_data in data.get("suggestedAdds", []):
|
||||
if isinstance(add_data, dict):
|
||||
session.suggestedAdds.append(SuggestedAdd(
|
||||
@@ -332,24 +442,125 @@ class CoachService:
|
||||
synergyWith=add_data.get("synergyWith", []),
|
||||
))
|
||||
|
||||
# ── Status Check ───────────────────────────────────────────
|
||||
# Phase 2
|
||||
for item in data.get("upgradePriority", []):
|
||||
if isinstance(item, dict):
|
||||
session.upgradePriority.append(UpgradePriorityItem(
|
||||
rank=item.get("rank", 0),
|
||||
cut=item.get("cut", ""),
|
||||
add=item.get("add", ""),
|
||||
reasoning=item.get("reasoning", ""),
|
||||
expectedImpact=item.get("expectedImpact", ""),
|
||||
))
|
||||
|
||||
cd = data.get("commanderDependency")
|
||||
if isinstance(cd, dict):
|
||||
session.commanderDependency = CommanderDependency(
|
||||
score=cd.get("score", 5),
|
||||
dependentCards=cd.get("dependentCards", []),
|
||||
recoveryPlan=cd.get("recoveryPlan", ""),
|
||||
)
|
||||
|
||||
ma = data.get("mulliganAnalysis")
|
||||
if isinstance(ma, dict):
|
||||
session.mulliganAnalysis = MulliganAnalysis(
|
||||
estimatedKeepRate=ma.get("estimatedKeepRate", ""),
|
||||
worstOffenders=ma.get("worstOffenders", []),
|
||||
recommendation=ma.get("recommendation", ""),
|
||||
)
|
||||
|
||||
# Phase 3: Removal coverage
|
||||
rc = data.get("removalCoverage")
|
||||
if isinstance(rc, dict):
|
||||
session.removalCoverage = RemovalCoverage(**{
|
||||
k: rc.get(k, "" if k != "gaps" else [])
|
||||
for k in ["creatures", "artifacts", "enchantments", "planeswalkers", "lands", "massRemoval", "gaps"]
|
||||
})
|
||||
|
||||
# Phase 3: Ramp quality
|
||||
rq = data.get("rampQuality")
|
||||
if isinstance(rq, dict):
|
||||
session.rampQuality = RampQuality(**{
|
||||
k: rq.get(k, "") for k in ["manaRocks", "landFetch", "manaDorks", "costReducers", "fragility", "canReachFourByTurnThree"]
|
||||
})
|
||||
|
||||
# Phase 3: Draw engine profile
|
||||
dep = data.get("drawEngineProfile")
|
||||
if isinstance(dep, dict):
|
||||
session.drawEngineProfile = DrawEngineProfile(
|
||||
burstDraw=dep.get("burstDraw", []),
|
||||
repeatableEngines=dep.get("repeatableEngines", []),
|
||||
assessment=dep.get("assessment", ""),
|
||||
sustainability=dep.get("sustainability", ""),
|
||||
)
|
||||
|
||||
# Phase 3: Win conditions
|
||||
for wc in data.get("winConditions", []):
|
||||
if isinstance(wc, dict):
|
||||
session.winConditions.append(WinCondition(
|
||||
name=wc.get("name", ""),
|
||||
cards=wc.get("cards", []),
|
||||
independence=wc.get("independence", ""),
|
||||
description=wc.get("description", ""),
|
||||
))
|
||||
|
||||
# Phase 3: Anti-synergy flags
|
||||
for af in data.get("antiSynergyFlags", []):
|
||||
if isinstance(af, dict):
|
||||
session.antiSynergyFlags.append(AntiSynergyFlag(
|
||||
cards=af.get("cards", []),
|
||||
conflict=af.get("conflict", ""),
|
||||
severity=af.get("severity", ""),
|
||||
))
|
||||
|
||||
# Phase 5: Pod presence
|
||||
pp = data.get("podPresence")
|
||||
if isinstance(pp, dict):
|
||||
session.podPresence = PodPresence(
|
||||
threatLevel=pp.get("threatLevel", ""),
|
||||
politicalTools=pp.get("politicalTools", []),
|
||||
adversarialRating=pp.get("adversarialRating", ""),
|
||||
recommendation=pp.get("recommendation", ""),
|
||||
)
|
||||
|
||||
# Phase 5: Tempo assessment
|
||||
ta = data.get("tempoAssessment")
|
||||
if isinstance(ta, dict):
|
||||
session.tempoAssessment = TempoAssessment(
|
||||
peakTurn=ta.get("peakTurn", 0),
|
||||
profile=ta.get("profile", ""),
|
||||
survivalWindow=ta.get("survivalWindow", ""),
|
||||
assessment=ta.get("assessment", ""),
|
||||
)
|
||||
|
||||
# Phase 5: Meta matchups
|
||||
for mm in data.get("metaMatchups", []):
|
||||
if isinstance(mm, dict):
|
||||
session.metaMatchups.append(MetaMatchup(
|
||||
archetype=mm.get("archetype", ""),
|
||||
assessment=mm.get("assessment", ""),
|
||||
keyThreats=mm.get("keyThreats", []),
|
||||
keyAnswers=mm.get("keyAnswers", []),
|
||||
overallRating=mm.get("overallRating", ""),
|
||||
))
|
||||
|
||||
# ── Status Check ─────────────────────────────────────────
|
||||
|
||||
def get_status(self) -> CoachStatus:
|
||||
"""Check the health of all coach subsystems."""
|
||||
status = CoachStatus()
|
||||
|
||||
# Check LLM / Perplexity provider
|
||||
if COACH_PROVIDER == "perplexity":
|
||||
pplx_key = os.environ.get("PPLX_API_KEY", "")
|
||||
if pplx_key:
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
anthropic_key = ANTHROPIC_API_KEY
|
||||
if anthropic_key:
|
||||
status.llmConnected = True
|
||||
status.llmModel = f"Perplexity ({PPLX_MODEL})"
|
||||
status.llmModels = [PPLX_MODEL]
|
||||
status.llmModel = f"Claude Opus ({ANTHROPIC_MODEL})"
|
||||
status.llmModels = [ANTHROPIC_MODEL]
|
||||
else:
|
||||
status.llmConnected = False
|
||||
status.llmModel = None
|
||||
status.llmModels = []
|
||||
status.error = "Perplexity API key not configured. Set PPLX_API_KEY env var."
|
||||
status.error = "Anthropic API key not configured. Set ANTHROPIC_API_KEY env var."
|
||||
else:
|
||||
llm_status = self.llm.check_connection()
|
||||
status.llmConnected = llm_status.get("connected", False)
|
||||
@@ -358,11 +569,7 @@ class CoachService:
|
||||
if not status.llmConnected:
|
||||
status.error = llm_status.get("error", "Local LLM not reachable")
|
||||
|
||||
# Check embeddings
|
||||
status.embeddingsLoaded = self.embeddings.loaded
|
||||
status.embeddingCards = self.embeddings.card_count
|
||||
|
||||
# Check deck reports
|
||||
status.deckReportsAvailable = len(self.list_deck_reports())
|
||||
|
||||
return status
|
||||
+28
-29
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Commander AI Lab — Coach Configuration
|
||||
═══════════════════════════════════════
|
||||
────────────────────────────────────────
|
||||
Central config for Ollama, embeddings, and coach service paths.
|
||||
All paths are relative to the commander-ai-lab root directory.
|
||||
"""
|
||||
@@ -8,61 +8,60 @@ All paths are relative to the commander-ai-lab root directory.
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# ── Base Paths ──────────────────────────────────────────────
|
||||
# Resolve relative to wherever the lab is installed
|
||||
# ── Base Paths ───────────────────────────────────────────────────────
|
||||
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
|
||||
|
||||
# ── Ollama (OpenAI-compatible API) ──────────────────────
|
||||
# ── Ollama (OpenAI-compatible API) ────────────────────────────
|
||||
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
|
||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
|
||||
LLM_MAX_RETRIES = 3
|
||||
|
||||
# ── Embeddings ─────────────────────────────────────────────
|
||||
# ── Embeddings ───────────────────────────────────────────────────
|
||||
EMBEDDINGS_DIR = LAB_ROOT / "data"
|
||||
EMBEDDINGS_NPZ = EMBEDDINGS_DIR / "mtg-embeddings.npz"
|
||||
EMBEDDINGS_PARQUET = EMBEDDINGS_DIR / "mtg-embeddings.parquet"
|
||||
|
||||
# ── Deck Reports ───────────────────────────────────────────
|
||||
# ── Deck Reports ────────────────────────────────────────────────
|
||||
DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
|
||||
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
|
||||
|
||||
# ── Prompt Limits ──────────────────────────────────────────
|
||||
# ── Prompt Limits ───────────────────────────────────────────────
|
||||
MAX_PROMPT_TOKENS = 2048
|
||||
MAX_CANDIDATES_PER_UNDERPERFORMER = 5
|
||||
MAX_UNDERPERFORMERS = 4
|
||||
MAX_CANDIDATES_PER_UNDERPERFORMER = 8
|
||||
MAX_UNDERPERFORMERS = 8
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
|
||||
|
||||
# ── LLM Generation Settings (Coach — Ollama) ──────────────
|
||||
# ── LLM Generation Settings (Coach — Ollama fallback) ─────────────
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
|
||||
# so we need a generous limit to avoid truncation
|
||||
DEFAULT_MAX_TOKENS = 8192
|
||||
DEFAULT_MAX_TOKENS = 16384
|
||||
|
||||
# ── Perplexity Deck Generation Settings ───────────────────────
|
||||
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
|
||||
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
|
||||
# Perplexity cloud model (sonar / sonar-pro / sonar-deep-research)
|
||||
PPLX_MODEL = os.environ.get("PPLX_MODEL", "sonar-pro")
|
||||
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
|
||||
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "local")
|
||||
# ── Anthropic Claude Deck Generation Settings ──────────────────────
|
||||
# Provider for deck generation: "anthropic" (Claude) or "local" (Ollama)
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "anthropic")
|
||||
# Model used when DECK_GEN_PROVIDER="anthropic"
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "claude-opus-4-20250514")
|
||||
# Anthropic API key
|
||||
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
# Anthropic model for deck generation and coaching
|
||||
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-20250514")
|
||||
# Coach chat provider: "anthropic" (Claude API) or "local" (Ollama)
|
||||
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "anthropic")
|
||||
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://localhost:11434/v1")
|
||||
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
|
||||
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
|
||||
|
||||
# ── Smart Substitution Settings ───────────────────────────────
|
||||
# Minimum embedding similarity to accept a substitute without Perplexity fallback
|
||||
# ── Smart Substitution Settings ───────────────────────────────────
|
||||
# Minimum embedding similarity to accept a substitute without Anthropic fallback
|
||||
SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY", "0.75"))
|
||||
# Maximum number of alternatives to suggest per missing card
|
||||
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
|
||||
# Model for substitution fallback (sonar is fine — small focused queries)
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "gpt-oss:20b")
|
||||
# Enable Perplexity fallback for low-confidence embedding matches
|
||||
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
|
||||
# Model for substitution fallback (claude-haiku-4-5 is fast + cheap for focused queries)
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "claude-haiku-4-5")
|
||||
# Enable Anthropic fallback for low-confidence embedding matches
|
||||
SUBSTITUTION_USE_ANTHROPIC_FALLBACK = os.environ.get("SUBSTITUTION_USE_ANTHROPIC_FALLBACK", "true").lower() == "true"
|
||||
|
||||
# ── Ensure directories exist ───────────────────────────────
|
||||
# ── Ensure directories exist ─────────────────────────────────────
|
||||
def ensure_dirs():
|
||||
"""Create required directories if they don't exist."""
|
||||
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
+3
-11
@@ -21,7 +21,6 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.request import urlopen, Request
|
||||
@@ -29,6 +28,7 @@ from urllib.parse import quote
|
||||
|
||||
import numpy as np
|
||||
|
||||
from services.card_text import build_card_text
|
||||
from .config import EMBEDDINGS_DIR, EMBEDDINGS_NPZ, EMBEDDINGS_PARQUET
|
||||
|
||||
logger = logging.getLogger("coach.embeddings")
|
||||
@@ -115,15 +115,8 @@ def _fetch_scryfall_card(card_name: str) -> Optional[dict]:
|
||||
|
||||
|
||||
def _build_card_text(card: dict) -> str:
|
||||
"""Build the text string to embed, matching minimaxir/mtg-embeddings format."""
|
||||
parts = [card["name"]]
|
||||
if card.get("mana_cost"):
|
||||
parts.append(card["mana_cost"])
|
||||
if card.get("type_line"):
|
||||
parts.append(card["type_line"])
|
||||
if card.get("oracle_text"):
|
||||
parts.append(card["oracle_text"])
|
||||
return " | ".join(parts)
|
||||
"""Delegate to shared build_card_text() -- see services/card_text.py."""
|
||||
return build_card_text(card)
|
||||
|
||||
|
||||
class CardMatch:
|
||||
@@ -331,7 +324,6 @@ class MTGEmbeddingIndex:
|
||||
d = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
|
||||
for n in d["names"]:
|
||||
existing_names.add(n.lower())
|
||||
existing = dict(d)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
+127
-130
@@ -1,22 +1,25 @@
|
||||
"""
|
||||
Commander AI Lab — Ollama Client
|
||||
Commander AI Lab — Ollama LLM Client
|
||||
════════════════════════════════════
|
||||
Async HTTP client targeting Ollama's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Includes retry logic for
|
||||
non-JSON responses and configurable timeout.
|
||||
Fully async HTTP client targeting Ollama's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Every network call uses
|
||||
httpx.AsyncClient so it NEVER blocks the FastAPI event loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
LLM_URL, LLM_MODEL,
|
||||
LLM_TIMEOUT, LLM_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
|
||||
LLM_URL,
|
||||
LLM_MODEL,
|
||||
LLM_TIMEOUT,
|
||||
LLM_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("coach.llm")
|
||||
@@ -24,9 +27,15 @@ logger = logging.getLogger("coach.llm")
|
||||
|
||||
class LLMResponse:
|
||||
"""Parsed LLM response container."""
|
||||
def __init__(self, content: str, parsed_json: Optional[dict],
|
||||
prompt_tokens: int, completion_tokens: int,
|
||||
model: str):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
parsed_json: Optional[dict],
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
model: str,
|
||||
):
|
||||
self.content = content
|
||||
self.parsed_json = parsed_json
|
||||
self.prompt_tokens = prompt_tokens
|
||||
@@ -36,7 +45,7 @@ class LLMResponse:
|
||||
|
||||
class LLMClient:
|
||||
"""
|
||||
Client for Ollama's OpenAI-compatible API.
|
||||
Fully async client for Ollama's OpenAI-compatible API.
|
||||
|
||||
Usage:
|
||||
client = LLMClient()
|
||||
@@ -45,19 +54,25 @@ class LLMClient:
|
||||
suggestions = response.parsed_json
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = None, model: str = None,
|
||||
timeout: int = None):
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = None,
|
||||
model: str = None,
|
||||
timeout: int = None,
|
||||
):
|
||||
self.base_url = (base_url or LLM_URL).rstrip("/")
|
||||
self.model = model or LLM_MODEL
|
||||
self.timeout = timeout or LLM_TIMEOUT
|
||||
self._resolved_model = None # actual model name from /models endpoint
|
||||
self._resolved_model: Optional[str] = None
|
||||
|
||||
def _resolve_model(self) -> str:
|
||||
# ── Model resolution ───────────────────────────────────────
|
||||
|
||||
async def _resolve_model(self) -> str:
|
||||
"""Get the actual loaded model name from Ollama /models endpoint."""
|
||||
if self._resolved_model:
|
||||
return self._resolved_model
|
||||
try:
|
||||
status = self.check_connection()
|
||||
status = await self.check_connection()
|
||||
if status.get("connected") and status.get("active_model"):
|
||||
self._resolved_model = status["active_model"]
|
||||
logger.info("Resolved Ollama model: %s", self._resolved_model)
|
||||
@@ -66,11 +81,18 @@ class LLMClient:
|
||||
pass
|
||||
return self.model
|
||||
|
||||
def _build_request_body(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float, max_tokens: int,
|
||||
force_json: bool) -> dict:
|
||||
model_name = self._resolve_model()
|
||||
body = {
|
||||
# ── Request building ──────────────────────────────────────
|
||||
|
||||
async def _build_request_body(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
force_json: bool,
|
||||
) -> dict:
|
||||
model_name = await self._resolve_model()
|
||||
body: dict = {
|
||||
"model": model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
@@ -79,32 +101,27 @@ class LLMClient:
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
# Only request JSON format if using a model that supports it
|
||||
# Some models (esp. smaller ones) don't support response_format
|
||||
if force_json:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
return body
|
||||
|
||||
# ── Response parsing ──────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _strip_think_tags(text: str) -> str:
|
||||
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks from LLM output."""
|
||||
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks."""
|
||||
import re
|
||||
# Remove all <think>...</think> blocks (greedy, handles multiline)
|
||||
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
|
||||
return cleaned.strip()
|
||||
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
|
||||
|
||||
@staticmethod
|
||||
def _extract_json_object(text: str) -> Optional[dict]:
|
||||
"""Find and parse the first complete JSON object in text.
|
||||
Uses brace-counting to handle nested objects reliably."""
|
||||
"""Find and parse the first complete JSON object via brace-counting."""
|
||||
start = text.find('{')
|
||||
if start == -1:
|
||||
return None
|
||||
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
|
||||
for i in range(start, len(text)):
|
||||
ch = text[i]
|
||||
if escape_next:
|
||||
@@ -124,66 +141,56 @@ class LLMClient:
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidate = text[start:i+1]
|
||||
candidate = text[start : i + 1]
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# Malformed — try next opening brace
|
||||
next_start = text.find('{', start + 1)
|
||||
if next_start != -1 and next_start < i:
|
||||
start = next_start
|
||||
depth = 1 # we're already past this brace
|
||||
return None
|
||||
depth = 1
|
||||
return None
|
||||
return None
|
||||
|
||||
def _parse_response(self, raw: dict) -> LLMResponse:
|
||||
"""Extract content and usage from OpenAI-format response.
|
||||
Handles DeepSeek-R1 <think> tags, markdown fences, and raw JSON."""
|
||||
"""Extract content and usage from an OpenAI-format response."""
|
||||
choice = raw.get("choices", [{}])[0]
|
||||
content = choice.get("message", {}).get("content", "")
|
||||
usage = raw.get("usage", {})
|
||||
|
||||
# Step 1: Strip <think>...</think> blocks (DeepSeek-R1)
|
||||
cleaned = self._strip_think_tags(content)
|
||||
|
||||
# Step 2: Try direct JSON parse on cleaned content
|
||||
parsed = None
|
||||
parsed: Optional[dict] = None
|
||||
# 1. Direct JSON parse
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Step 3: Try extracting from markdown fences
|
||||
# 2. Markdown fences
|
||||
if parsed is None and "```json" in cleaned:
|
||||
try:
|
||||
start = cleaned.index("```json") + 7
|
||||
end = cleaned.index("```", start)
|
||||
parsed = json.loads(cleaned[start:end].strip())
|
||||
s = cleaned.index("```json") + 7
|
||||
e = cleaned.index("```", s)
|
||||
parsed = json.loads(cleaned[s:e].strip())
|
||||
except (json.JSONDecodeError, ValueError, IndexError):
|
||||
pass
|
||||
if parsed is None and "```" in cleaned:
|
||||
try:
|
||||
start = cleaned.index("```") + 3
|
||||
end = cleaned.index("```", start)
|
||||
parsed = json.loads(cleaned[start:end].strip())
|
||||
s = cleaned.index("```") + 3
|
||||
e = cleaned.index("```", s)
|
||||
parsed = json.loads(cleaned[s:e].strip())
|
||||
except (json.JSONDecodeError, ValueError, IndexError):
|
||||
pass
|
||||
|
||||
# Step 4: Brute-force — find the first valid JSON object in the text
|
||||
# 3. Brace-counting extraction
|
||||
if parsed is None:
|
||||
parsed = self._extract_json_object(cleaned)
|
||||
|
||||
# Step 5: If still nothing, try on the original content (in case
|
||||
# think-tag stripping was too aggressive)
|
||||
# 4. Fallback on original content
|
||||
if parsed is None and content != cleaned:
|
||||
parsed = self._extract_json_object(content)
|
||||
|
||||
if parsed is not None:
|
||||
logger.info("Successfully parsed JSON from LLM response")
|
||||
else:
|
||||
logger.warning("Could not extract JSON from LLM response (%d chars)",
|
||||
len(content))
|
||||
|
||||
logger.warning(
|
||||
"Could not extract JSON from LLM response (%d chars)", len(content)
|
||||
)
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
parsed_json=parsed,
|
||||
@@ -192,94 +199,84 @@ class LLMClient:
|
||||
model=raw.get("model", self.model),
|
||||
)
|
||||
|
||||
def chat_sync(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
# ── Public API ─────────────────────────────────────────────
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
temperature: float = None,
|
||||
max_tokens: int = None,
|
||||
force_json: bool = True,
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
Synchronous chat call to Ollama.
|
||||
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
|
||||
Async chat call to Ollama. Retries up to LLM_MAX_RETRIES.
|
||||
Never blocks the event loop — uses httpx.AsyncClient throughout.
|
||||
"""
|
||||
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
||||
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
||||
body = self._build_request_body(system_prompt, user_prompt,
|
||||
temp, tokens, force_json)
|
||||
|
||||
last_error = None
|
||||
body = await self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, force_json
|
||||
)
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||
try:
|
||||
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
|
||||
req = Request(
|
||||
f"{self.base_url}/chat/completions",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(float(self.timeout))
|
||||
) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
result = self._parse_response(raw)
|
||||
|
||||
# If we need JSON and didn't get it, retry
|
||||
if force_json and result.parsed_json is None:
|
||||
logger.warning("Attempt %d: LLM returned non-JSON, retrying", attempt)
|
||||
logger.warning(
|
||||
"Attempt %d: LLM returned non-JSON, retrying", attempt
|
||||
)
|
||||
last_error = ValueError("Non-JSON response from LLM")
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning("Attempt %d HTTP error %s", attempt, e.response.status_code)
|
||||
last_error = e
|
||||
# 400 with json format → retry without response_format constraint
|
||||
if e.response.status_code == 400 and force_json:
|
||||
logger.warning("Got 400 — retrying without response_format")
|
||||
force_json = False
|
||||
body = await self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Attempt %d failed: %s", attempt, e)
|
||||
last_error = e
|
||||
|
||||
# If 400 and we used json response_format, retry without it
|
||||
error_str = str(e)
|
||||
if "400" in error_str and force_json:
|
||||
logger.warning("Got 400 — retrying without response_format constraint")
|
||||
force_json = False
|
||||
body = self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False)
|
||||
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
import time
|
||||
time.sleep(2 * attempt) # Simple backoff
|
||||
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
import asyncio
|
||||
await asyncio.sleep(2 * attempt)
|
||||
raise ConnectionError(
|
||||
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
|
||||
)
|
||||
|
||||
async def chat(self, system_prompt: str, user_prompt: str,
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
"""
|
||||
Async chat call — runs the sync call in a thread executor
|
||||
so it doesn't block the FastAPI event loop.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.chat_sync(system_prompt, user_prompt,
|
||||
temperature, max_tokens, force_json)
|
||||
)
|
||||
|
||||
def check_connection(self) -> dict:
|
||||
"""
|
||||
Check if Ollama is reachable. Returns status dict.
|
||||
"""
|
||||
async def check_connection(self) -> dict:
|
||||
"""Check if Ollama is reachable. Returns status dict."""
|
||||
try:
|
||||
req = Request(
|
||||
f"{self.base_url}/models",
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="GET",
|
||||
)
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
models = data.get("data", [])
|
||||
model_ids = [m.get("id", "unknown") for m in models]
|
||||
return {
|
||||
"connected": True,
|
||||
"models": model_ids,
|
||||
"active_model": model_ids[0] if model_ids else self.model,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
|
||||
resp = await client.get(
|
||||
f"{self.base_url}/models",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
models = data.get("data", [])
|
||||
model_ids = [m.get("id", "unknown") for m in models]
|
||||
return {
|
||||
"connected": True,
|
||||
"models": model_ids,
|
||||
"active_model": model_ids[0] if model_ids else self.model,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"connected": False,
|
||||
|
||||
+140
-17
@@ -4,12 +4,9 @@ Commander AI Lab — Coach Data Models
|
||||
Pydantic v2 models for deck reports, coach requests/responses,
|
||||
and coaching session persistence.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Deck Report Models (mirrors Java DeckReport)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -17,16 +14,16 @@ from pydantic import BaseModel, Field
|
||||
class CardPerformance(BaseModel):
|
||||
"""Per-card aggregated stats from simulation data."""
|
||||
name: str
|
||||
drawnRate: float = 0.0 # Fraction of games where card was drawn
|
||||
castRate: float = 0.0 # Fraction of games where drawn AND cast
|
||||
drawnRate: float = 0.0
|
||||
castRate: float = 0.0
|
||||
keptInOpeningHandRate: float = 0.0
|
||||
deadCardRate: float = 0.0 # Drawn but never cast/used
|
||||
impactScore: float = 0.0 # (winRateWhenCast - overallWinRate) * castRate
|
||||
synergyScore: float = 0.0 # Co-occurrence uplift with other deck cards
|
||||
clunkinessScore: float = 0.0 # deadCardRate * (1 - castRate)
|
||||
deadCardRate: float = 0.0
|
||||
impactScore: float = 0.0
|
||||
synergyScore: float = 0.0
|
||||
clunkinessScore: float = 0.0
|
||||
avgTurnCast: Optional[float] = None
|
||||
avgDamageDealt: float = 0.0
|
||||
tags: List[str] = Field(default_factory=list) # ramp, draw, removal, etc.
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MatchupRecord(BaseModel):
|
||||
@@ -48,7 +45,7 @@ class DeckMeta(BaseModel):
|
||||
class DeckStructure(BaseModel):
|
||||
"""Deck composition breakdown."""
|
||||
landCount: int = 0
|
||||
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8) # CMC 0-7+
|
||||
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8)
|
||||
cardTypeCounts: Dict[str, int] = Field(default_factory=dict)
|
||||
functionalCounts: Dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
@@ -57,7 +54,7 @@ class ComboRecord(BaseModel):
|
||||
"""Known combo with performance data."""
|
||||
cardNames: List[str]
|
||||
winRateWhenAssembled: float = 0.0
|
||||
assemblyRate: float = 0.0 # Fraction of games where all pieces drawn
|
||||
assemblyRate: float = 0.0
|
||||
|
||||
|
||||
class DeckReport(BaseModel):
|
||||
@@ -84,10 +81,10 @@ class DeckReport(BaseModel):
|
||||
|
||||
class CoachGoals(BaseModel):
|
||||
"""User-specified coaching goals."""
|
||||
targetPowerLevel: Optional[int] = None # 1-10
|
||||
metaFocus: Optional[str] = None # aggro, control, combo, midrange, stax
|
||||
budget: Optional[str] = None # budget, medium, no-limit
|
||||
focusAreas: List[str] = Field(default_factory=list) # e.g., ["ramp", "card draw"]
|
||||
targetPowerLevel: Optional[int] = None
|
||||
metaFocus: Optional[str] = None
|
||||
budget: Optional[str] = None
|
||||
focusAreas: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CoachRequest(BaseModel):
|
||||
@@ -107,12 +104,120 @@ class SuggestedCut(BaseModel):
|
||||
class SuggestedAdd(BaseModel):
|
||||
"""A card the coach suggests adding."""
|
||||
cardName: str
|
||||
role: str = "" # ramp, draw, removal, finisher, etc.
|
||||
role: str = ""
|
||||
reason: str = ""
|
||||
synergyWith: List[str] = Field(default_factory=list)
|
||||
estimatedManaValue: Optional[float] = None
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Phase 2: Structured Analysis Models
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class UpgradePriorityItem(BaseModel):
|
||||
"""A single prioritized cut/add swap ranked by expected impact."""
|
||||
rank: int
|
||||
cut: str
|
||||
add: str
|
||||
reasoning: str = ""
|
||||
expectedImpact: str = ""
|
||||
|
||||
|
||||
class CommanderDependency(BaseModel):
|
||||
"""How reliant the deck is on having the commander in play."""
|
||||
score: int = 5
|
||||
dependentCards: List[str] = Field(default_factory=list)
|
||||
recoveryPlan: str = ""
|
||||
|
||||
|
||||
class MulliganAnalysis(BaseModel):
|
||||
"""Opening hand keepability assessment."""
|
||||
estimatedKeepRate: str = ""
|
||||
worstOffenders: List[str] = Field(default_factory=list)
|
||||
recommendation: str = ""
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Phase 3: Qualitative Analysis Models
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class RemovalCoverage(BaseModel):
|
||||
"""Categorized removal package by permanent type."""
|
||||
creatures: str = ""
|
||||
artifacts: str = ""
|
||||
enchantments: str = ""
|
||||
planeswalkers: str = ""
|
||||
lands: str = ""
|
||||
massRemoval: str = ""
|
||||
gaps: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RampQuality(BaseModel):
|
||||
"""Ramp package breakdown by subtype and fragility."""
|
||||
manaRocks: str = ""
|
||||
landFetch: str = ""
|
||||
manaDorks: str = ""
|
||||
costReducers: str = ""
|
||||
fragility: str = ""
|
||||
canReachFourByTurnThree: str = ""
|
||||
|
||||
|
||||
class DrawEngineProfile(BaseModel):
|
||||
"""Card draw sustainability analysis."""
|
||||
burstDraw: List[str] = Field(default_factory=list)
|
||||
repeatableEngines: List[str] = Field(default_factory=list)
|
||||
assessment: str = ""
|
||||
sustainability: str = ""
|
||||
|
||||
|
||||
class WinCondition(BaseModel):
|
||||
"""A named win condition with independence rating."""
|
||||
name: str
|
||||
cards: List[str] = Field(default_factory=list)
|
||||
independence: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
class AntiSynergyFlag(BaseModel):
|
||||
"""An internal conflict between cards in the deck."""
|
||||
cards: List[str] = Field(default_factory=list)
|
||||
conflict: str = ""
|
||||
severity: str = ""
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Phase 5: Pod Politics & Meta Awareness Models
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class PodPresence(BaseModel):
|
||||
"""Commander pod politics and threat assessment."""
|
||||
threatLevel: str = "" # early/medium/late/low
|
||||
politicalTools: List[str] = Field(default_factory=list)
|
||||
adversarialRating: str = "" # low/medium/high
|
||||
recommendation: str = ""
|
||||
|
||||
|
||||
class TempoAssessment(BaseModel):
|
||||
"""When the deck 'comes online' and whether that's fast enough."""
|
||||
peakTurn: int = 0
|
||||
profile: str = "" # early/mid/late
|
||||
survivalWindow: str = "" # Assessment of whether it survives to peak
|
||||
assessment: str = ""
|
||||
|
||||
|
||||
class MetaMatchup(BaseModel):
|
||||
"""Strategic reasoning about deck vs a meta archetype."""
|
||||
archetype: str # fast-combo, stax, control, aggro, etc.
|
||||
assessment: str = ""
|
||||
keyThreats: List[str] = Field(default_factory=list)
|
||||
keyAnswers: List[str] = Field(default_factory=list)
|
||||
overallRating: str = "" # favored/even/unfavored
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Coach Session (persisted result)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class CoachSession(BaseModel):
|
||||
"""
|
||||
Complete coaching session result.
|
||||
@@ -131,6 +236,24 @@ class CoachSession(BaseModel):
|
||||
promptTokens: int = 0
|
||||
completionTokens: int = 0
|
||||
goals: Optional[CoachGoals] = None
|
||||
isQuickDigest: bool = False # True for quick digest, False for full analysis
|
||||
|
||||
# Phase 2: Structured analysis fields
|
||||
upgradePriority: List[UpgradePriorityItem] = Field(default_factory=list)
|
||||
commanderDependency: Optional[CommanderDependency] = None
|
||||
mulliganAnalysis: Optional[MulliganAnalysis] = None
|
||||
|
||||
# Phase 3: Qualitative analysis fields
|
||||
removalCoverage: Optional[RemovalCoverage] = None
|
||||
rampQuality: Optional[RampQuality] = None
|
||||
drawEngineProfile: Optional[DrawEngineProfile] = None
|
||||
winConditions: List[WinCondition] = Field(default_factory=list)
|
||||
antiSynergyFlags: List[AntiSynergyFlag] = Field(default_factory=list)
|
||||
|
||||
# Phase 5: Pod politics & meta
|
||||
podPresence: Optional[PodPresence] = None
|
||||
tempoAssessment: Optional[TempoAssessment] = None
|
||||
metaMatchups: List[MetaMatchup] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ApplyRequest(BaseModel):
|
||||
|
||||
+336
-130
@@ -6,7 +6,6 @@ Injects deck report data, underperformer analysis, and
|
||||
candidate replacement cards into a structured template.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from .models import DeckReport, CoachGoals, CardPerformance
|
||||
@@ -27,40 +26,186 @@ RULES:
|
||||
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
|
||||
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
|
||||
- When suggesting replacements, prefer cards that fill the same functional role.
|
||||
- IMPORTANT: Suggest 3-5 DIFFERENT cards to cut and 3-5 DIFFERENT cards to add.
|
||||
- IMPORTANT: Suggest 5-8 DIFFERENT cards to cut and 5-8 DIFFERENT cards to add.
|
||||
- Each suggested add MUST be a different card — never suggest the same card twice.
|
||||
- Vary your suggestions across different functional roles (ramp, draw, removal, threats, utility).
|
||||
- If the deck is missing a functional role (e.g., no card draw, no removal), prioritize adding cards for that role.
|
||||
- If candidate replacements are provided from embeddings search, prefer those over generic suggestions.
|
||||
- For each suggested cut, provide a DETAILED reason citing specific stats (impact score, cast rate, dead card rate, clunkiness, synergy score).
|
||||
- For each suggested add, explain the SPECIFIC synergy with existing cards in the deck.
|
||||
- When reasoning about cuts, also consider keptInOpeningHandRate (low = bad in opening hands) and avgTurnCast (high = too slow for the curve).
|
||||
- When evaluating synergy, use the synergyScore: cards below 0.1 have weak co-occurrence with the rest of the deck.
|
||||
- Use perArchetypeWinRates to identify which matchup types this deck struggles with and tailor suggestions accordingly.
|
||||
- Provide at least 5 heuristic hints covering different strategic dimensions.
|
||||
- Assess how dependent the deck is on the commander being in play.
|
||||
- Evaluate opening hand quality and identify cards that cause bad mulligans.
|
||||
- Rank your suggested swaps by priority so the user knows what to change first.
|
||||
- Categorize the removal package by permanent type and flag coverage gaps.
|
||||
- Evaluate the ramp package for fragility (creature/artifact-heavy vs land-based).
|
||||
- Distinguish one-time draw from repeatable draw engines.
|
||||
- Identify the top 3 win conditions and rate each for independence.
|
||||
- Flag internal anti-synergies or card conflicts.
|
||||
- Assess pod threat perception: does this deck draw too much hate too early?
|
||||
- Evaluate when the deck comes online (tempo profile) and whether that's viable.
|
||||
- Reason about matchups vs fast combo, stax/prison, and control archetypes.
|
||||
{goals_section}
|
||||
|
||||
You MUST respond ONLY with valid JSON matching this exact structure:
|
||||
{{
|
||||
"summary": "2-3 sentence overview of the deck's strengths and weaknesses",
|
||||
"summary": "4-6 sentence detailed overview of the deck's strengths, weaknesses, key synergies, and strategic identity. Reference specific win rates and simulation data.",
|
||||
"suggestedCuts": [
|
||||
{{
|
||||
"cardName": "Card To Remove",
|
||||
"reason": "Why this card underperforms",
|
||||
"replacementOptions": ["Better Card 1", "Better Card 2"]
|
||||
"reason": "Detailed explanation citing specific simulation stats (impact score, cast rate, dead card rate, clunkiness score, synergy score, avg turn cast) and why this card underperforms in this specific deck context",
|
||||
"replacementOptions": ["Better Card 1", "Better Card 2"],
|
||||
"functionalRole": "ramp|draw|removal|threat|utility|combo|protection",
|
||||
"estimatedImpact": "How cutting this card is expected to improve the deck"
|
||||
}}
|
||||
],
|
||||
"suggestedAdds": [
|
||||
{{
|
||||
"cardName": "Card To Add",
|
||||
"role": "ramp|draw|removal|threat|utility|combo|protection",
|
||||
"reason": "Why this card improves the deck",
|
||||
"synergyWith": ["Card In Deck 1", "Card In Deck 2"]
|
||||
"reason": "Detailed explanation of why this card improves the deck, referencing specific weaknesses it addresses",
|
||||
"synergyWith": ["Card In Deck 1", "Card In Deck 2"],
|
||||
"estimatedManaCost": "The mana value of the suggested card",
|
||||
"strategicRationale": "How this card fits into the overall game plan and addresses a specific gap"
|
||||
}}
|
||||
],
|
||||
"heuristicHints": [
|
||||
"Concise strategic tip based on data"
|
||||
"upgradePriority": [
|
||||
{{
|
||||
"rank": 1,
|
||||
"cut": "Card to remove",
|
||||
"add": "Card to add instead",
|
||||
"reasoning": "Why this is the single highest-impact swap",
|
||||
"expectedImpact": "high|medium|low"
|
||||
}}
|
||||
],
|
||||
"manaBaseAdvice": "Specific mana base improvement suggestion or null",
|
||||
"rawTextExplanation": "Detailed paragraph explaining the analysis and reasoning"
|
||||
"commanderDependency": {{
|
||||
"score": 5,
|
||||
"dependentCards": ["Card that is nearly dead without commander"],
|
||||
"recoveryPlan": "Strategy if the commander gets removed or tucked repeatedly"
|
||||
}},
|
||||
"mulliganAnalysis": {{
|
||||
"estimatedKeepRate": "Percentage estimate of keepable 7-card hands, e.g. ~70%",
|
||||
"worstOffenders": ["Card most responsible for unkeepable hands"],
|
||||
"recommendation": "What to change to improve opening hand quality"
|
||||
}},
|
||||
"manaCurveAnalysis": {{
|
||||
"currentAssessment": "Detailed analysis of the current mana curve distribution and its implications",
|
||||
"recommendations": "Specific suggestions for curve adjustments with reasoning",
|
||||
"idealRange": "What the curve should look like for this deck's strategy"
|
||||
}},
|
||||
"roleBreakdown": {{
|
||||
"ramp": "Assessment of ramp package - count, quality, and suggestions",
|
||||
"draw": "Assessment of card draw package",
|
||||
"removal": "Assessment of removal package",
|
||||
"threats": "Assessment of win conditions and threats",
|
||||
"protection": "Assessment of protection and interaction"
|
||||
}},
|
||||
"synergyAnalysis": [
|
||||
"Description of a key synergy chain in the deck and how well it performed in simulation"
|
||||
],
|
||||
"heuristicHints": [
|
||||
"Detailed strategic tip explaining the reasoning and data behind the suggestion"
|
||||
],
|
||||
"manaBaseAdvice": "Specific mana base improvement suggestion including land count, color fixing, and utility lands, or null if mana base is solid",
|
||||
"rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates, and (5) prioritized list of improvements ranked by expected impact on win rate",
|
||||
"removalCoverage": {{
|
||||
"creatures": "Assessment of creature removal",
|
||||
"artifacts": "Assessment of artifact removal",
|
||||
"enchantments": "Assessment of enchantment removal",
|
||||
"planeswalkers": "Assessment of planeswalker removal",
|
||||
"lands": "Assessment of land destruction",
|
||||
"massRemoval": "Assessment of board wipes",
|
||||
"gaps": ["Permanent types with zero or weak coverage"]
|
||||
}},
|
||||
"rampQuality": {{
|
||||
"manaRocks": "2-mana rocks, signets, etc.",
|
||||
"landFetch": "Cultivate, Kodama's Reach, etc.",
|
||||
"manaDorks": "Llanowar Elves, Birds, etc.",
|
||||
"costReducers": "Medallions, Helm of Awakening, etc.",
|
||||
"fragility": "Overall fragility assessment",
|
||||
"canReachFourByTurnThree": "yes/no with reasoning"
|
||||
}},
|
||||
"drawEngineProfile": {{
|
||||
"burstDraw": ["One-time draw spells"],
|
||||
"repeatableEngines": ["Sustained draw engines"],
|
||||
"assessment": "Overall draw quality",
|
||||
"sustainability": "Whether draw is sustainable or burst-only"
|
||||
}},
|
||||
"winConditions": [
|
||||
{{
|
||||
"name": "Name of win condition",
|
||||
"cards": ["Key cards involved"],
|
||||
"independence": "high|medium|low",
|
||||
"description": "How this win con functions"
|
||||
}}
|
||||
],
|
||||
"antiSynergyFlags": [
|
||||
{{
|
||||
"cards": ["Card A", "Card B"],
|
||||
"conflict": "Description of conflict",
|
||||
"severity": "high|medium|low"
|
||||
}}
|
||||
],
|
||||
"podPresence": {{
|
||||
"threatLevel": "early|medium|late|low",
|
||||
"politicalTools": ["Pillowfort or group effect cards"],
|
||||
"adversarialRating": "low|medium|high",
|
||||
"recommendation": "How to manage threat perception in a pod"
|
||||
}},
|
||||
"tempoAssessment": {{
|
||||
"peakTurn": 5,
|
||||
"profile": "early|mid|late",
|
||||
"survivalWindow": "Assessment of whether the deck survives to its power window",
|
||||
"assessment": "Overall tempo evaluation"
|
||||
}},
|
||||
"metaMatchups": [
|
||||
{{
|
||||
"archetype": "fast-combo|stax|control|aggro|midrange",
|
||||
"assessment": "How the deck fares against this archetype",
|
||||
"keyThreats": ["Cards/strategies that threaten this deck"],
|
||||
"keyAnswers": ["Cards in the deck that answer this archetype"],
|
||||
"overallRating": "favored|even|unfavored"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Quick Digest Prompt
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
QUICK_DIGEST_SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander deck coach.
|
||||
Provide a FAST summary analysis of this deck. Be concise.
|
||||
|
||||
RULES:
|
||||
- NEVER suggest cutting the commander ({commander}).
|
||||
- Only suggest cards within the deck's color identity: {color_identity}.
|
||||
{goals_section}
|
||||
|
||||
You MUST respond ONLY with valid JSON matching this exact structure:
|
||||
{{
|
||||
"summary": "3-sentence deck overview: what it does, main strength, main weakness.",
|
||||
"upgradePriority": [
|
||||
{{
|
||||
"rank": 1,
|
||||
"cut": "Card to remove",
|
||||
"add": "Card to add instead",
|
||||
"reasoning": "Why this is a high-impact swap",
|
||||
"expectedImpact": "high|medium|low"
|
||||
}}
|
||||
],
|
||||
"commanderDependency": {{
|
||||
"score": 5,
|
||||
"dependentCards": ["Card that is nearly dead without commander"],
|
||||
"recoveryPlan": "Brief recovery strategy"
|
||||
}}
|
||||
}}
|
||||
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# User Prompt Builder
|
||||
@@ -82,124 +227,173 @@ Avg Game Length: {avg_game_length:.1f} turns
|
||||
### Matchup Data
|
||||
{matchup_section}
|
||||
|
||||
### Archetype Win Rates
|
||||
{archetype_section}
|
||||
|
||||
### Top Performing Cards (by Impact Score)
|
||||
{top_cards}
|
||||
|
||||
### Underperforming Cards (candidates for cuts)
|
||||
{underperformer_section}
|
||||
|
||||
|
||||
### RAG-Retrieved Similar Cards (from ChromaDB)
|
||||
{rag_section}
|
||||
### Candidate Replacement Cards (from embeddings search)
|
||||
{candidate_section}
|
||||
|
||||
{combo_section}
|
||||
|
||||
Based on this simulation data, provide your coaching suggestions."""
|
||||
|
||||
|
||||
def _format_goals_section(goals: Optional[CoachGoals]) -> str:
|
||||
"""Format goals into system prompt rules."""
|
||||
if not goals:
|
||||
return "- Aim for a balanced, well-rounded deck."
|
||||
|
||||
lines = []
|
||||
if goals.targetPowerLevel is not None:
|
||||
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
|
||||
if goals.targetPowerLevel <= 4:
|
||||
lines.append("- Keep suggestions casual and fun-focused.")
|
||||
elif goals.targetPowerLevel >= 8:
|
||||
lines.append("- Prioritize efficiency and competitive staples.")
|
||||
if goals.metaFocus:
|
||||
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
|
||||
if goals.budget:
|
||||
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
|
||||
"no-limit": "no budget restrictions"}
|
||||
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
|
||||
if goals.focusAreas:
|
||||
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
|
||||
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
|
||||
"""Format goals into system prompt rules."""
|
||||
if not goals:
|
||||
return "- Aim for a balanced, well-rounded deck."
|
||||
lines = []
|
||||
if goals.targetPowerLevel is not None:
|
||||
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
|
||||
if goals.targetPowerLevel <= 4:
|
||||
lines.append("- Keep suggestions casual and fun-focused.")
|
||||
elif goals.targetPowerLevel >= 8:
|
||||
lines.append("- Prioritize efficiency and competitive staples.")
|
||||
if goals.metaFocus:
|
||||
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
|
||||
if goals.budget:
|
||||
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
|
||||
"no-limit": "no budget restrictions"}
|
||||
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
|
||||
if goals.focusAreas:
|
||||
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
|
||||
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
|
||||
|
||||
|
||||
def _format_curve(buckets: List[int]) -> str:
|
||||
"""Format mana curve buckets as a readable string."""
|
||||
labels = ["0", "1", "2", "3", "4", "5", "6", "7+"]
|
||||
parts = [f"{labels[i]}={buckets[i]}" for i in range(min(len(buckets), 8)) if buckets[i] > 0]
|
||||
return ", ".join(parts) if parts else "not available (analyze deck structure manually)"
|
||||
"""Format mana curve buckets as a readable string."""
|
||||
labels = ["0", "1", "2", "3", "4", "5", "6", "7+"]
|
||||
parts = [f"{labels[i]}={buckets[i]}" for i in range(min(len(buckets), 8)) if buckets[i] > 0]
|
||||
return ", ".join(parts) if parts else "not available (analyze deck structure manually)"
|
||||
|
||||
|
||||
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
|
||||
"""Format the best performing cards."""
|
||||
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
|
||||
lines = []
|
||||
for c in sorted_cards[:top_n]:
|
||||
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
|
||||
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}{tags}")
|
||||
return "\n".join(lines) if lines else "No card performance data available."
|
||||
"""Format the best performing cards with extended stats."""
|
||||
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
|
||||
lines = []
|
||||
for c in sorted_cards[:top_n]:
|
||||
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
|
||||
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
|
||||
lines.append(
|
||||
f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
|
||||
f"synergy={c.synergyScore:.3f}{turn_str}{tags}"
|
||||
)
|
||||
return "\n".join(lines) if lines else "No card performance data available."
|
||||
|
||||
|
||||
def _format_underperformers(cards: List[CardPerformance],
|
||||
underperformer_names: List[str]) -> str:
|
||||
"""Format underperforming cards with their stats."""
|
||||
card_map = {c.name: c for c in cards}
|
||||
lines = []
|
||||
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
|
||||
c = card_map.get(name)
|
||||
if c:
|
||||
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"deadCardRate={c.deadCardRate:.1%}, "
|
||||
f"castRate={c.castRate:.1%}, "
|
||||
f"clunkiness={c.clunkinessScore:.3f}")
|
||||
else:
|
||||
lines.append(f"- {name}: (no detailed stats)")
|
||||
return "\n".join(lines) if lines else "No clear underperformers identified."
|
||||
underperformer_names: List[str]) -> str:
|
||||
"""Format underperforming cards with full available stats."""
|
||||
card_map = {c.name: c for c in cards}
|
||||
lines = []
|
||||
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
|
||||
c = card_map.get(name)
|
||||
if c:
|
||||
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
|
||||
lines.append(
|
||||
f"- {c.name}: impact={c.impactScore:.3f}, "
|
||||
f"deadCardRate={c.deadCardRate:.1%}, "
|
||||
f"castRate={c.castRate:.1%}, "
|
||||
f"clunkiness={c.clunkinessScore:.3f}, "
|
||||
f"synergy={c.synergyScore:.3f}, "
|
||||
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
|
||||
f"{turn_str}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"- {name}: (no detailed stats)")
|
||||
return "\n".join(lines) if lines else "No clear underperformers identified."
|
||||
|
||||
|
||||
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
||||
"""Format replacement candidates grouped by the card they'd replace."""
|
||||
if not candidates:
|
||||
return ("No embedding-based candidates available. "
|
||||
"Use your MTG knowledge to suggest cards that fit the deck's "
|
||||
"color identity, strategy, and missing functional roles. "
|
||||
"Suggest at least 3 different cards across different roles "
|
||||
"(ramp, draw, removal, threats, utility).")
|
||||
"""Format replacement candidates grouped by the card they'd replace."""
|
||||
if not candidates:
|
||||
return (
|
||||
"No embedding-based candidates available. "
|
||||
"Use your MTG knowledge to suggest cards that fit the deck's "
|
||||
"color identity, strategy, and missing functional roles. "
|
||||
"Suggest at least 3 different cards across different roles "
|
||||
"(ramp, draw, removal, threats, utility)."
|
||||
)
|
||||
lines = []
|
||||
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
|
||||
lines.append(f"\nReplacements for '{underperformer}':")
|
||||
for r in replacements[:MAX_CANDIDATES_PER_UNDERPERFORMER]:
|
||||
name = r.get("name", "Unknown")
|
||||
types = r.get("types", "")
|
||||
mv = r.get("mana_value", "?")
|
||||
sim = r.get("similarity", 0)
|
||||
text_preview = r.get("text", "")[:80]
|
||||
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
|
||||
if text_preview:
|
||||
lines.append(f" Oracle: {text_preview}")
|
||||
lines.append("\nChoose from these candidates when possible, but also suggest ")
|
||||
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_rag_cards(rag_cards: List[dict]) -> str:
|
||||
"""Format RAG-retrieved cards from ChromaDB vector search."""
|
||||
if not rag_cards:
|
||||
return "No RAG results available."
|
||||
lines = []
|
||||
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
|
||||
lines.append(f"\nReplacements for '{underperformer}':")
|
||||
for r in replacements[:MAX_CANDIDATES_PER_UNDERPERFORMER]:
|
||||
name = r.get("name", "Unknown")
|
||||
types = r.get("types", "")
|
||||
mv = r.get("mana_value", "?")
|
||||
sim = r.get("similarity", 0)
|
||||
text_preview = r.get("text", "")[:80]
|
||||
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
|
||||
if text_preview:
|
||||
lines.append(f" Oracle: {text_preview}")
|
||||
lines.append("\nChoose from these candidates when possible, but also suggest ")
|
||||
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
|
||||
for card in rag_cards[:15]:
|
||||
name = card.get("name", "Unknown")
|
||||
types = card.get("types", "")
|
||||
mv = card.get("mana_value", "?")
|
||||
mc = card.get("mana_cost", "")
|
||||
dist = card.get("distance", 0)
|
||||
text = card.get("text", "")[:80]
|
||||
lines.append(f"- {name} ({mc}, MV:{mv}, {types}) [distance: {dist:.3f}]")
|
||||
if text:
|
||||
lines.append(f" Oracle: {text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
def _format_matchups(report: DeckReport) -> str:
|
||||
"""Format matchup data."""
|
||||
if not report.matchups:
|
||||
return "No matchup data available."
|
||||
lines = []
|
||||
for m in report.matchups:
|
||||
lines.append(f"- vs {m.opponentDeck}: {m.winRate:.1%} "
|
||||
f"({m.gamesPlayed} games)")
|
||||
return "\n".join(lines)
|
||||
"""Format per-opponent matchup data."""
|
||||
if not report.matchups:
|
||||
return "No matchup data available."
|
||||
lines = []
|
||||
for m in report.matchups:
|
||||
lines.append(
|
||||
f"- vs {m.opponentDeck}: {m.winRate:.1%} "
|
||||
f"({m.gamesPlayed} games)"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_archetypes(report: DeckReport) -> str:
|
||||
"""Format per-archetype win rates from DeckMeta."""
|
||||
rates = report.meta.perArchetypeWinRates
|
||||
if not rates:
|
||||
return "No archetype breakdown available."
|
||||
lines = []
|
||||
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]):
|
||||
lines.append(f"- vs {archetype}: {wr:.1%}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_combos(report: DeckReport) -> str:
|
||||
"""Format known combos section."""
|
||||
if not report.knownCombos:
|
||||
return ""
|
||||
lines = ["### Known Combos"]
|
||||
for combo in report.knownCombos:
|
||||
cards = " + ".join(combo.cardNames)
|
||||
lines.append(f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
|
||||
f"assemblyRate={combo.assemblyRate:.1%}")
|
||||
return "\n".join(lines)
|
||||
"""Format known combos section."""
|
||||
if not report.knownCombos:
|
||||
return ""
|
||||
lines = ["### Known Combos"]
|
||||
for combo in report.knownCombos:
|
||||
cards = " + ".join(combo.cardNames)
|
||||
lines.append(
|
||||
f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
|
||||
f"assemblyRate={combo.assemblyRate:.1%}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -207,41 +401,53 @@ def _format_combos(report: DeckReport) -> str:
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def build_system_prompt(report: DeckReport,
|
||||
goals: Optional[CoachGoals] = None) -> str:
|
||||
"""Build the system prompt with deck-specific rules."""
|
||||
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
|
||||
return SYSTEM_PROMPT_TEMPLATE.format(
|
||||
commander=report.commander,
|
||||
color_identity=color_str,
|
||||
goals_section=_format_goals_section(goals),
|
||||
)
|
||||
goals: Optional[CoachGoals] = None) -> str:
|
||||
"""Build the system prompt with deck-specific rules."""
|
||||
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
|
||||
return SYSTEM_PROMPT_TEMPLATE.format(
|
||||
commander=report.commander,
|
||||
color_identity=color_str,
|
||||
goals_section=_format_goals_section(goals),
|
||||
)
|
||||
|
||||
|
||||
def build_quick_system_prompt(report: DeckReport,
|
||||
goals: Optional[CoachGoals] = None) -> str:
|
||||
"""Build the quick digest system prompt (~400-600 token target)."""
|
||||
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
|
||||
return QUICK_DIGEST_SYSTEM_PROMPT.format(
|
||||
commander=report.commander,
|
||||
color_identity=color_str,
|
||||
goals_section=_format_goals_section(goals),
|
||||
)
|
||||
|
||||
|
||||
def build_user_prompt(report: DeckReport,
|
||||
candidates: Dict[str, List[dict]] = None) -> str:
|
||||
"""Build the user prompt with full deck report data."""
|
||||
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
|
||||
|
||||
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
|
||||
if report.structure.cardTypeCounts else ["unknown"]
|
||||
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
|
||||
if report.structure.functionalCounts else ["unknown"]
|
||||
|
||||
return USER_PROMPT_TEMPLATE.format(
|
||||
deck_name=report.deckId,
|
||||
commander=report.commander,
|
||||
color_identity=color_str,
|
||||
games_simulated=report.meta.gamesSimulated,
|
||||
win_rate=report.meta.overallWinRate,
|
||||
avg_game_length=report.meta.avgGameLength,
|
||||
land_count=report.structure.landCount,
|
||||
curve_summary=_format_curve(report.structure.curveBuckets),
|
||||
type_summary=", ".join(type_parts),
|
||||
role_summary=", ".join(role_parts),
|
||||
matchup_section=_format_matchups(report),
|
||||
top_cards=_format_top_cards(report.cards),
|
||||
underperformer_section=_format_underperformers(
|
||||
report.cards, report.underperformers),
|
||||
candidate_section=_format_candidates(candidates or {}),
|
||||
combo_section=_format_combos(report),
|
||||
)
|
||||
candidates: Dict[str, List[dict]] = None,
|
||||
rag_cards: List[dict] = None) -> str:
|
||||
"""Build the user prompt with full deck report data."""
|
||||
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
|
||||
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
|
||||
if report.structure.cardTypeCounts else ["unknown"]
|
||||
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
|
||||
if report.structure.functionalCounts else ["unknown"]
|
||||
return USER_PROMPT_TEMPLATE.format(
|
||||
deck_name=report.deckId,
|
||||
commander=report.commander,
|
||||
color_identity=color_str,
|
||||
games_simulated=report.meta.gamesSimulated,
|
||||
win_rate=report.meta.overallWinRate,
|
||||
avg_game_length=report.meta.avgGameLength,
|
||||
land_count=report.structure.landCount,
|
||||
curve_summary=_format_curve(report.structure.curveBuckets),
|
||||
type_summary=", ".join(type_parts),
|
||||
role_summary=", ".join(role_parts),
|
||||
matchup_section=_format_matchups(report),
|
||||
archetype_section=_format_archetypes(report),
|
||||
top_cards=_format_top_cards(report.cards),
|
||||
underperformer_section=_format_underperformers(
|
||||
report.cards, report.underperformers),
|
||||
candidate_section=_format_candidates(candidates or {}),
|
||||
rag_section=_format_rag_cards(rag_cards or []),
|
||||
combo_section=_format_combos(report),
|
||||
)
|
||||
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
Commander AI Lab — Deck Generation System Prompt
|
||||
═════════════════════════════════════════════════
|
||||
Phase 1 changes:
|
||||
- Added explicit 99-card count enforcement rule
|
||||
- Clarified that commander counts separately (not in the 99)
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """You are an elite Magic: The Gathering Commander (EDH) deck builder with encyclopedic knowledge of every card ever printed, current metagame trends, and Commander Rules Committee bracket guidelines.
|
||||
@@ -9,6 +12,8 @@ Your task is to build a complete, legal 100-card Commander deck.
|
||||
|
||||
RULES:
|
||||
- Exactly 100 cards total: 1 commander + 99 other cards
|
||||
- The commander IS included in the cards list but counts as card #100
|
||||
- The other 99 cards must exactly match the SLOT BUDGET provided in the user prompt
|
||||
- No more than 1 copy of any card except basic lands
|
||||
- All cards must share the commander's color identity
|
||||
- All cards must be legal in Commander format
|
||||
@@ -16,6 +21,12 @@ RULES:
|
||||
- Include approximately: 10 ramp sources, 10 card draw sources, 8-10 targeted removal, 2-3 board wipes, 3-5 protection pieces
|
||||
- Build a cohesive strategy around the commander's abilities
|
||||
|
||||
CARD COUNT ENFORCEMENT (critical):
|
||||
- The user prompt will specify a SLOT BUDGET with exact per-category counts
|
||||
- You MUST fill every slot — returning fewer cards than the budget specifies will cause a validation error
|
||||
- If you cannot find a perfect fit for a slot, use the best available card for that role
|
||||
- Basic lands count toward the Land slot total
|
||||
|
||||
BRACKET RULES (Commander Rules Committee):
|
||||
- Bracket 1 (Casual): Precon-level. No tutors, no fast mana, no infinite combos, no mass land destruction. Game Changers: 0
|
||||
- Bracket 2 (Upgraded Casual): Moderate power. Limited tutors (1-2 total), no 2-card infinite combos, no fast mana except Sol Ring. Game Changers: 0-1
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
"""
|
||||
Commander AI Lab — Deck Generation User Prompt Builder
|
||||
═══════════════════════════════════════════════════════
|
||||
Constructs the user message for Perplexity deck generation
|
||||
from a DeckGenV3Request.
|
||||
Constructs the user message for LLM deck generation.
|
||||
|
||||
Phase 1 changes:
|
||||
- slot_budget parameter injected into prompt so LLM targets exactly 99 cards
|
||||
- Explicit card count requirement in prompt text
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
def build_slot_budget_segment(slot_budget: Dict[str, int]) -> str:
|
||||
"""
|
||||
Build the slot budget prompt segment.
|
||||
Informs the LLM of exact per-category card counts so it never undershoots.
|
||||
"""
|
||||
total = sum(slot_budget.values())
|
||||
lines = [f"SLOT BUDGET (you must fill EXACTLY {total} cards, excluding the commander):"]
|
||||
for cat, count in slot_budget.items():
|
||||
lines.append(f" {cat}: {count}")
|
||||
lines.append(f" TOTAL: {total}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"CRITICAL: Return exactly {total} cards in the JSON 'cards' array "
|
||||
"(not counting the commander). Every slot in the budget above must be filled. "
|
||||
"Do not return more or fewer cards."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_user_prompt(
|
||||
@@ -18,6 +40,7 @@ def build_user_prompt(
|
||||
budget_mode: str = "total",
|
||||
omit_cards: List[str] = None,
|
||||
collection_summary: str = "",
|
||||
slot_budget: Optional[Dict[str, int]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build the user prompt for deck generation.
|
||||
@@ -26,18 +49,19 @@ def build_user_prompt(
|
||||
commander: Commander card name
|
||||
commander_type: Type line from Scryfall
|
||||
color_identity: List of color letters, e.g. ["U", "B"]
|
||||
strategy: User-specified strategy focus (e.g. "zombie tokens")
|
||||
strategy: User-specified strategy focus
|
||||
target_bracket: Target bracket level 1-4
|
||||
budget_usd: Optional budget constraint
|
||||
budget_mode: "total" for total deck cost, "per_card" for per-card max
|
||||
omit_cards: Cards to exclude
|
||||
collection_summary: Pre-built collection summary text block
|
||||
slot_budget: Dict of category -> card count (must sum to 99)
|
||||
"""
|
||||
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
|
||||
|
||||
lines = [
|
||||
f"Build a complete 100-card Commander deck.",
|
||||
f"",
|
||||
"Build a complete 100-card Commander deck.",
|
||||
"",
|
||||
f"COMMANDER: {commander}",
|
||||
]
|
||||
|
||||
@@ -60,16 +84,21 @@ def build_user_prompt(
|
||||
lines.append(f"OMIT LIST (do NOT include these cards): {', '.join(omit_cards)}")
|
||||
|
||||
if collection_summary:
|
||||
lines.append(f"")
|
||||
lines.append("")
|
||||
lines.append(collection_summary)
|
||||
lines.append(f"")
|
||||
lines.append(f"STRONGLY PREFER cards from the collection when they fit the strategy.")
|
||||
lines.append("")
|
||||
lines.append("STRONGLY PREFER cards from the collection when they fit the strategy.")
|
||||
else:
|
||||
lines.append(f"")
|
||||
lines.append(f"No collection data available — suggest the best cards regardless of ownership.")
|
||||
lines.append("")
|
||||
lines.append("No collection data available — suggest the best cards regardless of ownership.")
|
||||
|
||||
lines.append(f"")
|
||||
lines.append(f"Build the deck as a structured JSON response. Include the commander in the cards list.")
|
||||
lines.append(f"Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
|
||||
# Inject slot budget so LLM knows exact target counts
|
||||
if slot_budget:
|
||||
lines.append("")
|
||||
lines.append(build_slot_budget_segment(slot_budget))
|
||||
|
||||
lines.append("")
|
||||
lines.append("Build the deck as a structured JSON response. Include the commander in the cards list.")
|
||||
lines.append("Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Substitution Prompt Templates
|
||||
═════════════════════════════════════════════════
|
||||
Prompts for the Perplexity fallback substitution pass.
|
||||
Prompts for the Claude Opus substitution pass.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Deck Generation JSON Schemas
|
||||
═════════════════════════════════════════════════
|
||||
Pydantic models and JSON Schema dicts for Perplexity
|
||||
Pydantic models and JSON Schema dicts for Claude Opus
|
||||
structured output deck generation.
|
||||
"""
|
||||
|
||||
@@ -58,7 +58,7 @@ class GeneratedDeckList(BaseModel):
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# JSON Schema dicts (for Perplexity response_format)
|
||||
# JSON Schema dicts (for Claude Opus response_format)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
DECK_CARD_SCHEMA = {
|
||||
|
||||
@@ -3,6 +3,10 @@ Commander AI Lab — Substitution Schemas
|
||||
════════════════════════════════════════
|
||||
Pydantic models and JSON Schema dicts for the
|
||||
Smart Substitution Engine output.
|
||||
|
||||
Phase 1 changes:
|
||||
- SubstitutionBatchResult gains padded_slots field
|
||||
- DeckCardWithStatus gains original_card field (was missing)
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
@@ -18,16 +22,17 @@ class CardAlternative(BaseModel):
|
||||
name: str = Field(description="Exact card name")
|
||||
similarity_score: float = Field(default=0.0, description="Cosine similarity score (0-1)")
|
||||
reason: str = Field(default="", description="Why this card is a good substitute")
|
||||
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'perplexity'")
|
||||
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'anthropic'")
|
||||
role_overlap: List[str] = Field(default_factory=list, description="Shared functional roles")
|
||||
cmc_delta: float = Field(default=0.0, description="CMC difference from original")
|
||||
|
||||
|
||||
class DeckCardWithStatus(BaseModel):
|
||||
"""A deck card enriched with ownership and substitution data."""
|
||||
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
name: str
|
||||
count: int = 1
|
||||
category: str = ""
|
||||
@@ -41,9 +46,15 @@ class DeckCardWithStatus(BaseModel):
|
||||
owned_qty: int = 0
|
||||
scryfall_id: str = ""
|
||||
status: str = Field(default="owned", description="owned | substituted | missing")
|
||||
original_card: Optional[str] = Field(default=None, description="Original card name if this is a substitution")
|
||||
original_card: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Original card name before substitution (None if not substituted)"
|
||||
)
|
||||
alternatives: List[CardAlternative] = Field(default_factory=list)
|
||||
selected_substitute: Optional[str] = Field(default=None, description="User-selected substitute name")
|
||||
selected_substitute: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The substitute card name chosen for this slot"
|
||||
)
|
||||
|
||||
|
||||
class SubstitutionRequest(BaseModel):
|
||||
@@ -61,11 +72,16 @@ class SubstitutionBatchResult(BaseModel):
|
||||
owned_count: int = 0
|
||||
substituted_count: int = 0
|
||||
missing_count: int = 0
|
||||
padded_slots: int = Field(
|
||||
default=0,
|
||||
description="Number of slots filled by the pad-to-99 circuit breaker. "
|
||||
"Any value > 0 indicates a bug in the substitution pipeline."
|
||||
)
|
||||
cards: List[DeckCardWithStatus] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# JSON Schema dicts (for Perplexity fallback substitution)
|
||||
# JSON Schema dicts (for LLM fallback substitution)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
SUBSTITUTION_RESPONSE_SCHEMA = {
|
||||
|
||||
+409
-125
@@ -1,19 +1,28 @@
|
||||
"""
|
||||
Commander AI Lab — V3 Deck Generator Service
|
||||
═════════════════════════════════════════════
|
||||
Generates Commander decks using Perplexity Sonar
|
||||
Generates Commander decks using Anthropic Claude
|
||||
structured JSON output, then runs ownership check
|
||||
and smart substitution.
|
||||
|
||||
Phase 1 changes:
|
||||
- smart_substitute() non-destructive contract (never returns None)
|
||||
- apply_substitutions() loop never silently drops cards
|
||||
- _pad_to_99() circuit breaker before BuildResult validation
|
||||
- Pre-validation count logging per category
|
||||
- Startup SLOT_BUDGET assertion
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
from ..clients.perplexity_client import PerplexityClient
|
||||
from ..clients.anthropic_client import AnthropicClient
|
||||
from ..schemas.deck_schema import DECK_LIST_SCHEMA, GeneratedDeckList
|
||||
from ..schemas.substitution_schema import (
|
||||
CardAlternative, DeckCardWithStatus, SubstitutionBatchResult,
|
||||
@@ -26,35 +35,261 @@ from ..prompts.substitution_prompt import (
|
||||
from ..config import (
|
||||
DECK_GEN_MODEL, DECK_GEN_TEMPERATURE, DECK_GEN_MAX_TOKENS,
|
||||
SUBSTITUTION_MIN_SIMILARITY, SUBSTITUTION_MAX_ALTERNATIVES,
|
||||
SUBSTITUTION_MODEL, SUBSTITUTION_USE_PPLX_FALLBACK,
|
||||
SUBSTITUTION_MODEL, SUBSTITUTION_USE_ANTHROPIC_FALLBACK,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("coach.deckgen")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Slot Budget — MUST sum to 99 (commander is the 100th card)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
SLOT_BUDGET: dict[str, int] = {
|
||||
"Land": 36,
|
||||
"Ramp": 10,
|
||||
"Card Draw": 10,
|
||||
"Removal": 8,
|
||||
"Board Wipe": 3,
|
||||
"Win Condition": 4,
|
||||
"Protection": 4,
|
||||
"Synergy": 20,
|
||||
"Creature": 2,
|
||||
"Utility": 2,
|
||||
}
|
||||
|
||||
# ── Startup assertion — catches misconfigured budgets at boot time ──
|
||||
_SLOT_BUDGET_TOTAL = sum(SLOT_BUDGET.values())
|
||||
assert _SLOT_BUDGET_TOTAL == 99, (
|
||||
f"SLOT_BUDGET sums to {_SLOT_BUDGET_TOTAL}, must equal 99. "
|
||||
f"Current distribution: {SLOT_BUDGET}"
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# SubstitutionMethod — tracks HOW each card was resolved
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class SubstitutionMethod(str, Enum):
|
||||
EXACT_MATCH = "exact_match" # Card found verbatim in collection
|
||||
FUZZY_MATCH = "fuzzy_match" # Case/punctuation-normalised match
|
||||
EMBEDDING_ANALOG = "embedding_analog" # Embedding similarity match
|
||||
ANTHROPIC_FALLBACK = "anthropic_fallback" # LLM suggested replacement
|
||||
ORIGINAL_KEPT = "original_kept" # Not in collection but Scryfall-valid
|
||||
BASIC_LAND_FALLBACK = "basic_land_fallback" # Last resort — slot becomes a basic
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubstitutionRecord:
|
||||
"""Audit trail entry for a single card's resolution."""
|
||||
original: str
|
||||
resolved: str
|
||||
method: SubstitutionMethod
|
||||
in_collection: bool
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Color → Basic Land mapping
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
_COLOR_TO_BASIC: dict[str, str] = {
|
||||
"W": "Plains",
|
||||
"U": "Island",
|
||||
"B": "Swamp",
|
||||
"R": "Mountain",
|
||||
"G": "Forest",
|
||||
}
|
||||
_BASIC_NAMES: set[str] = set(_COLOR_TO_BASIC.values()) | {"Wastes"}
|
||||
|
||||
|
||||
def _get_basic_land(color_identity: List[str]) -> str:
|
||||
"""Return the first appropriate basic land for the given color identity."""
|
||||
for color in color_identity:
|
||||
if color.upper() in _COLOR_TO_BASIC:
|
||||
return _COLOR_TO_BASIC[color.upper()]
|
||||
return "Wastes" # Colorless commander
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Non-destructive Smart Substitution
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def smart_substitute(
|
||||
card_name: str,
|
||||
collection: set[str],
|
||||
color_identity: List[str],
|
||||
embedding_index=None,
|
||||
) -> SubstitutionRecord:
|
||||
"""
|
||||
Non-destructive substitution — ALWAYS returns a valid card string.
|
||||
|
||||
Fallback chain:
|
||||
1. Exact match in collection
|
||||
2. Fuzzy match (case + punctuation normalised)
|
||||
3. Embedding similarity match (if index available)
|
||||
4. Keep original (Scryfall-valid, not in collection)
|
||||
5. Basic land for the commander's colors (last resort — NEVER None)
|
||||
"""
|
||||
# 1. Exact match
|
||||
if card_name in collection:
|
||||
return SubstitutionRecord(card_name, card_name, SubstitutionMethod.EXACT_MATCH, True)
|
||||
|
||||
# 2. Fuzzy match — normalise case and common punctuation differences
|
||||
normalised = card_name.lower().strip()
|
||||
for owned in collection:
|
||||
if owned.lower().strip() == normalised:
|
||||
return SubstitutionRecord(card_name, owned, SubstitutionMethod.FUZZY_MATCH, True)
|
||||
|
||||
# 3. Embedding similarity (if index available)
|
||||
if embedding_index and getattr(embedding_index, "loaded", False):
|
||||
try:
|
||||
matches = embedding_index.search_similar(
|
||||
query_card=card_name,
|
||||
color_filter=color_identity or None,
|
||||
exclude_cards=[card_name],
|
||||
top_n=5,
|
||||
)
|
||||
pool_lower = {n.lower() for n in collection}
|
||||
for match in matches:
|
||||
if match.name.lower() in pool_lower:
|
||||
return SubstitutionRecord(
|
||||
card_name, match.name, SubstitutionMethod.EMBEDDING_ANALOG, True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Embedding substitution failed for '%s': %s", card_name, e)
|
||||
|
||||
# 4. Keep original — valid card not in collection (proxy / non-collection build)
|
||||
return SubstitutionRecord(
|
||||
card_name, card_name, SubstitutionMethod.ORIGINAL_KEPT, False
|
||||
)
|
||||
|
||||
|
||||
def apply_substitutions(
|
||||
card_list: List[DeckCardWithStatus],
|
||||
collection: set[str],
|
||||
color_identity: List[str],
|
||||
embedding_index=None,
|
||||
) -> Tuple[List[DeckCardWithStatus], List[SubstitutionRecord]]:
|
||||
"""
|
||||
Apply smart substitution to a card list.
|
||||
|
||||
Guarantees: output list is NEVER shorter than input list.
|
||||
Logs a warning for every BASIC_LAND_FALLBACK.
|
||||
"""
|
||||
records: List[SubstitutionRecord] = []
|
||||
result: List[DeckCardWithStatus] = []
|
||||
|
||||
for card in card_list:
|
||||
rec = smart_substitute(card.name, collection, color_identity, embedding_index)
|
||||
records.append(rec)
|
||||
|
||||
if rec.method == SubstitutionMethod.BASIC_LAND_FALLBACK:
|
||||
logger.warning(
|
||||
"[substitution] BASIC_LAND_FALLBACK for '%s' — slot filled with '%s'",
|
||||
rec.original, rec.resolved,
|
||||
)
|
||||
|
||||
# Update the card in-place — never drop it
|
||||
updated = card.model_copy(update={
|
||||
"name": rec.resolved,
|
||||
"owned": rec.in_collection,
|
||||
"status": "owned" if rec.in_collection else
|
||||
("substituted" if rec.method != SubstitutionMethod.ORIGINAL_KEPT
|
||||
else "missing"),
|
||||
"original_card": card.name if rec.resolved != card.name else card.original_card,
|
||||
})
|
||||
result.append(updated)
|
||||
|
||||
fallback_count = sum(1 for r in records if r.method == SubstitutionMethod.BASIC_LAND_FALLBACK)
|
||||
if fallback_count:
|
||||
logger.error(
|
||||
"[substitution] %d cards fell back to basic lands — investigate LLM output quality",
|
||||
fallback_count,
|
||||
)
|
||||
|
||||
return result, records
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Pad-to-99 Circuit Breaker
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _pad_to_99(
|
||||
cards: List[DeckCardWithStatus],
|
||||
color_identity: List[str],
|
||||
target: int = 99,
|
||||
) -> Tuple[List[DeckCardWithStatus], int]:
|
||||
"""
|
||||
Emergency safety pad — should NEVER fire if substitution contract holds.
|
||||
|
||||
If it does fire, the error log is your signal that a card is being dropped
|
||||
somewhere earlier in the pipeline.
|
||||
|
||||
Returns (padded_cards, number_of_slots_padded).
|
||||
"""
|
||||
# Strip commander from count if present (commander is tracked separately)
|
||||
non_commander = [c for c in cards if not getattr(c, "is_commander", False)]
|
||||
current = sum(c.count for c in non_commander)
|
||||
shortfall = target - current
|
||||
|
||||
if shortfall <= 0:
|
||||
return cards, 0
|
||||
|
||||
logger.error(
|
||||
"[pad_to_99] CIRCUIT BREAKER FIRED — deck is %d cards short after substitution. "
|
||||
"Padding with basic lands. Check substitution pipeline for silent drops.",
|
||||
shortfall,
|
||||
)
|
||||
|
||||
basic = _get_basic_land(color_identity)
|
||||
padding_card = DeckCardWithStatus(
|
||||
name=basic,
|
||||
count=shortfall,
|
||||
category="Land",
|
||||
role_tags=[],
|
||||
reason="Emergency pad — basic land inserted by circuit breaker",
|
||||
estimated_price_usd=0.10,
|
||||
owned=True,
|
||||
owned_qty=shortfall,
|
||||
status="owned",
|
||||
)
|
||||
return cards + [padding_card], shortfall
|
||||
|
||||
|
||||
def _log_pre_validation_counts(cards: List[DeckCardWithStatus]) -> None:
|
||||
"""Log per-category card counts before Pydantic validation."""
|
||||
by_cat: dict[str, int] = {}
|
||||
for card in cards:
|
||||
cat = card.category or "Uncategorized"
|
||||
by_cat[cat] = by_cat.get(cat, 0) + card.count
|
||||
total = sum(by_cat.values())
|
||||
logger.info("[pre_validation] Category counts: %s", by_cat)
|
||||
logger.info("[pre_validation] Total: %d/99 (delta: %d)", total, 99 - total)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# DeckGeneratorV3
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class DeckGeneratorV3:
|
||||
"""
|
||||
V3 Deck Generator using Perplexity structured output.
|
||||
V3 Deck Generator using Anthropic Claude structured output.
|
||||
|
||||
Pipeline:
|
||||
1. Resolve commander via Scryfall
|
||||
2. Build collection summary
|
||||
3. Call Perplexity with structured JSON schema
|
||||
3. Call Anthropic Claude with structured JSON schema
|
||||
4. Validate card names via Scryfall
|
||||
5. Check ownership against collection DB
|
||||
6. Run Smart Substitution (embedding + Perplexity fallback)
|
||||
7. Return finalized deck with substitution data
|
||||
6. Run Smart Substitution (non-destructive)
|
||||
7. _pad_to_99 circuit breaker
|
||||
8. Pre-validation count logging
|
||||
9. Return finalized deck — guaranteed 99 cards
|
||||
"""
|
||||
|
||||
def __init__(self, pplx_client: PerplexityClient, db_conn_factory,
|
||||
def __init__(self, anthropic_client: AnthropicClient, db_conn_factory,
|
||||
embedding_index=None):
|
||||
"""
|
||||
Args:
|
||||
pplx_client: Configured PerplexityClient instance
|
||||
db_conn_factory: Callable that returns sqlite3.Connection
|
||||
embedding_index: Optional MTGEmbeddingIndex for substitution
|
||||
"""
|
||||
self.pplx = pplx_client
|
||||
self.anthropic = anthropic_client
|
||||
self.db_conn_factory = db_conn_factory
|
||||
self.embedding_index = embedding_index
|
||||
|
||||
@@ -77,9 +312,12 @@ class DeckGeneratorV3:
|
||||
"mana_cost": data.get("mana_cost", ""),
|
||||
"image_url": data.get("image_uris", {}).get("normal", ""),
|
||||
"oracle_text": data.get("oracle_text", ""),
|
||||
"scryfall_found": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Scryfall lookup failed for '%s': %s", commander_name, e)
|
||||
# Non-canonical commander (silver-bordered, custom, etc.)
|
||||
# Skip collection substitution for these — trust LLM output directly
|
||||
return {
|
||||
"name": commander_name,
|
||||
"scryfall_id": "",
|
||||
@@ -88,6 +326,7 @@ class DeckGeneratorV3:
|
||||
"mana_cost": "",
|
||||
"image_url": "",
|
||||
"oracle_text": "",
|
||||
"scryfall_found": False,
|
||||
}
|
||||
|
||||
# ── Step 2: Build Collection Summary ─────────────────────
|
||||
@@ -110,16 +349,10 @@ class DeckGeneratorV3:
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
# Filter by color identity
|
||||
ci_set = set(color_identity) if color_identity else None
|
||||
filtered = []
|
||||
for row in rows:
|
||||
name = row["name"]
|
||||
type_line = row["type_line"] or ""
|
||||
# Basic lands and colorless are always allowed
|
||||
if ci_set:
|
||||
# Simple color check from type_line and name
|
||||
# For proper filtering we'd check mana cost but this is good enough for summary
|
||||
card_colors = set()
|
||||
oracle = row["oracle_text"] or ""
|
||||
mana_cost = ""
|
||||
@@ -137,7 +370,6 @@ class DeckGeneratorV3:
|
||||
if not filtered:
|
||||
return ""
|
||||
|
||||
# Group by card type
|
||||
groups = {}
|
||||
for row in filtered:
|
||||
type_line = row["type_line"] or ""
|
||||
@@ -159,14 +391,12 @@ class DeckGeneratorV3:
|
||||
group = "Other"
|
||||
groups.setdefault(group, []).append(row)
|
||||
|
||||
# Sort each group by popularity/price and build text
|
||||
lines = [f"COLLECTION SUMMARY ({len(filtered)} cards in deck colors):"]
|
||||
for group_name in ["Creatures", "Instants", "Sorceries", "Artifacts",
|
||||
"Enchantments", "Planeswalkers", "Lands", "Other"]:
|
||||
cards = groups.get(group_name, [])
|
||||
if not cards:
|
||||
continue
|
||||
# Sort by price descending (higher value = likely better card)
|
||||
cards.sort(key=lambda r: r["tcg_price"] or 0, reverse=True)
|
||||
card_strs = []
|
||||
for c in cards[:max_per_group]:
|
||||
@@ -176,7 +406,7 @@ class DeckGeneratorV3:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Step 3: Generate Deck via Perplexity ─────────────────
|
||||
# ── Step 3: Generate Deck via Anthropic Claude ─────────────────
|
||||
def generate_deck(
|
||||
self,
|
||||
commander_name: str,
|
||||
@@ -193,24 +423,33 @@ class DeckGeneratorV3:
|
||||
|
||||
Returns dict with:
|
||||
- commander: dict with Scryfall data
|
||||
- raw_deck: parsed GeneratedDeckList from Perplexity
|
||||
- cards: list of DeckCardWithStatus (after ownership check)
|
||||
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
|
||||
- cards: list of DeckCardWithStatus (after ownership check + substitution)
|
||||
- stats: deck statistics
|
||||
- substitution_log: list of SubstitutionRecord dicts
|
||||
- padded_slots: int > 0 means circuit breaker fired
|
||||
- tokens_used: {prompt, completion}
|
||||
"""
|
||||
# Step 1: Resolve commander
|
||||
cmdr = self.resolve_commander(commander_name)
|
||||
color_identity = cmdr["color_identity"]
|
||||
logger.info("Commander: %s, CI: %s", cmdr["name"], color_identity)
|
||||
scryfall_found = cmdr.get("scryfall_found", True)
|
||||
logger.info("Commander: %s, CI: %s, Scryfall: %s",
|
||||
cmdr["name"], color_identity, scryfall_found)
|
||||
|
||||
# Step 2: Collection summary
|
||||
collection_block = ""
|
||||
if use_collection:
|
||||
if use_collection and scryfall_found:
|
||||
collection_block = self.build_collection_summary(color_identity)
|
||||
logger.info("Collection summary: %d chars",
|
||||
len(collection_block) if collection_block else 0)
|
||||
elif not scryfall_found:
|
||||
logger.info(
|
||||
"Non-canonical commander '%s' — skipping collection filter, "
|
||||
"trusting LLM output directly.", cmdr["name"]
|
||||
)
|
||||
|
||||
# Step 3: Build prompts
|
||||
# Step 3: Build prompts (with slot budget injected)
|
||||
user_prompt = build_user_prompt(
|
||||
commander=cmdr["name"],
|
||||
commander_type=cmdr["type_line"],
|
||||
@@ -221,13 +460,14 @@ class DeckGeneratorV3:
|
||||
budget_mode=budget_mode,
|
||||
omit_cards=omit_cards,
|
||||
collection_summary=collection_block,
|
||||
slot_budget=SLOT_BUDGET,
|
||||
)
|
||||
|
||||
# Step 4: Call Perplexity with structured output
|
||||
# Step 4: Call LLM with structured output
|
||||
use_model = model or DECK_GEN_MODEL
|
||||
logger.info("Calling Perplexity (%s) for deck generation...", use_model)
|
||||
logger.info("Calling LLM (%s) for deck generation...", use_model)
|
||||
|
||||
response = self.pplx.chat_structured(
|
||||
response = self.anthropic.chat_structured(
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
user_prompt=user_prompt,
|
||||
json_schema=DECK_LIST_SCHEMA,
|
||||
@@ -239,26 +479,29 @@ class DeckGeneratorV3:
|
||||
|
||||
if not response.ok:
|
||||
raise ValueError(
|
||||
f"Perplexity returned invalid response: {response.content[:300]}"
|
||||
f"LLM returned invalid response: {response.content[:300]}"
|
||||
)
|
||||
|
||||
raw_deck = response.parsed_json
|
||||
card_count = len(raw_deck.get("cards", []))
|
||||
truncated = card_count < 90 # Commander decks should have ~100 cards
|
||||
logger.info("Deck generated: %d cards, bracket %d%s",
|
||||
card_count,
|
||||
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
|
||||
" (TRUNCATED — response may have hit token limit)" if truncated else "")
|
||||
truncated = card_count < 90
|
||||
logger.info(
|
||||
"Deck generated: %d cards, bracket %s%s",
|
||||
card_count,
|
||||
(raw_deck.get("bracket", {}).get("level", "?") if isinstance(raw_deck.get("bracket"), dict)
|
||||
else raw_deck.get("bracket", "?")),
|
||||
" (TRUNCATED — hit token limit)" if truncated else "",
|
||||
)
|
||||
|
||||
# Step 5: Check ownership and build enriched card list
|
||||
# Step 5: Check ownership
|
||||
cards_with_status = self._check_ownership(raw_deck.get("cards", []))
|
||||
|
||||
# Step 5b: Fill basic lands to hit exactly 100 cards
|
||||
# Step 5b: Fill basic lands to reach target_total=100
|
||||
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
|
||||
|
||||
# Step 5c: Secondary safety-net deduplication
|
||||
seen = {}
|
||||
deduped = []
|
||||
# Step 5c: Secondary deduplication
|
||||
seen: dict[str, int] = {}
|
||||
deduped: List[DeckCardWithStatus] = []
|
||||
for c in cards_with_status:
|
||||
key = c.name.lower()
|
||||
if key in seen:
|
||||
@@ -269,7 +512,27 @@ class DeckGeneratorV3:
|
||||
deduped.append(c)
|
||||
cards_with_status = deduped
|
||||
|
||||
# Step 6: Compute stats
|
||||
# Step 6: Run non-destructive smart substitution
|
||||
# For non-canonical commanders (e.g. Sonic), skip collection filter
|
||||
owned_pool: set[str] = set()
|
||||
if use_collection and scryfall_found:
|
||||
conn = self.db_conn_factory()
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
|
||||
).fetchall()
|
||||
owned_pool = {r["name"] for r in rows}
|
||||
|
||||
cards_with_status, substitution_log = apply_substitutions(
|
||||
cards_with_status, owned_pool, color_identity, self.embedding_index
|
||||
)
|
||||
|
||||
# Step 7: Pad-to-99 circuit breaker
|
||||
cards_with_status, padded_slots = _pad_to_99(cards_with_status, color_identity)
|
||||
|
||||
# Step 8: Pre-validation count logging
|
||||
_log_pre_validation_counts(cards_with_status)
|
||||
|
||||
# Step 9: Compute stats
|
||||
stats = self._compute_stats(cards_with_status)
|
||||
|
||||
return {
|
||||
@@ -283,6 +546,12 @@ class DeckGeneratorV3:
|
||||
"archetype": raw_deck.get("archetype", ""),
|
||||
"reasoning": raw_deck.get("reasoning", {}),
|
||||
"estimated_total_usd": raw_deck.get("estimated_total_usd", 0),
|
||||
"substitution_log": [
|
||||
{"original": r.original, "resolved": r.resolved,
|
||||
"method": r.method.value, "in_collection": r.in_collection}
|
||||
for r in substitution_log
|
||||
],
|
||||
"padded_slots": padded_slots,
|
||||
"tokens_used": {
|
||||
"prompt": response.prompt_tokens,
|
||||
"completion": response.completion_tokens,
|
||||
@@ -296,13 +565,11 @@ class DeckGeneratorV3:
|
||||
def _check_ownership(self, cards: List[dict]) -> List[DeckCardWithStatus]:
|
||||
"""
|
||||
Cross-reference generated cards against the collection DB.
|
||||
Sets owned/scryfall_id/owned_qty and real price.
|
||||
Deduplicates cards by name (case-insensitive) — if the LLM
|
||||
returns the same card twice, the counts are merged.
|
||||
Deduplicates by name (case-insensitive).
|
||||
"""
|
||||
conn = self.db_conn_factory()
|
||||
enriched = []
|
||||
seen_names: dict[str, int] = {} # name_lower → index in enriched
|
||||
enriched: List[DeckCardWithStatus] = []
|
||||
seen_names: dict[str, int] = {}
|
||||
|
||||
for card in cards:
|
||||
name = card.get("name", "")
|
||||
@@ -314,10 +581,9 @@ class DeckGeneratorV3:
|
||||
if name_lower in seen_names:
|
||||
idx = seen_names[name_lower]
|
||||
enriched[idx].count += card.get("count", 1)
|
||||
logger.warning("Duplicate card '%s' merged (LLM returned it twice)", name)
|
||||
logger.warning("Duplicate card '%s' merged", name)
|
||||
continue
|
||||
|
||||
# Look up in collection
|
||||
row = conn.execute(
|
||||
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
|
||||
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
|
||||
@@ -348,11 +614,8 @@ class DeckGeneratorV3:
|
||||
return enriched
|
||||
|
||||
# ── Step 5b: Basic Land Fill ──────────────────────────────
|
||||
_COLOR_TO_BASIC = {
|
||||
'W': 'Plains', 'U': 'Island', 'B': 'Swamp',
|
||||
'R': 'Mountain', 'G': 'Forest',
|
||||
}
|
||||
_BASIC_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'}
|
||||
_COLOR_TO_BASIC = _COLOR_TO_BASIC
|
||||
_BASIC_NAMES = _BASIC_NAMES
|
||||
|
||||
def _fill_basic_lands(
|
||||
self,
|
||||
@@ -374,7 +637,7 @@ class DeckGeneratorV3:
|
||||
ci = [c.upper() for c in color_identity]
|
||||
basic_names = [self._COLOR_TO_BASIC[c] for c in ci if c in self._COLOR_TO_BASIC]
|
||||
if not basic_names:
|
||||
basic_names = ['Wastes']
|
||||
basic_names = ["Wastes"]
|
||||
|
||||
per_color = basics_needed // len(basic_names)
|
||||
remainder = basics_needed % len(basic_names)
|
||||
@@ -385,20 +648,20 @@ class DeckGeneratorV3:
|
||||
non_basics.append(DeckCardWithStatus(
|
||||
name=bname,
|
||||
count=qty,
|
||||
category='Land',
|
||||
category="Land",
|
||||
role_tags=[],
|
||||
reason='Basic land for mana fixing',
|
||||
reason="Basic land for mana fixing",
|
||||
estimated_price_usd=0.10,
|
||||
owned=True,
|
||||
owned_qty=qty,
|
||||
status='owned',
|
||||
status="owned",
|
||||
))
|
||||
|
||||
logger.info("Basic land fill: %d basics added across %d color(s)",
|
||||
basics_needed, len(basic_names))
|
||||
basics_needed, len(basic_names))
|
||||
return non_basics
|
||||
|
||||
# ── Step 6: Smart Substitution ───────────────────────────
|
||||
# ── Step 6: Smart Substitution (legacy wrapper for route compatibility) ──
|
||||
def run_substitution(
|
||||
self,
|
||||
cards: List[DeckCardWithStatus],
|
||||
@@ -408,15 +671,12 @@ class DeckGeneratorV3:
|
||||
"""
|
||||
Run the Smart Substitution Engine on missing cards.
|
||||
|
||||
Pipeline:
|
||||
a) Identify missing cards
|
||||
b) For each: try embedding similarity (free, fast)
|
||||
c) For low-confidence matches: batch Perplexity fallback
|
||||
d) Auto-select best substitute per card
|
||||
|
||||
Returns SubstitutionBatchResult with all cards updated.
|
||||
Uses the non-destructive apply_substitutions() under the hood.
|
||||
Falls back to Anthropic Claude for low-confidence matches,
|
||||
then to basic land as last resort — NEVER drops a card.
|
||||
"""
|
||||
color_identity = commander.get("color_identity", [])
|
||||
scryfall_found = commander.get("scryfall_found", True)
|
||||
missing = [c for c in cards if not c.owned]
|
||||
owned = [c for c in cards if c.owned]
|
||||
|
||||
@@ -431,47 +691,63 @@ class DeckGeneratorV3:
|
||||
|
||||
logger.info("Substitution: %d missing / %d owned", len(missing), len(owned))
|
||||
|
||||
# Build list of owned card names for substitution pool
|
||||
# Build owned pool
|
||||
conn = self.db_conn_factory()
|
||||
owned_pool_rows = conn.execute(
|
||||
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
|
||||
).fetchall()
|
||||
owned_pool = [r["name"] for r in owned_pool_rows]
|
||||
owned_pool = {r["name"] for r in owned_pool_rows}
|
||||
deck_names = {c.name.lower() for c in cards}
|
||||
available_pool = {n for n in owned_pool if n.lower() not in deck_names}
|
||||
|
||||
# Exclude cards already in the deck
|
||||
deck_names = set(c.name.lower() for c in cards)
|
||||
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
|
||||
|
||||
# a) Embedding pass
|
||||
needs_pplx_fallback = []
|
||||
# a) Embedding pass for missing cards
|
||||
needs_anthropic_fallback: List[DeckCardWithStatus] = []
|
||||
for card in missing:
|
||||
embedding_alts = self._embedding_substitution(
|
||||
card, color_identity, available_pool
|
||||
card, color_identity, list(available_pool)
|
||||
)
|
||||
card.alternatives = embedding_alts
|
||||
|
||||
# Check if best match meets threshold
|
||||
if embedding_alts and embedding_alts[0].similarity_score >= SUBSTITUTION_MIN_SIMILARITY:
|
||||
card.selected_substitute = embedding_alts[0].name
|
||||
card.status = "substituted"
|
||||
# Remove selected from pool
|
||||
if card.selected_substitute.lower() in [n.lower() for n in available_pool]:
|
||||
available_pool = [n for n in available_pool
|
||||
if n.lower() != card.selected_substitute.lower()]
|
||||
card.owned = True
|
||||
available_pool.discard(card.selected_substitute.lower())
|
||||
else:
|
||||
needs_pplx_fallback.append(card)
|
||||
needs_anthropic_fallback.append(card)
|
||||
|
||||
# b) Perplexity fallback for low-confidence matches
|
||||
if needs_pplx_fallback and SUBSTITUTION_USE_PPLX_FALLBACK:
|
||||
self._pplx_substitution(
|
||||
needs_pplx_fallback, available_pool,
|
||||
# b) Anthropic Claude fallback
|
||||
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
|
||||
self._anthropic_substitution(
|
||||
needs_anthropic_fallback, list(available_pool),
|
||||
commander, strategy
|
||||
)
|
||||
|
||||
# Count results
|
||||
# c) Any card STILL missing after both passes gets a basic land — never drop it
|
||||
padded = 0
|
||||
for card in missing:
|
||||
if card.status == "missing":
|
||||
basic = _get_basic_land(color_identity)
|
||||
logger.warning(
|
||||
"[substitution] '%s' unresolved after all passes — "
|
||||
"replacing with basic land '%s'",
|
||||
card.name, basic,
|
||||
)
|
||||
card.name = basic
|
||||
card.category = "Land"
|
||||
card.role_tags = []
|
||||
card.owned = True
|
||||
card.status = "substituted"
|
||||
card.original_card = card.name
|
||||
padded += 1
|
||||
|
||||
substituted = sum(1 for c in cards if c.status == "substituted")
|
||||
still_missing = sum(1 for c in cards if c.status == "missing")
|
||||
|
||||
# Validate count hasn't changed
|
||||
assert len(cards) == len(owned) + len(missing), \
|
||||
f"[substitution] Card count changed: {len(owned) + len(missing)} -> {len(cards)}"
|
||||
|
||||
return SubstitutionBatchResult(
|
||||
total_cards=len(cards),
|
||||
owned_count=len(owned),
|
||||
@@ -490,39 +766,37 @@ class DeckGeneratorV3:
|
||||
if not self.embedding_index or not self.embedding_index.loaded:
|
||||
return []
|
||||
|
||||
# Use the embeddings search to find similar cards
|
||||
matches = self.embedding_index.search_similar(
|
||||
query_card=card.name,
|
||||
color_filter=color_identity if color_identity else None,
|
||||
type_filter=card.category if card.category else None,
|
||||
exclude_cards=[card.name],
|
||||
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3, # Over-fetch, then filter to owned
|
||||
)
|
||||
try:
|
||||
matches = self.embedding_index.search_similar(
|
||||
query_card=card.name,
|
||||
color_filter=color_identity if color_identity else None,
|
||||
type_filter=card.category if card.category else None,
|
||||
exclude_cards=[card.name],
|
||||
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Embedding search failed for '%s': %s", card.name, e)
|
||||
return []
|
||||
|
||||
# Filter to only cards in the available pool
|
||||
pool_lower = set(n.lower() for n in available_pool)
|
||||
pool_lower = {n.lower() for n in available_pool}
|
||||
alternatives = []
|
||||
for match in matches:
|
||||
if match.name.lower() in pool_lower:
|
||||
# Compute role overlap
|
||||
match_roles = [] # Would need card's role_tags from DB
|
||||
role_overlap = list(set(card.role_tags) & set(match_roles)) if match_roles else []
|
||||
|
||||
role_overlap = list(set(card.role_tags) & set(getattr(match, "role_tags", [])))
|
||||
alternatives.append(CardAlternative(
|
||||
name=match.name,
|
||||
similarity_score=match.similarity,
|
||||
reason=f"Similar to {card.name} (cosine sim: {match.similarity:.3f})",
|
||||
source="embedding",
|
||||
role_overlap=role_overlap,
|
||||
cmc_delta=match.mana_value - (card.estimated_price_usd or 0),
|
||||
cmc_delta=getattr(match, "mana_value", 0) - (card.estimated_price_usd or 0),
|
||||
))
|
||||
|
||||
if len(alternatives) >= SUBSTITUTION_MAX_ALTERNATIVES:
|
||||
break
|
||||
|
||||
return alternatives
|
||||
|
||||
def _pplx_substitution(
|
||||
def _anthropic_substitution(
|
||||
self,
|
||||
missing_cards: List[DeckCardWithStatus],
|
||||
available_pool: List[str],
|
||||
@@ -530,13 +804,14 @@ class DeckGeneratorV3:
|
||||
strategy: str,
|
||||
):
|
||||
"""
|
||||
Batch Perplexity fallback for cards with low embedding confidence.
|
||||
Mutates the cards in-place with substitution data.
|
||||
Batch Anthropic Claude fallback for cards with low embedding confidence.
|
||||
Mutates cards in-place. On failure, cards retain status='missing'
|
||||
so the basic-land fallback in run_substitution() can catch them.
|
||||
"""
|
||||
if not available_pool:
|
||||
logger.warning("[anthropic_substitution] No available pool — skipping")
|
||||
return
|
||||
|
||||
# Build the request
|
||||
missing_dicts = [
|
||||
{
|
||||
"name": c.name,
|
||||
@@ -549,7 +824,7 @@ class DeckGeneratorV3:
|
||||
|
||||
user_prompt = build_substitution_prompt(
|
||||
missing_cards=missing_dicts,
|
||||
allowed_cards=available_pool[:200], # Limit to avoid token overflow
|
||||
allowed_cards=available_pool[:200],
|
||||
commander=commander.get("name", ""),
|
||||
color_identity=commander.get("color_identity", []),
|
||||
strategy=strategy,
|
||||
@@ -557,7 +832,7 @@ class DeckGeneratorV3:
|
||||
|
||||
try:
|
||||
from ..schemas.substitution_schema import SUBSTITUTION_RESPONSE_SCHEMA
|
||||
response = self.pplx.chat_structured(
|
||||
response = self.anthropic.chat_structured(
|
||||
system_prompt=SUBSTITUTION_SYSTEM,
|
||||
user_prompt=user_prompt,
|
||||
json_schema=SUBSTITUTION_RESPONSE_SCHEMA,
|
||||
@@ -568,13 +843,16 @@ class DeckGeneratorV3:
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
logger.warning("Perplexity substitution fallback failed — non-JSON response")
|
||||
logger.warning(
|
||||
"[anthropic_substitution] LLM returned non-JSON — "
|
||||
"%d cards will fall through to basic land fallback",
|
||||
len(missing_cards),
|
||||
)
|
||||
return
|
||||
|
||||
subs = response.parsed_json.get("substitutions", [])
|
||||
logger.info("Perplexity suggested %d substitutions", len(subs))
|
||||
logger.info("[anthropic_substitution] Received %d substitutions", len(subs))
|
||||
|
||||
# Apply substitutions
|
||||
card_by_name = {c.name.lower(): c for c in missing_cards}
|
||||
for sub in subs:
|
||||
orig = sub.get("original", "").lower()
|
||||
@@ -583,32 +861,38 @@ class DeckGeneratorV3:
|
||||
|
||||
if orig in card_by_name and substitute:
|
||||
card = card_by_name[orig]
|
||||
# Add as top alternative
|
||||
pplx_alt = CardAlternative(
|
||||
anthropic_alt = CardAlternative(
|
||||
name=substitute,
|
||||
similarity_score=0.80, # Nominal score for Perplexity suggestions
|
||||
similarity_score=0.80,
|
||||
reason=reason,
|
||||
source="perplexity",
|
||||
source="anthropic",
|
||||
role_overlap=sub.get("role_overlap", []),
|
||||
)
|
||||
# Insert at front
|
||||
card.alternatives.insert(0, pplx_alt)
|
||||
card.alternatives.insert(0, anthropic_alt)
|
||||
card.selected_substitute = substitute
|
||||
card.status = "substituted"
|
||||
card.owned = True
|
||||
card.original_card = card.name
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Perplexity substitution fallback error: %s", e)
|
||||
logger.error(
|
||||
"[anthropic_substitution] Exception: %s — "
|
||||
"%d cards will fall through to basic land fallback",
|
||||
e, len(missing_cards),
|
||||
)
|
||||
# Do NOT re-raise — cards stay status='missing' and
|
||||
# run_substitution()'s basic-land fallback will handle them
|
||||
|
||||
# ── Stats ────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _compute_stats(cards: List[DeckCardWithStatus]) -> dict:
|
||||
"""Compute deck statistics from the enriched card list."""
|
||||
by_category = {}
|
||||
by_status = {"owned": 0, "substituted": 0, "missing": 0}
|
||||
by_category: dict[str, int] = {}
|
||||
by_status: dict[str, int] = {"owned": 0, "substituted": 0, "missing": 0}
|
||||
total_price = 0.0
|
||||
owned_price = 0.0
|
||||
missing_price = 0.0
|
||||
role_counts = {}
|
||||
role_counts: dict[str, int] = {}
|
||||
|
||||
for card in cards:
|
||||
cat = card.category or "Other"
|
||||
|
||||
@@ -137,7 +137,7 @@ if (-not (Test-Path $envFile)) {
|
||||
# Uncomment and fill in the values you need:
|
||||
|
||||
# XIMILAR_API_KEY=your_ximilar_key_here
|
||||
# PPLX_API_KEY=your_perplexity_key_here
|
||||
# ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
# FORGE_JAR=C:\Path\To\forge-gui-desktop.jar
|
||||
# FORGE_DIR=C:\Path\To\Forge
|
||||
# LAB_PORT=8080
|
||||
|
||||
+107
-68
@@ -3,9 +3,13 @@
|
||||
Commander AI Lab — FastAPI Backend
|
||||
Thin entry point: registers routers and starts uvicorn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
@@ -22,7 +26,7 @@ except ImportError:
|
||||
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
|
||||
import sys; sys.exit(1)
|
||||
|
||||
from models.state import CFG, COMMANDER_META, load_commander_meta
|
||||
from models.state import CFG, COMMANDER_META, load_commander_meta, validate_forge_config
|
||||
from services.database import init_collection_db
|
||||
from services.logging import setup_logging
|
||||
from services.precon_service import PRECON_INDEX, download_precon_database
|
||||
@@ -32,20 +36,86 @@ from routes.precon import router as precon_router
|
||||
from routes.import_routes import router as import_router
|
||||
from routes.lab import router as lab_router
|
||||
from routes.scanner import router as scanner_router
|
||||
from routes.rag import router as rag_router
|
||||
from routes.deepseek import router as deepseek_router
|
||||
from routes.deckgen import router as deckgen_router
|
||||
from routes.coach import router as coach_router, init_coach_service
|
||||
from routes.ml import router as ml_router
|
||||
from routes.ws_game import router as ws_game_router
|
||||
from routes.game import router as game_router
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
# ── CORS origins (env-configurable) ────────────────────────────
|
||||
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:8080"
|
||||
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
|
||||
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
|
||||
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
||||
|
||||
# ── App ────────────────────────────────────────────────────────
|
||||
app = FastAPI(title="Commander AI Lab API", version="3.0.0")
|
||||
|
||||
# ── Lifespan (replaces deprecated @app.on_event) ────────────────────────────────────────────────────────
|
||||
@asynccontextmanager
|
||||
async def _lifespan(application: FastAPI):
|
||||
# ──── STARTUP ────
|
||||
if not CFG.precon_dir:
|
||||
CFG.precon_dir = _resolve_precon_dir()
|
||||
init_collection_db()
|
||||
validate_forge_config()
|
||||
if not PRECON_INDEX:
|
||||
download_precon_database()
|
||||
if not COMMANDER_META:
|
||||
load_commander_meta()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# RAG Phase 1: kick off Scryfall bulk download in background thread.
|
||||
from services.scryfall_bulk import ensure_bulk_db
|
||||
loop.run_in_executor(None, ensure_bulk_db)
|
||||
|
||||
# RAG Phase 2: kick off ChromaDB vector store build in background thread.
|
||||
from services.rag_store import ensure_rag_store
|
||||
loop.run_in_executor(None, ensure_rag_store)
|
||||
|
||||
# RAG Phase 3: background staleness monitor (rebuilds if >14 days old)
|
||||
async def _rag_staleness_monitor():
|
||||
"""Periodically check if the RAG index needs rebuilding."""
|
||||
import time as _time
|
||||
while True:
|
||||
await asyncio.sleep(6 * 3600) # check every 6 hours
|
||||
try:
|
||||
from services.rag_store import check_staleness, build_index
|
||||
info = check_staleness()
|
||||
if info.get("stale"):
|
||||
log.info("RAG staleness monitor: %s — triggering rebuild.", info["reason"])
|
||||
loop.run_in_executor(None, lambda: build_index(force=True))
|
||||
else:
|
||||
log.debug("RAG staleness monitor: index is current (%.1f days).", info.get("age_days", 0))
|
||||
except Exception as exc:
|
||||
log.warning("RAG staleness monitor failed: %s", exc)
|
||||
asyncio.ensure_future(_rag_staleness_monitor())
|
||||
|
||||
# Policy routes: register if ML policy model is available
|
||||
try:
|
||||
from ml.serving.policy_server import PolicyInferenceService
|
||||
from routes.policy import register_policy_routes
|
||||
_policy_svc = PolicyInferenceService()
|
||||
if _policy_svc.load():
|
||||
register_policy_routes(application, _policy_svc)
|
||||
log.info("Policy routes registered (/api/policy/*)")
|
||||
else:
|
||||
log.warning("Policy model not loaded — /api/policy/* routes inactive")
|
||||
except ImportError:
|
||||
log.info("Policy service not available (ml.serving not installed)")
|
||||
except Exception as e:
|
||||
log.warning("Policy route registration failed (non-fatal): %s", e)
|
||||
|
||||
yield # ← server is running
|
||||
|
||||
# ──── SHUTDOWN ────
|
||||
from services.async_db import async_close_db
|
||||
await async_close_db()
|
||||
|
||||
|
||||
# ── App ─────────────────────────────────────────────────────────────────────────────
|
||||
app = FastAPI(title="Commander AI Lab API", version="3.0.0", lifespan=_lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allowed_origins,
|
||||
@@ -60,33 +130,29 @@ app.include_router(precon_router)
|
||||
app.include_router(import_router)
|
||||
app.include_router(lab_router)
|
||||
app.include_router(scanner_router)
|
||||
app.include_router(rag_router)
|
||||
app.include_router(deepseek_router)
|
||||
app.include_router(deckgen_router)
|
||||
app.include_router(coach_router)
|
||||
app.include_router(ws_game_router)
|
||||
app.include_router(ml_router)
|
||||
app.include_router(game_router) # live game session routes
|
||||
|
||||
|
||||
# ── API endpoints ─────────────────────────────────────────────────────────────────────────────
|
||||
# NOTE: all @app.get routes MUST be registered before app.mount() calls.
|
||||
# Once StaticFiles is mounted at "/", it registers a wildcard catch-all that
|
||||
# shadows any routes added after it.
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for load balancers and Unity client polling."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _on_startup():
|
||||
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
|
||||
# Ensure precon_dir is resolved even when started via uvicorn directly
|
||||
if not CFG.precon_dir:
|
||||
CFG.precon_dir = _resolve_precon_dir()
|
||||
init_collection_db()
|
||||
if not PRECON_INDEX:
|
||||
download_precon_database()
|
||||
if not COMMANDER_META:
|
||||
load_commander_meta()
|
||||
|
||||
# ── Static UI ──────────────────────────────────────────────────
|
||||
_legacy_ui_dir = Path(__file__).parent / "ui"
|
||||
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||
# ── Static UI (MUST come after all @app.get routes) ──────────────────────────────────────────
|
||||
from pathlib import Path as _Path
|
||||
_legacy_ui_dir = _Path(__file__).parent / "ui"
|
||||
_spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||
|
||||
if _legacy_ui_dir.exists():
|
||||
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
|
||||
@@ -94,6 +160,7 @@ elif _spa_dir.exists():
|
||||
_spa_assets = _spa_dir / "assets"
|
||||
if _spa_assets.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(_spa_assets)), name="spa-assets")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def _spa_catchall(full_path: str):
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -102,7 +169,8 @@ elif _spa_dir.exists():
|
||||
return FileResponse(str(requested_file))
|
||||
return FileResponse(str(_spa_dir / "index.html"))
|
||||
|
||||
# ── Startup ────────────────────────────────────────────────────
|
||||
|
||||
# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────────────
|
||||
def _parse_args():
|
||||
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
|
||||
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
|
||||
@@ -112,10 +180,11 @@ def _parse_args():
|
||||
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
|
||||
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
|
||||
p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
|
||||
p.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", ""))
|
||||
p.add_argument("--anthropic-key", default=os.environ.get("ANTHROPIC_API_KEY", ""))
|
||||
p.add_argument("--verbose", "-v", action="store_true")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def _resolve_forge_decks_dir() -> str:
|
||||
import sys
|
||||
if sys.platform == "win32":
|
||||
@@ -133,52 +202,35 @@ def _resolve_forge_decks_dir() -> str:
|
||||
return str(candidate)
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_forge_dir() -> str:
|
||||
"""Auto-detect the Forge installation directory.
|
||||
|
||||
The Forge dir is the root of a Forge install — it typically contains
|
||||
a 'res' subdirectory with card data. We derive it from the JAR
|
||||
location (parent of 'target/') or look in common install paths.
|
||||
"""
|
||||
"""Auto-detect the Forge installation directory."""
|
||||
import sys
|
||||
|
||||
# 1. If forge_jar is already resolved, derive forge_dir from it.
|
||||
# JAR lives in <forge-root>/forge-gui-desktop/target/<jar>
|
||||
# so forge_dir = JAR's grandparent's parent.
|
||||
if CFG.forge_jar and os.path.isfile(CFG.forge_jar):
|
||||
jar_path = Path(CFG.forge_jar)
|
||||
# target/ -> forge-gui-desktop/ -> <forge-root>
|
||||
candidate = jar_path.parent.parent.parent
|
||||
if candidate.is_dir() and (candidate / "res").is_dir():
|
||||
return str(candidate)
|
||||
# Also try just one level up from target/
|
||||
candidate = jar_path.parent.parent
|
||||
# Check forge-gui sibling of forge-gui-desktop (res/ lives there)
|
||||
forge_gui_sibling = jar_path.parent.parent.parent / "forge-gui"
|
||||
if forge_gui_sibling.is_dir() and (forge_gui_sibling / "res").is_dir():
|
||||
return str(forge_gui_sibling)
|
||||
if candidate.is_dir() and (candidate / "res").is_dir():
|
||||
return str(candidate)
|
||||
# Fall back to the directory containing the JAR itself
|
||||
candidate = jar_path.parent
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 2. Check common sibling/child directories relative to project root
|
||||
project_root = Path(__file__).parent
|
||||
for candidate in [
|
||||
project_root / "forge",
|
||||
project_root.parent / "forge-repo",
|
||||
project_root.parent / "forge-repo" / "forge-gui",
|
||||
project_root.parent / "forge-repo" / "forge-gui",
|
||||
project_root.parent / "forge",
|
||||
project_root / "forge-gui-desktop",
|
||||
]:
|
||||
if candidate.is_dir():
|
||||
# Prefer directory that has 'res' subfolder
|
||||
if (candidate / "res").is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 3. Platform-specific user data directories
|
||||
if sys.platform == "win32":
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
if appdata:
|
||||
@@ -186,32 +238,25 @@ def _resolve_forge_dir() -> str:
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
home = Path.home()
|
||||
for candidate in [
|
||||
home / ".forge",
|
||||
home / "Forge",
|
||||
]:
|
||||
for candidate in [home / ".forge", home / "Forge"]:
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_forge_jar() -> str:
|
||||
"""Auto-detect the Forge GUI desktop JAR in common locations."""
|
||||
project_root = Path(__file__).parent
|
||||
|
||||
# Candidate directories where the Forge JAR might live
|
||||
candidates = [
|
||||
project_root / "forge-gui-desktop" / "target",
|
||||
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
|
||||
project_root.parent / "forge" / "forge-gui-desktop" / "target",
|
||||
]
|
||||
|
||||
# Also check FORGE_DIR/../forge-gui-desktop/target if FORGE_DIR is set
|
||||
forge_dir_env = os.environ.get("FORGE_DIR", "")
|
||||
if forge_dir_env:
|
||||
fd = Path(forge_dir_env)
|
||||
candidates.append(fd.parent / "forge-gui-desktop" / "target")
|
||||
candidates.append(fd / "target")
|
||||
|
||||
for target_dir in candidates:
|
||||
if target_dir.is_dir():
|
||||
for pattern in [
|
||||
@@ -225,6 +270,7 @@ def _resolve_forge_jar() -> str:
|
||||
return str(jars[0])
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_lab_jar() -> str:
|
||||
target_dir = Path(__file__).parent / "target"
|
||||
if target_dir.exists():
|
||||
@@ -239,36 +285,25 @@ def _resolve_lab_jar() -> str:
|
||||
return str(jars[0])
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_precon_dir() -> str:
|
||||
"""Auto-detect the precon-decks directory.
|
||||
|
||||
The precon-decks dir lives in the project root and contains downloaded
|
||||
.dck files plus precon-index.json. We check relative to this file
|
||||
(lab_api.py) first, then common sibling paths.
|
||||
"""
|
||||
"""Auto-detect the precon-decks directory."""
|
||||
project_root = Path(__file__).parent
|
||||
|
||||
# 1. Standard location: <project-root>/precon-decks
|
||||
candidate = project_root / "precon-decks"
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 2. Check parent directory (in case running from a subdirectory)
|
||||
candidate = project_root.parent / "precon-decks"
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 3. Derive from forge_dir if available
|
||||
if CFG.forge_dir:
|
||||
candidate = Path(CFG.forge_dir) / "precon-decks"
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 4. Fall back: create the directory in the project root so downloads work
|
||||
fallback = project_root / "precon-decks"
|
||||
fallback.mkdir(parents=True, exist_ok=True)
|
||||
return str(fallback)
|
||||
|
||||
|
||||
def main():
|
||||
args = _parse_args()
|
||||
setup_logging(logging.DEBUG if args.verbose else logging.INFO)
|
||||
@@ -280,7 +315,7 @@ def main():
|
||||
CFG.precon_dir = args.precon_dir or _resolve_precon_dir()
|
||||
CFG.port = args.port
|
||||
CFG.ximilar_api_key = args.ximilar_key
|
||||
CFG.pplx_api_key = args.pplx_key
|
||||
CFG.anthropic_api_key = args.anthropic_key
|
||||
|
||||
from services.forge_runner import get_java17
|
||||
j17 = get_java17()
|
||||
@@ -294,7 +329,7 @@ def main():
|
||||
log.info(f" Lab JAR: {CFG.lab_jar or 'NOT SET'}")
|
||||
log.info(f" Results: {CFG.results_dir}")
|
||||
log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET'}")
|
||||
log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET'}")
|
||||
log.info(f" Anthropic: {'configured' if CFG.anthropic_api_key else 'NOT SET'}")
|
||||
log.info(f" Java 17: {j17}")
|
||||
|
||||
load_commander_meta()
|
||||
@@ -302,8 +337,12 @@ def main():
|
||||
init_collection_db()
|
||||
init_coach_service()
|
||||
|
||||
# RAG Phase 1 + 2 are handled by _lifespan when uvicorn starts.
|
||||
# No need to call them here — doing so causes a double-run.
|
||||
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+25
-1
@@ -16,6 +16,7 @@ Reuses `run_match`, `LearnedPolicy`, `HeuristicPolicy`,
|
||||
Usage:
|
||||
python -m ml.eval.elo_tracker
|
||||
python -m ml.eval.elo_tracker --episodes 50 --output data/elo_history.json
|
||||
python -m ml.eval.elo_tracker --top-n 10
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -285,12 +286,17 @@ def run_generation_tournament(
|
||||
episodes_per_matchup: int = 50,
|
||||
playstyle: str = "midrange",
|
||||
k_factor: float = DEFAULT_K,
|
||||
top_n: int = 10,
|
||||
) -> EloResult:
|
||||
"""
|
||||
Discover generation checkpoints and run an ELO tournament.
|
||||
|
||||
Looks for `gen-*/best_policy.pt` under checkpoint_dir.
|
||||
Also includes heuristic and random baselines.
|
||||
|
||||
Args:
|
||||
top_n: Only include the N most recent gen-* checkpoints.
|
||||
0 means include all (unbounded). Default is 10.
|
||||
"""
|
||||
policies: Dict[str, object] = {
|
||||
"heuristic": HeuristicPolicy(),
|
||||
@@ -299,7 +305,20 @@ def run_generation_tournament(
|
||||
|
||||
ckpt_path = Path(checkpoint_dir)
|
||||
if ckpt_path.exists():
|
||||
for gen_dir in sorted(ckpt_path.glob("gen-*")):
|
||||
gen_dirs = sorted(ckpt_path.glob("gen-*"))
|
||||
|
||||
# Apply top_n cutoff — keep only the latest N generation dirs
|
||||
if top_n > 0 and len(gen_dirs) > top_n:
|
||||
skipped = len(gen_dirs) - top_n
|
||||
logger.info(
|
||||
"top_n=%d: skipping %d older generation(s), using %s … %s",
|
||||
top_n, skipped,
|
||||
gen_dirs[-top_n].name,
|
||||
gen_dirs[-1].name,
|
||||
)
|
||||
gen_dirs = gen_dirs[-top_n:]
|
||||
|
||||
for gen_dir in gen_dirs:
|
||||
model_path = gen_dir / "best_policy.pt"
|
||||
if model_path.exists():
|
||||
name = gen_dir.name # e.g., "gen-001"
|
||||
@@ -363,6 +382,10 @@ def main():
|
||||
"--k-factor", type=float, default=32,
|
||||
help="ELO K-factor (default: 32)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top-n", type=int, default=10,
|
||||
help="Only include the N most recent gen checkpoints (0 = all, default: 10)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -373,6 +396,7 @@ def main():
|
||||
episodes_per_matchup=args.episodes,
|
||||
playstyle=args.playstyle,
|
||||
k_factor=args.k_factor,
|
||||
top_n=args.top_n,
|
||||
)
|
||||
|
||||
# Print results
|
||||
|
||||
@@ -567,7 +567,7 @@ def collect_rollouts(
|
||||
total_losses = 0
|
||||
total_draws = 0
|
||||
warnings.warn(_TRAINING_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
|
||||
total_steps = 0
|
||||
total_steps = 0
|
||||
total_rewards = 0.0
|
||||
|
||||
for ep in range(num_episodes):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -25,9 +26,35 @@ class Config:
|
||||
port: int = 8080
|
||||
ximilar_api_key: str = ""
|
||||
pplx_api_key: str = ""
|
||||
anthropic_api_key: str = ""
|
||||
|
||||
|
||||
CFG = Config()
|
||||
# Pre-populate from env so ASGI mode (uvicorn lab_api:app) works without main()
|
||||
CFG.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
|
||||
|
||||
def validate_forge_config() -> None:
|
||||
"""Warn clearly at startup if FORGE_DIR is misconfigured."""
|
||||
if not CFG.forge_dir:
|
||||
log.warning("FORGE_DIR not set -- Forge card enrichment disabled. "
|
||||
"Set FORGE_DIR to your forge-repo root or forge-gui directory.")
|
||||
return
|
||||
base = Path(CFG.forge_dir)
|
||||
a = base / "forge-gui" / "res" / "cardsfolder"
|
||||
b = base / "res" / "cardsfolder"
|
||||
if not a.is_dir() and not b.is_dir():
|
||||
log.warning(
|
||||
"FORGE_DIR=%s is set but cardsfolder not found at:\n"
|
||||
" %s\n %s\n"
|
||||
"Check that FORGE_DIR points to forge-repo root or forge-gui/.",
|
||||
CFG.forge_dir, a, b
|
||||
)
|
||||
else:
|
||||
resolved = a if a.is_dir() else b
|
||||
log.info("Forge cardsfolder confirmed at %s", resolved)
|
||||
CFG.ximilar_api_key = os.environ.get("XIMILAR_API_KEY", "")
|
||||
CFG.pplx_api_key = os.environ.get("PPLX_API_KEY", "")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -49,7 +76,9 @@ class BatchState:
|
||||
self.sims_per_sec: float = 0.0
|
||||
|
||||
|
||||
import asyncio as _asyncio
|
||||
active_batches: dict[str, BatchState] = {}
|
||||
active_batches_lock = _asyncio.Lock()
|
||||
|
||||
|
||||
COMMANDER_META: dict = {}
|
||||
|
||||
+75
-4
@@ -25,6 +25,7 @@ import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -42,6 +43,9 @@ DEFAULT_CONFIG = {
|
||||
"time_limit_hours": 11.5, # stop after this many hours (leave buffer for training)
|
||||
"pause_between_batches": 10, # seconds to pause between batches
|
||||
"auto_train": True, # trigger ML training after all batches
|
||||
"auto_update_weights": True, # run update_weights.py after sims
|
||||
"weights_min_games": 10, # minimum games required for weight update
|
||||
"weights_lr": 0.05, # learning rate for weight update
|
||||
"train_epochs": 100, # training epochs
|
||||
"train_lr": 0.001, # learning rate
|
||||
"train_batch_size": 256, # training batch size
|
||||
@@ -229,6 +233,52 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
|
||||
time.sleep(poll_interval)
|
||||
|
||||
|
||||
def run_update_weights(cfg):
|
||||
"""
|
||||
Run ml.scripts.update_weights to nudge learned_weights.json from sim
|
||||
decision logs. This feeds the deck builder's sim_insights injection.
|
||||
|
||||
Returns True on success, False on failure (non-fatal — overnight run
|
||||
continues regardless).
|
||||
"""
|
||||
log.info("")
|
||||
log.info("── Auto-update sim weights ─────────────────────────────────")
|
||||
log.info("Running update_weights.py to refresh learned_weights.json...")
|
||||
|
||||
cmd = [
|
||||
sys.executable, "-m", "ml.scripts.update_weights",
|
||||
"--min-games", str(cfg["weights_min_games"]),
|
||||
"--lr", str(cfg["weights_lr"]),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120, # 2 min ceiling — this should complete in seconds
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Print the last ~800 chars of the weight report to the log
|
||||
output = (result.stdout or "").strip()
|
||||
if output:
|
||||
for line in output.splitlines()[-20:]:
|
||||
log.info(f" {line}")
|
||||
log.info("Sim weights updated — deck builder will use new insights on next build.")
|
||||
return True
|
||||
else:
|
||||
log.warning(f"update_weights.py exited with code {result.returncode}")
|
||||
if result.stderr:
|
||||
log.warning(result.stderr.strip()[-300:])
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
log.warning("update_weights.py timed out after 120s — skipping")
|
||||
return False
|
||||
except Exception as e:
|
||||
log.warning(f"update_weights.py failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def start_training(base_url, epochs, lr, batch_size, patience):
|
||||
"""Trigger ML training and return immediately."""
|
||||
resp = api_post(base_url, "/api/ml/train", {
|
||||
@@ -364,6 +414,7 @@ def run_overnight(cfg):
|
||||
else:
|
||||
log.info(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
|
||||
log.info(f" Engine: {engine}")
|
||||
log.info(f" Auto-update weights: {'Yes' if cfg['auto_update_weights'] else 'No'}")
|
||||
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
|
||||
log.info("")
|
||||
|
||||
@@ -440,6 +491,17 @@ def run_overnight(cfg):
|
||||
log.info(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
|
||||
log.info("")
|
||||
|
||||
# ── Auto-update sim weights ───────────────────────────────
|
||||
# Runs update_weights.py to nudge learned_weights.json from the
|
||||
# decision logs produced during this run. This feeds sim_insights
|
||||
# into the deck builder's Ollama prompts on the next deck build.
|
||||
# Runs before neural net training so both pipelines benefit from
|
||||
# the freshest possible data.
|
||||
if cfg["auto_update_weights"]:
|
||||
run_update_weights(cfg)
|
||||
else:
|
||||
log.info("Skipping sim weight update (--no-update-weights flag set)")
|
||||
|
||||
# ── Auto-train ────────────────────────────────────────────
|
||||
if cfg["auto_train"]:
|
||||
if decisions_after < 100:
|
||||
@@ -482,17 +544,17 @@ def main():
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Run DeepSeek sims for 11.5 hours, then train:
|
||||
# Run DeepSeek sims for 11.5 hours, then update weights + train:
|
||||
python overnight-run.py
|
||||
|
||||
# Run 20 batches of 50 games each, then train:
|
||||
# Run 20 batches of 50 games each, then update weights + train:
|
||||
python overnight-run.py --batches 20 --games 50
|
||||
|
||||
# Run for 6 hours with Java engine:
|
||||
python overnight-run.py --engine java --hours 6 --decks "Deck A" "Deck B" "Deck C"
|
||||
|
||||
# Just run sims, no training:
|
||||
python overnight-run.py --no-train
|
||||
# Just run sims, skip both weight update and training:
|
||||
python overnight-run.py --no-train --no-update-weights
|
||||
|
||||
# Custom training settings:
|
||||
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
|
||||
@@ -514,6 +576,12 @@ Examples:
|
||||
help="Deck names (auto-detected if not specified)")
|
||||
parser.add_argument("--no-train", action="store_true",
|
||||
help="Skip auto-training after sims")
|
||||
parser.add_argument("--no-update-weights", action="store_true",
|
||||
help="Skip updating learned_weights.json after sims")
|
||||
parser.add_argument("--weights-min-games", type=int, default=10,
|
||||
help="Minimum games required for weight update (default: 10)")
|
||||
parser.add_argument("--weights-lr", type=float, default=0.05,
|
||||
help="Learning rate for sim weight update (default: 0.05)")
|
||||
parser.add_argument("--epochs", type=int, default=100,
|
||||
help="Training epochs (default: 100)")
|
||||
parser.add_argument("--lr", type=float, default=0.001,
|
||||
@@ -536,6 +604,9 @@ Examples:
|
||||
cfg["time_limit_hours"] = args.hours
|
||||
cfg["pause_between_batches"] = args.pause
|
||||
cfg["auto_train"] = not args.no_train
|
||||
cfg["auto_update_weights"] = not args.no_update_weights
|
||||
cfg["weights_min_games"] = args.weights_min_games
|
||||
cfg["weights_lr"] = args.weights_lr
|
||||
cfg["train_epochs"] = args.epochs
|
||||
cfg["train_lr"] = args.lr
|
||||
cfg["train_batch_size"] = args.batch_size
|
||||
|
||||
@@ -25,6 +25,28 @@ dev = [
|
||||
"httpx>=0.24.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
# RAG Phase 2: ChromaDB vector store + Ollama embedding support
|
||||
# Install with: pip install ".[rag]"
|
||||
rag = [
|
||||
"chromadb>=0.5.0",
|
||||
]
|
||||
# Local coach embeddings (sentence-transformers, no Ollama required)
|
||||
# Install with: pip install ".[coach-embeddings]"
|
||||
coach-embeddings = [
|
||||
"sentence-transformers>=3.0.0",
|
||||
"torch>=2.0.0",
|
||||
"pandas>=1.5.0",
|
||||
"pyarrow>=12.0.0",
|
||||
]
|
||||
# Full local AI stack (RAG + coach embeddings)
|
||||
# Install with: pip install ".[all]"
|
||||
all = [
|
||||
"chromadb>=0.5.0",
|
||||
"sentence-transformers>=3.0.0",
|
||||
"torch>=2.0.0",
|
||||
"pandas>=1.5.0",
|
||||
"pyarrow>=12.0.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -7,6 +7,11 @@ uvicorn[standard]>=0.23.0
|
||||
pydantic>=2.0.0
|
||||
httpx>=0.24.0
|
||||
websockets>=11.0
|
||||
openai>=1.0.0
|
||||
aiosqlite>=0.19.0
|
||||
|
||||
# Anthropic Claude API
|
||||
anthropic>=0.40.0
|
||||
|
||||
# Deck Builder dependencies
|
||||
requests>=2.31.0
|
||||
@@ -15,5 +20,8 @@ ollama>=0.2.0
|
||||
scrython>=2.0.0
|
||||
|
||||
# Embeddings
|
||||
|
||||
# RAG vector store
|
||||
chromadb>=0.5.0
|
||||
sentence-transformers>=3.0.0
|
||||
torch>=2.0.0
|
||||
+248
-389
@@ -1,11 +1,12 @@
|
||||
"""
|
||||
routes/coach.py
|
||||
===============
|
||||
================
|
||||
LLM-powered deck coaching endpoints:
|
||||
GET /api/coach/status
|
||||
GET /api/coach/decks
|
||||
GET /api/coach/decks/{deck_id}/report
|
||||
POST /api/coach/decks/{deck_id}
|
||||
POST /api/coach/decks/{deck_id}/quick
|
||||
GET /api/coach/sessions
|
||||
GET /api/coach/sessions/{session_id}
|
||||
POST /api/coach/embeddings/download
|
||||
@@ -19,6 +20,9 @@ Globals:
|
||||
_coach_service, _coach_embeddings, _coach_llm
|
||||
_deck_gen_v3, _deck_gen_v3_error
|
||||
init_coach_service(), _build_deck_report_from_db()
|
||||
|
||||
Async-safety: every DB and HTTP call in this module uses
|
||||
async helpers so nothing blocks the FastAPI event loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -34,27 +38,34 @@ from fastapi.responses import StreamingResponse
|
||||
from models.requests import CoachRequestBody, CoachChatMessage, CoachChatRequest, CoachApplyRequest, CoachGoalsRequest
|
||||
|
||||
from models.state import CFG
|
||||
from services.database import _get_db_conn
|
||||
from services.async_db import (
|
||||
async_get_db, async_fetchall, async_fetchone,
|
||||
async_execute, async_execute_commit, async_commit,
|
||||
)
|
||||
from services.deck_service import _compute_deck_analysis
|
||||
from services.logging import log_coach, log_deckgen
|
||||
import httpx
|
||||
from coach.config import COACH_PROVIDER, PPLX_MODEL
|
||||
|
||||
from coach.config import COACH_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL, DECK_GEN_PROVIDER, DECK_GEN_MODEL
|
||||
|
||||
LLM_TIMEOUT = 120 # seconds
|
||||
|
||||
router = APIRouter(tags=["coach"])
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Coach Service (LLM-powered deck coaching)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# Global coach instances (initialized at startup)
|
||||
_coach_service = None
|
||||
_coach_embeddings = None
|
||||
_coach_llm = None
|
||||
_deck_gen_v3 = None # V3 Deck Generator (Perplexity structured output)
|
||||
_deck_gen_v3_error = None # Error message if V3 init failed
|
||||
_deck_gen_v3 = None
|
||||
_deck_gen_v3_error = None
|
||||
|
||||
# Known basic land names whose type_line may be absent when the card was added
|
||||
# via plain-text import and has no matching collection_entries row yet.
|
||||
_BASIC_LAND_NAMES: frozenset[str] = frozenset({
|
||||
"plains", "island", "swamp", "mountain", "forest", "wastes",
|
||||
"snow-covered plains", "snow-covered island", "snow-covered swamp",
|
||||
"snow-covered mountain", "snow-covered forest",
|
||||
})
|
||||
|
||||
|
||||
def init_coach_service():
|
||||
@@ -65,108 +76,90 @@ def init_coach_service():
|
||||
from coach.embeddings import MTGEmbeddingIndex
|
||||
from coach.coach_service import CoachService
|
||||
from coach.config import ensure_dirs
|
||||
|
||||
from services.database import _get_db_conn
|
||||
ensure_dirs()
|
||||
|
||||
_coach_llm = LLMClient()
|
||||
_coach_embeddings = MTGEmbeddingIndex()
|
||||
|
||||
# Try to load embeddings (non-blocking — download happens on first use)
|
||||
try:
|
||||
from coach.config import EMBEDDINGS_NPZ
|
||||
if EMBEDDINGS_NPZ.exists():
|
||||
_coach_embeddings.load()
|
||||
log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
|
||||
log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
|
||||
else:
|
||||
log_coach.warning(" Coach: Embeddings not yet downloaded (will download on first use)")
|
||||
log_coach.warning(" Coach: Embeddings not yet downloaded")
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings load failed: {e}")
|
||||
|
||||
log_coach.error(f" Coach: Embeddings load failed: {e}")
|
||||
_coach_service = CoachService(_coach_llm, _coach_embeddings)
|
||||
|
||||
# Check LLM connection
|
||||
llm_status = _coach_llm.check_connection()
|
||||
if llm_status.get("connected"):
|
||||
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
log_coach.info(f" Coach LLM: Anthropic Claude ({ANTHROPIC_MODEL})")
|
||||
else:
|
||||
log_coach.warning(f" Coach LLM: Not connected (start Ollama on localhost:11434)")
|
||||
|
||||
# Initialize V3 Deck Generator (Ollama-based)
|
||||
llm_status = _coach_llm.check_connection_sync()
|
||||
if llm_status.get("connected"):
|
||||
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
||||
else:
|
||||
log_coach.warning(" Coach LLM: Not connected (start Ollama on localhost:11434)")
|
||||
_deck_gen_v3_error = None
|
||||
try:
|
||||
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
|
||||
_deck_gen_v3 = DeckBuilderAdapter(
|
||||
db_conn_factory=_get_db_conn,
|
||||
model="gpt-oss:20b",
|
||||
)
|
||||
log_deckgen.info(" Deck Gen V3: Initialized (Ollama DeckBuilderAdapter)")
|
||||
if DECK_GEN_PROVIDER == "anthropic":
|
||||
_deck_gen_v3 = DeckBuilderAdapter(db_conn_factory=_get_db_conn, model=DECK_GEN_MODEL)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (Anthropic Claude {DECK_GEN_MODEL})")
|
||||
else:
|
||||
from coach.config import LLM_MODEL
|
||||
_deck_gen_v3 = DeckBuilderAdapter(db_conn_factory=_get_db_conn, model=LLM_MODEL)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (Ollama {LLM_MODEL})")
|
||||
except ImportError as e:
|
||||
_deck_gen_v3_error = f"Missing dependency: {e}"
|
||||
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||
_deck_gen_v3 = None
|
||||
except Exception as e:
|
||||
_deck_gen_v3_error = str(e)
|
||||
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
||||
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
||||
_deck_gen_v3 = None
|
||||
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Failed to initialize: {e}\n{traceback.format_exc()}")
|
||||
log_coach.error(f" Coach: Failed to initialize: {e}\n")
|
||||
_coach_service = None
|
||||
|
||||
|
||||
@router.get("/api/coach/status")
|
||||
async def coach_status():
|
||||
"""Check coach subsystem health."""
|
||||
if _coach_service is None:
|
||||
return {"llmConnected": False, "embeddingsLoaded": False,
|
||||
"embeddingCards": 0, "deckReportsAvailable": 0,
|
||||
"error": "Coach service not initialized"}
|
||||
return {"llmConnected": False, "embeddingsLoaded": False, "embeddingCards": 0, "deckReportsAvailable": 0, "error": "Coach service not initialized"}
|
||||
status = _coach_service.get_status()
|
||||
return status.model_dump()
|
||||
|
||||
|
||||
@router.get("/api/coach/decks")
|
||||
async def coach_list_decks():
|
||||
"""List all decks available for coaching (from deck builder DB)."""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"""List all decks available for coaching (async DB)."""
|
||||
rows = await async_fetchall(
|
||||
"""SELECT d.id as deck_id, d.name as deck_name, d.commander_name,
|
||||
COUNT(dc.id) as card_count
|
||||
FROM decks d
|
||||
LEFT JOIN deck_cards dc ON dc.deck_id = d.id
|
||||
GROUP BY d.id
|
||||
ORDER BY d.name"""
|
||||
).fetchall()
|
||||
|
||||
# Also get report availability from coach service
|
||||
COUNT(dc.id) as card_count
|
||||
FROM decks d LEFT JOIN deck_cards dc ON dc.deck_id = d.id
|
||||
GROUP BY d.id ORDER BY d.name"""
|
||||
)
|
||||
report_ids = set()
|
||||
if _coach_service:
|
||||
report_ids = set(_coach_service.list_deck_reports())
|
||||
|
||||
decks = []
|
||||
for r in rows:
|
||||
deck_name = r["deck_name"]
|
||||
# Check if a report exists (by deck name slug match)
|
||||
has_report = any(
|
||||
deck_name.lower().replace(" ", "-") == rid.lower() or
|
||||
deck_name.lower() == rid.lower()
|
||||
deck_name.lower().replace(" ", "-") == rid.lower()
|
||||
or deck_name.lower() == rid.lower()
|
||||
for rid in report_ids
|
||||
)
|
||||
decks.append({
|
||||
"deck_id": r["deck_id"],
|
||||
"deck_name": r["deck_name"],
|
||||
"deck_id": r["deck_id"], "deck_name": r["deck_name"],
|
||||
"commander": r["commander_name"] or "",
|
||||
"card_count": r["card_count"],
|
||||
"has_report": has_report,
|
||||
"report_count": 1 if has_report else 0,
|
||||
"last_report_date": None,
|
||||
"card_count": r["card_count"], "has_report": has_report,
|
||||
"report_count": 1 if has_report else 0, "last_report_date": None,
|
||||
})
|
||||
return decks
|
||||
|
||||
|
||||
@router.get("/api/coach/decks/{deck_id}/report")
|
||||
async def coach_get_report(deck_id: str):
|
||||
"""Get the latest DeckReport for a deck."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
report = _coach_service.load_deck_report(deck_id)
|
||||
@@ -177,17 +170,13 @@ async def coach_get_report(deck_id: str):
|
||||
|
||||
@router.post("/api/coach/decks/{deck_id}")
|
||||
async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
|
||||
"""Trigger a coaching session for a deck."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
|
||||
# Load embeddings on first use if not loaded
|
||||
if _coach_embeddings and not _coach_embeddings.loaded:
|
||||
try:
|
||||
_coach_embeddings.load(force_download=True)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings download failed: {e}")
|
||||
|
||||
goals = None
|
||||
if body and body.goals:
|
||||
from coach.models import CoachGoals
|
||||
@@ -195,14 +184,11 @@ async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
|
||||
# Build a fallback DeckReport from the DB if no simulation report exists
|
||||
fallback_report = None
|
||||
try:
|
||||
fallback_report = _build_deck_report_from_db(deck_id)
|
||||
fallback_report = await _build_deck_report_from_db(deck_id)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
|
||||
|
||||
try:
|
||||
session = await _coach_service.run_coaching_session(deck_id, goals, fallback_report=fallback_report)
|
||||
return session.model_dump()
|
||||
@@ -214,74 +200,80 @@ async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
|
||||
raise HTTPException(500, f"Coach session failed: {e}")
|
||||
|
||||
|
||||
def _build_deck_report_from_db(deck_slug: str):
|
||||
"""
|
||||
Build a lightweight DeckReport from the deck builder DB when no
|
||||
simulation report exists. Allows the coach to analyze deck composition
|
||||
even without simulation data.
|
||||
"""
|
||||
from coach.models import DeckReport, CardPerformance, DeckStructure
|
||||
conn = _get_db_conn()
|
||||
@router.post("/api/coach/decks/{deck_id}/quick")
|
||||
async def coach_run_quick_digest(deck_id: str, body: CoachRequestBody = None):
|
||||
"""Fast ~5s quick digest: summary, top 3 upgrades, commander dependency."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
goals = None
|
||||
if body and body.goals:
|
||||
from coach.models import CoachGoals
|
||||
try:
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
fallback_report = None
|
||||
try:
|
||||
fallback_report = await _build_deck_report_from_db(deck_id)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Quick fallback failed for '{deck_id}': {e}")
|
||||
try:
|
||||
session = await _coach_service.run_quick_digest(deck_id, goals, fallback_report=fallback_report)
|
||||
return session.model_dump()
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ConnectionError as e:
|
||||
raise HTTPException(503, f"LLM connection failed: {e}")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Quick digest failed: {e}")
|
||||
|
||||
# --- Input validation ---
|
||||
|
||||
async def _build_deck_report_from_db(deck_slug: str):
|
||||
"""Build a lightweight DeckReport from DB (async). Allows coaching without simulation data."""
|
||||
from coach.models import DeckReport, CardPerformance, DeckStructure
|
||||
import re
|
||||
if not deck_slug or len(deck_slug) > 200:
|
||||
return None
|
||||
deck_slug = re.sub(r'[^a-zA-Z0-9 _-]', '', deck_slug)
|
||||
if not deck_slug:
|
||||
return None
|
||||
|
||||
# Find the deck by parameterized query instead of full-table scan
|
||||
slug_lower = deck_slug.lower()
|
||||
slug_hyphenated = slug_lower.replace(' ', '-')
|
||||
clean_slug = re.sub(r'[^a-z0-9]+', '-', slug_lower).strip('-')
|
||||
|
||||
if slug_lower.isdigit():
|
||||
# Numeric slug -- match by ID directly
|
||||
matched_deck = conn.execute(
|
||||
matched_deck = await async_fetchone(
|
||||
"SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
|
||||
(int(slug_lower),)
|
||||
).fetchone()
|
||||
)
|
||||
else:
|
||||
# Try exact name match, hyphenated match, or clean-slug match
|
||||
matched_deck = conn.execute(
|
||||
matched_deck = await async_fetchone(
|
||||
"""SELECT id, name, commander_name, color_identity FROM decks
|
||||
WHERE LOWER(name) = ?
|
||||
OR LOWER(REPLACE(name, ' ', '-')) = ?
|
||||
OR LOWER(REPLACE(name, ' ', '-')) = ?
|
||||
LIMIT 1""",
|
||||
WHERE LOWER(name) = ? OR LOWER(REPLACE(name, ' ', '-')) = ?
|
||||
OR LOWER(REPLACE(name, ' ', '-')) = ? LIMIT 1""",
|
||||
(slug_lower, slug_hyphenated, clean_slug)
|
||||
).fetchone()
|
||||
|
||||
)
|
||||
if matched_deck is None:
|
||||
return None
|
||||
|
||||
deck_id = matched_deck["id"]
|
||||
deck_name = matched_deck["name"]
|
||||
commander = matched_deck["commander_name"] or ""
|
||||
|
||||
# Parse color identity
|
||||
ci_raw = matched_deck["color_identity"] or "[]"
|
||||
try:
|
||||
color_identity = json.loads(ci_raw) if isinstance(ci_raw, str) else ci_raw
|
||||
except Exception:
|
||||
color_identity = []
|
||||
|
||||
# Load all cards in this deck
|
||||
card_rows = conn.execute(
|
||||
card_rows = await async_fetchall(
|
||||
"""SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
|
||||
ce.type_line, ce.cmc, ce.oracle_text
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name)
|
||||
WHERE dc.deck_id = ?""",
|
||||
ce.type_line, ce.cmc, ce.oracle_text
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name)
|
||||
WHERE dc.deck_id = ?""",
|
||||
(deck_id,)
|
||||
).fetchall()
|
||||
|
||||
)
|
||||
cards = []
|
||||
type_counts = {}
|
||||
cmc_buckets = [0] * 8
|
||||
land_count = 0
|
||||
|
||||
for cr in card_rows:
|
||||
card_name = cr["card_name"] or ""
|
||||
type_line = cr["type_line"] or ""
|
||||
@@ -289,7 +281,12 @@ def _build_deck_report_from_db(deck_slug: str):
|
||||
qty = cr["quantity"] or 1
|
||||
role_tag = cr["role_tag"] or ""
|
||||
|
||||
# Build tags from type_line and role_tag
|
||||
# Fallback for cards added via plain-text import that have no
|
||||
# collection_entries row yet (type_line comes back empty from the LEFT JOIN).
|
||||
# Basic lands are identified by name so their land_count is never lost.
|
||||
if not type_line and card_name.lower() in _BASIC_LAND_NAMES:
|
||||
type_line = "Basic Land"
|
||||
|
||||
tags = []
|
||||
if role_tag:
|
||||
tags.append(role_tag)
|
||||
@@ -308,42 +305,24 @@ def _build_deck_report_from_db(deck_slug: str):
|
||||
tags.append("enchantment")
|
||||
elif "Planeswalker" in type_line:
|
||||
tags.append("planeswalker")
|
||||
|
||||
# Type count
|
||||
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
|
||||
if t in type_line:
|
||||
type_counts[t] = type_counts.get(t, 0) + qty
|
||||
|
||||
# CMC bucket
|
||||
# Mirrors deck_service._compute_deck_analysis(): lands are excluded from
|
||||
# the mana curve so cmc=0 lands don't inflate the free-spell bucket and
|
||||
# mislead the LLM coach into thinking the deck has many 0-cost spells.
|
||||
bucket = min(int(cmc), 7)
|
||||
cmc_buckets[bucket] += qty
|
||||
|
||||
# CardPerformance with zeroed-out sim stats
|
||||
cards.append(CardPerformance(
|
||||
name=card_name,
|
||||
drawnRate=0.0,
|
||||
castRate=0.0,
|
||||
impactScore=0.0,
|
||||
tags=tags,
|
||||
))
|
||||
|
||||
slug = deck_slug
|
||||
if "Land" not in type_line:
|
||||
cmc_buckets[bucket] += qty
|
||||
cards.append(CardPerformance(name=card_name, drawnRate=0.0, castRate=0.0, impactScore=0.0, tags=tags))
|
||||
return DeckReport(
|
||||
deckId=slug,
|
||||
commander=commander,
|
||||
colorIdentity=color_identity,
|
||||
cards=cards,
|
||||
structure=DeckStructure(
|
||||
landCount=land_count,
|
||||
curveBuckets=cmc_buckets,
|
||||
cardTypeCounts=type_counts,
|
||||
),
|
||||
deckId=deck_slug, commander=commander, colorIdentity=color_identity,
|
||||
cards=cards, structure=DeckStructure(landCount=land_count, curveBuckets=cmc_buckets, cardTypeCounts=type_counts),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/coach/sessions")
|
||||
async def coach_list_sessions(deck_id: str = None):
|
||||
"""List all coaching sessions, optionally filtered by deck."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
return {"sessions": _coach_service.list_sessions(deck_id)}
|
||||
@@ -351,7 +330,6 @@ async def coach_list_sessions(deck_id: str = None):
|
||||
|
||||
@router.get("/api/coach/sessions/{session_id}")
|
||||
async def coach_get_session(session_id: str):
|
||||
"""Get a specific coaching session."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
session = _coach_service.load_session(session_id)
|
||||
@@ -362,52 +340,39 @@ async def coach_get_session(session_id: str):
|
||||
|
||||
@router.post("/api/coach/embeddings/download")
|
||||
async def coach_download_embeddings():
|
||||
"""Download and convert MTG card embeddings from HuggingFace."""
|
||||
if _coach_embeddings is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
try:
|
||||
_coach_embeddings.load(force_download=True)
|
||||
return {
|
||||
"success": True,
|
||||
"cards": _coach_embeddings.card_count,
|
||||
"message": f"Loaded {_coach_embeddings.card_count} card embeddings"
|
||||
}
|
||||
return {"success": True, "cards": _coach_embeddings.card_count,
|
||||
"message": f"Loaded {_coach_embeddings.card_count} card embeddings"}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to download embeddings: {e}")
|
||||
|
||||
|
||||
@router.get("/api/coach/embeddings/search")
|
||||
async def coach_search_similar(card: str, colors: str = None, top_n: int = 10):
|
||||
"""Search for similar cards by name."""
|
||||
if _coach_embeddings is None or not _coach_embeddings.loaded:
|
||||
raise HTTPException(503, "Embeddings not loaded")
|
||||
color_filter = list(colors.upper()) if colors else None
|
||||
matches = _coach_embeddings.search_similar(
|
||||
query_card=card, color_filter=color_filter, top_n=top_n
|
||||
)
|
||||
matches = _coach_embeddings.search_similar(query_card=card, color_filter=color_filter, top_n=top_n)
|
||||
return {"query": card, "matches": [m.to_dict() for m in matches]}
|
||||
|
||||
|
||||
@router.post("/api/coach/chat")
|
||||
async def coach_chat(body: CoachChatRequest):
|
||||
"""Multi-turn coaching chat. Sends conversation history to LLM and returns response."""
|
||||
"""Multi-turn coaching chat. Fully async - no event loop blocking."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
|
||||
# Load deck report for context
|
||||
report = _coach_service.load_deck_report(body.deck_id)
|
||||
|
||||
# Build system prompt with report context
|
||||
from coach.prompt_template import build_system_prompt
|
||||
from coach.models import CoachGoals
|
||||
|
||||
goals = None
|
||||
if body.goals:
|
||||
try:
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
|
||||
system_prompt = ""
|
||||
if report:
|
||||
system_prompt = build_system_prompt(report, goals)
|
||||
@@ -417,251 +382,161 @@ async def coach_chat(body: CoachChatRequest):
|
||||
"Answer questions about deck building, strategy, card choices, and game theory. "
|
||||
"Be specific and actionable in your advice."
|
||||
)
|
||||
|
||||
# Build messages array for LLM
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
for msg in body.messages:
|
||||
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
|
||||
# ── Perplexity provider path ──────────────────────────────
|
||||
if COACH_PROVIDER == 'perplexity':
|
||||
if not CFG.pplx_api_key:
|
||||
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var.')
|
||||
pplx_payload = {
|
||||
'model': PPLX_MODEL,
|
||||
'messages': messages,
|
||||
'max_tokens': 4096,
|
||||
'temperature': 0.7,
|
||||
}
|
||||
pplx_headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {CFG.pplx_api_key}',
|
||||
}
|
||||
if body.stream:
|
||||
async def pplx_stream():
|
||||
pplx_payload['stream'] = True
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
async with client.stream('POST', 'https://api.perplexity.ai/chat/completions',
|
||||
json=pplx_payload, headers=pplx_headers) as resp:
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith('data: '):
|
||||
data = line[6:]
|
||||
if data == '[DONE]':
|
||||
yield 'data: [DONE]\n\n'
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
content = chunk.get('choices', [{}])[0].get('delta', {}).get('content', '')
|
||||
if content:
|
||||
yield f'data: {json.dumps({"content": content})}\n\n'
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return StreamingResponse(pplx_stream(), media_type='text/event-stream')
|
||||
else:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
'https://api.perplexity.ai/chat/completions',
|
||||
json=pplx_payload, headers=pplx_headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
content = raw.get('choices', [{}])[0].get('message', {}).get('content', '')
|
||||
usage = raw.get('usage', {})
|
||||
return {
|
||||
'content': content,
|
||||
'model': raw.get('model', ''),
|
||||
'prompt_tokens': usage.get('prompt_tokens', 0),
|
||||
'completion_tokens': usage.get('completion_tokens', 0),
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(502, f'Perplexity API returned {e.response.status_code}')
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(502, f'Perplexity API call failed: {e}')
|
||||
|
||||
# Call LLM with multi-turn messages
|
||||
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
|
||||
# ── Anthropic Claude provider path (async httpx) ──
|
||||
if COACH_PROVIDER == 'anthropic':
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(400, 'Anthropic API key not configured.')
|
||||
import anthropic
|
||||
aclient = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
|
||||
if body.stream:
|
||||
async def anthropic_stream():
|
||||
async with aclient.messages.stream(
|
||||
model=ANTHROPIC_MODEL, system=system_prompt,
|
||||
messages=[{"role": m["role"], "content": m["content"]} for m in messages if m["role"] != "system"],
|
||||
max_tokens=4096, temperature=0.7,
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
yield f"data: {json.dumps({'content': text})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return StreamingResponse(anthropic_stream(), media_type="text/event-stream")
|
||||
else:
|
||||
try:
|
||||
resp = await aclient.messages.create(
|
||||
model=ANTHROPIC_MODEL, system=system_prompt,
|
||||
messages=[{"role": m["role"], "content": m["content"]} for m in messages if m["role"] != "system"],
|
||||
max_tokens=4096, temperature=0.7,
|
||||
)
|
||||
content = resp.content[0].text if resp.content else ""
|
||||
usage = resp.usage
|
||||
return {
|
||||
"content": content, "model": resp.model,
|
||||
"prompt_tokens": usage.input_tokens if usage else 0,
|
||||
"completion_tokens": usage.output_tokens if usage else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Anthropic API call failed: {e}")
|
||||
# ── Local Ollama provider path (async httpx) ──
|
||||
try:
|
||||
import json
|
||||
from urllib.request import urlopen, Request as UrlRequest
|
||||
|
||||
|
||||
model_name = _coach_llm._resolve_model()
|
||||
llm_body = {
|
||||
"model": model_name,
|
||||
"messages": messages,
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
|
||||
model_name = await _coach_llm._resolve_model()
|
||||
llm_body = {"model": model_name, "messages": messages, "temperature": 0.7, "max_tokens": 4096}
|
||||
if body.stream:
|
||||
# SSE streaming response
|
||||
llm_body["stream"] = True
|
||||
|
||||
async def generate():
|
||||
import asyncio
|
||||
def _stream():
|
||||
req = UrlRequest(
|
||||
f"{_coach_llm.base_url}/chat/completions",
|
||||
data=json.dumps(llm_body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
import http.client
|
||||
import urllib.parse
|
||||
parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions")
|
||||
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LLM_TIMEOUT)
|
||||
conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
resp = conn.getresponse()
|
||||
chunks = []
|
||||
while True:
|
||||
line = resp.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.decode("utf-8").strip()
|
||||
if line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if data == "[DONE]":
|
||||
chunks.append("[DONE]")
|
||||
break
|
||||
chunks.append(data)
|
||||
conn.close()
|
||||
return chunks
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
chunks = await loop.run_in_executor(None, _stream)
|
||||
|
||||
for chunk_str in chunks:
|
||||
if chunk_str == "[DONE]":
|
||||
yield f"data: [DONE]\n\n"
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(chunk_str)
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
content = delta.get("content", "")
|
||||
if content:
|
||||
# Strip think tags from streaming chunks
|
||||
yield f"data: {json.dumps({'content': content})}\n\n"
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(LLM_TIMEOUT))) as client:
|
||||
async with client.stream("POST", f"{_coach_llm.base_url}/chat/completions",
|
||||
json=llm_body, headers={"Content-Type": "application/json"}) as resp:
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if data == "[DONE]":
|
||||
yield "data: [DONE]\n\n"
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
content = chunk.get("choices", [{}])[0].get("delta", {}).get("content", "")
|
||||
if content:
|
||||
yield f"data: {json.dumps({'content': content})}\n\n"
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
else:
|
||||
# Non-streaming: regular call
|
||||
req = UrlRequest(
|
||||
f"{_coach_llm.base_url}/chat/completions",
|
||||
data=json.dumps(llm_body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
def _call():
|
||||
with urlopen(req, timeout=LLM_TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
raw = await loop.run_in_executor(None, _call)
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(LLM_TIMEOUT))) as client:
|
||||
resp = await client.post(
|
||||
f"{_coach_llm.base_url}/chat/completions",
|
||||
json=llm_body, headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
# Strip think tags
|
||||
content = _coach_llm._strip_think_tags(content)
|
||||
usage = raw.get("usage", {})
|
||||
|
||||
return {
|
||||
"content": content,
|
||||
"model": raw.get("model", ""),
|
||||
"content": content, "model": raw.get("model", ""),
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Chat failed: {e}")
|
||||
|
||||
|
||||
@router.post("/api/coach/apply")
|
||||
async def coach_apply_suggestions(body: CoachApplyRequest):
|
||||
"""Apply accepted coaching suggestions to a deck — remove cuts, add adds."""
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Verify deck exists
|
||||
deck = conn.execute("SELECT * FROM decks WHERE id = ?", (body.deck_id,)).fetchone()
|
||||
"""Apply accepted coaching suggestions to a deck (async DB + async HTTP)."""
|
||||
db = await async_get_db()
|
||||
deck = await async_fetchone("SELECT * FROM decks WHERE id = ?", (body.deck_id,))
|
||||
if not deck:
|
||||
raise HTTPException(404, f"Deck {body.deck_id} not found")
|
||||
|
||||
results = {"cuts": [], "adds": [], "errors": []}
|
||||
|
||||
# Process cuts: remove cards by name
|
||||
for card_name in body.accepted_cuts:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": card_name, "status": "removed"})
|
||||
else:
|
||||
# Try case-insensitive
|
||||
row = conn.execute(
|
||||
"SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1",
|
||||
try:
|
||||
for card_name in body.accepted_cuts:
|
||||
row = await async_fetchone(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
).fetchone()
|
||||
)
|
||||
if row:
|
||||
conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": row["card_name"], "status": "removed"})
|
||||
await async_execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": card_name, "status": "removed"})
|
||||
else:
|
||||
results["errors"].append({"name": card_name, "error": "Card not found in deck"})
|
||||
|
||||
# Process adds: look up in collection first, then Scryfall
|
||||
for card_name in body.accepted_adds:
|
||||
# Try to find in collection
|
||||
ce = conn.execute(
|
||||
"SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(card_name,)
|
||||
).fetchone()
|
||||
|
||||
scryfall_id = None
|
||||
resolved_name = card_name
|
||||
|
||||
if ce:
|
||||
scryfall_id = ce["scryfall_id"]
|
||||
resolved_name = ce["name"]
|
||||
else:
|
||||
# Try Scryfall API lookup
|
||||
try:
|
||||
import urllib.parse
|
||||
from urllib.request import urlopen, Request as UrlRequest
|
||||
encoded = urllib.parse.quote(card_name)
|
||||
scry_req = UrlRequest(
|
||||
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
|
||||
headers={"User-Agent": "commander-ai-lab/1.0"}
|
||||
row = await async_fetchone(
|
||||
"SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
)
|
||||
with urlopen(scry_req, timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
if row:
|
||||
await async_execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": row["card_name"], "status": "removed"})
|
||||
else:
|
||||
results["errors"].append({"name": card_name, "error": "Card not found in deck"})
|
||||
for card_name in body.accepted_adds:
|
||||
ce = await async_fetchone(
|
||||
"SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(card_name,)
|
||||
)
|
||||
scryfall_id = None
|
||||
resolved_name = card_name
|
||||
if ce:
|
||||
scryfall_id = ce["scryfall_id"]
|
||||
resolved_name = ce["name"]
|
||||
else:
|
||||
try:
|
||||
import urllib.parse
|
||||
encoded = urllib.parse.quote(card_name)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
|
||||
headers={"User-Agent": "commander-ai-lab/1.0"}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
card_data = resp.json()
|
||||
scryfall_id = card_data.get("id")
|
||||
resolved_name = card_data.get("name", card_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if scryfall_id:
|
||||
# Check if already in deck
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
|
||||
(body.deck_id, scryfall_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)",
|
||||
(body.deck_id, scryfall_id, resolved_name)
|
||||
except Exception:
|
||||
pass
|
||||
if scryfall_id:
|
||||
existing = await async_fetchone(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
|
||||
(body.deck_id, scryfall_id)
|
||||
)
|
||||
results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"})
|
||||
if not existing:
|
||||
await async_execute(
|
||||
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)",
|
||||
(body.deck_id, scryfall_id, resolved_name)
|
||||
)
|
||||
results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"})
|
||||
else:
|
||||
results["adds"].append({"name": resolved_name, "status": "already_in_deck"})
|
||||
else:
|
||||
results["adds"].append({"name": resolved_name, "status": "already_in_deck"})
|
||||
else:
|
||||
results["errors"].append({"name": card_name, "error": "Could not resolve card"})
|
||||
|
||||
conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,))
|
||||
conn.commit()
|
||||
|
||||
results["errors"].append({"name": card_name, "error": "Could not resolve card"})
|
||||
await async_execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,))
|
||||
await async_commit()
|
||||
except Exception as e:
|
||||
try:
|
||||
await async_execute("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(500, f"Apply failed and was rolled back: {e}")
|
||||
results["total_cuts"] = len(results["cuts"])
|
||||
results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"])
|
||||
return results
|
||||
@@ -669,49 +544,35 @@ async def coach_apply_suggestions(body: CoachApplyRequest):
|
||||
|
||||
@router.get("/api/coach/cards-like")
|
||||
async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
|
||||
"""Find cards similar to a given card using embeddings. UI-friendly version."""
|
||||
"""Find similar cards using embeddings (async DB for ownership check)."""
|
||||
if _coach_embeddings is None or not _coach_embeddings.loaded:
|
||||
raise HTTPException(503, "Embeddings not loaded")
|
||||
|
||||
color_filter = list(colors.upper()) if colors else None
|
||||
matches = _coach_embeddings.search_similar(
|
||||
query_card=card, color_filter=color_filter, top_n=top_n
|
||||
)
|
||||
|
||||
conn = _get_db_conn()
|
||||
matches = _coach_embeddings.search_similar(query_card=card, color_filter=color_filter, top_n=top_n)
|
||||
results = []
|
||||
for m in matches:
|
||||
# Check if card is in collection
|
||||
owned = conn.execute(
|
||||
owned = await async_fetchone(
|
||||
"SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)",
|
||||
(m.name,)
|
||||
).fetchone()
|
||||
)
|
||||
owned_qty = (owned["qty"] or 0) if owned else 0
|
||||
|
||||
# Get image URL and price
|
||||
ce = conn.execute(
|
||||
ce = await async_fetchone(
|
||||
"SELECT scryfall_id, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(m.name,)
|
||||
).fetchone()
|
||||
|
||||
)
|
||||
results.append({
|
||||
"name": m.name,
|
||||
"similarity": round(m.similarity, 4),
|
||||
"types": m.types,
|
||||
"mana_value": m.mana_value,
|
||||
"mana_cost": m.mana_cost,
|
||||
"text": m.text[:200] if m.text else "",
|
||||
"name": m.name, "similarity": round(m.similarity, 4),
|
||||
"types": m.types, "mana_value": m.mana_value,
|
||||
"mana_cost": m.mana_cost, "text": m.text[:200] if m.text else "",
|
||||
"owned_qty": owned_qty,
|
||||
"image_url": f"https://api.scryfall.com/cards/{ce['scryfall_id']}?format=image&version=normal" if ce and ce["scryfall_id"] else None,
|
||||
"tcg_price": ce["tcg_price"] if ce else None,
|
||||
})
|
||||
|
||||
return {"query": card, "results": results}
|
||||
|
||||
|
||||
@router.post("/api/coach/reports/generate")
|
||||
async def coach_generate_reports():
|
||||
"""Rebuild all deck reports from batch result JSONs in results/."""
|
||||
try:
|
||||
from coach.report_generator import generate_deck_reports
|
||||
lab_root = Path(__file__).parent.parent
|
||||
@@ -719,9 +580,7 @@ async def coach_generate_reports():
|
||||
reports_dir = str(lab_root / "deck-reports")
|
||||
updated = generate_deck_reports(results_dir, reports_dir)
|
||||
return {
|
||||
"status": "ok",
|
||||
"decksUpdated": updated,
|
||||
"count": len(updated),
|
||||
"status": "ok", "decksUpdated": updated, "count": len(updated),
|
||||
"message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found",
|
||||
}
|
||||
except Exception as e:
|
||||
|
||||
@@ -406,6 +406,11 @@ async def update_collection_card(cardId: int, body: dict):
|
||||
if finish_val not in ("NORMAL", "FOIL", "ETCHED"):
|
||||
raise HTTPException(400, "finish must be NORMAL, FOIL, or ETCHED")
|
||||
updates["finish"] = finish_val
|
||||
if "quantity" in body:
|
||||
qty_val = int(body["quantity"])
|
||||
if qty_val < 0:
|
||||
raise HTTPException(400, "quantity must be >= 0")
|
||||
updates["quantity"] = qty_val
|
||||
|
||||
if not updates:
|
||||
return _add_image_url(_row_to_dict(row))
|
||||
|
||||
+21
-51
@@ -45,7 +45,7 @@ from services.card_analysis import _detect_card_roles
|
||||
from services.database import _get_db_conn, _row_to_dict, _add_image_url
|
||||
from services.deck_service import (
|
||||
_get_deck_or_404, _compute_deck_analysis, _check_ratio_limit,
|
||||
_classify_card_type, _to_edhrec_slug, _TYPE_TARGETS,
|
||||
_classify_card_type, _classify_card_category, _to_edhrec_slug, _TYPE_TARGETS,
|
||||
_save_profile_to_dck,
|
||||
)
|
||||
from services.import_helpers import (
|
||||
@@ -121,7 +121,6 @@ async def get_deck(deck_id: int):
|
||||
deck["cards"] = [dict(r) for r in card_rows]
|
||||
deck["total_cards"] = sum(r["quantity"] for r in card_rows)
|
||||
deck["card_slots"] = len(card_rows)
|
||||
# Composition summary by type
|
||||
analysis = _compute_deck_analysis(deck_id)
|
||||
deck["composition"] = analysis["counts_by_type"]
|
||||
return deck
|
||||
@@ -130,7 +129,7 @@ async def get_deck(deck_id: int):
|
||||
@router.put("/api/decks/{deck_id}")
|
||||
async def update_deck(deck_id: int, req: UpdateDeckRequest):
|
||||
"""Update deck metadata."""
|
||||
_get_deck_or_404(deck_id) # ensure exists
|
||||
_get_deck_or_404(deck_id)
|
||||
conn = _get_db_conn()
|
||||
|
||||
updates = {}
|
||||
@@ -158,7 +157,7 @@ async def update_deck(deck_id: int, req: UpdateDeckRequest):
|
||||
@router.delete("/api/decks/{deck_id}")
|
||||
async def delete_deck(deck_id: int):
|
||||
"""Delete a deck and all its cards (cascade)."""
|
||||
_get_deck_or_404(deck_id) # ensure exists
|
||||
_get_deck_or_404(deck_id)
|
||||
conn = _get_db_conn()
|
||||
conn.execute("DELETE FROM deck_cards WHERE deck_id = ?", (deck_id,))
|
||||
conn.execute("DELETE FROM decks WHERE id = ?", (deck_id,))
|
||||
@@ -181,7 +180,7 @@ async def delete_all_decks():
|
||||
|
||||
@router.get("/api/decks/{deck_id}/cards")
|
||||
async def get_deck_cards(deck_id: int):
|
||||
"""Get all cards in a deck with joined collection data."""
|
||||
"""Get all cards in a deck with joined collection and card_records data."""
|
||||
_get_deck_or_404(deck_id)
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
@@ -189,7 +188,12 @@ async def get_deck_cards(deck_id: int):
|
||||
SELECT
|
||||
dc.id, dc.deck_id, dc.scryfall_id, dc.card_name, dc.quantity,
|
||||
dc.is_commander, dc.role_tag,
|
||||
ce.type_line, ce.cmc, ce.mana_cost, ce.color_identity, ce.oracle_text, ce.keywords,
|
||||
COALESCE(ce.type_line, cr.type_line, '') as type_line,
|
||||
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
|
||||
COALESCE(ce.mana_cost, '') as mana_cost,
|
||||
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
|
||||
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
|
||||
COALESCE(ce.keywords, cr.keywords, '[]') as keywords,
|
||||
ce.tcg_price, ce.quantity AS owned_qty, ce.is_legendary,
|
||||
ce.salt_score, ce.is_game_changer
|
||||
FROM deck_cards dc
|
||||
@@ -200,6 +204,11 @@ async def get_deck_cards(deck_id: int):
|
||||
FROM collection_entries
|
||||
GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
LEFT JOIN (
|
||||
SELECT name, type_line, cmc, color_identity, oracle_text, keywords
|
||||
FROM card_records
|
||||
GROUP BY name
|
||||
) cr ON cr.name = dc.card_name
|
||||
WHERE dc.deck_id = ?
|
||||
ORDER BY dc.is_commander DESC, dc.card_name ASC
|
||||
""",
|
||||
@@ -208,7 +217,6 @@ async def get_deck_cards(deck_id: int):
|
||||
cards = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
# Parse JSON fields
|
||||
for f in ("color_identity", "keywords"):
|
||||
if isinstance(d.get(f), str):
|
||||
try:
|
||||
@@ -219,6 +227,8 @@ async def get_deck_cards(deck_id: int):
|
||||
f"https://api.scryfall.com/cards/{d['scryfall_id']}?format=image&version=normal"
|
||||
if d.get("scryfall_id") else None
|
||||
)
|
||||
d["card_type"] = _classify_card_type(d.get("type_line", ""))
|
||||
d["card_category"] = _classify_card_category(d.get("type_line", ""), d.get("oracle_text", ""))
|
||||
cards.append(d)
|
||||
return {"cards": cards, "total": len(cards)}
|
||||
|
||||
@@ -229,14 +239,12 @@ async def add_deck_card(deck_id: int, req: AddDeckCardRequest):
|
||||
_get_deck_or_404(deck_id)
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Look up card_name from collection_entries by scryfall_id, fall back to request
|
||||
ce_row = conn.execute(
|
||||
"SELECT name FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
|
||||
(req.scryfall_id,)
|
||||
).fetchone()
|
||||
card_name = ce_row["name"] if ce_row else (req.card_name or "")
|
||||
|
||||
# Check if this scryfall_id is already in the deck
|
||||
existing = conn.execute(
|
||||
"SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
|
||||
(deck_id, req.scryfall_id)
|
||||
@@ -266,7 +274,6 @@ async def add_deck_card(deck_id: int, req: AddDeckCardRequest):
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM deck_cards WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
|
||||
# Update deck updated_at
|
||||
conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (deck_id,))
|
||||
conn.commit()
|
||||
return dict(row)
|
||||
@@ -341,28 +348,23 @@ async def recommend_from_collection(deck_id: int, max_results: int = 20, roles:
|
||||
deck_color_identity = deck.get("color_identity", [])
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Run analysis to find shortfalls
|
||||
analysis = _compute_deck_analysis(deck_id)
|
||||
deltas = analysis["deltas"]
|
||||
deltas = analysis.get("deltas", {})
|
||||
counts_by_type = analysis["counts_by_type"]
|
||||
|
||||
# Find types that are below their target midpoint (shortfall)
|
||||
shortfall_types = set()
|
||||
for t, delta in deltas.items():
|
||||
lo, hi = _TYPE_TARGETS.get(t, [0, 0])
|
||||
if counts_by_type.get(t, 0) < lo:
|
||||
shortfall_types.add(t)
|
||||
|
||||
# Get scryfall_ids already in deck to exclude
|
||||
in_deck_ids = set(
|
||||
r["scryfall_id"] for r in
|
||||
conn.execute("SELECT scryfall_id FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall()
|
||||
)
|
||||
|
||||
# Parse role filter
|
||||
role_filter = [r.strip() for r in roles.split(",") if r.strip()] if roles else []
|
||||
|
||||
# Query collection for candidate cards
|
||||
coll_rows = conn.execute(
|
||||
"SELECT * FROM collection_entries WHERE quantity > 0"
|
||||
).fetchall()
|
||||
@@ -372,11 +374,9 @@ async def recommend_from_collection(deck_id: int, max_results: int = 20, roles:
|
||||
card = _row_to_dict(row)
|
||||
card_id = card.get("scryfall_id", "")
|
||||
|
||||
# Skip cards already in deck
|
||||
if card_id in in_deck_ids:
|
||||
continue
|
||||
|
||||
# Color identity check: all card colors must be in deck colors
|
||||
card_ci = card.get("color_identity", [])
|
||||
if isinstance(card_ci, str):
|
||||
try:
|
||||
@@ -393,22 +393,17 @@ async def recommend_from_collection(deck_id: int, max_results: int = 20, roles:
|
||||
card_type = _classify_card_type(type_line)
|
||||
card_roles = _detect_card_roles(oracle_text, type_line, keywords)
|
||||
|
||||
# Role filter
|
||||
if role_filter and not any(r in card_roles for r in role_filter):
|
||||
continue
|
||||
|
||||
# Scoring
|
||||
score = 0
|
||||
# +3 if card type is in shortfall
|
||||
if card_type in shortfall_types:
|
||||
score += 3
|
||||
# +2 per matching role
|
||||
for r in card_roles:
|
||||
if r in ("Ramp", "Draw", "Removal", "BoardWipe"):
|
||||
score += 2
|
||||
else:
|
||||
score += 1
|
||||
# +1 for lower CMC (curve fit)
|
||||
cmc = float(card.get("cmc", 0))
|
||||
if cmc <= 3:
|
||||
score += 1
|
||||
@@ -429,7 +424,6 @@ async def recommend_from_collection(deck_id: int, max_results: int = 20, roles:
|
||||
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
# Group by type
|
||||
grouped = {}
|
||||
for card in scored[:max_results]:
|
||||
ct = card["card_type"]
|
||||
@@ -456,7 +450,6 @@ async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int
|
||||
if not commander_name:
|
||||
raise HTTPException(400, "Deck has no commander_name set. Update the deck first.")
|
||||
|
||||
# Cache check
|
||||
cache_key = f"edhrec:avg:{_to_edhrec_slug(commander_name)}"
|
||||
cached_profile = _edhrec_cache_get(cache_key)
|
||||
if cached_profile is None:
|
||||
@@ -471,13 +464,11 @@ async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int
|
||||
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Get cards already in deck
|
||||
in_deck_names = set(
|
||||
r["card_name"].lower() for r in
|
||||
conn.execute("SELECT card_name FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall()
|
||||
)
|
||||
|
||||
# Build collection lookup: name (lower) -> {owned, qty, scryfall_id}
|
||||
coll_rows = conn.execute(
|
||||
"SELECT name, quantity, scryfall_id FROM collection_entries"
|
||||
).fetchall()
|
||||
@@ -488,7 +479,6 @@ async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int
|
||||
if not existing or r["quantity"] > existing["qty"]:
|
||||
coll_map[key] = {"owned": True, "qty": r["quantity"], "scryfall_id": r["scryfall_id"]}
|
||||
|
||||
# Also look up card details from collection_entries for type/role
|
||||
coll_details = {}
|
||||
for r in conn.execute("SELECT name, type_line, oracle_text, keywords, scryfall_id FROM collection_entries").fetchall():
|
||||
key = r["name"].lower()
|
||||
@@ -498,7 +488,6 @@ async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int
|
||||
results = []
|
||||
for card_name in mainboard:
|
||||
name_lower = card_name.lower()
|
||||
# Skip cards already in deck
|
||||
if name_lower in in_deck_names:
|
||||
continue
|
||||
|
||||
@@ -507,7 +496,6 @@ async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int
|
||||
if only_owned and not owned_info["owned"]:
|
||||
continue
|
||||
|
||||
# Get type/role from collection if available
|
||||
details = coll_details.get(name_lower, {})
|
||||
type_line = details.get("type_line", "")
|
||||
oracle_text = details.get("oracle_text", "")
|
||||
@@ -520,7 +508,7 @@ async def deck_edh_recs(deck_id: int, only_owned: bool = False, max_results: int
|
||||
"type_line": type_line,
|
||||
"role": card_roles[0] if card_roles else "Other",
|
||||
"roles": card_roles,
|
||||
"inclusion_pct": None, # EDHREC average doesn't expose % directly in this path
|
||||
"inclusion_pct": None,
|
||||
"synergy_score": None,
|
||||
"owned": owned_info["owned"],
|
||||
"owned_qty": owned_info["qty"],
|
||||
@@ -569,7 +557,6 @@ async def bulk_add_cards(deck_id: int, req: BulkAddRequest):
|
||||
details.append({"scryfall_id": scryfall_id, "status": "skipped", "reason": "missing scryfall_id"})
|
||||
continue
|
||||
|
||||
# Look up card name and type from collection
|
||||
ce_row = conn.execute(
|
||||
"SELECT name, type_line, oracle_text, keywords FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
|
||||
(scryfall_id,)
|
||||
@@ -589,7 +576,6 @@ async def bulk_add_cards(deck_id: int, req: BulkAddRequest):
|
||||
})
|
||||
continue
|
||||
|
||||
# Upsert
|
||||
existing = conn.execute(
|
||||
"SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
|
||||
(deck_id, scryfall_id)
|
||||
@@ -631,7 +617,6 @@ async def bulk_add_recommended(deck_id: int, req: BulkAddRecommendedRequest):
|
||||
candidates = []
|
||||
|
||||
if source == "collection":
|
||||
# Reuse the recommend_from_collection logic
|
||||
deck = _get_deck_or_404(deck_id)
|
||||
deck_color_identity = deck.get("color_identity", [])
|
||||
analysis = _compute_deck_analysis(deck_id)
|
||||
@@ -691,7 +676,7 @@ async def bulk_add_recommended(deck_id: int, req: BulkAddRecommendedRequest):
|
||||
if req.only_owned and not coll_info:
|
||||
continue
|
||||
if not coll_info:
|
||||
continue # can't add without scryfall_id
|
||||
continue
|
||||
scryfall_id = coll_info.get("scryfall_id", "")
|
||||
if not scryfall_id:
|
||||
continue
|
||||
@@ -706,7 +691,6 @@ async def bulk_add_recommended(deck_id: int, req: BulkAddRecommendedRequest):
|
||||
else:
|
||||
raise HTTPException(400, f"Unknown source '{source}'. Use 'collection' or 'edhrec'.")
|
||||
|
||||
# Now bulk-add candidates
|
||||
added = 0
|
||||
skipped = 0
|
||||
details = []
|
||||
@@ -770,7 +754,6 @@ async def export_deck_to_sim(deck_id: int):
|
||||
if not card_rows:
|
||||
raise HTTPException(400, "Deck has no cards to export")
|
||||
|
||||
# Build .dck content
|
||||
lines = ["[metadata]"]
|
||||
deck_name = deck.get("name", f"Deck {deck_id}")
|
||||
lines.append(f"Name={deck_name}")
|
||||
@@ -795,7 +778,6 @@ async def export_deck_to_sim(deck_id: int):
|
||||
|
||||
content = "\n".join(lines)
|
||||
|
||||
# Determine save directory
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", deck_name).replace(" ", "_").strip()
|
||||
if not safe_name:
|
||||
safe_name = f"deck_{deck_id}"
|
||||
@@ -835,14 +817,13 @@ def _parse_decklist_text(text: str) -> dict:
|
||||
"""
|
||||
commanders = []
|
||||
cards = []
|
||||
section = 'main' # default section
|
||||
section = 'main'
|
||||
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith('//') or line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Section headers
|
||||
low = line.lower()
|
||||
if low.startswith('[commander'):
|
||||
section = 'commander'
|
||||
@@ -854,14 +835,12 @@ def _parse_decklist_text(text: str) -> dict:
|
||||
section = 'skip'
|
||||
continue
|
||||
if low.startswith('['):
|
||||
# Unknown section, treat as main
|
||||
section = 'main'
|
||||
continue
|
||||
|
||||
if section == 'skip':
|
||||
continue
|
||||
|
||||
# Parse quantity + name
|
||||
m = re.match(r'^(\d+)\s*[xX]?\s+(.+)$', line)
|
||||
if m:
|
||||
qty = int(m.group(1))
|
||||
@@ -870,7 +849,6 @@ def _parse_decklist_text(text: str) -> dict:
|
||||
qty = 1
|
||||
name = line
|
||||
|
||||
# Strip set codes like "(M21)" or "(M21) 123" from MTGA exports
|
||||
name = re.sub(r'\s*\([A-Z0-9]+\)\s*\d*\s*$', '', name).strip()
|
||||
|
||||
if not name:
|
||||
@@ -912,7 +890,6 @@ async def import_decklist(deck_id: int, request: FastAPIRequest):
|
||||
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Optionally clear existing cards
|
||||
if clear_first:
|
||||
conn.execute('DELETE FROM deck_cards WHERE deck_id = ?', (deck_id,))
|
||||
conn.commit()
|
||||
@@ -926,7 +903,6 @@ async def import_decklist(deck_id: int, request: FastAPIRequest):
|
||||
qty = entry['qty']
|
||||
is_cmd = entry['is_commander']
|
||||
|
||||
# Look up on Scryfall
|
||||
sf = _scryfall_fuzzy_lookup(name)
|
||||
if not sf:
|
||||
failed.append(name)
|
||||
@@ -936,7 +912,6 @@ async def import_decklist(deck_id: int, request: FastAPIRequest):
|
||||
scryfall_id = sf.get('id', '')
|
||||
resolved_name = sf.get('name', name)
|
||||
|
||||
# Check if already in deck
|
||||
existing = conn.execute(
|
||||
'SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?',
|
||||
(deck_id, scryfall_id)
|
||||
@@ -956,11 +931,9 @@ async def import_decklist(deck_id: int, request: FastAPIRequest):
|
||||
added += 1
|
||||
results.append({'name': resolved_name, 'qty': qty, 'status': 'added', 'isCommander': is_cmd})
|
||||
|
||||
# Update commander on deck record if we found one
|
||||
cmd_entries = [r for r in results if r.get('isCommander')]
|
||||
if cmd_entries:
|
||||
cmd_name = cmd_entries[0]['name']
|
||||
# Look up scryfall_id from the deck_cards we just inserted
|
||||
cmd_row = conn.execute(
|
||||
'SELECT scryfall_id FROM deck_cards WHERE deck_id = ? AND is_commander = 1 LIMIT 1',
|
||||
(deck_id,)
|
||||
@@ -1007,7 +980,6 @@ async def import_decklist_new(request: FastAPIRequest):
|
||||
if not all_entries:
|
||||
return JSONResponse({'error': 'No cards found in decklist'}, status_code=400)
|
||||
|
||||
# Auto-name from first commander if no name given
|
||||
if not deck_name:
|
||||
cmd = parsed['commanders'][0]['name'] if parsed['commanders'] else None
|
||||
deck_name = cmd or 'Imported Deck'
|
||||
@@ -1020,7 +992,6 @@ async def import_decklist_new(request: FastAPIRequest):
|
||||
conn.commit()
|
||||
new_deck_id = cur.lastrowid
|
||||
|
||||
# Set commander on the deck if we have one
|
||||
first_cmd_scryfall = None
|
||||
first_cmd_name = None
|
||||
|
||||
@@ -1051,7 +1022,6 @@ async def import_decklist_new(request: FastAPIRequest):
|
||||
conn.commit()
|
||||
added += 1
|
||||
|
||||
# Update commander on deck record
|
||||
if first_cmd_scryfall:
|
||||
conn.execute(
|
||||
'UPDATE decks SET commander_name = ?, commander_scryfall_id = ? WHERE id = ?',
|
||||
|
||||
+45
-37
@@ -1,14 +1,14 @@
|
||||
"""
|
||||
routes/deckgen.py
|
||||
=================
|
||||
Deck generation endpoints (classic, Perplexity V1, V3):
|
||||
Deck generation endpoints (classic, Anthropic Claude V1, V3):
|
||||
GET /api/deck-generator/config
|
||||
POST /api/deck-generator/preview
|
||||
POST /api/deck-generator/commit
|
||||
GET /api/deck-generator/commander-search
|
||||
POST /api/deck-research
|
||||
POST /api/deck-generate
|
||||
GET /api/pplx/status
|
||||
GET /api/anthropic/status
|
||||
GET /api/deck/v3/status
|
||||
POST /api/deck/v3/generate
|
||||
POST /api/deck/v3/commit
|
||||
@@ -724,7 +724,7 @@ async def deck_generator_commander_search(q: str = ""):
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Perplexity API — AI Deck Research & Generation
|
||||
# Anthropic Claude API — AI Deck Research & Generation
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@@ -847,54 +847,62 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
||||
"""Call Perplexity API chat/completions endpoint (non-blocking).
|
||||
async def _call_anthropic_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
||||
"""Call Anthropic Claude API messages endpoint (non-blocking).
|
||||
Uses httpx.AsyncClient so the uvicorn event loop is never stalled.
|
||||
Returns the assistant message content.
|
||||
"""
|
||||
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL, PPLX_MODEL
|
||||
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL, ANTHROPIC_MODEL
|
||||
|
||||
if DECK_GEN_PROVIDER == 'local':
|
||||
base_url = DECK_GEN_BASE_URL
|
||||
model = DECK_GEN_MODEL
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
else:
|
||||
if not CFG.pplx_api_key:
|
||||
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
|
||||
base_url = 'https://api.perplexity.ai'
|
||||
model = PPLX_MODEL
|
||||
headers = {
|
||||
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 = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {CFG.pplx_api_key}',
|
||||
}
|
||||
'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']
|
||||
system_text = '\n'.join(system_msgs) if system_msgs else None
|
||||
|
||||
|
||||
payload = {
|
||||
'model': model,
|
||||
'messages': messages,
|
||||
'messages': user_msgs,
|
||||
'max_tokens': max_tokens,
|
||||
'temperature': temperature,
|
||||
}
|
||||
if system_text:
|
||||
payload['system'] = system_text
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
f'{base_url}/chat/completions',
|
||||
f'{base_url}/v1/messages',
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(502, f'Perplexity API returned {e.response.status_code}: {e.response.text[:200]}')
|
||||
raise HTTPException(502, f'Anthropic Claude API returned {e.response.status_code}: {e.response.text[:200]}')
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(502, f'Perplexity API call failed: {e}')
|
||||
raise HTTPException(502, f'Anthropic Claude API call failed: {e}')
|
||||
|
||||
choices = data.get('choices', [])
|
||||
if not choices:
|
||||
raise HTTPException(502, 'Empty response from Perplexity API')
|
||||
content = choices[0].get('message', {}).get('content', '')
|
||||
content_blocks = data.get('content', [])
|
||||
if not content_blocks:
|
||||
raise HTTPException(502, 'Empty response from Anthropic Claude API')
|
||||
content = content_blocks[0].get('text', '')
|
||||
usage = data.get('usage', {})
|
||||
log_pplx.debug(
|
||||
f'tokens: prompt={usage.get("prompt_tokens", "?")}, '
|
||||
f'completion={usage.get("completion_tokens", "?")}, '
|
||||
f'tokens: prompt={usage.get("input_tokens", "?")}, '
|
||||
f'completion={usage.get("output_tokens", "?")}, '
|
||||
f'model={data.get("model", "?")}'
|
||||
)
|
||||
return content
|
||||
@@ -1032,7 +1040,7 @@ def _fill_basic_lands(cards: list[dict], color_identity: list[str] | None = None
|
||||
|
||||
@router.post('/api/deck-research')
|
||||
async def deck_research(req: DeckResearchRequest):
|
||||
"""Analyze an existing deck using Perplexity AI and suggest improvements."""
|
||||
"""Analyze an existing deck using Anthropic Claude and suggest improvements."""
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Load deck info
|
||||
@@ -1157,7 +1165,7 @@ DECKLIST ({len(card_rows)} cards):
|
||||
|
||||
Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4 per Commander Rules Committee), ALL synergy packages between cards, ALL win conditions, game plan by phase (early/mid/late), mana base health, every role gap. Suggest specific cuts with severity and specific adds with priority and synergy tags. For adds, prioritize cards from the COLLECTION SUMMARY when available."""
|
||||
|
||||
content = await _call_pplx_api([
|
||||
content = await _call_anthropic_api([
|
||||
{'role': 'system', 'content': system_msg},
|
||||
{'role': 'user', 'content': user_msg},
|
||||
], max_tokens=8192)
|
||||
@@ -1187,7 +1195,7 @@ Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4
|
||||
|
||||
@router.post('/api/deck-generate')
|
||||
async def deck_generate_ai(req: DeckGenerateAIRequest):
|
||||
"""Generate a full 100-card Commander deck using Perplexity AI."""
|
||||
"""Generate a full 100-card Commander deck using Anthropic Claude."""
|
||||
commander = req.commander.strip()
|
||||
if not commander:
|
||||
raise HTTPException(400, 'Commander name is required')
|
||||
@@ -1267,7 +1275,7 @@ COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'}
|
||||
|
||||
Build the deck as JSON. Prioritize collection cards when they fit the strategy."""
|
||||
|
||||
content = await _call_pplx_api([
|
||||
content = await _call_anthropic_api([
|
||||
{'role': 'system', 'content': system_msg},
|
||||
{'role': 'user', 'content': user_msg},
|
||||
], max_tokens=8192, temperature=0.3)
|
||||
@@ -1294,16 +1302,16 @@ Build the deck as JSON. Prioritize collection cards when they fit the strategy."
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/api/pplx/status')
|
||||
async def pplx_status():
|
||||
"""Check if Perplexity API is configured."""
|
||||
@router.get('/api/anthropic/status')
|
||||
async def anthropic_status():
|
||||
"""Check if Anthropic Claude API is configured."""
|
||||
return {
|
||||
'configured': bool(CFG.pplx_api_key),
|
||||
'configured': bool(CFG.anthropic_api_key),
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# V3 Deck Generator (Perplexity Structured Output)
|
||||
# V3 Deck Generator (Anthropic Claude Structured Output)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get('/api/deck/v3/status')
|
||||
@@ -1311,8 +1319,8 @@ async def deck_gen_v3_status():
|
||||
"""Check V3 deck generator status."""
|
||||
return {
|
||||
'initialized': _coach._deck_gen_v3 is not None,
|
||||
'pplx_configured': bool(CFG.pplx_api_key),
|
||||
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'pplx', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else 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', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
|
||||
'embeddings_loaded': False,
|
||||
'embedding_cards': 0,
|
||||
'error': _coach._deck_gen_v3_error,
|
||||
@@ -1322,14 +1330,14 @@ async def deck_gen_v3_status():
|
||||
@router.post('/api/deck/v3/generate')
|
||||
async def deck_gen_v3_generate(req: DeckGenV3Request):
|
||||
"""
|
||||
V3 Deck Generation — Perplexity structured output with Smart Substitution.
|
||||
V3 Deck Generation — Anthropic Claude Structured Output with Smart Substitution.
|
||||
|
||||
Pipeline:
|
||||
1. Resolve commander via Scryfall
|
||||
2. Build collection summary from DB
|
||||
3. Call Perplexity Sonar with JSON schema enforcement
|
||||
3. Call Anthropic Claude with JSON schema enforcement
|
||||
4. Cross-reference cards with collection for ownership
|
||||
5. Run Smart Substitution (embedding + Perplexity fallback)
|
||||
5. Run Smart Substitution (embedding + Anthropic fallback)
|
||||
6. Return complete deck with substitution data
|
||||
"""
|
||||
if _coach._deck_gen_v3 is None:
|
||||
|
||||
+19
-60
@@ -37,11 +37,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["deepseek"])
|
||||
|
||||
# In-memory store for simulation runs
|
||||
_sim_runs = {} # sim_id -> { status, result, error }
|
||||
_sim_lock = _threading.Lock()
|
||||
|
||||
# ── Validator singleton ──
|
||||
_validator_brain: ValidatorBrain | None = None
|
||||
_validator_lock = _threading.Lock()
|
||||
|
||||
@@ -61,15 +59,11 @@ def _get_validator_brain() -> ValidatorBrain | None:
|
||||
_validator_brain = ValidatorBrain(cfg)
|
||||
return _validator_brain
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Shared Helpers (extracted to deduplicate sim threads — #42)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
_SRC_PATH_ADDED = False
|
||||
|
||||
|
||||
def _ensure_src_path():
|
||||
"""Add project src/ to sys.path once."""
|
||||
global _SRC_PATH_ADDED
|
||||
if not _SRC_PATH_ADDED:
|
||||
src_dir = str(Path(__file__).resolve().parent.parent / 'src')
|
||||
@@ -126,7 +120,6 @@ def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
|
||||
|
||||
for i in range(num_games):
|
||||
kw = dict(run_kwargs)
|
||||
# DeepSeek engine needs per-game IDs
|
||||
if 'game_id_prefix' in kw:
|
||||
prefix = kw.pop('game_id_prefix')
|
||||
kw['game_id'] = f'{prefix}-g{i+1}'
|
||||
@@ -134,7 +127,6 @@ def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
|
||||
name_b=opponent_name, **kw)
|
||||
game_data = result.to_dict()
|
||||
game_data['gameNumber'] = i + 1
|
||||
# Phase 8: Surface player_explanation from validation in turn log
|
||||
for turn_entry in game_data.get('log', []):
|
||||
v = turn_entry.get('validation')
|
||||
if isinstance(v, dict) and v.get('player_explanation'):
|
||||
@@ -178,7 +170,6 @@ def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
|
||||
|
||||
def _build_summary(stats: dict, deck_name: str, opponent_name: str,
|
||||
num_games: int, elapsed: float, **extra) -> dict:
|
||||
"""Build the JSON-serialisable summary dict from accumulated stats."""
|
||||
n = num_games
|
||||
summary = {
|
||||
'deckName': deck_name,
|
||||
@@ -208,7 +199,6 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
||||
_sim_runs[sim_id]['status'] = 'complete'
|
||||
_sim_runs[sim_id]['result'] = {'summary': summary, 'games': game_results}
|
||||
|
||||
# --- Persist ml-decision-*.json for coach / training dashboard ---
|
||||
try:
|
||||
deck_name = summary.get('deckName', 'Unknown')
|
||||
opponent_name = summary.get('opponentName', 'Training Deck')
|
||||
@@ -240,7 +230,6 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
||||
json.dump(batch_data, f, indent=2, default=str)
|
||||
logger.info(f'Saved batch results to {batch_path}')
|
||||
|
||||
# Auto-generate deck report for training dashboard / coach
|
||||
try:
|
||||
from coach.report_generator import generate_deck_reports
|
||||
reports_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'deck-reports')
|
||||
@@ -254,9 +243,6 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to persist sim results: {e}')
|
||||
|
||||
# --- Flush ML decisions JSONL for training pipeline ---
|
||||
# NOTE: DeepSeek thread flushes manually before calling _finish_sim(engine=None)
|
||||
# to avoid a double-flush that would produce an empty .jsonl file.
|
||||
if engine is not None and hasattr(engine, 'flush_ml_decisions'):
|
||||
ml_decisions = engine.flush_ml_decisions()
|
||||
if ml_decisions:
|
||||
@@ -269,18 +255,13 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
||||
|
||||
|
||||
def _fail_sim(sim_id, error):
|
||||
"""Mark a sim run as errored."""
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'error'
|
||||
_sim_runs[sim_id]['error'] = str(error)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Background Sim Threads (now thin wrappers around shared helpers)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _run_sim_thread(sim_id: str, decklist: list, num_games: int,
|
||||
deck_name: str, record_logs: bool):
|
||||
"""Background thread: basic Monte Carlo sim from a raw decklist."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
@@ -307,7 +288,6 @@ def _run_sim_thread(sim_id: str, decklist: list, num_games: int,
|
||||
|
||||
def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int,
|
||||
deck_name: str, record_logs: bool):
|
||||
"""Background thread: sim with full card data from the DB."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
@@ -335,7 +315,6 @@ def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int,
|
||||
def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
|
||||
num_games: int, deck_name: str,
|
||||
record_logs: bool):
|
||||
"""Background thread: sim using DeepSeek AI opponent."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
|
||||
@@ -351,7 +330,7 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
|
||||
if brain and not brain._connected:
|
||||
brain.check_connection()
|
||||
|
||||
validator = _get_validator_brain() # None if disabled
|
||||
validator = _get_validator_brain()
|
||||
engine = DeepSeekGameEngine(
|
||||
brain=brain, ai_player_index=1,
|
||||
max_turns=25, record_log=record_logs, ml_log=True,
|
||||
@@ -363,8 +342,6 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
|
||||
game_id_prefix=f'ds-sim-{sim_id[:8]}',
|
||||
archetype='midrange')
|
||||
|
||||
# Flush ML decisions once here — pass engine=None to _finish_sim
|
||||
# to prevent a second flush that would produce an empty .jsonl file.
|
||||
ml_decisions = engine.flush_ml_decisions()
|
||||
if ml_decisions and brain and brain._connected:
|
||||
ml_path = os.path.join('results',
|
||||
@@ -387,21 +364,15 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
|
||||
summary = _build_summary(
|
||||
stats, deck_name, 'DeepSeek AI', num_games, elapsed,
|
||||
opponentType='deepseek', deepseekStats=ds_stats)
|
||||
# engine=None: ML decisions already flushed above — do not flush again
|
||||
_finish_sim(sim_id, summary, game_results, engine=None)
|
||||
except Exception as e:
|
||||
_fail_sim(sim_id, e)
|
||||
traceback.print_exc()
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# N-Player Sim Thread
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _run_sim_thread_n_player(sim_id: str, decks_data: list[list[dict]],
|
||||
deck_names: list[str], num_games: int,
|
||||
record_logs: bool, engine_type: str):
|
||||
"""Background thread: N-player (2-4) batch simulation."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
@@ -423,7 +394,6 @@ def _run_sim_thread_n_player(sim_id: str, decks_data: list[list[dict]],
|
||||
else:
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
|
||||
|
||||
# Per-seat accumulators
|
||||
seat_wins = [0] * n_players
|
||||
seat_turns = [0] * n_players
|
||||
seat_damage_dealt = [0] * n_players
|
||||
@@ -493,13 +463,7 @@ def _run_sim_thread_n_player(sim_id: str, decks_data: list[list[dict]],
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Simulation API Endpoints
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _create_sim_run(num_games, deck_name):
|
||||
"""Create a queued sim run entry and return its ID."""
|
||||
sim_id = str(_uuid.uuid4())[:8]
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id] = {
|
||||
@@ -521,31 +485,42 @@ def _fetch_deck_card_data(deck_id):
|
||||
deck_name = row[0]
|
||||
cur.execute("""
|
||||
SELECT dc.card_name, dc.quantity,
|
||||
ce.type_line, ce.cmc, ce.power, ce.toughness,
|
||||
ce.oracle_text, ce.keywords, ce.mana_cost
|
||||
COALESCE(ce.type_line, cr.type_line, '') as type_line,
|
||||
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
|
||||
ce.power, ce.toughness,
|
||||
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
|
||||
COALESCE(ce.keywords, cr.keywords, '') as keywords,
|
||||
COALESCE(ce.mana_cost, '') as mana_cost
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, cmc, power, toughness,
|
||||
oracle_text, keywords, mana_cost
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
LEFT JOIN (
|
||||
SELECT name, type_line, cmc, oracle_text, keywords
|
||||
FROM card_records GROUP BY name
|
||||
) cr ON cr.name = dc.card_name
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,))
|
||||
card_data = []
|
||||
for r in cur.fetchall():
|
||||
for _ in range(r[1] or 1):
|
||||
card_data.append({
|
||||
'name': r[0], 'type_line': r[2] or '',
|
||||
'cmc': r[3] or 0, 'power': r[4] or '',
|
||||
'toughness': r[5] or '', 'oracle_text': r[6] or '',
|
||||
'keywords': r[7] or '', 'mana_cost': r[8] or '',
|
||||
'name': r[0],
|
||||
'type_line': r[2] or '',
|
||||
'cmc': r[3] or 0,
|
||||
'power': r[4] or '',
|
||||
'toughness': r[5] or '',
|
||||
'oracle_text': r[6] or '',
|
||||
'keywords': r[7] or '',
|
||||
'mana_cost': r[8] or '',
|
||||
})
|
||||
return deck_name, card_data
|
||||
|
||||
|
||||
@router.post('/api/sim/run')
|
||||
async def sim_run(request: FastAPIRequest):
|
||||
"""Start a Monte Carlo simulation with the Python engine."""
|
||||
body = await request.json()
|
||||
decklist = body.get('decklist', [])
|
||||
num_games = body.get('numGames', 10)
|
||||
@@ -566,7 +541,6 @@ async def sim_run(request: FastAPIRequest):
|
||||
|
||||
@router.get('/api/sim/status')
|
||||
async def sim_status(simId: str):
|
||||
"""Poll simulation progress."""
|
||||
with _sim_lock:
|
||||
run = _sim_runs.get(simId)
|
||||
if not run:
|
||||
@@ -580,7 +554,6 @@ async def sim_status(simId: str):
|
||||
|
||||
@router.get('/api/sim/result')
|
||||
async def sim_result(simId: str):
|
||||
"""Get completed simulation results."""
|
||||
with _sim_lock:
|
||||
run = _sim_runs.get(simId)
|
||||
if not run:
|
||||
@@ -593,7 +566,6 @@ async def sim_result(simId: str):
|
||||
|
||||
@router.post('/api/sim/run-from-deck')
|
||||
async def sim_run_from_deck(request: FastAPIRequest):
|
||||
"""Start simulation using a deck from the Deck Builder (by deck ID)."""
|
||||
body = await request.json()
|
||||
deck_id = body.get('deckId')
|
||||
num_games = body.get('numGames', 10)
|
||||
@@ -618,7 +590,6 @@ async def sim_run_from_deck(request: FastAPIRequest):
|
||||
|
||||
@router.post('/api/sim/run-deepseek')
|
||||
async def sim_run_deepseek(request: FastAPIRequest):
|
||||
"""Start simulation using DeepSeek AI as the opponent brain."""
|
||||
body = await request.json()
|
||||
deck_id = body.get('deckId')
|
||||
num_games = body.get('numGames', 5)
|
||||
@@ -647,7 +618,6 @@ async def sim_run_deepseek(request: FastAPIRequest):
|
||||
|
||||
@router.post('/api/sim/run-n-player')
|
||||
async def sim_run_n_player(request: FastAPIRequest):
|
||||
"""Start an N-player (2-4) batch simulation."""
|
||||
body = await request.json()
|
||||
deck_ids = body.get('deckIds', [])
|
||||
num_games = body.get('numGames', 10)
|
||||
@@ -681,14 +651,8 @@ async def sim_run_n_player(request: FastAPIRequest):
|
||||
})
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# DeepSeek AI Brain Endpoints
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post('/api/deepseek/connect')
|
||||
async def deepseek_connect(request: FastAPIRequest):
|
||||
"""Test connection to the DeepSeek LLM endpoint and auto-detect model."""
|
||||
body = await request.json() if await request.body() else {}
|
||||
api_base = body.get('apiBase', None)
|
||||
model = body.get('model', None)
|
||||
@@ -714,7 +678,6 @@ async def deepseek_connect(request: FastAPIRequest):
|
||||
|
||||
@router.get('/api/deepseek/status')
|
||||
async def deepseek_status():
|
||||
"""Get DeepSeek brain status and performance stats."""
|
||||
brain = _get_deepseek_brain()
|
||||
if brain is None:
|
||||
return JSONResponse({'connected': False, 'error': 'Brain not initialized'})
|
||||
@@ -723,7 +686,6 @@ async def deepseek_status():
|
||||
|
||||
@router.post('/api/deepseek/configure')
|
||||
async def deepseek_configure(request: FastAPIRequest):
|
||||
"""Update DeepSeek configuration at runtime."""
|
||||
body = await request.json()
|
||||
brain = _get_deepseek_brain()
|
||||
if brain is None:
|
||||
@@ -764,7 +726,6 @@ async def deepseek_configure(request: FastAPIRequest):
|
||||
|
||||
@router.get('/api/deepseek/logs')
|
||||
async def deepseek_logs():
|
||||
"""Get decision log stats and flush pending entries."""
|
||||
brain = _get_deepseek_brain()
|
||||
if brain is None:
|
||||
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
|
||||
@@ -799,7 +760,6 @@ async def deepseek_logs():
|
||||
|
||||
@router.get("/api/validator/status")
|
||||
async def validator_status():
|
||||
"""Get DeepSeek-R1-14B validator brain status."""
|
||||
v = _get_validator_brain()
|
||||
if v is None:
|
||||
return JSONResponse({"enabled": False,
|
||||
@@ -809,7 +769,6 @@ async def validator_status():
|
||||
|
||||
@router.post("/api/validator/configure")
|
||||
async def validator_configure(request: FastAPIRequest):
|
||||
"""Update validator configuration at runtime."""
|
||||
body = await request.json()
|
||||
v = _get_validator_brain()
|
||||
if v is None:
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
"""Live game session routes.
|
||||
|
||||
Endpoints:
|
||||
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 logging
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
log = logging.getLogger("routes.game")
|
||||
|
||||
router = APIRouter(tags=["game"])
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# Request / Response models
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
class SeatConfig(BaseModel):
|
||||
seat_index: int = 0
|
||||
seat_type: str = "AI" # "Human" | "AI Aggro" | "AI Control" | "AI Combo" | "AI Political"
|
||||
deck_name: str = ""
|
||||
player_name: str = ""
|
||||
|
||||
|
||||
class StartGameRequest(BaseModel):
|
||||
seats: List[SeatConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StartGameResponse(BaseModel):
|
||||
game_id: str
|
||||
status: str = "started"
|
||||
ws_url: str
|
||||
|
||||
|
||||
class GameActionRequest(BaseModel):
|
||||
game_id: str
|
||||
seat: int = 0
|
||||
action: str # "pass_priority" | "end_turn" | "declare_attackers" |
|
||||
# "declare_blockers" | "select_target"
|
||||
target_id: Optional[str] = None
|
||||
extra: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GameActionResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
game_id: str
|
||||
action: str
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# In-memory session store (replace with DB / Redis for persistence)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
# { 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 a new live game session.
|
||||
|
||||
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}.
|
||||
|
||||
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.")
|
||||
|
||||
game_id = uuid.uuid4().hex
|
||||
|
||||
_sessions[game_id] = {
|
||||
"game_id": game_id,
|
||||
"seats": [s.dict() for s in req.seats],
|
||||
"status": "active",
|
||||
"pending_actions": [],
|
||||
}
|
||||
|
||||
log.info(
|
||||
"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}",
|
||||
)
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# 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 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",
|
||||
"declare_attackers", "declare_blockers",
|
||||
"select_target",
|
||||
}
|
||||
|
||||
if req.action not in valid_actions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown action '{req.action}'. Valid: {sorted(valid_actions)}",
|
||||
)
|
||||
|
||||
if req.action == "select_target" and not req.target_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="select_target requires a target_id.",
|
||||
)
|
||||
|
||||
session = _sessions.get(req.game_id)
|
||||
if session is None:
|
||||
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 session '{req.game_id}' is not active (status={session['status']}).",
|
||||
)
|
||||
|
||||
action_record = {
|
||||
"seat": req.seat,
|
||||
"action": req.action,
|
||||
"target_id": req.target_id,
|
||||
"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 endpoint)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/game/{game_id}/actions")
|
||||
async def poll_actions(game_id: str):
|
||||
"""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:
|
||||
raise HTTPException(status_code=404, detail=f"Game session '{game_id}' not found.")
|
||||
|
||||
actions = session["pending_actions"].copy()
|
||||
session["pending_actions"].clear()
|
||||
return {"game_id": game_id, "actions": actions}
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# GET /api/game/sessions (debug)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/game/sessions")
|
||||
async def list_sessions():
|
||||
"""List active game sessions (debug endpoint)."""
|
||||
return {
|
||||
"count": len(_sessions),
|
||||
"sessions": [
|
||||
{"game_id": gid, "status": s["status"], "seats": len(s["seats"])}
|
||||
for gid, s in _sessions.items()
|
||||
],
|
||||
}
|
||||
+2
-2
@@ -66,8 +66,8 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||
raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled")
|
||||
if req.numGames < 1 or req.numGames > 10000:
|
||||
raise HTTPException(400, "numGames must be 1-10000")
|
||||
if req.threads < 1 or req.threads > 16:
|
||||
raise HTTPException(400, "threads must be 1-16")
|
||||
if req.threads < 1 or req.threads > 32:
|
||||
raise HTTPException(400, "threads must be 1-32")
|
||||
|
||||
if not os.path.exists(CFG.forge_jar):
|
||||
raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}")
|
||||
|
||||
+15
-6
@@ -1097,7 +1097,7 @@ _elo_tournament_state = _EloTournamentState()
|
||||
_elo_tournament_lock = threading.Lock()
|
||||
|
||||
|
||||
def _run_elo_tournament_pipeline(episodes: int, playstyle: str):
|
||||
def _run_elo_tournament_pipeline(episodes: int, playstyle: str, top_n: int):
|
||||
"""Run ELO tournament in a background thread."""
|
||||
try:
|
||||
import sys
|
||||
@@ -1108,7 +1108,7 @@ def _run_elo_tournament_pipeline(episodes: int, playstyle: str):
|
||||
with _elo_tournament_lock:
|
||||
_elo_tournament_state.running = True
|
||||
_elo_tournament_state.phase = "running"
|
||||
_elo_tournament_state.message = "Running ELO tournament..."
|
||||
_elo_tournament_state.message = f"Running ELO tournament (top {top_n} generations)..."
|
||||
_elo_tournament_state.error = None
|
||||
_elo_tournament_state.result = None
|
||||
|
||||
@@ -1121,6 +1121,7 @@ def _run_elo_tournament_pipeline(episodes: int, playstyle: str):
|
||||
checkpoint_dir=ckpt_dir,
|
||||
episodes_per_matchup=episodes,
|
||||
playstyle=playstyle,
|
||||
top_n=top_n,
|
||||
)
|
||||
|
||||
# Save to ELO history
|
||||
@@ -1155,29 +1156,37 @@ def _run_elo_tournament_pipeline(episodes: int, playstyle: str):
|
||||
|
||||
@router.post("/api/ml/elo/tournament")
|
||||
async def ml_start_elo_tournament(request: FastAPIRequest):
|
||||
"""Start an ELO-rated tournament across all generation checkpoints."""
|
||||
"""Start an ELO-rated tournament across generation checkpoints.
|
||||
|
||||
Body params:
|
||||
episodes (int): Games per matchup pair (default 50)
|
||||
playstyle (str): Game playstyle (default midrange)
|
||||
topN (int): Max number of most-recent gen checkpoints to include
|
||||
(default 10, 0 = unlimited)
|
||||
"""
|
||||
if _elo_tournament_state.running:
|
||||
raise HTTPException(409, "ELO tournament already in progress")
|
||||
|
||||
body = await request.json() if await request.body() else {}
|
||||
episodes = body.get("episodes", 50)
|
||||
playstyle = body.get("playstyle", "midrange")
|
||||
top_n = body.get("topN", 10)
|
||||
|
||||
with _elo_tournament_lock:
|
||||
_elo_tournament_state.running = True
|
||||
_elo_tournament_state.phase = "starting"
|
||||
_elo_tournament_state.message = "Initializing ELO tournament..."
|
||||
_elo_tournament_state.message = f"Initializing ELO tournament (top {top_n} generations)..."
|
||||
_elo_tournament_state.result = None
|
||||
_elo_tournament_state.error = None
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run_elo_tournament_pipeline,
|
||||
args=(episodes, playstyle),
|
||||
args=(episodes, playstyle, top_n),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return {"status": "started", "episodes": episodes}
|
||||
return {"status": "started", "episodes": episodes, "top_n": top_n}
|
||||
|
||||
|
||||
@router.get("/api/ml/elo/tournament/status")
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
routes/rag.py
|
||||
=============
|
||||
RAG (Retrieval-Augmented Generation) endpoints for semantic card search.
|
||||
|
||||
Endpoints:
|
||||
POST /api/rag/build — build / rebuild the ChromaDB vector index
|
||||
POST /api/rag/search — semantic search over indexed cards
|
||||
GET /api/rag/status — index health & stats
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.routes.rag")
|
||||
|
||||
router = APIRouter(tags=["rag"])
|
||||
|
||||
|
||||
# ── Request / Response models ────────────────────────────────────────────────
|
||||
|
||||
class RAGSearchRequest(BaseModel):
|
||||
query: str = Field(..., description="Natural-language card search query")
|
||||
color_identity: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="Optional color identity filter, e.g. ['W','U','B']",
|
||||
)
|
||||
top_k: int = Field(10, ge=1, le=50, description="Number of results to return")
|
||||
|
||||
|
||||
class RAGCardResult(BaseModel):
|
||||
name: str
|
||||
type_line: str = ""
|
||||
oracle_text: str = ""
|
||||
color_identity: str = ""
|
||||
score: float = 0.0
|
||||
|
||||
|
||||
class RAGSearchResponse(BaseModel):
|
||||
query: str
|
||||
results: List[RAGCardResult]
|
||||
total: int
|
||||
|
||||
|
||||
class RAGBuildRequest(BaseModel):
|
||||
force: bool = Field(False, description="Force full rebuild even if index exists")
|
||||
batch_size: int = Field(500, ge=50, le=5000)
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/rag/build")
|
||||
async def rag_build(body: RAGBuildRequest = RAGBuildRequest()):
|
||||
"""
|
||||
Build or rebuild the ChromaDB vector index from the Scryfall bulk DB.
|
||||
Runs synchronously (may take 1-5 min on first build).
|
||||
"""
|
||||
try:
|
||||
from services.rag_store import build_index
|
||||
result = build_index(force=body.force, batch_size=body.batch_size)
|
||||
return {"status": "ok", **result}
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except Exception as exc:
|
||||
log.exception("RAG build failed")
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/api/rag/search")
|
||||
async def rag_search(body: RAGSearchRequest):
|
||||
"""
|
||||
Semantic search over the ChromaDB card index.
|
||||
Returns ranked card results with relevance scores.
|
||||
"""
|
||||
try:
|
||||
from services.rag_store import query_cards
|
||||
raw_results = query_cards(
|
||||
query=body.query,
|
||||
n_results=body.top_k,
|
||||
color_identity=body.color_identity,
|
||||
)
|
||||
results = [
|
||||
RAGCardResult(
|
||||
name=r.get("name", ""),
|
||||
type_line=r.get("type_line", ""),
|
||||
oracle_text=r.get("oracle_text", ""),
|
||||
color_identity=r.get("color_identity", ""),
|
||||
score=round(1.0 - r.get("distance", 0.0), 4),
|
||||
)
|
||||
for r in raw_results
|
||||
]
|
||||
return RAGSearchResponse(
|
||||
query=body.query,
|
||||
results=results,
|
||||
total=len(results),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Index not built yet. Call POST /api/rag/build first. {exc}",
|
||||
)
|
||||
except Exception as exc:
|
||||
log.exception("RAG search failed")
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/api/rag/status")
|
||||
async def rag_status_detail():
|
||||
"""
|
||||
Detailed RAG index status including card count and age.
|
||||
"""
|
||||
try:
|
||||
from services.rag_store import get_rag_stats
|
||||
return {"status": "ok", **get_rag_stats()}
|
||||
except Exception as exc:
|
||||
log.exception("RAG status check failed")
|
||||
return {"status": "error", "error": str(exc)}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""WebSocket route for real-time game state streaming.
|
||||
|
||||
Endpoints:
|
||||
WS /ws/game/{game_id} — Stream state deltas to Unity
|
||||
POST /api/ws/game/{game_id}/push — Push a state update (called by Forge IPC)
|
||||
POST /api/ws/game/{game_id}/decision — Push AI decision event
|
||||
POST /api/ws/game/{game_id}/end — Signal game over
|
||||
GET /api/ws/stats — Connection statistics
|
||||
|
||||
Unity connects to the WebSocket endpoint and receives:
|
||||
1. An initial full snapshot on first state push
|
||||
2. Subsequent delta-only messages as state changes
|
||||
3. Decision events when the AI acts
|
||||
4. A game_over event when the game ends
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ws.connection_manager import game_manager
|
||||
from ws.game_state import get_tracker, remove_tracker
|
||||
|
||||
log = logging.getLogger("routes.ws_game")
|
||||
|
||||
router = APIRouter(tags=["websocket"])
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# WebSocket endpoint — Unity connects here
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.websocket("/ws/game/{game_id}")
|
||||
async def game_ws(websocket: WebSocket, game_id: str):
|
||||
"""WebSocket endpoint for Unity game-state streaming.
|
||||
|
||||
Query params:
|
||||
client_id — optional unique identifier for this client
|
||||
|
||||
The connection stays open for the duration of the game.
|
||||
The server pushes messages; the client can send:
|
||||
{"type": "ping"} — keep-alive
|
||||
{"type": "request_snapshot"} — request a full re-sync
|
||||
"""
|
||||
client_id = (
|
||||
websocket.query_params.get("client_id")
|
||||
or f"unity-{uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
|
||||
client = await game_manager.connect(websocket, game_id, client_id)
|
||||
|
||||
# If the tracker already has a baseline, send the current state
|
||||
tracker = get_tracker(game_id)
|
||||
if tracker.has_baseline:
|
||||
snapshot_msg = tracker.update(tracker._last_snapshot)
|
||||
await websocket.send_json(snapshot_msg)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
msg_type = data.get("type", "")
|
||||
|
||||
if msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
elif msg_type == "request_snapshot":
|
||||
if tracker.has_baseline:
|
||||
snap = copy.deepcopy(tracker._last_snapshot)
|
||||
tracker._seq += 1
|
||||
await websocket.send_json({
|
||||
"type": "snapshot",
|
||||
"seq": tracker._seq,
|
||||
"game_id": game_id,
|
||||
"timestamp": time.time(),
|
||||
"state": snap,
|
||||
})
|
||||
else:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": "No game state available yet",
|
||||
})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning("WS error game=%s client=%s: %s", game_id, client_id, e)
|
||||
finally:
|
||||
await game_manager.disconnect(game_id, client_id)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# REST endpoints — called by Forge IPC / policy server
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
class PushStateRequest(BaseModel):
|
||||
"""Game state snapshot pushed from Forge."""
|
||||
game_id: str = ""
|
||||
turn: int = 0
|
||||
phase: str = ""
|
||||
active_player: int = 0
|
||||
players: list = Field(default_factory=list)
|
||||
stack: list = Field(default_factory=list)
|
||||
legal_actions: list = Field(default_factory=list)
|
||||
|
||||
|
||||
class DecisionEvent(BaseModel):
|
||||
"""AI decision event pushed after policy inference."""
|
||||
action: str
|
||||
action_index: int = 0
|
||||
confidence: float = 0.0
|
||||
probabilities: dict = Field(default_factory=dict)
|
||||
inference_ms: float = 0.0
|
||||
|
||||
|
||||
class GameOverEvent(BaseModel):
|
||||
"""Game-over event."""
|
||||
winner_seat: int = -1
|
||||
reason: str = ""
|
||||
turns_played: int = 0
|
||||
|
||||
|
||||
@router.post("/api/ws/game/{game_id}/push")
|
||||
async def push_game_state(game_id: str, req: PushStateRequest):
|
||||
"""Push a game state update to all connected Unity clients."""
|
||||
tracker = get_tracker(game_id)
|
||||
snapshot = req.dict()
|
||||
delta_msg = tracker.update(snapshot)
|
||||
sent = await game_manager.broadcast_to_game(game_id, delta_msg)
|
||||
return {
|
||||
"status": "ok",
|
||||
"type": delta_msg["type"],
|
||||
"seq": delta_msg["seq"],
|
||||
"clients_notified": sent,
|
||||
"changes": len(delta_msg.get("changes", [])),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/ws/game/{game_id}/decision")
|
||||
async def push_decision(game_id: str, event: DecisionEvent):
|
||||
"""Push an AI decision event to connected Unity clients."""
|
||||
tracker = get_tracker(game_id)
|
||||
msg = tracker.make_decision_event(
|
||||
action=event.action,
|
||||
action_index=event.action_index,
|
||||
confidence=event.confidence,
|
||||
probabilities=event.probabilities,
|
||||
inference_ms=event.inference_ms,
|
||||
)
|
||||
sent = await game_manager.broadcast_to_game(game_id, msg)
|
||||
return {"status": "ok", "clients_notified": sent}
|
||||
|
||||
|
||||
@router.post("/api/ws/game/{game_id}/end")
|
||||
async def push_game_over(game_id: str, event: GameOverEvent):
|
||||
"""Signal game over to all connected clients and clean up."""
|
||||
tracker = get_tracker(game_id)
|
||||
msg = tracker.make_game_over_event(
|
||||
winner_seat=event.winner_seat,
|
||||
reason=event.reason,
|
||||
turns_played=event.turns_played,
|
||||
)
|
||||
sent = await game_manager.broadcast_to_game(game_id, msg)
|
||||
remove_tracker(game_id)
|
||||
return {"status": "ok", "clients_notified": sent}
|
||||
|
||||
|
||||
@router.get("/api/ws/stats")
|
||||
async def ws_stats():
|
||||
"""Return WebSocket connection statistics."""
|
||||
return game_manager.stats()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Async SQLite wrapper for FastAPI — eliminates event-loop blocking.
|
||||
|
||||
Drop-in async replacements for the sync helpers in services/database.py.
|
||||
Uses aiosqlite under the hood so every DB call yields back to the
|
||||
event loop instead of freezing it.
|
||||
|
||||
Usage in route handlers:
|
||||
from services.async_db import async_get_db, async_execute, async_fetchall
|
||||
|
||||
rows = await async_fetchall("SELECT * FROM decks")
|
||||
row = await async_fetchone("SELECT * FROM decks WHERE id = ?", (1,))
|
||||
await async_execute("INSERT INTO ...", params)
|
||||
await async_execute_commit("DELETE FROM ...", params)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.async_db")
|
||||
|
||||
# Re-use the same DB path as the sync module
|
||||
COLLECTION_DB_PATH = Path(__file__).parent.parent / "collection.db"
|
||||
|
||||
# Module-level connection (lazy-init, one per process)
|
||||
_aconn: Optional[aiosqlite.Connection] = None
|
||||
|
||||
|
||||
async def async_get_db() -> aiosqlite.Connection:
|
||||
"""Return (or create) the module-level async SQLite connection."""
|
||||
global _aconn
|
||||
if _aconn is None:
|
||||
_aconn = await aiosqlite.connect(str(COLLECTION_DB_PATH))
|
||||
_aconn.row_factory = aiosqlite.Row
|
||||
await _aconn.execute("PRAGMA journal_mode=WAL")
|
||||
log.info("Async SQLite connection opened: %s", COLLECTION_DB_PATH)
|
||||
return _aconn
|
||||
|
||||
|
||||
async def async_close_db() -> None:
|
||||
"""Close the module-level connection (call on shutdown)."""
|
||||
global _aconn
|
||||
if _aconn is not None:
|
||||
await _aconn.close()
|
||||
_aconn = None
|
||||
log.info("Async SQLite connection closed.")
|
||||
|
||||
|
||||
# ── Convenience helpers ─────────────────────────────────────────
|
||||
|
||||
async def async_fetchall(
|
||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
||||
) -> list[dict]:
|
||||
"""Execute a SELECT and return all rows as dicts."""
|
||||
db = conn or await async_get_db()
|
||||
async with db.execute(sql, params) as cursor:
|
||||
columns = [d[0] for d in cursor.description]
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(zip(columns, row)) for row in rows]
|
||||
|
||||
|
||||
async def async_fetchone(
|
||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
||||
) -> Optional[dict]:
|
||||
"""Execute a SELECT and return one row as a dict (or None)."""
|
||||
db = conn or await async_get_db()
|
||||
async with db.execute(sql, params) as cursor:
|
||||
columns = [d[0] for d in cursor.description]
|
||||
row = await cursor.fetchone()
|
||||
return dict(zip(columns, row)) if row else None
|
||||
|
||||
|
||||
async def async_execute(
|
||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
||||
) -> int:
|
||||
"""Execute a write statement. Returns lastrowid."""
|
||||
db = conn or await async_get_db()
|
||||
cursor = await db.execute(sql, params)
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def async_execute_commit(
|
||||
sql: str, params: tuple = (), *, conn: aiosqlite.Connection = None
|
||||
) -> int:
|
||||
"""Execute a write statement and commit. Returns lastrowid."""
|
||||
db = conn or await async_get_db()
|
||||
cursor = await db.execute(sql, params)
|
||||
await db.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def async_executemany_commit(
|
||||
sql: str, params_seq, *, conn: aiosqlite.Connection = None
|
||||
) -> None:
|
||||
"""Execute many and commit."""
|
||||
db = conn or await async_get_db()
|
||||
await db.executemany(sql, params_seq)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def async_commit(*, conn: aiosqlite.Connection = None) -> None:
|
||||
"""Commit the current transaction."""
|
||||
db = conn or await async_get_db()
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── JSON field helpers (mirrors database._row_to_dict) ──────────
|
||||
|
||||
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
|
||||
|
||||
|
||||
def row_to_dict_parsed(row: dict) -> dict:
|
||||
"""Parse JSON string columns into Python lists (same as database._row_to_dict)."""
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
for f in _JSON_FIELDS:
|
||||
if f in d and isinstance(d[f], str):
|
||||
try:
|
||||
d[f] = json.loads(d[f])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
d[f] = []
|
||||
return d
|
||||
|
||||
|
||||
# ── Coach session async helpers ─────────────────────────────────
|
||||
|
||||
async def async_save_coach_session(session_data: dict) -> int:
|
||||
"""Insert or update a coach session. Returns row id."""
|
||||
db = await async_get_db()
|
||||
existing = await async_fetchone(
|
||||
"SELECT id FROM coach_sessions WHERE session_id = ?",
|
||||
(session_data["session_id"],),
|
||||
)
|
||||
if existing:
|
||||
await db.execute(
|
||||
"""UPDATE coach_sessions SET
|
||||
deck_id=?, timestamp=?, model_used=?, prompt_tokens=?,
|
||||
completion_tokens=?, summary=?, goals_json=?, cuts_json=?,
|
||||
adds_json=?, heuristic_hints_json=?, mana_base_advice=?,
|
||||
raw_text=?
|
||||
WHERE session_id=?""",
|
||||
(
|
||||
session_data.get("deck_id", ""),
|
||||
session_data.get("timestamp", ""),
|
||||
session_data.get("model_used", ""),
|
||||
session_data.get("prompt_tokens", 0),
|
||||
session_data.get("completion_tokens", 0),
|
||||
session_data.get("summary", ""),
|
||||
session_data.get("goals_json", "{}"),
|
||||
session_data.get("cuts_json", "[]"),
|
||||
session_data.get("adds_json", "[]"),
|
||||
session_data.get("heuristic_hints_json", "[]"),
|
||||
session_data.get("mana_base_advice", ""),
|
||||
session_data.get("raw_text", ""),
|
||||
session_data["session_id"],
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return existing["id"]
|
||||
else:
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO coach_sessions
|
||||
(session_id, deck_id, timestamp, model_used, prompt_tokens,
|
||||
completion_tokens, summary, goals_json, cuts_json, adds_json,
|
||||
heuristic_hints_json, mana_base_advice, raw_text)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
session_data["session_id"],
|
||||
session_data.get("deck_id", ""),
|
||||
session_data.get("timestamp", ""),
|
||||
session_data.get("model_used", ""),
|
||||
session_data.get("prompt_tokens", 0),
|
||||
session_data.get("completion_tokens", 0),
|
||||
session_data.get("summary", ""),
|
||||
session_data.get("goals_json", "{}"),
|
||||
session_data.get("cuts_json", "[]"),
|
||||
session_data.get("adds_json", "[]"),
|
||||
session_data.get("heuristic_hints_json", "[]"),
|
||||
session_data.get("mana_base_advice", ""),
|
||||
session_data.get("raw_text", ""),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def async_load_coach_session(session_id: str) -> Optional[dict]:
|
||||
"""Load a single coach session by session_id."""
|
||||
return await async_fetchone(
|
||||
"SELECT * FROM coach_sessions WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
|
||||
|
||||
async def async_list_coach_sessions(deck_id: str = None) -> list[dict]:
|
||||
"""List coach sessions, optionally filtered by deck_id."""
|
||||
if deck_id:
|
||||
return await async_fetchall(
|
||||
"SELECT session_id, deck_id, timestamp, summary, created_at "
|
||||
"FROM coach_sessions WHERE deck_id = ? ORDER BY timestamp DESC",
|
||||
(deck_id,),
|
||||
)
|
||||
return await async_fetchall(
|
||||
"SELECT session_id, deck_id, timestamp, summary, created_at "
|
||||
"FROM coach_sessions ORDER BY timestamp DESC"
|
||||
)
|
||||
|
||||
|
||||
async def async_delete_coach_session(session_id: str) -> bool:
|
||||
"""Delete a coach session. Returns True if a row was deleted."""
|
||||
db = await async_get_db()
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM coach_sessions WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
@@ -15,6 +15,14 @@ def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
|
||||
kw_raw = []
|
||||
kw = " ".join(str(k).lower() for k in kw_raw)
|
||||
|
||||
# Land check must come first — basic lands have empty oracle_text so every
|
||||
# other check below would silently miss them, leaving them as Unknown role.
|
||||
if "land" in tl:
|
||||
roles.append("Land")
|
||||
if not ot:
|
||||
# Basic lands have no oracle text; no other role applies.
|
||||
return roles
|
||||
|
||||
if ("add {" in ot or "add one mana" in ot
|
||||
or ("search your library for a" in ot and "land" in ot)
|
||||
or "treasure token" in ot or "add mana" in ot):
|
||||
@@ -78,4 +86,4 @@ def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
|
||||
or "take an extra" in ot
|
||||
or ("double" in ot and ("damage" in ot or "counters" in ot or "tokens" in ot))):
|
||||
roles.append("Combo")
|
||||
return roles
|
||||
return roles
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
services/card_text.py
|
||||
=====================
|
||||
Shared utility for building card text representations.
|
||||
|
||||
Both services/rag_store.py and coach/embeddings.py need to produce
|
||||
identical text strings for embedding so that vector similarity is
|
||||
consistent across both systems. This module provides a single
|
||||
canonical implementation.
|
||||
|
||||
Format: Name | Mana Cost | Type | Oracle Text
|
||||
(fields that are empty/missing are omitted)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def build_card_text(card: dict) -> str:
|
||||
"""Build the canonical text representation of a card for embedding.
|
||||
|
||||
Args:
|
||||
card: Dict with keys 'name', 'mana_cost', 'type_line', 'oracle_text'.
|
||||
Missing keys are treated as empty strings.
|
||||
|
||||
Returns:
|
||||
Pipe-separated string like 'Sol Ring | {1} | Artifact | {T}: Add {C}{C}.'
|
||||
"""
|
||||
parts = [card.get("name", "") or ""]
|
||||
mana_cost = card.get("mana_cost", "") or ""
|
||||
if mana_cost:
|
||||
parts.append(mana_cost)
|
||||
type_line = card.get("type_line", "") or ""
|
||||
if type_line:
|
||||
parts.append(type_line)
|
||||
oracle = card.get("oracle_text", "") or ""
|
||||
if oracle:
|
||||
parts.append(oracle)
|
||||
return " | ".join(parts)
|
||||
+297
-266
@@ -1,266 +1,297 @@
|
||||
"""Deck service: .dck parsing, deck profile saving, deck loading."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from models.state import CFG
|
||||
from services.database import _get_db_conn, _row_to_dict
|
||||
|
||||
log_collect = logging.getLogger("lab.collection")
|
||||
|
||||
|
||||
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
|
||||
_TYPE_TARGETS = {
|
||||
"Land": [36, 38],
|
||||
"Instant": [9, 11],
|
||||
"Sorcery": [7, 9],
|
||||
"Artifact": [9, 11],
|
||||
"Creature": [20, 30],
|
||||
"Enchantment": [5, 10],
|
||||
"Planeswalker": [0, 5],
|
||||
}
|
||||
|
||||
|
||||
def _classify_card_type(type_line: str) -> str:
|
||||
tl = type_line or ""
|
||||
for t in _TYPE_PRIORITY:
|
||||
if t in tl:
|
||||
return t
|
||||
return "Other"
|
||||
|
||||
|
||||
def _get_deck_or_404(deck_id: int):
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute("SELECT * FROM decks WHERE id = ?", (deck_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Deck {deck_id} not found")
|
||||
d = dict(row)
|
||||
try:
|
||||
d["color_identity"] = json.loads(d.get("color_identity", "[]"))
|
||||
except Exception:
|
||||
d["color_identity"] = []
|
||||
return d
|
||||
|
||||
|
||||
def _compute_deck_analysis(deck_id: int) -> dict:
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute("""
|
||||
SELECT dc.id, dc.scryfall_id, dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
|
||||
ce.type_line, ce.oracle_text, ce.keywords, ce.cmc, ce.color_identity
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, oracle_text, keywords, cmc, color_identity
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,)).fetchall()
|
||||
counts_by_type = {t: 0 for t in _TYPE_PRIORITY}
|
||||
counts_by_type["Other"] = 0
|
||||
mana_curve = {"0": 0, "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6+": 0}
|
||||
color_pips = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
|
||||
total_cards = 0
|
||||
roles_count = {}
|
||||
for row in rows:
|
||||
r = dict(row)
|
||||
qty = r.get("quantity", 1)
|
||||
total_cards += qty
|
||||
card_type = _classify_card_type(r.get("type_line", ""))
|
||||
counts_by_type[card_type] = counts_by_type.get(card_type, 0) + qty
|
||||
cmc = int(r.get("cmc", 0) or 0)
|
||||
if card_type != "Land":
|
||||
key = str(min(cmc, 6)) if cmc < 6 else "6+"
|
||||
mana_curve[key] = mana_curve.get(key, 0) + qty
|
||||
ci_raw = r.get("color_identity", "[]")
|
||||
if isinstance(ci_raw, str):
|
||||
try:
|
||||
ci_raw = json.loads(ci_raw)
|
||||
except Exception:
|
||||
ci_raw = []
|
||||
for c in (ci_raw or []):
|
||||
if c in color_pips:
|
||||
color_pips[c] += qty
|
||||
role = r.get("role_tag") or "Unknown"
|
||||
roles_count[role] = roles_count.get(role, 0) + qty
|
||||
return {
|
||||
"total_cards": total_cards,
|
||||
"counts_by_type": counts_by_type,
|
||||
"mana_curve": mana_curve,
|
||||
"color_pips": color_pips,
|
||||
"total_cards": total_cards,
|
||||
"roles": roles_count,
|
||||
}
|
||||
|
||||
|
||||
def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool:
|
||||
analysis = _compute_deck_analysis(deck_id)
|
||||
current = analysis["counts_by_type"].get(card_type, 0)
|
||||
target_max = _TYPE_TARGETS.get(card_type, [0, 9999])[1]
|
||||
return (current + count_to_add) <= target_max
|
||||
|
||||
|
||||
def _to_edhrec_slug(name: str) -> str:
|
||||
slug = name.lower()
|
||||
slug = re.sub(r"[',.]" , "", slug)
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
||||
return slug.strip("-")
|
||||
|
||||
|
||||
def parse_dck_file(deck_path: str) -> dict:
|
||||
"""Parse a Forge .dck file into {name, commanders, mainboard, colorIdentity, totalCards}."""
|
||||
path = Path(deck_path)
|
||||
if not path.exists():
|
||||
return {"error": f"File not found: {deck_path}"}
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
name = path.stem
|
||||
section = "main"
|
||||
commanders: dict = {}
|
||||
mainboard: dict = {}
|
||||
commander_name = None
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line == "[metadata]":
|
||||
section = "metadata"; continue
|
||||
if line.startswith("Name="):
|
||||
name = line.split("=", 1)[1].strip(); continue
|
||||
if line == "[Commander]":
|
||||
section = "commander"; continue
|
||||
if line == "[Main]":
|
||||
section = "main"; continue
|
||||
if line.lower().startswith("sideboard") or line == "[Sideboard]":
|
||||
section = "sideboard"; continue
|
||||
clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip()
|
||||
clean = re.sub(r"\s*\*\.\*$", "", clean).strip()
|
||||
m = re.match(r"^(\d+)x?\s+(.+)$", clean)
|
||||
qty, card_name = (int(m.group(1)), m.group(2).strip()) if m else (1, clean)
|
||||
if not card_name:
|
||||
continue
|
||||
if section == "commander":
|
||||
commanders[card_name] = qty
|
||||
if not commander_name:
|
||||
commander_name = card_name
|
||||
elif section != "sideboard":
|
||||
mainboard[card_name] = qty
|
||||
if commander_name:
|
||||
name = f"{commander_name} - Text Import"
|
||||
total = sum(commanders.values()) + sum(mainboard.values())
|
||||
return {"name": name, "commander": commander_name, "commanders": commanders, "mainboard": mainboard, "colorIdentity": [], "totalCards": total}
|
||||
|
||||
|
||||
def _save_profile_to_dck(profile: dict) -> Path:
|
||||
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
|
||||
for name, qty in profile.get("commanders", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
lines += ["", "[Main]"]
|
||||
for name, qty in profile.get("mainboard", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
content = "\n".join(lines)
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", profile.get("name", "imported")).replace(" ", "_").strip()
|
||||
if not safe_name:
|
||||
safe_name = "imported_deck"
|
||||
save_dir = CFG.forge_decks_dir
|
||||
if not save_dir or not os.path.isdir(save_dir):
|
||||
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")
|
||||
log_collect.info(f" Saved .dck: {out_path}")
|
||||
return out_path
|
||||
|
||||
|
||||
def _load_deck_cards_by_name(deck_name: str) -> list:
|
||||
"""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 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")
|
||||
|
||||
|
||||
def _build_dck_lines(
|
||||
deck_name: str,
|
||||
commander_name: str,
|
||||
cards: list[dict],
|
||||
*,
|
||||
resolve_substitutes: bool = False,
|
||||
) -> list[str]:
|
||||
"""Build Forge .dck file lines from deck name, commander, and card list."""
|
||||
lines: list[str] = [
|
||||
"[metadata]",
|
||||
f"Name={deck_name}",
|
||||
"",
|
||||
"[Commander]",
|
||||
f"1 {commander_name}",
|
||||
"",
|
||||
"[Main]",
|
||||
]
|
||||
for card in cards:
|
||||
cname = card.get("name", "")
|
||||
if resolve_substitutes:
|
||||
if card.get("status") == "substituted" and card.get("selected_substitute"):
|
||||
cname = card["selected_substitute"]
|
||||
if cname and cname != commander_name:
|
||||
lines.append(f"{card.get('count', card.get('quantity', 1))} {cname}")
|
||||
return lines
|
||||
|
||||
|
||||
def _write_dck_file(
|
||||
deck_name: str,
|
||||
commander_name: str,
|
||||
cards: list[dict],
|
||||
decks_dir: str | None = None,
|
||||
*,
|
||||
fallback_id: int | str = 0,
|
||||
resolve_substitutes: bool = False,
|
||||
) -> str | None:
|
||||
"""Write a Forge .dck file to decks_dir. Returns path on success, None otherwise."""
|
||||
if not decks_dir:
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if not decks_dir or not os.path.isdir(decks_dir):
|
||||
return None
|
||||
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9_\-\s]", "", deck_name).strip().replace(" ", "_")
|
||||
if not safe_name:
|
||||
safe_name = f"Deck_{fallback_id}"
|
||||
|
||||
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
|
||||
lines = _build_dck_lines(
|
||||
deck_name, commander_name, cards,
|
||||
resolve_substitutes=resolve_substitutes,
|
||||
)
|
||||
with open(dck_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
log_deckgen.info(f" Exported .dck: {dck_path}")
|
||||
return dck_path
|
||||
"""Deck service: .dck parsing, deck profile saving, deck loading."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from models.state import CFG
|
||||
from services.database import _get_db_conn, _row_to_dict
|
||||
|
||||
log_collect = logging.getLogger("lab.collection")
|
||||
|
||||
|
||||
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
|
||||
_TYPE_TARGETS = {
|
||||
"Land": [36, 38],
|
||||
"Instant": [9, 11],
|
||||
"Sorcery": [7, 9],
|
||||
"Artifact": [9, 11],
|
||||
"Creature": [20, 30],
|
||||
"Enchantment": [5, 10],
|
||||
"Planeswalker": [0, 5],
|
||||
}
|
||||
|
||||
|
||||
def _classify_card_type(type_line: str) -> str:
|
||||
tl = type_line or ""
|
||||
for t in _TYPE_PRIORITY:
|
||||
if t in tl:
|
||||
return t
|
||||
return "Other"
|
||||
|
||||
|
||||
_CATEGORY_MAP = {
|
||||
"Ramp": ["search your library for a", "add one mana", "add {c}", "{t}: add"],
|
||||
"Removal": ["destroy target", "exile target", "counter target spell"],
|
||||
"Draw": ["draw a card", "draw two", "draw three", "draw cards"],
|
||||
"Board Wipe": ["destroy all", "exile all", "all creatures get -"],
|
||||
"Protection": ["hexproof", "indestructible", "shroud", "ward"],
|
||||
"Tutor": ["search your library"],
|
||||
}
|
||||
|
||||
|
||||
def _classify_card_category(type_line: str, oracle_text: str) -> str:
|
||||
"""Assign a functional category based on type_line and oracle text."""
|
||||
card_type = _classify_card_type(type_line)
|
||||
if card_type == "Land":
|
||||
return "Land"
|
||||
text = (oracle_text or "").lower()
|
||||
for cat, phrases in _CATEGORY_MAP.items():
|
||||
if any(p in text for p in phrases):
|
||||
return cat
|
||||
if card_type == "Creature":
|
||||
return "Creature"
|
||||
return card_type
|
||||
|
||||
|
||||
def _get_deck_or_404(deck_id: int):
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute("SELECT * FROM decks WHERE id = ?", (deck_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Deck {deck_id} not found")
|
||||
d = dict(row)
|
||||
try:
|
||||
d["color_identity"] = json.loads(d.get("color_identity", "[]"))
|
||||
except Exception:
|
||||
d["color_identity"] = []
|
||||
return d
|
||||
|
||||
|
||||
def _compute_deck_analysis(deck_id: int) -> dict:
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute("""
|
||||
SELECT dc.id, dc.scryfall_id, dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
|
||||
COALESCE(ce.type_line, cr.type_line, '') as type_line,
|
||||
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
|
||||
COALESCE(ce.keywords, cr.keywords, '[]') as keywords,
|
||||
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
|
||||
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, oracle_text, keywords, cmc, color_identity
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
LEFT JOIN (
|
||||
SELECT name, type_line, oracle_text, keywords, cmc, color_identity
|
||||
FROM card_records GROUP BY name
|
||||
) cr ON cr.name = dc.card_name
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,)).fetchall()
|
||||
counts_by_type = {t: 0 for t in _TYPE_PRIORITY}
|
||||
counts_by_type["Other"] = 0
|
||||
mana_curve = {"0": 0, "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6+": 0}
|
||||
color_pips = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
|
||||
total_cards = 0
|
||||
roles_count = {}
|
||||
for row in rows:
|
||||
r = dict(row)
|
||||
qty = r.get("quantity", 1)
|
||||
total_cards += qty
|
||||
card_type = _classify_card_type(r.get("type_line", ""))
|
||||
counts_by_type[card_type] = counts_by_type.get(card_type, 0) + qty
|
||||
cmc = int(r.get("cmc", 0) or 0)
|
||||
if card_type != "Land":
|
||||
key = str(min(cmc, 6)) if cmc < 6 else "6+"
|
||||
mana_curve[key] = mana_curve.get(key, 0) + qty
|
||||
ci_raw = r.get("color_identity", "[]")
|
||||
if isinstance(ci_raw, str):
|
||||
try:
|
||||
ci_raw = json.loads(ci_raw)
|
||||
except Exception:
|
||||
ci_raw = []
|
||||
for c in (ci_raw or []):
|
||||
if c in color_pips:
|
||||
color_pips[c] += qty
|
||||
role = r.get("role_tag") or "Unknown"
|
||||
roles_count[role] = roles_count.get(role, 0) + qty
|
||||
return {
|
||||
"total_cards": total_cards,
|
||||
"counts_by_type": counts_by_type,
|
||||
"mana_curve": mana_curve,
|
||||
"color_pips": color_pips,
|
||||
"roles": roles_count,
|
||||
}
|
||||
|
||||
|
||||
def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool:
|
||||
analysis = _compute_deck_analysis(deck_id)
|
||||
current = analysis["counts_by_type"].get(card_type, 0)
|
||||
target_max = _TYPE_TARGETS.get(card_type, [0, 9999])[1]
|
||||
return (current + count_to_add) <= target_max
|
||||
|
||||
|
||||
def _to_edhrec_slug(name: str) -> str:
|
||||
slug = name.lower()
|
||||
slug = re.sub(r"[',.]" , "", slug)
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
||||
return slug.strip("-")
|
||||
|
||||
|
||||
def parse_dck_file(deck_path: str) -> dict:
|
||||
"""Parse a Forge .dck file into {name, commanders, mainboard, colorIdentity, totalCards}."""
|
||||
path = Path(deck_path)
|
||||
if not path.exists():
|
||||
return {"error": f"File not found: {deck_path}"}
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
name = path.stem
|
||||
section = "main"
|
||||
commanders: dict = {}
|
||||
mainboard: dict = {}
|
||||
commander_name = None
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line == "[metadata]":
|
||||
section = "metadata"; continue
|
||||
if line.startswith("Name="):
|
||||
name = line.split("=", 1)[1].strip(); continue
|
||||
if line == "[Commander]":
|
||||
section = "commander"; continue
|
||||
if line == "[Main]":
|
||||
section = "main"; continue
|
||||
if line.lower().startswith("sideboard") or line == "[Sideboard]":
|
||||
section = "sideboard"; continue
|
||||
clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip()
|
||||
clean = re.sub(r"\s*\*\.\*$", "", clean).strip()
|
||||
m = re.match(r"^(\d+)x?\s+(.+)$", clean)
|
||||
qty, card_name = (int(m.group(1)), m.group(2).strip()) if m else (1, clean)
|
||||
if not card_name:
|
||||
continue
|
||||
if section == "commander":
|
||||
commanders[card_name] = qty
|
||||
if not commander_name:
|
||||
commander_name = card_name
|
||||
elif section != "sideboard":
|
||||
mainboard[card_name] = qty
|
||||
if commander_name:
|
||||
name = f"{commander_name} - Text Import"
|
||||
total = sum(commanders.values()) + sum(mainboard.values())
|
||||
return {"name": name, "commander": commander_name, "commanders": commanders, "mainboard": mainboard, "colorIdentity": [], "totalCards": total}
|
||||
|
||||
|
||||
def _save_profile_to_dck(profile: dict) -> Path:
|
||||
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
|
||||
for name, qty in profile.get("commanders", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
lines += ["", "[Main]"]
|
||||
for name, qty in profile.get("mainboard", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
content = "\n".join(lines)
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", profile.get("name", "imported")).replace(" ", "_").strip()
|
||||
if not safe_name:
|
||||
safe_name = "imported_deck"
|
||||
save_dir = CFG.forge_decks_dir
|
||||
if not save_dir or not os.path.isdir(save_dir):
|
||||
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")
|
||||
log_collect.info(f" Saved .dck: {out_path}")
|
||||
return out_path
|
||||
|
||||
|
||||
def _load_deck_cards_by_name(deck_name: str) -> list:
|
||||
"""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 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")
|
||||
|
||||
|
||||
def _build_dck_lines(
|
||||
deck_name: str,
|
||||
commander_name: str,
|
||||
cards: list[dict],
|
||||
*,
|
||||
resolve_substitutes: bool = False,
|
||||
) -> list[str]:
|
||||
"""Build Forge .dck file lines from deck name, commander, and card list."""
|
||||
lines: list[str] = [
|
||||
"[metadata]",
|
||||
f"Name={deck_name}",
|
||||
"",
|
||||
"[Commander]",
|
||||
f"1 {commander_name}",
|
||||
"",
|
||||
"[Main]",
|
||||
]
|
||||
for card in cards:
|
||||
cname = card.get("name", "")
|
||||
if resolve_substitutes:
|
||||
if card.get("status") == "substituted" and card.get("selected_substitute"):
|
||||
cname = card["selected_substitute"]
|
||||
if cname and cname != commander_name:
|
||||
lines.append(f"{card.get('count', card.get('quantity', 1))} {cname}")
|
||||
return lines
|
||||
|
||||
|
||||
def _write_dck_file(
|
||||
deck_name: str,
|
||||
commander_name: str,
|
||||
cards: list[dict],
|
||||
decks_dir: str | None = None,
|
||||
*,
|
||||
fallback_id: int | str = 0,
|
||||
resolve_substitutes: bool = False,
|
||||
) -> str | None:
|
||||
"""Write a Forge .dck file to decks_dir. Returns path on success, None otherwise."""
|
||||
if not decks_dir:
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if not decks_dir or not os.path.isdir(decks_dir):
|
||||
return None
|
||||
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9_\-\s]", "", deck_name).strip().replace(" ", "_")
|
||||
if not safe_name:
|
||||
safe_name = f"Deck_{fallback_id}"
|
||||
|
||||
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
|
||||
lines = _build_dck_lines(
|
||||
deck_name, commander_name, cards,
|
||||
resolve_substitutes=resolve_substitutes,
|
||||
)
|
||||
with open(dck_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
log_deckgen.info(f" Exported .dck: {dck_path}")
|
||||
return dck_path
|
||||
@@ -0,0 +1,558 @@
|
||||
"""
|
||||
services/rag_store.py
|
||||
=====================
|
||||
ChromaDB-backed semantic vector store over the Scryfall bulk card database.
|
||||
|
||||
Reads all cards from the Phase 1 SQLite bulk DB (services/scryfall_bulk.py),
|
||||
embeds their oracle text using Ollama's nomic-embed-text model, and stores
|
||||
the vectors in a persistent ChromaDB collection on disk.
|
||||
|
||||
This is RAG Phase 2. Phase 3 will query this store to inject relevant card
|
||||
oracle text into the LLM prompt before calling the coach.
|
||||
|
||||
Public API:
|
||||
ensure_rag_store() -> None (build if missing / stale)
|
||||
build_index(force, batch_size) -> dict (explicit build, returns stats)
|
||||
query_cards(text, n, colors) -> list[dict]
|
||||
get_rag_stats() -> dict
|
||||
|
||||
Data path: ./data/rag_chroma/
|
||||
Embedding: nomic-embed-text via Ollama (localhost:11434)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from services.card_text import build_card_text
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("commander_ai_lab.rag_store")
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────
|
||||
RAG_CHROMA_DIR = Path(__file__).parent.parent / "data" / "rag_chroma"
|
||||
BUILD_META_PATH = RAG_CHROMA_DIR / "build_meta.json"
|
||||
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
EMBEDDING_MODEL = os.environ.get("RAG_EMBEDDING_MODEL", "nomic-embed-text")
|
||||
COLLECTION_NAME = "mtg_oracle"
|
||||
DEFAULT_BATCH_SIZE = 500
|
||||
_rag_lock = threading.Lock()
|
||||
_rag_ready = False
|
||||
|
||||
# Module-level singletons to avoid re-creating client/collection on every call
|
||||
_chroma_client = None
|
||||
_chroma_collection = None
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_document(card: dict) -> str:
|
||||
"""Delegate to shared build_card_text() -- see services/card_text.py."""
|
||||
return build_card_text(card)
|
||||
|
||||
|
||||
def _build_metadata(card: dict) -> dict:
|
||||
"""Build ChromaDB metadata dict for filtering."""
|
||||
ci_raw = card.get("color_identity", "[]")
|
||||
if isinstance(ci_raw, str):
|
||||
try:
|
||||
ci_list = json.loads(ci_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
ci_list = []
|
||||
else:
|
||||
ci_list = ci_raw or []
|
||||
|
||||
return {
|
||||
"name": card.get("name", ""),
|
||||
"type_line": card.get("type_line", ""),
|
||||
"cmc": float(card.get("cmc") or 0),
|
||||
"color_identity": ",".join(sorted(ci_list)),
|
||||
"rarity": card.get("rarity", ""),
|
||||
"mana_cost": card.get("mana_cost", ""),
|
||||
}
|
||||
|
||||
|
||||
def _get_chromadb_client():
|
||||
"""Return the module-level persistent ChromaDB client (singleton)."""
|
||||
global _chroma_client
|
||||
if _chroma_client is not None:
|
||||
return _chroma_client
|
||||
try:
|
||||
import chromadb
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"chromadb is required for RAG Phase 2. "
|
||||
"Install with: pip install 'commander-ai-lab[rag]'"
|
||||
)
|
||||
RAG_CHROMA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_chroma_client = chromadb.PersistentClient(path=str(RAG_CHROMA_DIR))
|
||||
return _chroma_client
|
||||
|
||||
|
||||
def _get_ollama_embedding_fn():
|
||||
"""Return a ChromaDB-compatible embedding function using Ollama."""
|
||||
try:
|
||||
from chromadb.utils.embedding_functions import OllamaEmbeddingFunction
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"chromadb >= 0.4.0 with Ollama support is required. "
|
||||
"Install with: pip install 'commander-ai-lab[rag]'"
|
||||
)
|
||||
return OllamaEmbeddingFunction(
|
||||
url=f"{OLLAMA_BASE_URL}/api/embeddings",
|
||||
model_name=EMBEDDING_MODEL,
|
||||
)
|
||||
|
||||
|
||||
def _get_collection():
|
||||
"""Return the ChromaDB collection singleton, creating it if needed."""
|
||||
global _chroma_collection
|
||||
if _chroma_collection is not None:
|
||||
return _chroma_collection
|
||||
client = _get_chromadb_client()
|
||||
embed_fn = _get_ollama_embedding_fn()
|
||||
_chroma_collection = client.get_or_create_collection(
|
||||
name=COLLECTION_NAME,
|
||||
embedding_function=embed_fn,
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
)
|
||||
return _chroma_collection
|
||||
|
||||
|
||||
def _invalidate_collection_singleton() -> None:
|
||||
"""Reset the cached collection after a rebuild so the next call re-opens it."""
|
||||
global _chroma_collection
|
||||
_chroma_collection = None
|
||||
|
||||
|
||||
def _read_build_meta() -> dict:
|
||||
"""Read the sidecar build_meta.json, returning {} if missing or corrupt."""
|
||||
try:
|
||||
if BUILD_META_PATH.exists():
|
||||
return json.loads(BUILD_META_PATH.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("Could not read build_meta.json: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_build_meta(card_count: int) -> None:
|
||||
"""Persist build timestamp and card count to sidecar JSON file."""
|
||||
try:
|
||||
RAG_CHROMA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
BUILD_META_PATH.write_text(
|
||||
json.dumps({"built_at": time.time(), "card_count": card_count}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not write build_meta.json: %s", exc)
|
||||
|
||||
|
||||
def _collection_is_current(collection, bulk_card_count: int) -> bool:
|
||||
"""Check if the ChromaDB collection is reasonably up-to-date.
|
||||
|
||||
Uses sidecar build_meta.json for the age check (survives restarts),
|
||||
and compares card count within a 5% tolerance.
|
||||
"""
|
||||
chroma_count = collection.count()
|
||||
if chroma_count == 0:
|
||||
return False
|
||||
|
||||
# Age check via sidecar file (not collection.metadata which doesn't persist)
|
||||
meta = _read_build_meta()
|
||||
built_at = meta.get("built_at", 0)
|
||||
if built_at:
|
||||
age_days = (time.time() - float(built_at)) / 86400
|
||||
if age_days > 14:
|
||||
logger.info("RAG collection older than 14 days (%0.1f) — rebuilding.", age_days)
|
||||
return False
|
||||
|
||||
ratio = chroma_count / max(bulk_card_count, 1)
|
||||
if ratio < 0.95 or ratio > 1.05:
|
||||
logger.info(
|
||||
"RAG collection count mismatch: chroma=%d, bulk=%d — rebuilding.",
|
||||
chroma_count, bulk_card_count,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ── Build pipeline ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_collection_from_bulk(batch_size: int = DEFAULT_BATCH_SIZE) -> dict:
|
||||
"""Read all cards from the bulk SQLite DB and insert into ChromaDB.
|
||||
|
||||
Args:
|
||||
batch_size: Number of cards to embed and insert per ChromaDB call.
|
||||
|
||||
Returns:
|
||||
Dict with keys: indexed (int), failed (int).
|
||||
|
||||
Raises:
|
||||
RuntimeError: If more than 1% of cards failed to insert.
|
||||
"""
|
||||
from services.scryfall_bulk import get_bulk_db
|
||||
|
||||
conn = get_bulk_db()
|
||||
cursor = conn.execute(
|
||||
"SELECT name, oracle_text, type_line, mana_cost, cmc, "
|
||||
"color_identity, rarity, scryfall_id FROM cards "
|
||||
"WHERE oracle_text != '' "
|
||||
"ORDER BY edhrec_rank ASC"
|
||||
)
|
||||
all_rows = cursor.fetchall()
|
||||
total_rows = len(all_rows)
|
||||
|
||||
client = _get_chromadb_client()
|
||||
embed_fn = _get_ollama_embedding_fn()
|
||||
|
||||
# Drop and recreate collection to ensure a clean build
|
||||
try:
|
||||
client.delete_collection(COLLECTION_NAME)
|
||||
except Exception:
|
||||
pass
|
||||
_invalidate_collection_singleton()
|
||||
|
||||
collection = client.create_collection(
|
||||
name=COLLECTION_NAME,
|
||||
embedding_function=embed_fn,
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
)
|
||||
|
||||
inserted = 0
|
||||
failed_cards = 0
|
||||
failed_batches = 0
|
||||
batch_ids: list[str] = []
|
||||
batch_docs: list[str] = []
|
||||
batch_metas: list[dict] = []
|
||||
|
||||
for row in all_rows:
|
||||
card = dict(row)
|
||||
doc = _build_document(card)
|
||||
if not doc.strip():
|
||||
continue
|
||||
|
||||
card_id = card.get("scryfall_id", "")
|
||||
if not card_id:
|
||||
continue
|
||||
|
||||
batch_ids.append(card_id)
|
||||
batch_docs.append(doc)
|
||||
batch_metas.append(_build_metadata(card))
|
||||
|
||||
if len(batch_ids) >= batch_size:
|
||||
try:
|
||||
collection.add(
|
||||
ids=batch_ids,
|
||||
documents=batch_docs,
|
||||
metadatas=batch_metas,
|
||||
)
|
||||
inserted += len(batch_ids)
|
||||
if inserted % 2000 == 0:
|
||||
logger.info(" RAG indexed %d / %d cards...", inserted, total_rows)
|
||||
except Exception as exc:
|
||||
failed_cards += len(batch_ids)
|
||||
failed_batches += 1
|
||||
logger.warning(
|
||||
"RAG batch insert failed (batch #%d, %d cards lost): %s",
|
||||
failed_batches, len(batch_ids), exc,
|
||||
)
|
||||
batch_ids.clear()
|
||||
batch_docs.clear()
|
||||
batch_metas.clear()
|
||||
|
||||
# Final partial batch
|
||||
if batch_ids:
|
||||
try:
|
||||
collection.add(
|
||||
ids=batch_ids,
|
||||
documents=batch_docs,
|
||||
metadatas=batch_metas,
|
||||
)
|
||||
inserted += len(batch_ids)
|
||||
except Exception as exc:
|
||||
failed_cards += len(batch_ids)
|
||||
failed_batches += 1
|
||||
logger.warning("RAG final batch insert failed: %s", exc)
|
||||
|
||||
logger.info(
|
||||
"RAG vector store complete: %d indexed, %d failed (%d batches).",
|
||||
inserted, failed_cards, failed_batches,
|
||||
)
|
||||
|
||||
# Abort if failure rate exceeds 1% — delete partial collection
|
||||
if total_rows > 0 and (failed_cards / total_rows) > 0.01:
|
||||
logger.error(
|
||||
"RAG build failure rate %.1f%% exceeds 1%% threshold — deleting partial collection.",
|
||||
100.0 * failed_cards / total_rows,
|
||||
)
|
||||
try:
|
||||
client.delete_collection(COLLECTION_NAME)
|
||||
_invalidate_collection_singleton()
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(
|
||||
f"RAG build aborted: {failed_cards}/{total_rows} cards failed to insert. "
|
||||
"Check Ollama connectivity and retry."
|
||||
)
|
||||
|
||||
_write_build_meta(card_count=inserted)
|
||||
# Invalidate singleton so next _get_collection() picks up the new collection
|
||||
_invalidate_collection_singleton()
|
||||
return {"indexed": inserted, "failed": failed_cards}
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def build_index(force: bool = False, batch_size: int = DEFAULT_BATCH_SIZE) -> dict:
|
||||
"""Explicitly build or rebuild the ChromaDB vector index.
|
||||
|
||||
This is the function called by POST /api/rag/build.
|
||||
Unlike ensure_rag_store(), this always runs (no _rag_ready short-circuit)
|
||||
and returns stats so the API can surface them to the caller.
|
||||
|
||||
Args:
|
||||
force: If True, rebuild even if the index appears current.
|
||||
batch_size: Cards per ChromaDB insert batch (default 500).
|
||||
|
||||
Returns:
|
||||
Dict with keys: indexed (int), failed (int).
|
||||
"""
|
||||
global _rag_ready
|
||||
with _rag_lock:
|
||||
if not force:
|
||||
try:
|
||||
from services.scryfall_bulk import get_stats as bulk_stats
|
||||
stats = bulk_stats()
|
||||
bulk_count = stats.get("card_count", 0)
|
||||
collection = _get_collection()
|
||||
if _collection_is_current(collection, bulk_count):
|
||||
logger.info("RAG index is current — skipping rebuild (use force=True to override).")
|
||||
return {"indexed": collection.count(), "failed": 0, "skipped": True}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = _build_collection_from_bulk(batch_size=batch_size)
|
||||
_rag_ready = True
|
||||
return result
|
||||
|
||||
|
||||
def ensure_rag_store() -> None:
|
||||
"""Build the ChromaDB vector store if it is missing or stale.
|
||||
|
||||
Safe to call at startup — no-ops quickly if the store is current.
|
||||
Requires:
|
||||
- Phase 1 bulk DB to be populated (services/scryfall_bulk)
|
||||
- Ollama running with nomic-embed-text pulled
|
||||
"""
|
||||
global _rag_ready
|
||||
with _rag_lock:
|
||||
if _rag_ready:
|
||||
return
|
||||
try:
|
||||
from services.scryfall_bulk import get_stats as bulk_stats
|
||||
stats = bulk_stats()
|
||||
bulk_count = stats.get("card_count", 0)
|
||||
if bulk_count == 0:
|
||||
logger.warning(
|
||||
"Scryfall bulk DB is empty — skipping RAG store build. "
|
||||
"Run ensure_bulk_db() first."
|
||||
)
|
||||
return
|
||||
|
||||
collection = _get_collection()
|
||||
if _collection_is_current(collection, bulk_count):
|
||||
logger.info(
|
||||
"RAG vector store is current (%d cards).",
|
||||
collection.count(),
|
||||
)
|
||||
_rag_ready = True
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Building RAG vector store from %d bulk cards "
|
||||
"(this may take several minutes on first run)...",
|
||||
bulk_count,
|
||||
)
|
||||
_build_collection_from_bulk()
|
||||
_rag_ready = True
|
||||
|
||||
except ImportError as exc:
|
||||
logger.warning("RAG store unavailable: %s", exc)
|
||||
except Exception as exc:
|
||||
logger.error("RAG store build failed: %s", exc)
|
||||
|
||||
|
||||
def query_cards(
|
||||
query: str,
|
||||
n_results: int = 8,
|
||||
color_identity: Optional[list[str]] = None,
|
||||
type_filter: Optional[str] = None,
|
||||
) -> list[dict]:
|
||||
"""Semantic search over card oracle text.
|
||||
|
||||
Args:
|
||||
query: Natural language query (e.g. "draw cards when creatures die").
|
||||
n_results: Maximum number of results.
|
||||
color_identity: If provided, only return cards whose color identity
|
||||
is a subset (e.g. ["W", "U", "B"]).
|
||||
type_filter: Optional type_line substring filter (e.g. "Creature").
|
||||
Applied as a post-query Python filter for ChromaDB compatibility.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: name, type_line, oracle_text, cmc,
|
||||
color_identity, mana_cost, distance.
|
||||
"""
|
||||
if not _rag_ready:
|
||||
logger.warning("RAG store not ready — returning empty results.")
|
||||
return []
|
||||
|
||||
try:
|
||||
collection = _get_collection()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to get RAG collection: %s", exc)
|
||||
return []
|
||||
|
||||
try:
|
||||
# Fetch extra results to account for post-query color/type filtering
|
||||
fetch_n = n_results * 4 if (color_identity or type_filter) else n_results
|
||||
results = collection.query(
|
||||
query_texts=[query],
|
||||
n_results=min(fetch_n, 200),
|
||||
include=["documents", "metadatas", "distances"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("RAG query failed: %s", exc)
|
||||
return []
|
||||
|
||||
if not results or not results.get("ids") or not results["ids"][0]:
|
||||
return []
|
||||
|
||||
cards: list[dict] = []
|
||||
ids = results["ids"][0]
|
||||
docs = results["documents"][0] if results.get("documents") else [""] * len(ids)
|
||||
metas = results["metadatas"][0] if results.get("metadatas") else [{}] * len(ids)
|
||||
dists = results["distances"][0] if results.get("distances") else [0.0] * len(ids)
|
||||
|
||||
for i, card_id in enumerate(ids):
|
||||
meta = metas[i] if i < len(metas) else {}
|
||||
doc = docs[i] if i < len(docs) else ""
|
||||
|
||||
# Color identity post-filter (Python-side for ChromaDB compat)
|
||||
if color_identity:
|
||||
card_ci_str = meta.get("color_identity", "")
|
||||
card_ci = set(c for c in card_ci_str.split(",") if c) if card_ci_str else set()
|
||||
deck_ci = set(color_identity)
|
||||
if not card_ci.issubset(deck_ci):
|
||||
continue
|
||||
|
||||
# Type post-filter
|
||||
if type_filter:
|
||||
card_type = meta.get("type_line", "")
|
||||
if type_filter.lower() not in card_type.lower():
|
||||
continue
|
||||
|
||||
# Document format: Name | Mana Cost | Type | Oracle Text
|
||||
parts = doc.split(" | ", 3)
|
||||
oracle = parts[3] if len(parts) > 3 else (parts[-1] if parts else "")
|
||||
|
||||
cards.append({
|
||||
"name": meta.get("name", ""),
|
||||
"type_line": meta.get("type_line", ""),
|
||||
"oracle_text": oracle,
|
||||
"cmc": meta.get("cmc", 0),
|
||||
"color_identity": meta.get("color_identity", ""),
|
||||
"mana_cost": meta.get("mana_cost", ""),
|
||||
"distance": round(dists[i], 4) if i < len(dists) else 0.0,
|
||||
})
|
||||
if len(cards) >= n_results:
|
||||
break
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
def get_rag_stats() -> dict:
|
||||
"""Return metadata about the RAG vector store state."""
|
||||
result = {
|
||||
"ready": _rag_ready,
|
||||
"chroma_dir": str(RAG_CHROMA_DIR),
|
||||
"embedding_model": EMBEDDING_MODEL,
|
||||
"ollama_url": OLLAMA_BASE_URL,
|
||||
"card_count": 0,
|
||||
"built_at": None,
|
||||
"age_days": None,
|
||||
}
|
||||
try:
|
||||
collection = _get_collection()
|
||||
result["card_count"] = collection.count()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Read age from sidecar file (persists across restarts)
|
||||
meta = _read_build_meta()
|
||||
built_at = meta.get("built_at")
|
||||
if built_at:
|
||||
result["built_at"] = built_at
|
||||
try:
|
||||
age = (time.time() - float(built_at)) / 86400
|
||||
result["age_days"] = round(age, 1)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def is_chroma_available() -> bool:
|
||||
"""Check whether ChromaDB + Ollama are reachable without side effects."""
|
||||
try:
|
||||
_get_chromadb_client()
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
f"{OLLAMA_BASE_URL}/api/tags",
|
||||
headers={"User-Agent": "commander-ai-lab/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=3):
|
||||
pass
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_staleness() -> dict:
|
||||
"""Return staleness info so a background scheduler can trigger rebuilds.
|
||||
|
||||
Returns dict with keys:
|
||||
stale (bool): True if the index should be rebuilt.
|
||||
reason (str): Human-readable explanation.
|
||||
age_days (float | None): Age of the current build in days.
|
||||
"""
|
||||
meta = _read_build_meta()
|
||||
built_at = meta.get("built_at")
|
||||
if not built_at:
|
||||
return {"stale": True, "reason": "no build metadata found", "age_days": None}
|
||||
try:
|
||||
age_days = (time.time() - float(built_at)) / 86400
|
||||
except (ValueError, TypeError):
|
||||
return {"stale": True, "reason": "corrupt build timestamp", "age_days": None}
|
||||
if age_days > 14:
|
||||
return {"stale": True, "reason": f"index is {age_days:.1f} days old (>14)", "age_days": round(age_days, 1)}
|
||||
try:
|
||||
from services.scryfall_bulk import get_stats as bulk_stats
|
||||
bulk_count = bulk_stats().get("card_count", 0)
|
||||
collection = _get_collection()
|
||||
chroma_count = collection.count()
|
||||
ratio = chroma_count / max(bulk_count, 1)
|
||||
if ratio < 0.95 or ratio > 1.05:
|
||||
return {
|
||||
"stale": True,
|
||||
"reason": f"card count drift: chroma={chroma_count}, bulk={bulk_count}",
|
||||
"age_days": round(age_days, 1),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("check_staleness card count check failed: %s", exc)
|
||||
return {"stale": False, "reason": "index is current", "age_days": round(age_days, 1)}
|
||||
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
services/scryfall_bulk.py
|
||||
=========================
|
||||
Downloads and indexes the full Scryfall `default_cards` bulk export into a
|
||||
local SQLite database with FTS5 full-text search over card names and oracle
|
||||
text. This replaces per-card live Scryfall API calls for the RAG pipeline.
|
||||
|
||||
Usage (one-time or nightly):
|
||||
from services.scryfall_bulk import ensure_bulk_db
|
||||
ensure_bulk_db() # call at startup; no-ops if data is fresh
|
||||
|
||||
Public API:
|
||||
ensure_bulk_db() -> None (download if stale)
|
||||
lookup_card(name) -> dict | None
|
||||
search_oracle(query, colors, limit) -> list[dict]
|
||||
get_bulk_db() -> sqlite3.Connection
|
||||
|
||||
Data path: ./data/scryfall_bulk.db
|
||||
Re-download: every SCRYFALL_BULK_STALENESS_DAYS days (default 7)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
logger = logging.getLogger("commander_ai_lab.scryfall_bulk")
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────────────────
|
||||
BULK_DB_PATH = Path(__file__).parent.parent / "data" / "scryfall_bulk.db"
|
||||
BULK_META_URL = "https://api.scryfall.com/bulk-data"
|
||||
SCRYFALL_BULK_STALENESS_DAYS = int(
|
||||
os.environ.get("SCRYFALL_BULK_STALENESS_DAYS", "7")
|
||||
)
|
||||
_API_HEADERS = {"User-Agent": "CommanderAILab/3.0", "Accept": "application/json"}
|
||||
|
||||
# ── Module-level singletons ──────────────────────────────────────────────────
|
||||
_bulk_conn: sqlite3.Connection | None = None
|
||||
_bulk_lock = threading.Lock()
|
||||
|
||||
|
||||
# ── DB init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _init_db(conn: sqlite3.Connection) -> None:
|
||||
"""Create tables and FTS5 virtual table on first run."""
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS cards (
|
||||
scryfall_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
oracle_text TEXT DEFAULT '',
|
||||
type_line TEXT DEFAULT '',
|
||||
mana_cost TEXT DEFAULT '',
|
||||
cmc REAL DEFAULT 0,
|
||||
color_identity TEXT DEFAULT '[]',
|
||||
keywords TEXT DEFAULT '[]',
|
||||
power TEXT DEFAULT '',
|
||||
toughness TEXT DEFAULT '',
|
||||
rarity TEXT DEFAULT '',
|
||||
edhrec_rank INTEGER DEFAULT 0,
|
||||
legalities TEXT DEFAULT '{}',
|
||||
rulings_uri TEXT DEFAULT '',
|
||||
set_code TEXT DEFAULT '',
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS cards_fts USING fts5(
|
||||
name,
|
||||
oracle_text,
|
||||
content='cards',
|
||||
content_rowid='rowid'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ── Staleness check ───────────────────────────────────────────────────────────
|
||||
|
||||
def _is_stale(conn: sqlite3.Connection) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM meta WHERE key = 'last_download'"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return True
|
||||
try:
|
||||
age_days = (time.time() - float(row[0])) / 86400
|
||||
return age_days > SCRYFALL_BULK_STALENESS_DAYS
|
||||
except (ValueError, TypeError):
|
||||
return True
|
||||
|
||||
|
||||
# ── Download + index ──────────────────────────────────────────────────────────
|
||||
|
||||
def _get_bulk_download_url() -> str:
|
||||
"""Fetch the current default_cards download URL from the Scryfall manifest."""
|
||||
req = Request(BULK_META_URL, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
meta = json.loads(resp.read())
|
||||
for entry in meta.get("data", []):
|
||||
if entry.get("type") == "default_cards":
|
||||
return entry["download_uri"]
|
||||
raise RuntimeError("Could not find default_cards entry in Scryfall bulk-data manifest")
|
||||
|
||||
|
||||
def _download_and_index(conn: sqlite3.Connection) -> int:
|
||||
"""
|
||||
Download the full default_cards JSON from Scryfall and index into SQLite.
|
||||
Replaces all existing rows. Returns number of cards indexed.
|
||||
"""
|
||||
logger.info("Fetching Scryfall bulk-data manifest...")
|
||||
url = _get_bulk_download_url()
|
||||
logger.info("Downloading Scryfall default_cards bulk export (~100 MB)...")
|
||||
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=300) as resp:
|
||||
raw_bytes = resp.read()
|
||||
|
||||
cards_json: list[dict] = json.loads(raw_bytes)
|
||||
logger.info(f"Received {len(cards_json)} card objects from Scryfall.")
|
||||
|
||||
# Wipe existing data for a clean rebuild
|
||||
conn.execute("DELETE FROM cards")
|
||||
conn.execute("DELETE FROM cards_fts")
|
||||
conn.commit()
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
inserted = 0
|
||||
batch: list[tuple] = []
|
||||
|
||||
_SKIP_LAYOUTS = {"token", "emblem", "art_series", "double_faced_token"}
|
||||
|
||||
for card in cards_json:
|
||||
if card.get("layout") in _SKIP_LAYOUTS:
|
||||
continue
|
||||
|
||||
# Handle double-faced / meld cards: concatenate face oracle texts
|
||||
oracle = card.get("oracle_text", "")
|
||||
if not oracle and card.get("card_faces"):
|
||||
oracle = "\n//\n".join(
|
||||
f.get("oracle_text", "")
|
||||
for f in card["card_faces"]
|
||||
if f.get("oracle_text")
|
||||
)
|
||||
|
||||
mana_cost = card.get("mana_cost", "")
|
||||
if not mana_cost and card.get("card_faces"):
|
||||
mana_cost = card["card_faces"][0].get("mana_cost", "")
|
||||
|
||||
batch.append((
|
||||
card.get("id", ""),
|
||||
card.get("name", ""),
|
||||
oracle,
|
||||
card.get("type_line", ""),
|
||||
mana_cost,
|
||||
float(card.get("cmc") or 0),
|
||||
json.dumps(card.get("color_identity", [])),
|
||||
json.dumps(card.get("keywords", [])),
|
||||
card.get("power", "") or "",
|
||||
card.get("toughness", "") or "",
|
||||
card.get("rarity", ""),
|
||||
int(card.get("edhrec_rank") or 0),
|
||||
json.dumps(card.get("legalities", {})),
|
||||
card.get("rulings_uri", "") or "",
|
||||
card.get("set", ""),
|
||||
))
|
||||
|
||||
if len(batch) >= BATCH_SIZE:
|
||||
_flush_batch(conn, batch)
|
||||
inserted += len(batch)
|
||||
batch.clear()
|
||||
logger.debug(f" Indexed {inserted} cards...")
|
||||
|
||||
if batch:
|
||||
_flush_batch(conn, batch)
|
||||
inserted += len(batch)
|
||||
|
||||
# Rebuild FTS5 index from cards table
|
||||
conn.execute("INSERT INTO cards_fts(cards_fts) VALUES('rebuild')")
|
||||
|
||||
# Record download timestamp
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta (key, value) VALUES ('last_download', ?)",
|
||||
(str(time.time()),)
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta (key, value) VALUES ('card_count', ?)",
|
||||
(str(inserted),)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"Scryfall bulk index complete: {inserted} cards stored.")
|
||||
return inserted
|
||||
|
||||
|
||||
def _flush_batch(conn: sqlite3.Connection, batch: list[tuple]) -> None:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cards
|
||||
(scryfall_id, name, oracle_text, type_line, mana_cost, cmc,
|
||||
color_identity, keywords, power, toughness, rarity,
|
||||
edhrec_rank, legalities, rulings_uri, set_code)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
batch,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_bulk_db() -> sqlite3.Connection:
|
||||
"""
|
||||
Return the module-level SQLite connection, creating it on first call.
|
||||
Thread-safe via _bulk_lock.
|
||||
"""
|
||||
global _bulk_conn
|
||||
with _bulk_lock:
|
||||
if _bulk_conn is None:
|
||||
BULK_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
_bulk_conn = sqlite3.connect(
|
||||
str(BULK_DB_PATH), check_same_thread=False
|
||||
)
|
||||
_bulk_conn.row_factory = sqlite3.Row
|
||||
_init_db(_bulk_conn)
|
||||
return _bulk_conn
|
||||
|
||||
|
||||
def ensure_bulk_db() -> None:
|
||||
"""
|
||||
Call at startup. Downloads and indexes the full Scryfall card set if the
|
||||
local DB is missing or older than SCRYFALL_BULK_STALENESS_DAYS.
|
||||
No-ops if data is already fresh.
|
||||
"""
|
||||
conn = get_bulk_db()
|
||||
if _is_stale(conn):
|
||||
logger.info(
|
||||
"Scryfall bulk DB is stale or missing — downloading now "
|
||||
"(~100 MB, one-time per week)..."
|
||||
)
|
||||
try:
|
||||
_download_and_index(conn)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"Scryfall bulk download failed: {exc}. "
|
||||
"Continuing without bulk data — per-card Scryfall cache will be used."
|
||||
)
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM meta WHERE key = 'card_count'"
|
||||
).fetchone()
|
||||
count = row[0] if row else "unknown"
|
||||
logger.info(f"Scryfall bulk DB is fresh ({count} cards cached).")
|
||||
|
||||
|
||||
def lookup_card(name: str) -> dict | None:
|
||||
"""
|
||||
Exact case-insensitive card name lookup.
|
||||
|
||||
Returns a dict with all columns from the `cards` table, or None if not
|
||||
found. Prefers the most-recently-printed printing (lowest edhrec_rank).
|
||||
"""
|
||||
conn = get_bulk_db()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM cards
|
||||
WHERE LOWER(name) = LOWER(?)
|
||||
ORDER BY edhrec_rank ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(name,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def search_oracle(
|
||||
query: str,
|
||||
color_identity: list[str] | None = None,
|
||||
type_filter: str | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Full-text search over card names and oracle text using SQLite FTS5.
|
||||
|
||||
Args:
|
||||
query: Natural language or keyword query string.
|
||||
color_identity: If provided, restrict results to cards whose color
|
||||
identity is a subset of this list (e.g. ['W', 'U']).
|
||||
type_filter: Optional type-line substring filter (e.g. 'Creature').
|
||||
limit: Maximum number of results to return.
|
||||
|
||||
Returns:
|
||||
List of card dicts ordered by FTS5 relevance rank.
|
||||
"""
|
||||
conn = get_bulk_db()
|
||||
|
||||
# Build FTS5 query: wrap each token in quotes to allow phrase matching
|
||||
tokens = [t.strip() for t in query.split() if t.strip()]
|
||||
if not tokens:
|
||||
return []
|
||||
fts_query = " OR ".join(f'"{t}"' for t in tokens)
|
||||
|
||||
# Over-fetch to allow post-filtering by color/type
|
||||
fetch_limit = max(limit * 5, 50)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT c.*
|
||||
FROM cards c
|
||||
JOIN cards_fts fts ON fts.rowid = c.rowid
|
||||
WHERE cards_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
""",
|
||||
(fts_query, fetch_limit),
|
||||
).fetchall()
|
||||
|
||||
results: list[dict] = []
|
||||
for row in rows:
|
||||
card = dict(row)
|
||||
|
||||
# Color identity filter: card's CI must be a subset of deck's CI
|
||||
if color_identity:
|
||||
try:
|
||||
card_ci = set(json.loads(card.get("color_identity") or "[]"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
card_ci = set()
|
||||
if not card_ci.issubset(set(color_identity)):
|
||||
continue
|
||||
|
||||
# Optional type-line filter
|
||||
if type_filter:
|
||||
if type_filter.lower() not in (card.get("type_line") or "").lower():
|
||||
continue
|
||||
|
||||
results.append(card)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_stats() -> dict:
|
||||
"""Return metadata about the current bulk DB state."""
|
||||
conn = get_bulk_db()
|
||||
rows = conn.execute("SELECT key, value FROM meta").fetchall()
|
||||
meta = {r["key"]: r["value"] for r in rows}
|
||||
card_count = conn.execute("SELECT COUNT(*) FROM cards").fetchone()[0]
|
||||
stale = _is_stale(conn)
|
||||
last_dl = meta.get("last_download")
|
||||
age_days: float | None = None
|
||||
if last_dl:
|
||||
try:
|
||||
age_days = round((time.time() - float(last_dl)) / 86400, 1)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return {
|
||||
"card_count": card_count,
|
||||
"stale": stale,
|
||||
"age_days": age_days,
|
||||
"db_path": str(BULK_DB_PATH),
|
||||
"staleness_threshold_days": SCRYFALL_BULK_STALENESS_DAYS,
|
||||
}
|
||||
@@ -102,7 +102,7 @@ def chat(
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
|
||||
# ── 1. Analyze synergies ─────────────────────────────────────────
|
||||
# ── 1. Analyze synergies ─────────────────────────────────────────────
|
||||
|
||||
def analyze_synergies(
|
||||
commander_name: str,
|
||||
@@ -145,7 +145,7 @@ Cards to evaluate:
|
||||
return []
|
||||
|
||||
|
||||
# ── 2. Suggest cards for a category ──────────────────────────────
|
||||
# ── 2. Suggest cards for a category ────────────────────────────────
|
||||
|
||||
def suggest_cards(
|
||||
commander_name: str,
|
||||
@@ -154,13 +154,23 @@ def suggest_cards(
|
||||
count: int = 10,
|
||||
exclude: Optional[List[str]] = None,
|
||||
strategy_notes: Optional[str] = None,
|
||||
sim_context: str = "",
|
||||
) -> List[str]:
|
||||
"""
|
||||
Ask the model to suggest cards for a specific deck category.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sim_context:
|
||||
Optional natural-language insight string from get_sim_insights().
|
||||
When non-empty, appended to the prompt so the LLM can factor in
|
||||
statistically successful card traits from simulation data.
|
||||
|
||||
Returns a list of card names.
|
||||
"""
|
||||
exclude_str = f"\nDo NOT suggest these cards: {json.dumps(exclude)}" if exclude else ""
|
||||
strategy = f"\nStrategy notes: {strategy_notes}" if strategy_notes else ""
|
||||
sim_block = f"\n\n{sim_context}" if sim_context else ""
|
||||
|
||||
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
|
||||
|
||||
@@ -168,7 +178,7 @@ Commander: {commander_name}
|
||||
Color identity: {', '.join(color_identity)}
|
||||
Category: {category}
|
||||
{strategy}
|
||||
{exclude_str}
|
||||
{exclude_str}{sim_block}
|
||||
|
||||
Suggest exactly {count} cards for the {category} category that work well with this commander.
|
||||
Only suggest cards that are legal in Commander and match the color identity.
|
||||
@@ -200,7 +210,7 @@ Return ONLY a JSON array of card name strings, like: ["Card A", "Card B", ...]""
|
||||
return []
|
||||
|
||||
|
||||
# ── 3. Filter by color identity ──────────────────────────────────
|
||||
# ── 3. Filter by color identity ────────────────────────────────────
|
||||
|
||||
def filter_color_identity(
|
||||
card_names: List[str],
|
||||
@@ -242,17 +252,28 @@ Cards:
|
||||
return card_names
|
||||
|
||||
|
||||
# ── 4. Enforce deck ratios ───────────────────────────────────────
|
||||
# ── 4. Enforce deck ratios ───────────────────────────────────────────
|
||||
|
||||
def enforce_deck_ratios(
|
||||
cards_by_category: Dict[str, List[str]],
|
||||
target_ratios: Dict[str, int],
|
||||
commander_name: str,
|
||||
sim_context: str = "",
|
||||
) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Ask the model to trim/expand categories to hit target counts.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sim_context:
|
||||
Optional natural-language insight string from get_sim_insights().
|
||||
When non-empty, appended to the prompt so the LLM preferentially
|
||||
retains cards matching statistically high-value traits when trimming.
|
||||
|
||||
Returns adjusted dict of category -> card names.
|
||||
"""
|
||||
sim_block = f"\n\n{sim_context}" if sim_context else ""
|
||||
|
||||
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
|
||||
|
||||
Commander: {commander_name}
|
||||
@@ -265,7 +286,7 @@ Target card counts per category:
|
||||
|
||||
Adjust each category to match the target count. Remove the weakest cards from
|
||||
over-filled categories. For under-filled categories, suggest additional cards.
|
||||
The total across all categories must be exactly 99.
|
||||
The total across all categories must be exactly 99.{sim_block}
|
||||
|
||||
Return a JSON object with the same category keys, each mapping to an array of card name strings."""
|
||||
|
||||
@@ -284,7 +305,7 @@ Return a JSON object with the same category keys, each mapping to an array of ca
|
||||
return cards_by_category
|
||||
|
||||
|
||||
# ── 5. Assemble final deck JSON ──────────────────────────────────
|
||||
# ── 5. Assemble final deck JSON ─────────────────────────────────────────
|
||||
|
||||
def assemble_deck_json(
|
||||
commander_name: str,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
sim_insights.py
|
||||
===============
|
||||
Translates learned simulation weights (learned_weights.json) into concise
|
||||
natural-language insight strings that can be injected into Ollama prompts.
|
||||
|
||||
This bridges the gap between the Monte Carlo engine's outcome-weighted
|
||||
reinforcement learning and the LLM-driven deck builder: keywords that
|
||||
statistically correlate with wins are surfaced to Ollama so it can
|
||||
prioritize those card traits when suggesting and trimming cards.
|
||||
|
||||
Usage::
|
||||
|
||||
from commander_ai_lab.deck_builder.api.sim_insights import get_sim_insights
|
||||
|
||||
context = get_sim_insights() # returns "" if no meaningful learning yet
|
||||
if context:
|
||||
prompt += f"\n\n{context}"
|
||||
|
||||
The function is intentionally safe to call when learned_weights.json does
|
||||
not yet exist (load_weights() falls back to AI_DEFAULT_WEIGHTS silently),
|
||||
and returns an empty string when no weight has drifted far enough from the
|
||||
default to be worth mentioning — so existing prompt behaviour is unchanged
|
||||
until there is real simulation data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from commander_ai_lab.sim.rules import AI_DEFAULT_WEIGHTS, load_weights
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maps weight keys → human-readable MTG concepts shown to the LLM.
|
||||
# Only keys the LLM can meaningfully act on are included.
|
||||
_KEY_TO_CONCEPT: dict[str, str] = {
|
||||
"kw_flying": "flying (evasion)",
|
||||
"kw_haste": "haste",
|
||||
"kw_deathtouch": "deathtouch",
|
||||
"kw_indestructible": "indestructible",
|
||||
"kw_doubleStrike": "double strike",
|
||||
"kw_hexproof": "hexproof",
|
||||
"kw_trample": "trample",
|
||||
"kw_lifelink": "lifelink",
|
||||
"kw_menace": "menace",
|
||||
"kw_ward": "ward",
|
||||
"kw_annihilator": "annihilator",
|
||||
"kw_cascade": "cascade",
|
||||
"spell_ramp": "mana ramp spells",
|
||||
"spell_destroy": "targeted removal",
|
||||
"spell_draw": "card draw",
|
||||
"spell_counter": "counterspells and board control",
|
||||
"trig_etb": "enters-the-battlefield triggers",
|
||||
"trig_dies": "death triggers",
|
||||
"trig_attack": "attack triggers",
|
||||
"card_ptBonus": "high power/toughness creatures",
|
||||
}
|
||||
|
||||
|
||||
def get_sim_insights(
|
||||
top_n: int = 6,
|
||||
threshold_delta: float = 0.05,
|
||||
weights_path: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Return a concise insight paragraph for injecting into LLM prompts.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
top_n:
|
||||
Maximum number of concepts to mention (avoids bloating the prompt).
|
||||
threshold_delta:
|
||||
Minimum absolute drift from the default weight before a concept
|
||||
is considered meaningful enough to report. 0.05 means a weight
|
||||
must have moved by at least 5% of its default before it matters.
|
||||
weights_path:
|
||||
Optional explicit path to learned_weights.json. If None, the
|
||||
default location (``src/.../sim/learned_weights.json``) is used.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
A multi-line insight string ready to append to an LLM prompt, or
|
||||
an empty string if no meaningful learning is available yet.
|
||||
"""
|
||||
try:
|
||||
learned = load_weights(weights_path)
|
||||
except Exception as exc:
|
||||
logger.warning("sim_insights: could not load weights: %s", exc)
|
||||
return ""
|
||||
|
||||
deltas: dict[str, float] = {}
|
||||
for key, concept in _KEY_TO_CONCEPT.items():
|
||||
default_val = AI_DEFAULT_WEIGHTS.get(key, 0.0)
|
||||
learned_val = learned.get(key, default_val)
|
||||
delta = learned_val - default_val
|
||||
if abs(delta) >= threshold_delta:
|
||||
deltas[concept] = delta
|
||||
|
||||
if not deltas:
|
||||
# No meaningful drift yet — don't inject noise into prompts
|
||||
return ""
|
||||
|
||||
# Sort by magnitude, take top_n
|
||||
sorted_items = sorted(deltas.items(), key=lambda x: -abs(x[1]))[:top_n]
|
||||
boosted = [c for c, d in sorted_items if d > 0]
|
||||
penalized = [c for c, d in sorted_items if d < 0]
|
||||
|
||||
lines = [
|
||||
"[Simulation Insights] Data from thousands of Commander games in this lab suggests:",
|
||||
]
|
||||
if boosted:
|
||||
lines.append(f" - PRIORITIZE cards with: {', '.join(boosted)}")
|
||||
if penalized:
|
||||
lines.append(f" - DEPRIORITIZE cards with: {', '.join(penalized)}")
|
||||
lines.append(
|
||||
"Use this to guide your selections where multiple options are otherwise comparable."
|
||||
)
|
||||
|
||||
result = "\n".join(lines)
|
||||
logger.debug("sim_insights: injecting %d concept(s) into prompt", len(sorted_items))
|
||||
return result
|
||||
@@ -13,6 +13,7 @@ from typing import Dict, List, Optional
|
||||
|
||||
from ..api import edhrec, scryfall
|
||||
from ..api import ollama_client as ollama
|
||||
from ..api.sim_insights import get_sim_insights
|
||||
from ..core.collection_filter import filter_names_by_collection
|
||||
from ..core.models import BuildRequest, BuildResult, CardEntry, CommanderDeck, DeckRatios
|
||||
from ..core.rules_engine import check_ban_list, filter_by_color_identity, validate_deck
|
||||
@@ -25,19 +26,19 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
Execute the full 7-step deck build pipeline.
|
||||
|
||||
Steps:
|
||||
1. Resolve commander via Scryfall
|
||||
2. Fetch EDHrec recommendations
|
||||
3. Fetch Scryfall candidates by category
|
||||
4. Ollama: suggest additional cards + fill gaps
|
||||
5. Filter by color identity + collection + ban list
|
||||
6. Ollama: enforce deck ratios (trim/expand to 99)
|
||||
7. Ollama: assemble final structured deck JSON
|
||||
1. Resolve commander via Scryfall
|
||||
2. Fetch EDHrec recommendations
|
||||
3. Fetch Scryfall candidates by category
|
||||
4. Ollama: suggest additional cards + fill gaps
|
||||
5. Filter by color identity + collection + ban list
|
||||
6. Ollama: enforce deck ratios (trim/expand to 99)
|
||||
7. Ollama: assemble final structured deck JSON
|
||||
"""
|
||||
start = time.time()
|
||||
warnings: List[str] = []
|
||||
sources: List[str] = []
|
||||
|
||||
# ── Step 1: Resolve commander ────────────────────────────────
|
||||
# ── Step 1: Resolve commander ────────────────────────────────────────────
|
||||
logger.info(f"Step 1: Resolving commander '{request.commander_name}'")
|
||||
commander = scryfall.search_commander(request.commander_name)
|
||||
if not commander:
|
||||
@@ -50,7 +51,17 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
logger.info(f"Commander: {commander.name}, CI: {ci_list}")
|
||||
sources.append("scryfall")
|
||||
|
||||
# ── Step 2: Fetch EDHrec recommendations ─────────────────────
|
||||
# Load simulation insights once; injected into Ollama prompts in Steps 4 & 6.
|
||||
# Returns "" when learned_weights.json doesn't exist or has no meaningful drift
|
||||
# yet — so behaviour is identical to before until real sim data is available.
|
||||
sim_context = get_sim_insights()
|
||||
if sim_context:
|
||||
logger.info("Sim insights available — injecting into Ollama prompts")
|
||||
sources.append("sim_weights")
|
||||
else:
|
||||
logger.debug("No sim insights available (run update_weights.py after overnight sims)")
|
||||
|
||||
# ── Step 2: Fetch EDHrec recommendations ─────────────────────────────
|
||||
logger.info(f"Step 2: Fetching EDHrec data for '{commander.name}'")
|
||||
edhrec_cards = edhrec.get_recommended_cards(commander.name)
|
||||
if edhrec_cards:
|
||||
@@ -59,7 +70,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
else:
|
||||
warnings.append("EDHrec returned no data — relying on Scryfall + Ollama")
|
||||
|
||||
# ── Step 3: Fetch Scryfall candidates by category ────────────
|
||||
# ── Step 3: Fetch Scryfall candidates by category ──────────────────────
|
||||
logger.info("Step 3: Fetching Scryfall candidates by category")
|
||||
scryfall_candidates: Dict[str, List[CardEntry]] = {
|
||||
"ramp": scryfall.search_ramp(commander_ci),
|
||||
@@ -70,7 +81,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
for cat, cards in scryfall_candidates.items():
|
||||
logger.info(f" Scryfall {cat}: {len(cards)} candidates")
|
||||
|
||||
# ── Step 4: Ollama suggestions to fill gaps ──────────────────
|
||||
# ── Step 4: Ollama suggestions to fill gaps ──────────────────────────
|
||||
logger.info("Step 4: Ollama suggesting additional cards")
|
||||
|
||||
# Collect all names we already have
|
||||
@@ -79,7 +90,9 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
all_names.extend(c.name for c in cards)
|
||||
all_names = list(set(all_names))
|
||||
|
||||
# Ask Ollama to suggest cards for categories that need more
|
||||
# Ask Ollama to suggest cards for categories that need more.
|
||||
# sim_context is forwarded so the LLM factors in what traits have
|
||||
# been statistically successful in our own game simulations.
|
||||
categories_to_fill = ["synergy", "protection", "wincon"]
|
||||
ollama_suggestions: Dict[str, List[str]] = {}
|
||||
|
||||
@@ -91,6 +104,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
count=15,
|
||||
exclude=all_names,
|
||||
strategy_notes=request.strategy_notes,
|
||||
sim_context=sim_context,
|
||||
)
|
||||
ollama_suggestions[category] = suggested
|
||||
all_names.extend(suggested)
|
||||
@@ -98,7 +112,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
|
||||
sources.append("ollama")
|
||||
|
||||
# ── Step 5: Filter (color identity, collection, ban list) ────
|
||||
# ── Step 5: Filter (color identity, collection, ban list) ────────────────
|
||||
logger.info("Step 5: Filtering candidates")
|
||||
|
||||
# Build a unified name pool per category
|
||||
@@ -140,7 +154,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
n for n in cards_by_category[cat] if n not in BANNED_CARDS
|
||||
]
|
||||
|
||||
# ── Step 6: Ollama enforce ratios ────────────────────────────
|
||||
# ── Step 6: Ollama enforce ratios ────────────────────────────────────
|
||||
logger.info("Step 6: Ollama enforcing deck ratios")
|
||||
target_ratios = {
|
||||
"lands": request.ratios.lands,
|
||||
@@ -153,10 +167,13 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
"uncategorized": request.ratios.uncategorized,
|
||||
}
|
||||
|
||||
# sim_context is forwarded so Ollama preferentially retains cards
|
||||
# matching high-value sim traits when trimming over-filled categories.
|
||||
adjusted = ollama.enforce_deck_ratios(
|
||||
cards_by_category=cards_by_category,
|
||||
target_ratios=target_ratios,
|
||||
commander_name=commander.name,
|
||||
sim_context=sim_context,
|
||||
)
|
||||
|
||||
# Verify total is 99
|
||||
@@ -165,25 +182,95 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
warnings.append(f"Ollama returned {total} cards instead of 99 — adjusting")
|
||||
logger.warning(f"Ratio enforcement returned {total} cards")
|
||||
|
||||
# ── Step 7: Assemble final deck ──────────────────────────────
|
||||
# ── Step 7: Assemble final deck ────────────────────────────────────────
|
||||
logger.info("Step 7: Assembling final deck")
|
||||
|
||||
# Convert to CardEntry list via Scryfall batch lookup
|
||||
all_card_names = []
|
||||
for names in adjusted.values():
|
||||
all_card_names.extend(names)
|
||||
# Build a globally-deduplicated name list. enforce_deck_ratios can
|
||||
# return the same card name under multiple category keys (e.g. a
|
||||
# protection card that also appears in synergy). Without this step
|
||||
# get_cards_by_names produces duplicate CardEntry objects that
|
||||
# immediately fail the CommanderDeck singleton validator.
|
||||
seen_names: dict[str, str] = {} # name -> first category that claimed it
|
||||
for cat, names in adjusted.items():
|
||||
for name in names:
|
||||
if name not in seen_names:
|
||||
seen_names[name] = cat
|
||||
else:
|
||||
logger.warning(
|
||||
"Duplicate card '%s' found in categories '%s' and '%s' — "
|
||||
"keeping first assignment, dropping second",
|
||||
name, seen_names[name], cat,
|
||||
)
|
||||
|
||||
if len(seen_names) < sum(len(v) for v in adjusted.values()):
|
||||
warnings.append(
|
||||
"enforce_deck_ratios returned duplicate card names across categories; "
|
||||
"duplicates were silently dropped before singleton validation."
|
||||
)
|
||||
|
||||
all_card_names = list(seen_names.keys())
|
||||
|
||||
card_entries = scryfall.get_cards_by_names(all_card_names)
|
||||
|
||||
# Map back categories
|
||||
name_to_cat: Dict[str, str] = {}
|
||||
for cat, names in adjusted.items():
|
||||
for name in names:
|
||||
name_to_cat[name] = cat
|
||||
|
||||
# Safety-net: deduplicate CardEntry list by name in case Scryfall
|
||||
# returns multiple printings for the same name.
|
||||
seen_entry_names: set[str] = set()
|
||||
unique_entries: List[CardEntry] = []
|
||||
for entry in card_entries:
|
||||
if entry.name in name_to_cat:
|
||||
entry.category = name_to_cat[entry.name]
|
||||
if entry.name not in seen_entry_names:
|
||||
seen_entry_names.add(entry.name)
|
||||
unique_entries.append(entry)
|
||||
else:
|
||||
logger.warning("Scryfall returned duplicate entry for '%s' — skipping", entry.name)
|
||||
card_entries = unique_entries
|
||||
|
||||
# Map back categories
|
||||
for entry in card_entries:
|
||||
if entry.name in seen_names:
|
||||
entry.category = seen_names[entry.name]
|
||||
|
||||
# -- Backfill basic lands to reach exactly 99 cards ----------------
|
||||
_BACKFILL_COLOR_TO_BASIC = {
|
||||
"W": "Plains", "U": "Island", "B": "Swamp",
|
||||
"R": "Mountain", "G": "Forest",
|
||||
}
|
||||
_BASIC_LAND_NAMES = set(_BACKFILL_COLOR_TO_BASIC.values()) | {"Wastes"}
|
||||
_current_total = sum(e.quantity for e in card_entries)
|
||||
_shortfall = 99 - _current_total
|
||||
if _shortfall > 0:
|
||||
logger.warning(
|
||||
"Deck has %d cards after dedup -- backfilling %d basic land(s)",
|
||||
_current_total, _shortfall,
|
||||
)
|
||||
warnings.append(
|
||||
f"Backfilled {_shortfall} basic land(s) to reach 99 cards"
|
||||
)
|
||||
_basics = [
|
||||
_BACKFILL_COLOR_TO_BASIC[c]
|
||||
for c in sorted(commander_ci)
|
||||
if c in _BACKFILL_COLOR_TO_BASIC
|
||||
] or ["Wastes"]
|
||||
# Distribute evenly across colors
|
||||
_per_color = _shortfall // len(_basics)
|
||||
_remainder = _shortfall % len(_basics)
|
||||
for _i, _bname in enumerate(_basics):
|
||||
_qty = _per_color + (1 if _i < _remainder else 0)
|
||||
if _qty <= 0:
|
||||
continue
|
||||
# Check if this basic is already in card_entries
|
||||
_found = False
|
||||
for _ce in card_entries:
|
||||
if _ce.name == _bname:
|
||||
_ce.quantity += _qty
|
||||
_found = True
|
||||
break
|
||||
if not _found:
|
||||
card_entries.append(CardEntry(
|
||||
name=_bname,
|
||||
quantity=_qty,
|
||||
category="lands",
|
||||
type_line="Basic Land",
|
||||
))
|
||||
|
||||
# Build the deck
|
||||
elapsed = time.time() - start
|
||||
|
||||
@@ -39,10 +39,22 @@ _FORGE_CACHE_LOADED: bool = False
|
||||
|
||||
|
||||
def _get_cards_folder() -> Path:
|
||||
"""Resolve the Forge cardsfolder directory from CFG.forge_dir."""
|
||||
"""Resolve the Forge cardsfolder directory from CFG.forge_dir.
|
||||
|
||||
Supports two layouts:
|
||||
A) forge_dir = .../forge-repo -> forge-gui/res/cardsfolder
|
||||
B) forge_dir = .../forge-repo/forge-gui -> res/cardsfolder
|
||||
"""
|
||||
if not CFG.forge_dir:
|
||||
return Path("")
|
||||
return Path(CFG.forge_dir) / "forge-gui" / "res" / "cardsfolder"
|
||||
base = Path(CFG.forge_dir)
|
||||
candidate_a = base / "forge-gui" / "res" / "cardsfolder"
|
||||
candidate_b = base / "res" / "cardsfolder"
|
||||
if candidate_a.is_dir():
|
||||
return candidate_a
|
||||
if candidate_b.is_dir():
|
||||
return candidate_b
|
||||
return candidate_a # fall back so warning shows the expected path
|
||||
|
||||
|
||||
def _parse_forge_file(filepath: Path) -> Optional[ForgeCardData]:
|
||||
@@ -58,7 +70,6 @@ def _parse_forge_file(filepath: Path) -> Optional[ForgeCardData]:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Name
|
||||
if line.startswith("Name:"):
|
||||
data.name = line.split(":", 1)[1].strip()
|
||||
@@ -150,3 +161,11 @@ def lookup_forge_card(card_name: str) -> Optional[ForgeCardData]:
|
||||
"""Look up a card by name from the Forge cache. Returns None on miss."""
|
||||
_load_forge_cache()
|
||||
return _FORGE_CACHE.get((card_name or "").lower().strip())
|
||||
|
||||
|
||||
def reset_forge_cache() -> None:
|
||||
"""Force a reload of the Forge card cache on next lookup."""
|
||||
global _FORGE_CACHE_LOADED
|
||||
_FORGE_CACHE.clear()
|
||||
_FORGE_CACHE_LOADED = False
|
||||
_log.info("Forge card cache cleared — will reload on next lookup")
|
||||
@@ -23,9 +23,9 @@ from commander_ai_lab.sim.models import Card
|
||||
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card, ForgeCardData
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# AI Weights (ported from AI_DEFAULT_WEIGHTS in app.js)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
AI_DEFAULT_WEIGHTS: dict[str, float] = {
|
||||
# Card scoring
|
||||
@@ -107,10 +107,10 @@ def load_weights(path: Optional[str] = None) -> dict[str, float]:
|
||||
return dict(AI_DEFAULT_WEIGHTS)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Card Enrichment — guess type/cost for cards without Scryfall data
|
||||
# Ported from enrichCardForSim() in deck_tester_js.txt
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
_BASIC_LAND_COLORS = {
|
||||
"plains": "W",
|
||||
@@ -141,6 +141,21 @@ _KNOWN_CARDS: list[tuple[re.Pattern, dict]] = [
|
||||
]
|
||||
|
||||
|
||||
# Nonbasic land name guard — compiled once at module load, not inside enrich_card().
|
||||
# Catches common land names that Forge may return with cmc=0 (causing has_real_data
|
||||
# to stay False) or that arrive with no Scryfall data at all.
|
||||
# Extended per Bug 3 to cover fetchlands, shocklands, filter lands, pain lands, etc.
|
||||
_land_kw = re.compile(
|
||||
r"\b(grove|garden|glade|falls|summit|crag|chapel|retreat|harbor|cemetery|"
|
||||
r"pool|fountain|shrine|foundry|vents|grave|crypt|tomb|ground|ruins|mesa|"
|
||||
r"tarn|delta|strand|heath|mire|bog|den|lair|hollow|flats|wastes|expanse|"
|
||||
r"vista|nexus|spire|sanctum|citadel|reaches|keeps|gate|springs|crossing|"
|
||||
r"command tower|path of ancestry|evolving wilds|terramorphic|"
|
||||
r"exotic orchard|mana confluence|city of brass|reflecting pool|"
|
||||
r"fabled passage|prismatic vista|command beacon|ash barrens)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _apply_forge_data(card: Card, forge_data: ForgeCardData) -> None:
|
||||
"""
|
||||
@@ -182,6 +197,7 @@ def _apply_forge_data(card: Card, forge_data: ForgeCardData) -> None:
|
||||
if forge_data.oracle_text and not card.oracle_text:
|
||||
card.oracle_text = forge_data.oracle_text
|
||||
|
||||
|
||||
def enrich_card(card: Card) -> Card:
|
||||
"""
|
||||
Enrich a card with type/cost heuristics if it lacks Scryfall data.
|
||||
@@ -198,8 +214,12 @@ def enrich_card(card: Card) -> Card:
|
||||
if forge_data:
|
||||
_apply_forge_data(card, forge_data) # sets oracle_text, keywords, etc.
|
||||
card.forge_enriched = True
|
||||
# Re-check: if Forge provided sufficient data, apply flags and return
|
||||
# Re-check: if Forge provided sufficient data, apply flags and return.
|
||||
# Bug 3 fix: lands always have cmc=0, so explicitly allow type_line
|
||||
# containing "land" to satisfy has_real_data even when cmc is 0.
|
||||
has_real_data = card.cmc > 0 or (card.type_line and len(card.type_line) > 5)
|
||||
if not has_real_data and card.type_line and "land" in card.type_line.lower():
|
||||
has_real_data = True # lands are valid cards even with cmc=0
|
||||
if has_real_data:
|
||||
_apply_oracle_flags(card)
|
||||
return card
|
||||
@@ -223,6 +243,14 @@ def enrich_card(card: Card) -> Card:
|
||||
_apply_oracle_flags(card)
|
||||
return card
|
||||
|
||||
# Nonbasic land name guard — catches fetchlands, shocklands, filter lands,
|
||||
# pain lands, and other common nonbasic lands BEFORE the creature fallback.
|
||||
if _land_kw.search(name_lower):
|
||||
card.type_line = "Land"
|
||||
card.cmc = 0
|
||||
_apply_oracle_flags(card)
|
||||
return card
|
||||
|
||||
# Default: treat as creature with deterministic CMC based on
|
||||
# card name hash (fixes #32 — no more random.randint jitter
|
||||
# between games for the same card).
|
||||
@@ -284,7 +312,7 @@ def _apply_oracle_flags(card: Card) -> None:
|
||||
card.is_ramp = True
|
||||
|
||||
|
||||
# ── Direct-damage (burn spell) detection ──────────────────────
|
||||
# ── Direct-damage (burn spell) detection ────────────────────────────────────
|
||||
|
||||
# Regex: "deals N damage to target|any target" (not "each creature" — that's a wipe)
|
||||
_DIRECT_DAMAGE_RE = re.compile(
|
||||
@@ -342,7 +370,7 @@ def _detect_direct_damage(card: Card, oracle: str) -> None:
|
||||
card.direct_damage_amount = int(m.group(1))
|
||||
|
||||
|
||||
# ── Multi-opponent scaling factors ────────────────────────────
|
||||
# ── Multi-opponent scaling factors ─────────────────────────────────────────────
|
||||
# Keywords / effects that become more valuable in larger pods.
|
||||
# Each entry maps a weight key → the extra bonus *per additional
|
||||
# opponent* beyond 1. 1v1 uses base weights; 4-player FFA adds
|
||||
|
||||
+42
-44
@@ -1,112 +1,110 @@
|
||||
@echo off
|
||||
REM ═══════════════════════════════════════════════════════════
|
||||
REM Commander AI Lab — One-Click Launcher (Windows)
|
||||
REM Commander AI Lab — One-Click Launcher (Windows)
|
||||
REM ═══════════════════════════════════════════════════════════
|
||||
REM
|
||||
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 - commander-ai-lab built: mvn package "-DskipTests"
|
||||
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 - commander-ai-lab built: mvn package "-DskipTests"
|
||||
REM
|
||||
REM Edit the paths below to match your system.
|
||||
REM Edit the paths below to match your system.
|
||||
REM ═══════════════════════════════════════════════════════════
|
||||
|
||||
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\forge-gui
|
||||
set FORGE_DIR=D:\ForgeCommander\forge-repo
|
||||
set FORGE_DECKS_DIR=%APPDATA%\Forge\decks\commander
|
||||
|
||||
REM ── Lab JAR (auto-detected from target/) ───────────────────
|
||||
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 PPLX_API_KEY=your-perplexity-key-here
|
||||
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
|
||||
REM Load from .env file if it exists and vars aren't already set
|
||||
if not defined XIMILAR_API_KEY (
|
||||
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"=="PPLX_API_KEY" set PPLX_API_KEY=%%B
|
||||
if "%%A"=="ANTHROPIC_API_KEY" set ANTHROPIC_API_KEY=%%B
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ╔══════════════════════════════════════════════════╗
|
||||
echo ║ Commander AI Lab — Launcher ║
|
||||
echo ╚══════════════════════════════════════════════════╝
|
||||
echo ╔══════════════════════════════════════════════════╗
|
||||
echo ║ Commander AI Lab — Launcher ║
|
||||
echo ╚══════════════════════════════════════════════════╝
|
||||
echo.
|
||||
echo Forge JAR: %FORGE_JAR%
|
||||
echo Forge Dir: %FORGE_DIR%
|
||||
echo Decks Dir: %FORGE_DECKS_DIR%
|
||||
echo Port: %LAB_PORT%
|
||||
echo Ximilar: Configured
|
||||
if defined PPLX_API_KEY (
|
||||
echo Perplexity: Configured
|
||||
echo Forge JAR: %FORGE_JAR%
|
||||
echo Forge Dir: %FORGE_DIR%
|
||||
echo Decks Dir: %FORGE_DECKS_DIR%
|
||||
echo Port: %LAB_PORT%
|
||||
echo Ximilar: Configured
|
||||
if defined ANTHROPIC_API_KEY (
|
||||
echo Claude Opus: Configured
|
||||
) else (
|
||||
echo Perplexity: Not configured ^(set PPLX_API_KEY env var for AI features^)
|
||||
echo Claude Opus: Not configured ^(set ANTHROPIC_API_KEY env var for AI features^)
|
||||
)
|
||||
echo.
|
||||
|
||||
REM ── Check prerequisites ────────────────────────────────────
|
||||
where python >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo ERROR: Python not found in PATH
|
||||
echo ERROR: Python not found in PATH
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
where java >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo ERROR: Java not found in PATH
|
||||
echo ERROR: Java not found in PATH
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%FORGE_JAR%" (
|
||||
echo ERROR: Forge JAR not found at: %FORGE_JAR%
|
||||
echo Run Forge build first:
|
||||
echo cd D:\ForgeCommander\forge-repo
|
||||
echo mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
||||
echo ERROR: Forge JAR not found at: %FORGE_JAR%
|
||||
echo Run Forge build first:
|
||||
echo cd D:\ForgeCommander\forge-repo
|
||||
echo mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ── Check if lab_api.py exists ─────────────────────────────
|
||||
if not exist "%~dp0lab_api.py" (
|
||||
echo ERROR: lab_api.py not found in %~dp0
|
||||
echo ERROR: lab_api.py not found in %~dp0
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ── Install Python deps if needed ──────────────────────────
|
||||
REM ── Install Python deps if needed ───────────────────────────
|
||||
python -c "import fastapi" >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo Installing FastAPI + Uvicorn...
|
||||
echo Installing FastAPI + Uvicorn...
|
||||
pip install fastapi uvicorn
|
||||
)
|
||||
|
||||
REM ── Launch the server ──────────────────────────────────────
|
||||
REM ── Launch the server ───────────────────────────────────────
|
||||
echo.
|
||||
echo Starting Commander AI Lab server...
|
||||
echo Web UI: http://localhost:%LAB_PORT%
|
||||
echo API: http://localhost:%LAB_PORT%/docs
|
||||
echo Press Ctrl+C to stop.
|
||||
echo Starting Commander AI Lab server...
|
||||
echo Web UI: http://localhost:%LAB_PORT%
|
||||
echo API: http://localhost:%LAB_PORT%/docs
|
||||
echo Press Ctrl+C to stop.
|
||||
echo.
|
||||
|
||||
if defined PPLX_API_KEY (
|
||||
if defined ANTHROPIC_API_KEY (
|
||||
python "%~dp0lab_api.py" ^
|
||||
--forge-jar "%FORGE_JAR%" ^
|
||||
--forge-dir "%FORGE_DIR%" ^
|
||||
--forge-decks-dir "%FORGE_DECKS_DIR%" ^
|
||||
--port %LAB_PORT% ^
|
||||
--ximilar-key "%XIMILAR_API_KEY%" ^
|
||||
--pplx-key "%PPLX_API_KEY%"
|
||||
--anthropic-key "%ANTHROPIC_API_KEY%"
|
||||
) else (
|
||||
python "%~dp0lab_api.py" ^
|
||||
--forge-jar "%FORGE_JAR%" ^
|
||||
|
||||
@@ -49,9 +49,9 @@ def _make_conn() -> sqlite3.Connection:
|
||||
|
||||
|
||||
def _make_generator(conn: sqlite3.Connection) -> DeckGeneratorV3:
|
||||
"""Build a DeckGeneratorV3 with a mock Perplexity client."""
|
||||
"""Build a DeckGeneratorV3 with a mock Anthropic Claude Opus client."""
|
||||
return DeckGeneratorV3(
|
||||
pplx_client=MagicMock(),
|
||||
anthropic_client=MagicMock(),
|
||||
db_conn_factory=lambda: conn,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for RAG Phase 3 — coach prompt injection."""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from coach.prompt_template import _format_rag_cards, build_user_prompt
|
||||
|
||||
|
||||
class TestFormatRagCards:
|
||||
"""Tests for _format_rag_cards helper."""
|
||||
|
||||
def test_empty_list_returns_fallback(self):
|
||||
assert _format_rag_cards([]) == "No RAG results available."
|
||||
|
||||
def test_none_returns_fallback(self):
|
||||
# build_user_prompt passes rag_cards or [] so None shouldn't happen,
|
||||
# but _format_rag_cards should handle it gracefully
|
||||
assert "No RAG" in _format_rag_cards([])
|
||||
|
||||
def test_single_card_formatted(self):
|
||||
cards = [{
|
||||
"name": "Sol Ring",
|
||||
"types": "Artifact",
|
||||
"mana_value": 1,
|
||||
"mana_cost": "{1}",
|
||||
"distance": 0.123,
|
||||
"text": "Tap: Add two colorless mana.",
|
||||
}]
|
||||
result = _format_rag_cards(cards)
|
||||
assert "Sol Ring" in result
|
||||
assert "{1}" in result
|
||||
assert "MV:1" in result
|
||||
assert "0.123" in result
|
||||
assert "Tap: Add two colorless" in result
|
||||
|
||||
def test_max_15_cards(self):
|
||||
cards = [{"name": f"Card {i}", "types": "Creature",
|
||||
"mana_value": i, "mana_cost": f"{{{i}}}",
|
||||
"distance": 0.1 * i, "text": ""} for i in range(20)]
|
||||
result = _format_rag_cards(cards)
|
||||
assert "Card 14" in result
|
||||
assert "Card 15" not in result
|
||||
|
||||
def test_missing_fields_use_defaults(self):
|
||||
cards = [{"name": "Mystery Card"}]
|
||||
result = _format_rag_cards(cards)
|
||||
assert "Mystery Card" in result
|
||||
assert "MV:?" in result
|
||||
|
||||
|
||||
class TestBuildUserPromptRagParam:
|
||||
"""Tests that build_user_prompt accepts and renders rag_cards."""
|
||||
|
||||
@patch("coach.prompt_template._format_rag_cards")
|
||||
def test_rag_cards_passed_to_formatter(self, mock_fmt):
|
||||
mock_fmt.return_value = "MOCK_RAG_OUTPUT"
|
||||
# We can't easily build a full DeckReport here without fixtures,
|
||||
# so just verify the function signature accepts rag_cards.
|
||||
# A full integration test would need the DeckReport factory.
|
||||
assert callable(build_user_prompt)
|
||||
import inspect
|
||||
sig = inspect.signature(build_user_prompt)
|
||||
assert "rag_cards" in sig.parameters
|
||||
|
||||
def test_rag_cards_default_is_none(self):
|
||||
import inspect
|
||||
sig = inspect.signature(build_user_prompt)
|
||||
param = sig.parameters["rag_cards"]
|
||||
assert param.default is None
|
||||
|
||||
|
||||
class TestRagQueryIntegration:
|
||||
"""Tests that coach_service calls query_cards (mocked)."""
|
||||
|
||||
@patch("services.rag_store.query_cards", return_value=[
|
||||
{"name": "Arcane Signet", "types": "Artifact", "mana_value": 2,
|
||||
"mana_cost": "{2}", "distance": 0.05, "text": "Tap: Add one mana."},
|
||||
])
|
||||
def test_query_cards_called_in_coaching(self, mock_qc):
|
||||
"""Smoke test: import doesn't crash."""
|
||||
from services.rag_store import query_cards
|
||||
result = query_cards("Atraxa commander deck", n=15, colors=["W", "U", "B", "G"])
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Arcane Signet"
|
||||
mock_qc.assert_called_once()
|
||||
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
tests/test_rag_store.py
|
||||
=======================
|
||||
Unit tests for services/rag_store.py.
|
||||
|
||||
All tests use mocked ChromaDB and Ollama dependencies — no external services
|
||||
or network access required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, mock_open
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to reset module-level singletons between tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _reset_rag_module():
|
||||
"""Reset all module-level state in rag_store between tests."""
|
||||
import services.rag_store as rs
|
||||
rs._rag_ready = False
|
||||
rs._chroma_client = None
|
||||
rs._chroma_collection = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_rag_state():
|
||||
_reset_rag_module()
|
||||
yield
|
||||
_reset_rag_module()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_document
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildDocument:
|
||||
def test_includes_all_four_fields(self):
|
||||
from services.rag_store import _build_document
|
||||
card = {
|
||||
"name": "Rampant Growth",
|
||||
"mana_cost": "{1}{G}",
|
||||
"type_line": "Sorcery",
|
||||
"oracle_text": "Search your library for a basic land card.",
|
||||
}
|
||||
doc = _build_document(card)
|
||||
assert "Rampant Growth" in doc
|
||||
assert "{1}{G}" in doc
|
||||
assert "Sorcery" in doc
|
||||
assert "Search your library" in doc
|
||||
|
||||
def test_mana_cost_present_in_document(self):
|
||||
"""Regression: mana_cost was previously missing (bug #128)."""
|
||||
from services.rag_store import _build_document
|
||||
card = {"name": "Lightning Bolt", "mana_cost": "{R}", "type_line": "Instant", "oracle_text": "Deal 3 damage."}
|
||||
doc = _build_document(card)
|
||||
assert "{R}" in doc
|
||||
|
||||
def test_missing_mana_cost_skipped(self):
|
||||
from services.rag_store import _build_document
|
||||
card = {"name": "Plains", "mana_cost": "", "type_line": "Basic Land", "oracle_text": ""}
|
||||
doc = _build_document(card)
|
||||
# Should not produce " | | " style empty fields
|
||||
assert "Plains" in doc
|
||||
assert doc.count(" | | ") == 0
|
||||
|
||||
def test_empty_card_returns_name_only(self):
|
||||
from services.rag_store import _build_document
|
||||
card = {"name": "Unnamed"}
|
||||
doc = _build_document(card)
|
||||
assert doc == "Unnamed"
|
||||
|
||||
def test_pipe_delimiter_format(self):
|
||||
from services.rag_store import _build_document
|
||||
card = {"name": "A", "mana_cost": "B", "type_line": "C", "oracle_text": "D"}
|
||||
assert _build_document(card) == "A | B | C | D"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildMetadata:
|
||||
def test_color_identity_json_string_parsed(self):
|
||||
from services.rag_store import _build_metadata
|
||||
card = {"name": "X", "color_identity": '["W", "U"]', "cmc": 3}
|
||||
meta = _build_metadata(card)
|
||||
assert meta["color_identity"] == "U,W" # sorted
|
||||
|
||||
def test_color_identity_list_input(self):
|
||||
from services.rag_store import _build_metadata
|
||||
card = {"name": "X", "color_identity": ["B", "G"], "cmc": 2}
|
||||
meta = _build_metadata(card)
|
||||
assert meta["color_identity"] == "B,G"
|
||||
|
||||
def test_colorless_card(self):
|
||||
from services.rag_store import _build_metadata
|
||||
card = {"name": "Sol Ring", "color_identity": "[]", "cmc": 1}
|
||||
meta = _build_metadata(card)
|
||||
assert meta["color_identity"] == ""
|
||||
|
||||
def test_malformed_color_identity_defaults_empty(self):
|
||||
from services.rag_store import _build_metadata
|
||||
card = {"name": "X", "color_identity": "not-json", "cmc": 0}
|
||||
meta = _build_metadata(card)
|
||||
assert meta["color_identity"] == ""
|
||||
|
||||
def test_cmc_none_defaults_zero(self):
|
||||
from services.rag_store import _build_metadata
|
||||
card = {"name": "X", "cmc": None}
|
||||
meta = _build_metadata(card)
|
||||
assert meta["cmc"] == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _read_build_meta / _write_build_meta
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildMeta:
|
||||
def test_write_then_read_roundtrip(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
original_dir = rs.RAG_CHROMA_DIR
|
||||
original_path = rs.BUILD_META_PATH
|
||||
try:
|
||||
rs.RAG_CHROMA_DIR = tmp_path
|
||||
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
|
||||
rs._write_build_meta(card_count=12345)
|
||||
meta = rs._read_build_meta()
|
||||
assert meta["card_count"] == 12345
|
||||
assert abs(meta["built_at"] - time.time()) < 5
|
||||
finally:
|
||||
rs.RAG_CHROMA_DIR = original_dir
|
||||
rs.BUILD_META_PATH = original_path
|
||||
|
||||
def test_read_missing_returns_empty_dict(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
original_path = rs.BUILD_META_PATH
|
||||
try:
|
||||
rs.BUILD_META_PATH = tmp_path / "nonexistent.json"
|
||||
assert rs._read_build_meta() == {}
|
||||
finally:
|
||||
rs.BUILD_META_PATH = original_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _collection_is_current
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCollectionIsCurrent:
|
||||
def _make_collection(self, count: int) -> MagicMock:
|
||||
col = MagicMock()
|
||||
col.count.return_value = count
|
||||
return col
|
||||
|
||||
def test_empty_collection_returns_false(self):
|
||||
from services.rag_store import _collection_is_current
|
||||
col = self._make_collection(0)
|
||||
assert _collection_is_current(col, 1000) is False
|
||||
|
||||
def test_current_collection_returns_true(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
original_path = rs.BUILD_META_PATH
|
||||
try:
|
||||
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
|
||||
rs._write_build_meta(card_count=1000)
|
||||
col = self._make_collection(1000)
|
||||
assert rs._collection_is_current(col, 1000) is True
|
||||
finally:
|
||||
rs.BUILD_META_PATH = original_path
|
||||
|
||||
def test_stale_by_age_returns_false(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
original_path = rs.BUILD_META_PATH
|
||||
try:
|
||||
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
|
||||
# Write a built_at 15 days ago
|
||||
rs.BUILD_META_PATH.write_text(
|
||||
json.dumps({"built_at": time.time() - (15 * 86400), "card_count": 1000}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
col = self._make_collection(1000)
|
||||
assert rs._collection_is_current(col, 1000) is False
|
||||
finally:
|
||||
rs.BUILD_META_PATH = original_path
|
||||
|
||||
def test_count_mismatch_returns_false(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
original_path = rs.BUILD_META_PATH
|
||||
try:
|
||||
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
|
||||
rs._write_build_meta(card_count=500)
|
||||
col = self._make_collection(500) # chroma has 500
|
||||
assert rs._collection_is_current(col, 1000) is False # bulk has 1000
|
||||
finally:
|
||||
rs.BUILD_META_PATH = original_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# query_cards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQueryCards:
|
||||
def test_returns_empty_when_not_ready(self):
|
||||
from services.rag_store import query_cards
|
||||
# _rag_ready is False (reset by fixture)
|
||||
result = query_cards("draw cards")
|
||||
assert result == []
|
||||
|
||||
def test_color_filter_removes_off_color_cards(self):
|
||||
import services.rag_store as rs
|
||||
rs._rag_ready = True
|
||||
|
||||
mock_col = MagicMock()
|
||||
mock_col.query.return_value = {
|
||||
"ids": [["id1", "id2"]],
|
||||
"documents": [["Wrath | {3}{W}{W} | Sorcery | Destroy all", "Dark Ritual | {B} | Instant | Add BBB"]],
|
||||
"metadatas": [[
|
||||
{"name": "Wrath", "type_line": "Sorcery", "color_identity": "W", "cmc": 5.0, "mana_cost": "{3}{W}{W}"},
|
||||
{"name": "Dark Ritual", "type_line": "Instant", "color_identity": "B", "cmc": 1.0, "mana_cost": "{B}"},
|
||||
]],
|
||||
"distances": [[0.1, 0.2]],
|
||||
}
|
||||
rs._chroma_collection = mock_col
|
||||
|
||||
results = rs.query_cards("mana acceleration", n_results=10, color_identity=["W"])
|
||||
names = [r["name"] for r in results]
|
||||
assert "Wrath" in names
|
||||
assert "Dark Ritual" not in names
|
||||
|
||||
def test_distance_converted_to_score_in_route(self):
|
||||
"""Verify distance field is present and in [0, 1] range."""
|
||||
import services.rag_store as rs
|
||||
rs._rag_ready = True
|
||||
|
||||
mock_col = MagicMock()
|
||||
mock_col.query.return_value = {
|
||||
"ids": [["id1"]],
|
||||
"documents": [["Sol Ring | {1} | Artifact | Add two colorless mana."]],
|
||||
"metadatas": [[
|
||||
{"name": "Sol Ring", "type_line": "Artifact", "color_identity": "", "cmc": 1.0, "mana_cost": "{1}"},
|
||||
]],
|
||||
"distances": [[0.15]],
|
||||
}
|
||||
rs._chroma_collection = mock_col
|
||||
|
||||
results = rs.query_cards("mana rock", n_results=1)
|
||||
assert len(results) == 1
|
||||
assert results[0]["distance"] == 0.15
|
||||
assert results[0]["name"] == "Sol Ring"
|
||||
|
||||
def test_empty_chroma_result_returns_empty_list(self):
|
||||
import services.rag_store as rs
|
||||
rs._rag_ready = True
|
||||
|
||||
mock_col = MagicMock()
|
||||
mock_col.query.return_value = {"ids": [[]], "documents": [[]], "metadatas": [[]], "distances": [[]]}
|
||||
rs._chroma_collection = mock_col
|
||||
|
||||
assert rs.query_cards("nothing") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_rag_stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetRagStats:
|
||||
def test_returns_expected_keys(self):
|
||||
import services.rag_store as rs
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 42000
|
||||
rs._chroma_collection = mock_col
|
||||
|
||||
stats = rs.get_rag_stats()
|
||||
for key in ["ready", "chroma_dir", "embedding_model", "ollama_url", "card_count", "built_at", "age_days"]:
|
||||
assert key in stats, f"Missing key: {key}"
|
||||
|
||||
def test_card_count_from_collection(self):
|
||||
import services.rag_store as rs
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 55000
|
||||
rs._chroma_collection = mock_col
|
||||
|
||||
stats = rs.get_rag_stats()
|
||||
assert stats["card_count"] == 55000
|
||||
|
||||
def test_ready_false_when_not_initialized(self):
|
||||
from services.rag_store import get_rag_stats
|
||||
stats = get_rag_stats()
|
||||
assert stats["ready"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_index (public API for POST /api/rag/build)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildIndex:
|
||||
def test_skips_rebuild_when_current(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
original_path = rs.BUILD_META_PATH
|
||||
original_dir = rs.RAG_CHROMA_DIR
|
||||
try:
|
||||
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
|
||||
rs.RAG_CHROMA_DIR = tmp_path
|
||||
rs._write_build_meta(card_count=1000)
|
||||
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 1000
|
||||
rs._chroma_collection = mock_col
|
||||
|
||||
with patch("services.rag_store._get_collection", return_value=mock_col), \
|
||||
patch("services.scryfall_bulk.get_stats", return_value={"card_count": 1000}):
|
||||
result = rs.build_index(force=False)
|
||||
|
||||
assert result.get("skipped") is True
|
||||
finally:
|
||||
rs.BUILD_META_PATH = original_path
|
||||
rs.RAG_CHROMA_DIR = original_dir
|
||||
|
||||
def test_force_triggers_rebuild(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
with patch("services.rag_store._build_collection_from_bulk") as mock_build:
|
||||
mock_build.return_value = {"indexed": 500, "failed": 0}
|
||||
result = rs.build_index(force=True, batch_size=250)
|
||||
mock_build.assert_called_once_with(batch_size=250)
|
||||
assert result["indexed"] == 500
|
||||
assert rs._rag_ready is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failure tracking in _build_collection_from_bulk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildCollectionFailureTracking:
|
||||
def test_high_failure_rate_raises_runtime_error(self, tmp_path):
|
||||
"""If >1% of batches fail, a RuntimeError should be raised."""
|
||||
import services.rag_store as rs
|
||||
|
||||
fake_rows = [
|
||||
{"name": f"Card {i}", "oracle_text": "text", "type_line": "Instant",
|
||||
"mana_cost": "{1}", "cmc": 1, "color_identity": "[]",
|
||||
"rarity": "common", "scryfall_id": f"id-{i}"}
|
||||
for i in range(200)
|
||||
]
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.fetchall.return_value = [types.SimpleNamespace(**r) for r in fake_rows]
|
||||
mock_conn.execute.return_value = mock_cursor
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.add.side_effect = Exception("Ollama timeout")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_collection.return_value = mock_collection
|
||||
|
||||
original_dir = rs.RAG_CHROMA_DIR
|
||||
original_path = rs.BUILD_META_PATH
|
||||
try:
|
||||
rs.RAG_CHROMA_DIR = tmp_path
|
||||
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
|
||||
with patch("services.scryfall_bulk.get_bulk_db", return_value=mock_conn), \
|
||||
patch("services.rag_store._get_chromadb_client", return_value=mock_client), \
|
||||
patch("services.rag_store._get_ollama_embedding_fn", return_value=MagicMock()), \
|
||||
patch("services.rag_store._invalidate_collection_singleton"):
|
||||
with pytest.raises(RuntimeError, match="RAG build aborted"):
|
||||
rs._build_collection_from_bulk(batch_size=50)
|
||||
finally:
|
||||
rs.RAG_CHROMA_DIR = original_dir
|
||||
rs.BUILD_META_PATH = original_path
|
||||
+3
-2
@@ -165,8 +165,9 @@ const AiLab = (() => {
|
||||
<option value="2">2</option>
|
||||
<option value="4" selected>4</option>
|
||||
<option value="8">8</option>
|
||||
<option value="12">12</option>
|
||||
<option value="16">16</option>
|
||||
<option value="16">16</option>
|
||||
<option value="24">24</option>
|
||||
<option value="32">32</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lab-control-group">
|
||||
|
||||
+5
-1
@@ -1132,7 +1132,11 @@
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
}
|
||||
td[data-card-name] .coach-card-name {
|
||||
td[data-card-name] .coach-card-name,
|
||||
[data-card-name].coach-replacement-chip,
|
||||
[data-card-name].coach-synergy-chip,
|
||||
[data-card-name].coach-pill,
|
||||
[data-card-name].coach-cl-name {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dashed var(--text-secondary, #888);
|
||||
}
|
||||
+32
-12
@@ -23,7 +23,7 @@
|
||||
<div class="coach-status-items">
|
||||
<div class="coach-status-item" id="status-llm">
|
||||
<span class="status-dot status-unknown"></span>
|
||||
<span class="status-label">Perplexity</span>
|
||||
<span class="status-label">Claude Opus</span>
|
||||
<span class="status-value" id="status-llm-value">Checking...</span>
|
||||
</div>
|
||||
<div class="coach-status-item" id="status-embeddings">
|
||||
@@ -58,7 +58,10 @@
|
||||
|
||||
<!-- Power Level Slider -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coach-power-level">Target Power Level</label>
|
||||
<label class="form-label" for="coach-power-level">
|
||||
Target Power Level
|
||||
<span class="tip-icon tip-below" data-tip="Guides the LLM on how competitive suggestions should be. 1-3 = casual jank; 4-6 = upgraded precon; 7-8 = high power; 9-10 = cEDH with fast mana and infinite combos.">?</span>
|
||||
</label>
|
||||
<div class="coach-slider-row">
|
||||
<input type="range" id="coach-power-level" min="1" max="10" value="7" />
|
||||
<span class="coach-slider-value" id="coach-power-value">7</span>
|
||||
@@ -68,7 +71,10 @@
|
||||
|
||||
<!-- Meta Focus -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coach-meta-focus">Meta Focus</label>
|
||||
<label class="form-label" for="coach-meta-focus">
|
||||
Meta Focus
|
||||
<span class="tip-icon tip-below" data-tip="The game plan the coach optimizes for. Aggro = fast damage; Control = answers and card advantage; Combo = tutors and win-con protection; Stax = slow everyone down.">?</span>
|
||||
</label>
|
||||
<select id="coach-meta-focus" class="form-select">
|
||||
<option value="">Any / Balanced</option>
|
||||
<option value="aggro">Aggro</option>
|
||||
@@ -81,7 +87,10 @@
|
||||
|
||||
<!-- Budget -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="coach-budget">Budget</label>
|
||||
<label class="form-label" for="coach-budget">
|
||||
Budget
|
||||
<span class="tip-icon tip-below" data-tip="Restricts suggestions to cards within a price range. Budget caps cards at ~$2 each; Medium at ~$10. No Limit allows any card including Reserved List staples.">?</span>
|
||||
</label>
|
||||
<select id="coach-budget" class="form-select">
|
||||
<option value="">No Limit</option>
|
||||
<option value="budget">Budget (< $2 per card)</option>
|
||||
@@ -92,7 +101,10 @@
|
||||
|
||||
<!-- Focus Areas -->
|
||||
<div class="form-group coach-field-wide">
|
||||
<label class="form-label">Focus Areas (optional)</label>
|
||||
<label class="form-label">
|
||||
Focus Areas (optional)
|
||||
<span class="tip-icon tip-below" data-tip="Narrows the coach to specific deck categories. Leaving all unchecked triggers a full-deck general analysis across all categories.">?</span>
|
||||
</label>
|
||||
<div class="coach-focus-tags">
|
||||
<label class="coach-tag"><input type="checkbox" value="ramp" /> Ramp</label>
|
||||
<label class="coach-tag"><input type="checkbox" value="card draw" /> Card Draw</label>
|
||||
@@ -108,9 +120,8 @@
|
||||
|
||||
<!-- Action Buttons Row -->
|
||||
<div class="coach-run-row">
|
||||
<button id="coach-run-btn" class="btn btn-primary btn-lg" disabled>
|
||||
<span class="coach-run-icon">⚡</span> Run Coach
|
||||
</button>
|
||||
<button id="coach-quick-btn" class="btn btn-secondary" disabled>⚡ Quick Digest</button>
|
||||
<button id="coach-run-btn" class="btn btn-primary" disabled>🔬 Full Analysis</button>
|
||||
<span class="coach-run-hint" id="coach-run-hint">Select a deck to begin</span>
|
||||
</div>
|
||||
|
||||
@@ -121,7 +132,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ Multi-turn Chat Panel ═══ -->
|
||||
<!-- Multi-turn Chat Panel -->
|
||||
<section class="panel coach-chat-section" id="coach-chat-section">
|
||||
<div class="panel-header coach-chat-header">
|
||||
<h2>Chat with Coach</h2>
|
||||
@@ -143,7 +154,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ Cards-Like Search Panel ═══ -->
|
||||
<!-- Cards-Like Search Panel -->
|
||||
<section class="panel coach-cardslike-section" id="coach-cardslike-section">
|
||||
<div class="panel-header coach-panel-collapsible" id="cardslike-header">
|
||||
<h2>Find Similar Cards</h2>
|
||||
@@ -163,7 +174,10 @@
|
||||
<button class="coach-color-pip coach-pip-G" data-color="G" title="Green">G</button>
|
||||
</div>
|
||||
<div class="form-group coach-cardslike-topn">
|
||||
<label class="form-label">Results</label>
|
||||
<label class="form-label">
|
||||
Results
|
||||
<span class="tip-icon tip-below" data-tip="How many similar cards to return. Uses vector embeddings to find cards with the most semantically similar oracle text and mechanics to your query card.">?</span>
|
||||
</label>
|
||||
<input type="number" id="cardslike-topn" class="form-input" value="10" min="1" max="50" />
|
||||
</div>
|
||||
<button id="cardslike-search-btn" class="btn btn-accent btn-sm">Search</button>
|
||||
@@ -173,7 +187,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ One-Shot Results Section ═══ -->
|
||||
<!-- One-Shot Results Section -->
|
||||
<section id="coach-results" class="coach-results" style="display:none;">
|
||||
|
||||
<!-- Apply Suggestions Bar -->
|
||||
@@ -191,6 +205,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Priority Swap (Phase 2 UI) -->
|
||||
<div id="coach-top-priority" class="coach-card" style="display:none; border-left: 4px solid var(--accent);">
|
||||
<h3>⚡ Top Priority Swap</h3>
|
||||
<div id="coach-top-priority-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Card -->
|
||||
<div class="panel" id="coach-summary-card">
|
||||
<div class="panel-header">
|
||||
|
||||
+141
-140
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Commander AI Lab — Deck Coach UI v28
|
||||
* Commander AI Lab — Deck Coach UI v29
|
||||
* ═════════════════════════════════════
|
||||
* Multi-turn chat with SSE streaming, apply suggestions,
|
||||
* cards-like search, enhanced session history.
|
||||
* v29: Quick Digest button + Top Priority Swap card.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
@@ -10,7 +11,7 @@
|
||||
|
||||
const API = window.location.origin;
|
||||
|
||||
// ── State ────────────────────────────────────────────────
|
||||
// ── State ────────────────────────────────────────────
|
||||
let chatHistory = []; // [{role, content}]
|
||||
let isStreaming = false;
|
||||
let currentSessionId = null;
|
||||
@@ -43,7 +44,8 @@
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
const url = (data.image_uris && data.image_uris.normal) || (data.card_faces && data.card_faces[0] && data.card_faces[0].image_uris && data.card_faces[0].image_uris.normal) || '';
|
||||
const url = (data.image_uris && data.image_uris.normal) ||
|
||||
(data.card_faces && data.card_faces[0] && data.card_faces[0].image_uris && data.card_faces[0].image_uris.normal) || '';
|
||||
_scryfallImgCache[cardName] = url;
|
||||
if (el.style.display !== 'none') img.src = url;
|
||||
}).catch(() => {});
|
||||
@@ -55,7 +57,7 @@
|
||||
function _positionPreview(evt) {
|
||||
if (!_previewEl) return;
|
||||
_previewEl.style.left = Math.max(0, Math.min(evt.clientX + 20, window.innerWidth - 260)) + 'px';
|
||||
_previewEl.style.top = Math.max(0, Math.min(evt.clientY - 150, window.innerHeight - 370)) + 'px';
|
||||
_previewEl.style.top = Math.max(0, Math.min(evt.clientY - 150, window.innerHeight - 370)) + 'px';
|
||||
}
|
||||
|
||||
function _hideCardPreview() {
|
||||
@@ -63,19 +65,20 @@
|
||||
}
|
||||
|
||||
function _attachCardHover(container) {
|
||||
container.querySelectorAll('td[data-card-name]').forEach(td => {
|
||||
container.querySelectorAll('[data-card-name]').forEach(td => {
|
||||
td.addEventListener('mouseenter', e => _showCardPreview(td.dataset.cardName, e));
|
||||
td.addEventListener('mousemove', e => _positionPreview(e));
|
||||
td.addEventListener('mousemove', e => _positionPreview(e));
|
||||
td.addEventListener('mouseleave', _hideCardPreview);
|
||||
});
|
||||
}
|
||||
|
||||
// ── DOM References ──────────────────────────────────────
|
||||
// ── DOM References ────────────────────────────────────
|
||||
const deckSelect = document.getElementById('coach-deck-select');
|
||||
const powerSlider = document.getElementById('coach-power-level');
|
||||
const powerValue = document.getElementById('coach-power-value');
|
||||
const metaFocus = document.getElementById('coach-meta-focus');
|
||||
const budgetSelect = document.getElementById('coach-budget');
|
||||
const quickBtn = document.getElementById('coach-quick-btn');
|
||||
const runBtn = document.getElementById('coach-run-btn');
|
||||
const runHint = document.getElementById('coach-run-hint');
|
||||
const progressEl = document.getElementById('coach-progress');
|
||||
@@ -106,6 +109,8 @@
|
||||
const rawText = document.getElementById('coach-raw-text');
|
||||
const sessionsList = document.getElementById('coach-sessions-list');
|
||||
const genReportsBtn = document.getElementById('generate-reports-btn');
|
||||
const topPriorityCard = document.getElementById('coach-top-priority');
|
||||
const topPriorityContent = document.getElementById('coach-top-priority-content');
|
||||
|
||||
// Chat
|
||||
const chatMessages = document.getElementById('coach-chat-messages');
|
||||
@@ -127,7 +132,7 @@
|
||||
const applyDeckSelect = document.getElementById('coach-apply-deck-select');
|
||||
const applyCountEl = document.getElementById('coach-apply-count');
|
||||
|
||||
// ── Init ────────────────────────────────────────────────
|
||||
// ── Init ──────────────────────────────────────────────
|
||||
|
||||
init();
|
||||
|
||||
@@ -138,11 +143,13 @@
|
||||
|
||||
deckSelect.addEventListener('change', () => {
|
||||
const hasDeck = deckSelect.value !== '';
|
||||
quickBtn.disabled = !hasDeck;
|
||||
runBtn.disabled = !hasDeck;
|
||||
chatSendBtn.disabled = !hasDeck;
|
||||
runHint.textContent = hasDeck ? 'Ready to analyze' : 'Select a deck to begin';
|
||||
});
|
||||
|
||||
quickBtn.addEventListener('click', runQuickDigest);
|
||||
runBtn.addEventListener('click', runCoach);
|
||||
genReportsBtn.addEventListener('click', generateReports);
|
||||
|
||||
@@ -198,7 +205,7 @@
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Goals Helper ────────────────────────────────────────
|
||||
// ── Goals Helper ────────────────────────────────────
|
||||
|
||||
function getGoals() {
|
||||
const goals = {};
|
||||
@@ -214,7 +221,7 @@
|
||||
return goals;
|
||||
}
|
||||
|
||||
// ── Status Check ────────────────────────────────────────
|
||||
// ── Status Check ────────────────────────────────────
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
@@ -254,18 +261,15 @@
|
||||
valueEl.textContent = text;
|
||||
}
|
||||
|
||||
// ── Load Decks ──────────────────────────────────────────
|
||||
// ── Load Decks ───────────────────────────────────────
|
||||
|
||||
async function loadDecks() {
|
||||
try {
|
||||
const res = await fetch(API + '/api/coach/decks');
|
||||
const data = await res.json();
|
||||
// Backend returns a bare array of objects, not {decks: [...]}
|
||||
const decks = Array.isArray(data) ? data : (data.decks || []);
|
||||
|
||||
while (deckSelect.options.length > 1) {
|
||||
deckSelect.remove(1);
|
||||
}
|
||||
while (deckSelect.options.length > 1) deckSelect.remove(1);
|
||||
|
||||
if (decks.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
@@ -275,7 +279,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by deck name
|
||||
decks.sort((a, b) => {
|
||||
const na = (typeof a === 'string' ? a : a.deck_name || '').toLowerCase();
|
||||
const nb = (typeof b === 'string' ? b : b.deck_name || '').toLowerCase();
|
||||
@@ -285,11 +288,9 @@
|
||||
for (const deck of decks) {
|
||||
const opt = document.createElement('option');
|
||||
if (typeof deck === 'string') {
|
||||
// Legacy: bare string ID
|
||||
opt.value = deck;
|
||||
opt.textContent = deck;
|
||||
} else {
|
||||
// Object format: {deck_id, deck_name, commander, has_report, ...}
|
||||
const slug = deck.deck_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
opt.value = slug;
|
||||
opt.textContent = deck.deck_name + (deck.commander ? ' (' + deck.commander + ')' : '');
|
||||
@@ -310,9 +311,7 @@
|
||||
const data = await res.json();
|
||||
dbDecks = data.decks || [];
|
||||
|
||||
while (applyDeckSelect.options.length > 1) {
|
||||
applyDeckSelect.remove(1);
|
||||
}
|
||||
while (applyDeckSelect.options.length > 1) applyDeckSelect.remove(1);
|
||||
|
||||
for (const deck of dbDecks) {
|
||||
const opt = document.createElement('option');
|
||||
@@ -346,11 +345,14 @@
|
||||
|
||||
const timeStr = s.timestamp ? formatTime(s.timestamp) : '';
|
||||
const isExpanded = expandedSessions[s.sessionId];
|
||||
const quickBadge = s.sessionId && s.sessionId.startsWith('quick-')
|
||||
? '<span class="coach-badge-quick" title="Quick Digest">\u26A1 Quick</span>'
|
||||
: '';
|
||||
|
||||
item.innerHTML =
|
||||
'<div class="coach-session-header" data-sid="' + escHtml(s.sessionId) + '">' +
|
||||
'<span class="coach-session-expand-arrow">' + (isExpanded ? '\u25BC' : '\u25B6') + '</span>' +
|
||||
'<span class="coach-session-deck">' + escHtml(s.deckId) + '</span>' +
|
||||
'<span class="coach-session-deck">' + escHtml(s.deckId) + quickBadge + '</span>' +
|
||||
'<span class="coach-session-summary">' + escHtml(s.summary || '(no summary)') + '</span>' +
|
||||
'<span class="coach-session-meta">' +
|
||||
'<span class="coach-session-cuts">-' + (s.cutsCount || 0) + '</span>' +
|
||||
@@ -362,14 +364,12 @@
|
||||
'<div class="coach-session-detail-loading">Loading...</div>' +
|
||||
'</div>';
|
||||
|
||||
// Click handler for expand/collapse
|
||||
item.querySelector('.coach-session-header').addEventListener('click', function () {
|
||||
toggleSession(s.sessionId);
|
||||
});
|
||||
|
||||
sessionsList.appendChild(item);
|
||||
|
||||
// If previously expanded, load detail
|
||||
if (isExpanded && sessionCache[s.sessionId]) {
|
||||
renderSessionDetail(s.sessionId, sessionCache[s.sessionId]);
|
||||
}
|
||||
@@ -383,23 +383,19 @@
|
||||
const detailEl = document.getElementById('session-detail-' + sessionId);
|
||||
const headerEl = detailEl?.previousElementSibling;
|
||||
const arrowEl = headerEl?.querySelector('.coach-session-expand-arrow');
|
||||
|
||||
if (!detailEl) return;
|
||||
|
||||
const wasHidden = detailEl.style.display === 'none';
|
||||
|
||||
if (wasHidden) {
|
||||
detailEl.style.display = 'block';
|
||||
expandedSessions[sessionId] = true;
|
||||
if (arrowEl) arrowEl.textContent = '\u25BC';
|
||||
|
||||
if (!sessionCache[sessionId]) {
|
||||
try {
|
||||
const res = await fetch(API + '/api/coach/sessions/' + encodeURIComponent(sessionId));
|
||||
if (!res.ok) throw new Error('Session not found');
|
||||
const session = await res.json();
|
||||
sessionCache[sessionId] = session;
|
||||
renderSessionDetail(sessionId, session);
|
||||
sessionCache[sessionId] = await res.json();
|
||||
renderSessionDetail(sessionId, sessionCache[sessionId]);
|
||||
} catch (e) {
|
||||
detailEl.innerHTML = '<div class="coach-session-detail-error">Failed to load session details</div>';
|
||||
}
|
||||
@@ -421,44 +417,30 @@
|
||||
const adds = session.suggestedAdds || [];
|
||||
|
||||
let html = '<div class="coach-session-detail-inner">';
|
||||
|
||||
// Summary
|
||||
if (session.summary) {
|
||||
html += '<p class="coach-session-detail-summary">' + escHtml(session.summary) + '</p>';
|
||||
}
|
||||
|
||||
// Cuts
|
||||
if (cuts.length > 0) {
|
||||
html += '<div class="coach-session-detail-group">';
|
||||
html += '<span class="coach-session-detail-label">Cuts:</span>';
|
||||
html += '<div class="coach-session-pills">';
|
||||
html += '<div class="coach-session-detail-group"><span class="coach-session-detail-label">Cuts:</span><div class="coach-session-pills">';
|
||||
for (const cut of cuts) {
|
||||
html += '<span class="coach-pill coach-pill-cut" title="' + escHtml(cut.reason || '') + '">' + escHtml(cut.cardName) + '</span>';
|
||||
html += '<span class="coach-pill coach-pill-cut" data-card-name="' + escHtml(cut.cardName) + '" title="' + escHtml(cut.reason || '') + '">' + escHtml(cut.cardName) + '</span>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// Adds
|
||||
if (adds.length > 0) {
|
||||
html += '<div class="coach-session-detail-group">';
|
||||
html += '<span class="coach-session-detail-label">Adds:</span>';
|
||||
html += '<div class="coach-session-pills">';
|
||||
html += '<div class="coach-session-detail-group"><span class="coach-session-detail-label">Adds:</span><div class="coach-session-pills">';
|
||||
for (const add of adds) {
|
||||
html += '<span class="coach-pill coach-pill-add" title="' + escHtml(add.reason || '') + '">' + escHtml(add.cardName) + '</span>';
|
||||
html += '<span class="coach-pill coach-pill-add" data-card-name="' + escHtml(add.cardName) + '" title="' + escHtml(add.reason || '') + '">' + escHtml(add.cardName) + '</span>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// Load in Chat button
|
||||
html += '<div class="coach-session-detail-actions">';
|
||||
html += '<button class="btn btn-ghost btn-sm coach-load-chat-btn" data-sid="' + escHtml(sessionId) + '">Load in Chat</button>';
|
||||
html += '<button class="btn btn-ghost btn-sm coach-view-full-btn" data-sid="' + escHtml(sessionId) + '">View Full Results</button>';
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
html += '</div></div>';
|
||||
detailEl.innerHTML = html;
|
||||
_attachCardHover(detailEl);
|
||||
|
||||
// Bind buttons
|
||||
detailEl.querySelector('.coach-load-chat-btn')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
loadSessionInChat(sessionId);
|
||||
@@ -473,10 +455,7 @@
|
||||
function loadSessionInChat(sessionId) {
|
||||
const session = sessionCache[sessionId];
|
||||
if (!session) return;
|
||||
|
||||
clearChat();
|
||||
|
||||
// Set deck selector if possible
|
||||
if (session.deckId) {
|
||||
for (let i = 0; i < deckSelect.options.length; i++) {
|
||||
if (deckSelect.options[i].value === session.deckId) {
|
||||
@@ -486,30 +465,24 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fill chat with a summary context
|
||||
const contextMsg = 'Based on a previous coaching session for this deck, here was the analysis:\n\n' +
|
||||
(session.summary || '') + '\n\n' +
|
||||
'Suggested cuts: ' + (session.suggestedCuts || []).map(c => c.cardName).join(', ') + '\n' +
|
||||
'Suggested adds: ' + (session.suggestedAdds || []).map(a => a.cardName).join(', ');
|
||||
|
||||
chatHistory.push({ role: 'assistant', content: contextMsg });
|
||||
appendChatBubble('assistant', contextMsg);
|
||||
|
||||
chatInput.focus();
|
||||
showToast('Session loaded into chat', 'success');
|
||||
}
|
||||
|
||||
// ── Generate Reports ────────────────────────────────────
|
||||
// ── Generate Reports ──────────────────────────────────
|
||||
|
||||
async function generateReports() {
|
||||
genReportsBtn.disabled = true;
|
||||
genReportsBtn.textContent = 'Generating...';
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/api/coach/reports/generate', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.count > 0) {
|
||||
genReportsBtn.textContent = data.count + ' reports generated';
|
||||
await checkStatus();
|
||||
@@ -527,14 +500,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Run Coach (one-shot) ────────────────────────────────
|
||||
// ── Quick Digest ───────────────────────────────────────
|
||||
|
||||
async function runQuickDigest() {
|
||||
const deckId = deckSelect.value;
|
||||
if (!deckId) return;
|
||||
|
||||
const goals = getGoals();
|
||||
quickBtn.disabled = true;
|
||||
runBtn.disabled = true;
|
||||
progressEl.style.display = 'flex';
|
||||
progressText.textContent = '\u26A1 Quick Digest running (~5s)...';
|
||||
resultsEl.style.display = 'none';
|
||||
|
||||
const prevError = document.querySelector('.coach-error');
|
||||
if (prevError) prevError.remove();
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/api/coach/decks/' + encodeURIComponent(deckId) + '/quick', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ goals }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
throw new Error(err.detail || 'Quick digest failed (' + res.status + ')');
|
||||
}
|
||||
const session = await res.json();
|
||||
currentSessionId = session.sessionId || null;
|
||||
displaySession(session);
|
||||
loadSessions();
|
||||
showToast('Quick Digest complete!', 'success');
|
||||
} catch (e) {
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'coach-error';
|
||||
errDiv.textContent = 'Quick digest error: ' + e.message;
|
||||
document.querySelector('.coach-config').appendChild(errDiv);
|
||||
} finally {
|
||||
quickBtn.disabled = false;
|
||||
runBtn.disabled = false;
|
||||
progressEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Full Analysis ───────────────────────────────────────
|
||||
|
||||
async function runCoach() {
|
||||
const deckId = deckSelect.value;
|
||||
if (!deckId) return;
|
||||
|
||||
const goals = getGoals();
|
||||
|
||||
quickBtn.disabled = true;
|
||||
runBtn.disabled = true;
|
||||
progressEl.style.display = 'flex';
|
||||
progressText.textContent = 'Analyzing deck and consulting LLM... This may take 30-60 seconds.';
|
||||
@@ -547,14 +563,12 @@
|
||||
const res = await fetch(API + '/api/coach/decks/' + encodeURIComponent(deckId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ goals: goals }),
|
||||
body: JSON.stringify({ goals }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
throw new Error(err.detail || 'Coach request failed (' + res.status + ')');
|
||||
}
|
||||
|
||||
const session = await res.json();
|
||||
currentSessionId = session.sessionId || null;
|
||||
displaySession(session);
|
||||
@@ -565,11 +579,45 @@
|
||||
errDiv.textContent = 'Coach error: ' + e.message;
|
||||
document.querySelector('.coach-config').appendChild(errDiv);
|
||||
} finally {
|
||||
quickBtn.disabled = false;
|
||||
runBtn.disabled = false;
|
||||
progressEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top Priority Swap ──────────────────────────────────
|
||||
|
||||
function displayTopPriority(upgradePriority) {
|
||||
if (!topPriorityCard || !topPriorityContent) return;
|
||||
if (!upgradePriority || upgradePriority.length === 0) {
|
||||
topPriorityCard.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
// Show rank-1 item (or first if rank field missing)
|
||||
const top = upgradePriority.find(u => u.rank === 1) || upgradePriority[0];
|
||||
const impactClass = {
|
||||
high: 'coach-impact-negative',
|
||||
medium: 'coach-impact-neutral',
|
||||
low: 'coach-impact-positive'
|
||||
}[top.expectedImpact] || 'coach-impact-neutral';
|
||||
|
||||
topPriorityContent.innerHTML =
|
||||
'<div class="coach-priority-swap">' +
|
||||
'<div class="coach-priority-row">' +
|
||||
'<span class="coach-priority-label">Cut</span>' +
|
||||
'<span class="coach-priority-card coach-pill-cut" data-card-name="' + escHtml(top.cut) + '">' + escHtml(top.cut) + '</span>' +
|
||||
'<span class="coach-priority-arrow">\u2192</span>' +
|
||||
'<span class="coach-priority-label">Add</span>' +
|
||||
'<span class="coach-priority-card coach-pill-add" data-card-name="' + escHtml(top.add) + '">' + escHtml(top.add) + '</span>' +
|
||||
'<span class="coach-impact ' + impactClass + '">' + escHtml(top.expectedImpact || '') + '</span>' +
|
||||
'</div>' +
|
||||
(top.reasoning ? '<p class="coach-priority-reasoning">' + escHtml(top.reasoning) + '</p>' : '') +
|
||||
'</div>';
|
||||
|
||||
_attachCardHover(topPriorityContent);
|
||||
topPriorityCard.style.display = 'block';
|
||||
}
|
||||
|
||||
// ── Display Session Results ──────────────────────────────
|
||||
|
||||
function displaySession(session) {
|
||||
@@ -579,18 +627,21 @@
|
||||
summaryText.textContent = session.summary || '(No summary provided)';
|
||||
|
||||
metaRow.innerHTML = '';
|
||||
if (session.modelUsed) addMetaChip('Model', session.modelUsed);
|
||||
if (session.promptTokens) addMetaChip('Prompt', session.promptTokens.toLocaleString() + ' tokens');
|
||||
if (session.modelUsed) addMetaChip('Model', session.modelUsed);
|
||||
if (session.promptTokens) addMetaChip('Prompt', session.promptTokens.toLocaleString() + ' tokens');
|
||||
if (session.completionTokens) addMetaChip('Completion', session.completionTokens.toLocaleString() + ' tokens');
|
||||
if (session.deckId) addMetaChip('Deck', session.deckId);
|
||||
if (session.timestamp) addMetaChip('Time', formatTime(session.timestamp));
|
||||
if (session.deckId) addMetaChip('Deck', session.deckId);
|
||||
if (session.timestamp) addMetaChip('Time', formatTime(session.timestamp));
|
||||
if (session.isQuickDigest) addMetaChip('Mode', '\u26A1 Quick Digest');
|
||||
|
||||
// Top Priority Swap
|
||||
displayTopPriority(session.upgradePriority || []);
|
||||
|
||||
// Cuts table
|
||||
const cuts = session.suggestedCuts || [];
|
||||
cutsCount.textContent = cuts.length;
|
||||
cutsBody.innerHTML = '';
|
||||
cutsAllCheck.checked = false;
|
||||
|
||||
for (const cut of cuts) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
@@ -599,7 +650,7 @@
|
||||
'<td>' + escHtml(cut.reason) + '</td>' +
|
||||
'<td><div class="coach-replacements">' +
|
||||
(cut.replacementOptions || []).map(r =>
|
||||
'<span class="coach-replacement-chip">' + escHtml(r) + '</span>'
|
||||
'<span class="coach-replacement-chip" data-card-name="' + escHtml(r) + '">' + escHtml(r) + '</span>'
|
||||
).join('') +
|
||||
'</div></td>' +
|
||||
'<td>' + formatImpact(cut.currentImpactScore) + '</td>';
|
||||
@@ -612,7 +663,6 @@
|
||||
addsCount.textContent = adds.length;
|
||||
addsBody.innerHTML = '';
|
||||
addsAllCheck.checked = false;
|
||||
|
||||
for (const add of adds) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
@@ -622,7 +672,7 @@
|
||||
'<td>' + escHtml(add.reason) + '</td>' +
|
||||
'<td><div class="coach-replacements">' +
|
||||
(add.synergyWith || []).map(s =>
|
||||
'<span class="coach-synergy-chip">' + escHtml(s) + '</span>'
|
||||
'<span class="coach-synergy-chip" data-card-name="' + escHtml(s) + '">' + escHtml(s) + '</span>'
|
||||
).join('') +
|
||||
'</div></td>';
|
||||
tr.querySelector('.coach-card-name').parentElement.dataset.cardName = add.cardName;
|
||||
@@ -651,10 +701,8 @@
|
||||
manaCard.style.display = 'none';
|
||||
}
|
||||
|
||||
// Raw text
|
||||
rawText.textContent = session.rawTextExplanation || session.summary || '(empty)';
|
||||
|
||||
|
||||
_attachCardHover(cutsBody);
|
||||
_attachCardHover(addsBody);
|
||||
updateApplyCount();
|
||||
@@ -665,31 +713,23 @@
|
||||
function updateApplyCount() {
|
||||
const cutChecked = cutsBody.querySelectorAll('input[type="checkbox"]:checked');
|
||||
const addChecked = addsBody.querySelectorAll('input[type="checkbox"]:checked');
|
||||
const total = cutChecked.length + addChecked.length;
|
||||
|
||||
applyCountEl.textContent = cutChecked.length + ' cuts, ' + addChecked.length + ' adds selected';
|
||||
applyBtn.disabled = total === 0 || !applyDeckSelect.value;
|
||||
applyBtn.disabled = (cutChecked.length + addChecked.length) === 0 || !applyDeckSelect.value;
|
||||
}
|
||||
|
||||
applyDeckSelect.addEventListener('change', updateApplyCount);
|
||||
|
||||
async function applySuggestions() {
|
||||
const deckId = parseInt(applyDeckSelect.value);
|
||||
if (!deckId) {
|
||||
showToast('Select a deck to apply suggestions to', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!deckId) { showToast('Select a deck to apply suggestions to', 'warning'); return; }
|
||||
|
||||
const acceptedCuts = [];
|
||||
cutsBody.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
|
||||
const name = cb.dataset.card;
|
||||
if (name) acceptedCuts.push(name);
|
||||
if (cb.dataset.card) acceptedCuts.push(cb.dataset.card);
|
||||
});
|
||||
|
||||
const acceptedAdds = [];
|
||||
addsBody.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
|
||||
const name = cb.dataset.card;
|
||||
if (name) acceptedAdds.push(name);
|
||||
if (cb.dataset.card) acceptedAdds.push(cb.dataset.card);
|
||||
});
|
||||
|
||||
if (acceptedCuts.length === 0 && acceptedAdds.length === 0) {
|
||||
@@ -711,17 +751,14 @@
|
||||
accepted_adds: acceptedAdds,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Apply failed' }));
|
||||
throw new Error(err.detail || 'Apply failed');
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
const msg = 'Applied: ' + result.total_cuts + ' cuts, ' + result.total_adds + ' adds' +
|
||||
(result.errors.length > 0 ? ' (' + result.errors.length + ' errors)' : '');
|
||||
showToast(msg, result.errors.length > 0 ? 'warning' : 'success');
|
||||
|
||||
} catch (e) {
|
||||
showToast('Apply error: ' + e.message, 'error');
|
||||
} finally {
|
||||
@@ -731,19 +768,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Multi-turn Chat ─────────────────────────────────────
|
||||
// ── Multi-turn Chat ────────────────────────────────────
|
||||
|
||||
async function sendChatMessage() {
|
||||
const text = chatInput.value.trim();
|
||||
if (!text || !deckSelect.value || isStreaming) return;
|
||||
|
||||
// Add user message
|
||||
chatHistory.push({ role: 'user', content: text });
|
||||
appendChatBubble('user', text);
|
||||
chatInput.value = '';
|
||||
chatSendBtn.disabled = true;
|
||||
|
||||
// Show typing indicator
|
||||
const typingEl = showTypingIndicator();
|
||||
isStreaming = true;
|
||||
|
||||
@@ -758,20 +793,16 @@
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Chat failed' }));
|
||||
throw new Error(err.detail || 'Chat failed (' + res.status + ')');
|
||||
}
|
||||
|
||||
// Remove typing indicator, add assistant bubble
|
||||
typingEl.remove();
|
||||
const bubbleEl = appendChatBubble('assistant', '');
|
||||
const contentEl = bubbleEl.querySelector('.coach-chat-bubble-content');
|
||||
|
||||
let fullContent = '';
|
||||
|
||||
// Read SSE stream
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
@@ -779,18 +810,14 @@
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data: ')) continue;
|
||||
|
||||
const payload = trimmed.slice(6);
|
||||
if (payload === '[DONE]') break;
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(payload);
|
||||
if (chunk.content) {
|
||||
@@ -798,17 +825,13 @@
|
||||
contentEl.innerHTML = formatMarkdown(fullContent);
|
||||
scrollChatToBottom();
|
||||
}
|
||||
} catch (e) {
|
||||
// skip malformed chunks
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize
|
||||
chatHistory.push({ role: 'assistant', content: fullContent });
|
||||
contentEl.innerHTML = formatMarkdown(fullContent);
|
||||
scrollChatToBottom();
|
||||
|
||||
} catch (e) {
|
||||
typingEl.remove();
|
||||
appendChatBubble('system', 'Error: ' + e.message);
|
||||
@@ -819,18 +842,14 @@
|
||||
}
|
||||
|
||||
function appendChatBubble(role, content) {
|
||||
// Remove welcome message
|
||||
const welcome = chatMessages.querySelector('.coach-chat-welcome');
|
||||
if (welcome) welcome.remove();
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'coach-chat-bubble coach-chat-' + role;
|
||||
|
||||
const label = role === 'user' ? 'You' : role === 'assistant' ? 'Coach' : 'System';
|
||||
bubble.innerHTML =
|
||||
'<div class="coach-chat-bubble-label">' + label + '</div>' +
|
||||
'<div class="coach-chat-bubble-content">' + (content ? formatMarkdown(content) : '') + '</div>';
|
||||
|
||||
chatMessages.appendChild(bubble);
|
||||
scrollChatToBottom();
|
||||
return bubble;
|
||||
@@ -847,9 +866,7 @@
|
||||
return el;
|
||||
}
|
||||
|
||||
function scrollChatToBottom() {
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
}
|
||||
function scrollChatToBottom() { chatMessages.scrollTop = chatMessages.scrollHeight; }
|
||||
|
||||
function clearChat() {
|
||||
chatHistory = [];
|
||||
@@ -862,7 +879,6 @@
|
||||
|
||||
function formatMarkdown(text) {
|
||||
if (!text) return '';
|
||||
// Basic markdown: bold, italic, code, line breaks
|
||||
return escHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
@@ -870,7 +886,7 @@
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// ── Cards-Like Search ───────────────────────────────────
|
||||
// ── Cards-Like Search ──────────────────────────────────
|
||||
|
||||
async function searchCardsLike() {
|
||||
const card = cardslikeInput.value.trim();
|
||||
@@ -880,16 +896,13 @@
|
||||
document.querySelectorAll('.coach-cardslike-colors .coach-color-pip.active').forEach(btn => {
|
||||
activeColors.push(btn.dataset.color);
|
||||
});
|
||||
|
||||
const topN = parseInt(cardslikeTopn.value) || 10;
|
||||
|
||||
cardslikeResults.innerHTML = '<div class="coach-cardslike-loading"><div class="spinner"></div> Searching embeddings...</div>';
|
||||
|
||||
try {
|
||||
let url = API + '/api/coach/cards-like?card=' + encodeURIComponent(card) + '&top_n=' + topN;
|
||||
if (activeColors.length > 0) {
|
||||
url += '&colors=' + activeColors.join('');
|
||||
}
|
||||
if (activeColors.length > 0) url += '&colors=' + activeColors.join('');
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
@@ -909,16 +922,14 @@
|
||||
for (const card of results) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'coach-cardslike-card';
|
||||
|
||||
const simPct = Math.round(card.similarity * 100);
|
||||
const ownedBadge = card.owned_qty > 0
|
||||
? '<span class="coach-cl-owned">' + card.owned_qty + ' owned</span>'
|
||||
: '<span class="coach-cl-unowned">not owned</span>';
|
||||
const price = card.tcg_price ? '$' + Number(card.tcg_price).toFixed(2) : '';
|
||||
|
||||
div.innerHTML =
|
||||
'<div class="coach-cl-top">' +
|
||||
'<span class="coach-cl-name">' + escHtml(card.name) + '</span>' +
|
||||
'<span class="coach-cl-name" data-card-name="' + escHtml(card.name) + '">' + escHtml(card.name) + '</span>' +
|
||||
'<span class="coach-cl-sim">' + simPct + '%</span>' +
|
||||
'</div>' +
|
||||
'<div class="coach-cl-meta">' +
|
||||
@@ -931,26 +942,24 @@
|
||||
'<div class="coach-cl-actions">' +
|
||||
'<button class="btn btn-ghost btn-xs coach-cl-chat-btn">Add to Chat</button>' +
|
||||
'</div>';
|
||||
|
||||
div.querySelector('.coach-cl-chat-btn').addEventListener('click', () => {
|
||||
chatInput.value += (chatInput.value ? ' ' : '') + card.name;
|
||||
chatInput.focus();
|
||||
showToast(card.name + ' added to chat input', 'success');
|
||||
});
|
||||
|
||||
cardslikeResults.appendChild(div);
|
||||
_attachCardHover(div);
|
||||
}
|
||||
} catch (e) {
|
||||
cardslikeResults.innerHTML = '<div class="coach-cardslike-empty">Error: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Color pip toggle
|
||||
document.querySelectorAll('.coach-cardslike-colors .coach-color-pip').forEach(btn => {
|
||||
btn.addEventListener('click', () => btn.classList.toggle('active'));
|
||||
});
|
||||
|
||||
// ── Toast ───────────────────────────────────────────────
|
||||
// ── Toast ─────────────────────────────────────────────
|
||||
|
||||
function showToast(message, type) {
|
||||
const container = document.getElementById('coach-toast-container');
|
||||
@@ -958,7 +967,6 @@
|
||||
toast.className = 'coach-toast coach-toast-' + (type || 'info');
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.classList.add('coach-toast-show'), 10);
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('coach-toast-show');
|
||||
@@ -966,7 +974,7 @@
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────
|
||||
// ── Helpers ───────────────────────────────────────────
|
||||
|
||||
function addMetaChip(label, value) {
|
||||
const chip = document.createElement('span');
|
||||
@@ -976,26 +984,19 @@
|
||||
}
|
||||
|
||||
function formatImpact(score) {
|
||||
if (score === undefined || score === null) {
|
||||
if (score === undefined || score === null)
|
||||
return '<span class="coach-impact coach-impact-neutral">\u2014</span>';
|
||||
}
|
||||
const fixed = score.toFixed(3);
|
||||
if (score < -0.01) {
|
||||
return '<span class="coach-impact coach-impact-negative">' + fixed + '</span>';
|
||||
} else if (score > 0.01) {
|
||||
return '<span class="coach-impact coach-impact-positive">+' + fixed + '</span>';
|
||||
} else {
|
||||
return '<span class="coach-impact coach-impact-neutral">' + fixed + '</span>';
|
||||
}
|
||||
if (score < -0.01) return '<span class="coach-impact coach-impact-negative">' + fixed + '</span>';
|
||||
if (score > 0.01) return '<span class="coach-impact coach-impact-positive">+' + fixed + '</span>';
|
||||
return '<span class="coach-impact coach-impact-neutral">' + fixed + '</span>';
|
||||
}
|
||||
|
||||
function formatTime(isoStr) {
|
||||
try {
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return isoStr;
|
||||
}
|
||||
} catch { return isoStr; }
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
|
||||
@@ -3147,3 +3147,88 @@
|
||||
.coll-col-reset:hover {
|
||||
background: rgba(99,102,241,0.25);
|
||||
}
|
||||
|
||||
|
||||
/* ── Bulk Action Bar ──────────────────────────────────────────── */
|
||||
.coll-bulk-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-card, #1e1e1e);
|
||||
border-top: 1px solid var(--border, #30363d);
|
||||
border-radius: 0 0 8px 8px;
|
||||
z-index: 50;
|
||||
flex-wrap: wrap;
|
||||
box-shadow: 0 -2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.coll-bulk-count {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #6366f1);
|
||||
min-width: 80px;
|
||||
}
|
||||
.coll-bulk-sep {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
padding: 0 2px;
|
||||
}
|
||||
.coll-bulk-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
.coll-bulk-btn {
|
||||
background: rgba(99,102,241,0.15);
|
||||
border: 1px solid rgba(99,102,241,0.35);
|
||||
color: var(--text, #e6edf3);
|
||||
border-radius: 5px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.coll-bulk-btn:hover:not(:disabled) {
|
||||
background: rgba(99,102,241,0.3);
|
||||
}
|
||||
.coll-bulk-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.coll-bulk-set-btn {
|
||||
background: rgba(99,102,241,0.25);
|
||||
font-weight: 600;
|
||||
}
|
||||
.coll-bulk-qty-input {
|
||||
width: 56px;
|
||||
background: var(--bg, #0d1117);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
color: var(--text, #e6edf3);
|
||||
border-radius: 5px;
|
||||
padding: 3px 6px;
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Select Checkbox Column ─────────────────────────────────── */
|
||||
.coll-th-select {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
}
|
||||
.coll-td-select {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
}
|
||||
.coll-row-check {
|
||||
cursor: pointer;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--accent, #6366f1);
|
||||
}
|
||||
.coll-row.selected-row {
|
||||
background: rgba(99,102,241,0.08);
|
||||
}
|
||||
+114
-1
@@ -164,6 +164,7 @@ const Collection = (() => {
|
||||
importHeaders: [],
|
||||
importMissingFinishNormal: true,
|
||||
exportOpen: false,
|
||||
selectedRows: new Set(),
|
||||
preferences: {
|
||||
pageSize: 50,
|
||||
sortField: 'name',
|
||||
@@ -662,6 +663,18 @@ const Collection = (() => {
|
||||
</table>
|
||||
</div>
|
||||
<div class="coll-pagination" id="coll-pagination"></div>
|
||||
<!-- Bulk Action Bar -->
|
||||
<div id="coll-bulk-bar" class="coll-bulk-bar" style="display:none">
|
||||
<span id="coll-bulk-count" class="coll-bulk-count">0 selected</span>
|
||||
<button class="coll-bulk-btn" onclick="Collection.selectAllRows()">Select All</button>
|
||||
<button class="coll-bulk-btn" onclick="Collection.clearSelection()">Clear</button>
|
||||
<span class="coll-bulk-sep">|</span>
|
||||
<span class="coll-bulk-label">Qty:</span>
|
||||
<button class="coll-bulk-btn" onclick="Collection.bulkAdjustQuantity(-1)" title="Subtract 1">−</button>
|
||||
<button class="coll-bulk-btn" onclick="Collection.bulkAdjustQuantity(1)" title="Add 1">+</button>
|
||||
<input type="number" id="coll-bulk-qty-input" class="coll-bulk-qty-input" min="0" value="1" placeholder="n">
|
||||
<button class="coll-bulk-btn coll-bulk-set-btn" onclick="Collection.bulkSetQuantity()">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1077,7 +1090,7 @@ const Collection = (() => {
|
||||
return `<th class="coll-th coll-th-${col.key} coll-th-sortable${activeClass}"
|
||||
onclick="Collection.setSort('${col.key}')">${col.label}${arrow}</th>`;
|
||||
}).join('');
|
||||
return `<thead><tr>${headers}</tr></thead>`;
|
||||
return `<thead><tr>$<th class="coll-th-select"><input type="checkbox" id="coll-select-all" onclick="Collection.toggleSelectAll(this.checked)" title="Select all"></th>{headers}</tr></thead>`;
|
||||
}
|
||||
|
||||
// ── Table Body ──────────────────────────────────────────
|
||||
@@ -1127,6 +1140,7 @@ const Collection = (() => {
|
||||
const edhrecRank = (card.edhrecRank || card.edhrec_rank) ? `#${card.edhrecRank || card.edhrec_rank}` : '—';
|
||||
|
||||
return `<tr class="coll-row" onclick="Collection.openDrawer(${card.id})" data-id="${card.id}" data-img-url="${card.imageUrl || ''}">
|
||||
<td class="coll-td coll-td-select" onclick="event.stopPropagation()"><input type="checkbox" class="coll-row-check" data-id="${card.id}" ${state.selectedRows.has(card.id) ? 'checked' : ''} onchange="Collection.toggleRowSelect(${card.id}, this.checked)"></td>
|
||||
<td class="coll-td coll-td-quantity">${escapeHtml(card.quantity ?? 1)}</td>
|
||||
<td class="coll-td coll-td-name">
|
||||
<span class="coll-card-name">${escapeHtml(card.name)}</span>
|
||||
@@ -2477,6 +2491,98 @@ const Collection = (() => {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
|
||||
// ── Bulk Row Selection & Quantity Edit ─────────────────────
|
||||
function toggleRowSelect(cardId, checked) {
|
||||
if (checked) {
|
||||
state.selectedRows.add(cardId);
|
||||
} else {
|
||||
state.selectedRows.delete(cardId);
|
||||
}
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
function toggleSelectAll(checked) {
|
||||
if (checked) {
|
||||
state.items.forEach(c => state.selectedRows.add(c.id));
|
||||
} else {
|
||||
state.selectedRows.clear();
|
||||
}
|
||||
// Update all checkboxes
|
||||
document.querySelectorAll('.coll-row-check').forEach(cb => {
|
||||
cb.checked = checked;
|
||||
});
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
function selectAllRows() {
|
||||
state.items.forEach(c => state.selectedRows.add(c.id));
|
||||
document.querySelectorAll('.coll-row-check').forEach(cb => cb.checked = true);
|
||||
const selectAllCb = document.getElementById('coll-select-all');
|
||||
if (selectAllCb) selectAllCb.checked = true;
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
state.selectedRows.clear();
|
||||
document.querySelectorAll('.coll-row-check').forEach(cb => cb.checked = false);
|
||||
const selectAllCb = document.getElementById('coll-select-all');
|
||||
if (selectAllCb) selectAllCb.checked = false;
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
function updateBulkBar() {
|
||||
const bar = document.getElementById('coll-bulk-bar');
|
||||
const countEl = document.getElementById('coll-bulk-count');
|
||||
const n = state.selectedRows.size;
|
||||
if (bar) bar.style.display = n > 0 ? 'flex' : 'none';
|
||||
if (countEl) countEl.textContent = `${n} selected`;
|
||||
}
|
||||
|
||||
async function bulkAdjustQuantity(delta) {
|
||||
if (state.selectedRows.size === 0) return;
|
||||
const ids = [...state.selectedRows];
|
||||
const btn = event.target;
|
||||
if (btn) btn.disabled = true;
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
const card = state.items.find(c => c.id === id);
|
||||
if (!card) continue;
|
||||
const newQty = Math.max(0, (card.quantity ?? 1) + delta);
|
||||
try {
|
||||
await patchCard(id, { quantity: newQty });
|
||||
card.quantity = newQty;
|
||||
successCount++;
|
||||
} catch (e) {
|
||||
console.warn('[Bulk] Failed to update card', id, e);
|
||||
}
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
renderTable();
|
||||
applyColumnVisibility();
|
||||
}
|
||||
|
||||
async function bulkSetQuantity() {
|
||||
if (state.selectedRows.size === 0) return;
|
||||
const input = document.getElementById('coll-bulk-qty-input');
|
||||
const newQty = input ? Math.max(0, parseInt(input.value, 10) || 0) : 0;
|
||||
const ids = [...state.selectedRows];
|
||||
const btn = document.querySelector('.coll-bulk-set-btn');
|
||||
if (btn) btn.disabled = true;
|
||||
for (const id of ids) {
|
||||
const card = state.items.find(c => c.id === id);
|
||||
if (!card) continue;
|
||||
try {
|
||||
await patchCard(id, { quantity: newQty });
|
||||
card.quantity = newQty;
|
||||
} catch (e) {
|
||||
console.warn('[Bulk] Failed to set card', id, e);
|
||||
}
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
renderTable();
|
||||
applyColumnVisibility();
|
||||
}
|
||||
|
||||
// ── Event Binding ────────────────────────────────────────
|
||||
|
||||
function bindEvents() {
|
||||
@@ -3081,5 +3187,12 @@ const Collection = (() => {
|
||||
openColumnPanel,
|
||||
closeColumnPanel,
|
||||
resetColumns,
|
||||
// Bulk selection & quantity
|
||||
toggleRowSelect,
|
||||
toggleSelectAll,
|
||||
selectAllRows,
|
||||
clearSelection,
|
||||
bulkAdjustQuantity,
|
||||
bulkSetQuantity,
|
||||
};
|
||||
})();
|
||||
+2
-2
@@ -284,7 +284,7 @@
|
||||
</div>
|
||||
<div class="deck-ai-loading" id="ai-research-loading" style="display:none">
|
||||
<div class="deck-ai-spinner"></div>
|
||||
<span>Analyzing with Perplexity AI...</span>
|
||||
<span>Analyzing with Claude Opus...</span>
|
||||
</div>
|
||||
<div class="deck-ai-results" id="ai-research-results" style="display:none"></div>
|
||||
</div>
|
||||
@@ -329,7 +329,7 @@
|
||||
</div>
|
||||
<div class="deck-ai-loading" id="ai-gen-loading" style="display:none">
|
||||
<div class="deck-ai-spinner"></div>
|
||||
<span>Generating deck with Perplexity AI...</span>
|
||||
<span>Generating deck with Claude Opus...</span>
|
||||
</div>
|
||||
<div class="deck-ai-results" id="ai-gen-results" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
+34
-50
@@ -62,9 +62,13 @@
|
||||
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</label>
|
||||
<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>
|
||||
@@ -105,7 +109,7 @@
|
||||
min="0"
|
||||
step="25"
|
||||
/>
|
||||
<select class="dg-select" id="dg-budget-mode">
|
||||
<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>
|
||||
@@ -120,25 +124,34 @@
|
||||
</div>
|
||||
<div class="dg-panel-body">
|
||||
<label class="dg-toggle-row">
|
||||
<span class="dg-toggle-label">Use my collection</span>
|
||||
<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>
|
||||
<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</label>
|
||||
<label class="dg-label">
|
||||
AI Model
|
||||
<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>
|
||||
<select class="dg-select" id="dg-model">
|
||||
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
|
||||
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Omit Cards (chips input) -->
|
||||
<!-- Omit Cards (chips input) -->
|
||||
<div class="dg-panel">
|
||||
<div class="dg-panel-header">
|
||||
<span class="dg-panel-title">Omit Cards</span>
|
||||
@@ -160,12 +173,12 @@
|
||||
<!-- Action Buttons -->
|
||||
<div class="dg-actions">
|
||||
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
|
||||
<span class="dg-btn-icon">⚡</span> Generate Deck
|
||||
<span class="dg-btn-icon">⚡</span> Generate Deck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conversation Log -->
|
||||
<div id="dg-conversation-log" class="dg-conversation-log">
|
||||
<!-- 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>
|
||||
|
||||
@@ -175,9 +188,9 @@
|
||||
<section class="dg-col-results">
|
||||
<!-- Empty State -->
|
||||
<div class="dg-empty-state" id="dg-empty-state">
|
||||
<div class="dg-empty-icon">⚡</div>
|
||||
<div class="dg-empty-icon">⚡</div>
|
||||
<div class="dg-empty-title">V3 AI Deck Generator</div>
|
||||
<div class="dg-empty-text">Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Perplexity AI with structured output.</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 -->
|
||||
@@ -185,47 +198,18 @@
|
||||
<div class="dg-spinner"></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">
|
||||
<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 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>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="dg-results" id="dg-results" style="display:none">
|
||||
|
||||
<!-- Strategy & Bracket Summary -->
|
||||
<div class="dg-strategy-bar" id="dg-strategy-bar"></div>
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="dg-stats-bar" id="dg-stats-bar"></div>
|
||||
|
||||
<!-- Substitution Summary -->
|
||||
<div class="dg-sub-summary" id="dg-sub-summary" style="display:none"></div>
|
||||
|
||||
<!-- Card Groups -->
|
||||
<div class="dg-card-groups" id="dg-card-groups"></div>
|
||||
|
||||
<!-- Export & Save Bar -->
|
||||
<div class="dg-save-bar" id="dg-save-bar">
|
||||
<button class="dg-btn dg-btn-save" id="dg-save-btn">
|
||||
<span class="dg-btn-icon">💾</span> Save to Deck Builder
|
||||
</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-regenerate-btn">
|
||||
<span class="dg-btn-icon">🔄</span> Regenerate
|
||||
</button>
|
||||
<div class="dg-export-group">
|
||||
<button class="dg-btn dg-btn-export" id="dg-export-csv">📊 CSV</button>
|
||||
<button class="dg-btn dg-btn-export" id="dg-export-dck">🎮 Forge .dck</button>
|
||||
<button class="dg-btn dg-btn-export" id="dg-export-moxfield">📋 Moxfield</button>
|
||||
<button class="dg-btn dg-btn-export" id="dg-export-shopping">🛒 Shopping List</button>
|
||||
</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 placeholder — rest generated dynamically by deckgenerator.js -->
|
||||
<div class="dg-results" id="dg-results" style="display:none"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
+44
-12
@@ -16,7 +16,7 @@
|
||||
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div>
|
||||
<h1>Closed-Loop Distillation</h1>
|
||||
<p>Iterative supervised → PPO → quality gate → merge pipeline</p>
|
||||
<p>Iterative supervised → PPO → quality gate → merge pipeline</p>
|
||||
</div>
|
||||
<button id="btn-refresh" class="btn btn-secondary" onclick="refreshAll()" title="Refresh status">
|
||||
<span class="btn-icon" id="refresh-icon">↻</span> Refresh
|
||||
@@ -59,43 +59,70 @@
|
||||
<div class="train-controls">
|
||||
<div class="param-grid form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-iterations">Max Generations</label>
|
||||
<label class="form-label" for="param-iterations">
|
||||
Max Generations
|
||||
<span class="tip-icon" data-tip="How many distillation cycles to run. Each cycle: supervised train → PPO fine-tune → quality gate. Stops early if convergence is detected.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-iterations" class="form-input" value="10" min="1" max="100" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-conv-window">Convergence Window</label>
|
||||
<label class="form-label" for="param-conv-window">
|
||||
Convergence Window
|
||||
<span class="tip-icon" data-tip="Number of recent generations to check for plateau. If ELO gain stays below threshold across this many gens, training stops early to save time.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-conv-window" class="form-input" value="3" min="2" max="10" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-sup-epochs">Supervised Epochs</label>
|
||||
<label class="form-label" for="param-sup-epochs">
|
||||
Supervised Epochs
|
||||
<span class="tip-icon" data-tip="Training epochs for the supervised step each generation. Fewer is fine since each cycle trains on incrementally better data. 30 is a solid starting point.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-sup-epochs" class="form-input" value="30" min="5" max="200" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-sup-lr">Supervised LR</label>
|
||||
<label class="form-label" for="param-sup-lr">
|
||||
Supervised LR
|
||||
<span class="tip-icon" data-tip="Learning rate for the supervised phase. 0.001 is a solid default. Lower it in later generations if the model is already strong.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-sup-lr" class="form-input" value="0.001" step="0.0001" min="0.00001" max="0.1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-grid form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-ppo-iters">PPO Iterations</label>
|
||||
<label class="form-label" for="param-ppo-iters">
|
||||
PPO Iterations
|
||||
<span class="tip-icon" data-tip="Number of PPO update steps per generation. Each step samples game episodes, computes advantages, and updates policy weights.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-ppo-iters" class="form-input" value="50" min="10" max="500" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-ppo-episodes">PPO Episodes/Iter</label>
|
||||
<label class="form-label" for="param-ppo-episodes">
|
||||
PPO Episodes/Iter
|
||||
<span class="tip-icon" data-tip="Game simulations per PPO update step. More episodes = more stable gradients but slower iterations. 64 balances speed and stability well.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-ppo-episodes" class="form-input" value="64" min="8" max="512" step="8" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-ppo-lr">PPO Learning Rate</label>
|
||||
<label class="form-label" for="param-ppo-lr">
|
||||
PPO Learning Rate
|
||||
<span class="tip-icon" data-tip="PPO step size. Keep this lower than the supervised LR. 0.0003 is the standard RL default and works well here.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-ppo-lr" class="form-input" value="0.0003" step="0.0001" min="0.00001" max="0.01" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-min-wr">Min Win Rate Gate</label>
|
||||
<label class="form-label" for="param-min-wr">
|
||||
Min Win Rate Gate
|
||||
<span class="tip-icon" data-tip="Quality gate threshold. A generation must beat the heuristic opponent at this win rate to be accepted and merged into the next cycle. 0.30 is a lenient starting gate.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-min-wr" class="form-input" value="0.30" step="0.05" min="0.1" max="0.9" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-grid form-row distill-bottom-params">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-opponent">Opponent</label>
|
||||
<label class="form-label" for="param-opponent">
|
||||
Opponent
|
||||
<span class="tip-icon" data-tip="Who the agent trains against during PPO. Heuristic is recommended for a consistent challenge. Self-play can become unstable without a strong supervised baseline first.">?</span>
|
||||
</label>
|
||||
<select id="param-opponent" class="form-select">
|
||||
<option value="heuristic">Heuristic</option>
|
||||
<option value="random">Random</option>
|
||||
@@ -103,7 +130,10 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-playstyle">Playstyle</label>
|
||||
<label class="form-label" for="param-playstyle">
|
||||
Playstyle
|
||||
<span class="tip-icon" data-tip="Deck archetype used for simulations in this distillation run. Midrange is the most balanced test bed. Match this to the archetype you want the model to master.">?</span>
|
||||
</label>
|
||||
<select id="param-playstyle" class="form-select">
|
||||
<option value="midrange">Midrange</option>
|
||||
<option value="aggro">Aggro</option>
|
||||
@@ -164,7 +194,9 @@
|
||||
<!-- ELO Rating Chart -->
|
||||
<section class="panel" id="elo-panel">
|
||||
<div class="panel-header elo-panel-header">
|
||||
<h2>ELO Ratings</h2>
|
||||
<h2>ELO Ratings
|
||||
<span class="tip-icon tip-below" data-tip="ELO scores measure relative strength. Baseline is 1200. Scores above that mean the model wins more than it loses. Tournament runs the top 10 most recent checkpoints head-to-head.">?</span>
|
||||
</h2>
|
||||
<button id="btn-run-elo" class="btn btn-sm btn-secondary" onclick="runEloTournament()">
|
||||
<span class="btn-icon">🏆</span> Run Tournament
|
||||
</button>
|
||||
|
||||
@@ -864,3 +864,92 @@ body {
|
||||
.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);
|
||||
}
|
||||
+25
-6
@@ -28,20 +28,27 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="sim-opponent-select">Opponent</label>
|
||||
<label class="form-label" for="sim-opponent-select">
|
||||
Opponent
|
||||
<span class="tip-icon tip-below" data-tip="Heuristic AI uses rule-based logic — fast and deterministic. DeepSeek AI routes decisions through your local Ollama LLM for smarter but slower play.">?</span>
|
||||
</label>
|
||||
<select id="sim-opponent-select" class="form-select">
|
||||
<option value="heuristic">Heuristic AI</option>
|
||||
<option value="deepseek">DeepSeek AI</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="sim-num-games">Games</label>
|
||||
<label class="form-label" for="sim-num-games">
|
||||
Games
|
||||
<span class="tip-icon tip-below" data-tip="Number of games to simulate. 10 is quick for a gut-check; 100+ gives statistically meaningful win rates. Each game is fully simulated end-to-end.">?</span>
|
||||
</label>
|
||||
<input type="number" id="sim-num-games" class="form-input sim-num-input" value="10" min="1" max="1000" />
|
||||
</div>
|
||||
<div class="form-group sim-field-auto sim-field-end">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" id="sim-record-logs" checked />
|
||||
Record turn logs
|
||||
<span class="tip-icon tip-below" data-tip="Saves per-turn decision snapshots to .jsonl files. Required for ML training data collection. Slightly slower — disable if you only need win rate stats.">?</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group sim-field-auto sim-field-end">
|
||||
@@ -61,19 +68,31 @@
|
||||
</div>
|
||||
<div class="deepseek-config-row">
|
||||
<div class="form-group sim-field-grow">
|
||||
<label class="form-label" for="ds-api-base">API Endpoint</label>
|
||||
<label class="form-label" for="ds-api-base">
|
||||
API Endpoint
|
||||
<span class="tip-icon tip-below" data-tip="Base URL of your Ollama server. Default is localhost:11434. Change this if Ollama is running on a different machine or WSL2 network address.">?</span>
|
||||
</label>
|
||||
<input type="text" id="ds-api-base" class="form-input" value="http://localhost:11434" placeholder="http://localhost:11434" />
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="ds-model">Model</label>
|
||||
<label class="form-label" for="ds-model">
|
||||
Model
|
||||
<span class="tip-icon tip-below" data-tip="Ollama model tag to use for AI decisions. Leave blank to auto-detect the best available model. Examples: deepseek-r1:7b, llama3:8b, mistral.">?</span>
|
||||
</label>
|
||||
<input type="text" id="ds-model" class="form-input" value="" placeholder="Auto-detect" style="width:200px" />
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="ds-temp">Temp</label>
|
||||
<label class="form-label" for="ds-temp">
|
||||
Temp
|
||||
<span class="tip-icon tip-below" data-tip="LLM sampling temperature. Lower = more deterministic decisions. 0.3 is a good balance — fully greedy (0.0) can get stuck in repetitive play patterns.">?</span>
|
||||
</label>
|
||||
<input type="number" id="ds-temp" class="form-input sim-num-input" value="0.3" min="0" max="2" step="0.1" />
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="ds-timeout">Timeout (s)</label>
|
||||
<label class="form-label" for="ds-timeout">
|
||||
Timeout (s)
|
||||
<span class="tip-icon tip-below" data-tip="Max seconds to wait for an LLM response per decision. If exceeded, falls back to heuristic AI automatically. Increase if your GPU is slow on cold inference.">?</span>
|
||||
</label>
|
||||
<input type="number" id="ds-timeout" class="form-input sim-num-input" value="10" min="1" max="60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+179
@@ -358,3 +358,182 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Intro / Getting Started Banner
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.intro-banner {
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--surface-1));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.intro-banner.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.intro-banner-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-3);
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.intro-banner-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.intro-banner-header strong {
|
||||
flex: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.intro-banner-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.intro-banner-close:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.intro-banner-lead {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--sp-3) 0;
|
||||
}
|
||||
|
||||
.intro-banner-steps {
|
||||
margin: 0 0 var(--sp-4) 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.intro-banner-steps li {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.intro-banner-steps li strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.intro-banner-steps li em {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.intro-banner-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Tooltip System (.tip-icon + data-tip)
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.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;
|
||||
/* prevent tooltip from triggering form-label hover states */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tip-icon:hover {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* The tooltip bubble itself */
|
||||
.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;
|
||||
/* reset inherited label styles */
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Arrow pointing down */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Flip tooltip below when near the top of the viewport */
|
||||
.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);
|
||||
}
|
||||
+71
-13
@@ -18,9 +18,35 @@
|
||||
<h1>ML Training Dashboard</h1>
|
||||
<p>Train, evaluate, and deploy the AI opponent policy network</p>
|
||||
</div>
|
||||
<button id="btn-refresh-data" class="btn btn-secondary" onclick="refreshAll()" title="Refresh training data status">
|
||||
<span class="btn-icon" id="refresh-icon">↻</span> Refresh
|
||||
</button>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn btn-ghost btn-sm" onclick="showIntroBanner()" title="Show getting started guide">❓ Help</button>
|
||||
<button id="btn-refresh-data" class="btn btn-secondary" onclick="refreshAll()" title="Refresh training data status">
|
||||
<span class="btn-icon" id="refresh-icon">↻</span> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intro / Getting Started Banner -->
|
||||
<div id="training-intro-banner" class="intro-banner hidden">
|
||||
<div class="intro-banner-header">
|
||||
<span class="intro-banner-icon">🎓</span>
|
||||
<strong>Getting Started with ML Training</strong>
|
||||
<button class="intro-banner-close" onclick="dismissIntroBanner()" title="Dismiss">✕</button>
|
||||
</div>
|
||||
<p class="intro-banner-lead">This page trains a neural network to play Commander decisions. Follow these steps in order on your first run:</p>
|
||||
<ol class="intro-banner-steps">
|
||||
<li><strong>Run batch simulations</strong> with ML logging enabled (from the Simulator page) to generate decision log files.</li>
|
||||
<li><strong>Start Training</strong> below — leave “Rebuild dataset” checked so it parses your logs into a dataset automatically.</li>
|
||||
<li>Check <strong>Evaluation Results</strong> after training. Aim for >60% test accuracy before moving on.</li>
|
||||
<li><em>(Optional)</em> Run <strong>PPO reinforcement learning</strong> to fine-tune the policy via self-play. Only recommended after a solid supervised baseline.</li>
|
||||
<li>Run a <strong>Tournament</strong> to benchmark your model against heuristic and random agents and get a win-rate score.</li>
|
||||
</ol>
|
||||
<div class="intro-banner-footer">
|
||||
<label class="form-checkbox" style="font-size:12px;">
|
||||
<input type="checkbox" id="intro-dont-show" /> Don’t show this again
|
||||
</label>
|
||||
<button class="btn btn-primary btn-sm" onclick="dismissIntroBanner()">Got it</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Overview Cards -->
|
||||
@@ -59,19 +85,31 @@
|
||||
<div class="train-controls">
|
||||
<div class="param-grid form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-epochs">Epochs</label>
|
||||
<label class="form-label" for="param-epochs">
|
||||
Epochs
|
||||
<span class="tip-icon" data-tip="Number of full passes over the training dataset. More epochs improve fit but risk overfitting. 50 is a safe starting point.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-epochs" class="form-input" value="50" min="1" max="500" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-lr">Learning Rate</label>
|
||||
<label class="form-label" for="param-lr">
|
||||
Learning Rate
|
||||
<span class="tip-icon" data-tip="Controls how large each weight update is. Too high causes instability; too low is very slow. 0.001 is a reliable default.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-lr" class="form-input" value="0.001" step="0.0001" min="0.00001" max="0.1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-batch">Batch Size</label>
|
||||
<label class="form-label" for="param-batch">
|
||||
Batch Size
|
||||
<span class="tip-icon" data-tip="Samples processed per gradient update. Larger batches are faster on GPU but use more VRAM. 256 is recommended for most setups.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-batch" class="form-input" value="256" min="16" max="2048" step="16" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="param-patience">Early Stop Patience</label>
|
||||
<label class="form-label" for="param-patience">
|
||||
Early Stop Patience
|
||||
<span class="tip-icon" data-tip="Stops training if validation loss doesn't improve for this many epochs in a row. Prevents wasted compute. 10 is a sensible default.">?</span>
|
||||
</label>
|
||||
<input type="number" id="param-patience" class="form-input" value="10" min="3" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,6 +117,7 @@
|
||||
<label class="form-checkbox checkbox-label">
|
||||
<input type="checkbox" id="param-rebuild" checked />
|
||||
Rebuild dataset from decision logs
|
||||
<span class="tip-icon" data-tip="Re-parses all .jsonl decision log files into a fresh dataset. Uncheck to reuse the last built dataset and save time on re-runs.">?</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="train-actions">
|
||||
@@ -132,19 +171,31 @@
|
||||
<div class="train-controls">
|
||||
<div class="param-grid form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="ppo-iterations">Iterations</label>
|
||||
<label class="form-label" for="ppo-iterations">
|
||||
Iterations
|
||||
<span class="tip-icon" data-tip="Each iteration runs N episodes, computes returns, and updates the policy. 100 iterations is a typical starting point.">?</span>
|
||||
</label>
|
||||
<input type="number" id="ppo-iterations" class="form-input" value="100" min="1" max="1000" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="ppo-episodes">Episodes/Iter</label>
|
||||
<label class="form-label" for="ppo-episodes">
|
||||
Episodes/Iter
|
||||
<span class="tip-icon" data-tip="Number of full game simulations per PPO update step. More episodes produce more stable gradients but slow down each iteration.">?</span>
|
||||
</label>
|
||||
<input type="number" id="ppo-episodes" class="form-input" value="64" min="8" max="512" step="8" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="ppo-lr">Learning Rate</label>
|
||||
<label class="form-label" for="ppo-lr">
|
||||
Learning Rate
|
||||
<span class="tip-icon" data-tip="PPO learning rate. Lower than supervised training is usually better — 0.0003 is a standard RL starting value.">?</span>
|
||||
</label>
|
||||
<input type="number" id="ppo-lr" class="form-input" value="0.0003" step="0.0001" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="ppo-opponent">Opponent</label>
|
||||
<label class="form-label" for="ppo-opponent">
|
||||
Opponent
|
||||
<span class="tip-icon" data-tip="Who the agent plays against during training. Heuristic is the best first opponent. Self-Play is more advanced and can be unstable early on.">?</span>
|
||||
</label>
|
||||
<select id="ppo-opponent" class="form-select">
|
||||
<option value="heuristic">Heuristic</option>
|
||||
<option value="random">Random</option>
|
||||
@@ -156,6 +207,7 @@
|
||||
<label class="form-checkbox checkbox-label">
|
||||
<input type="checkbox" id="ppo-load-supervised" />
|
||||
Initialize from supervised checkpoint (warm start)
|
||||
<span class="tip-icon" data-tip="Strongly recommended. Starts PPO from your best supervised model instead of random weights. PPO from scratch is very slow and unstable.">?</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="train-actions">
|
||||
@@ -184,11 +236,17 @@
|
||||
<div class="train-controls">
|
||||
<div class="param-grid form-row tourney-param-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="tourney-episodes">Games per Matchup</label>
|
||||
<label class="form-label" for="tourney-episodes">
|
||||
Games per Matchup
|
||||
<span class="tip-icon" data-tip="Number of games played per policy pair. Use at least 50–100 games for a statistically reliable win rate.">?</span>
|
||||
</label>
|
||||
<input type="number" id="tourney-episodes" class="form-input" value="50" min="10" max="500" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="tourney-style">Playstyle</label>
|
||||
<label class="form-label" for="tourney-style">
|
||||
Playstyle
|
||||
<span class="tip-icon" data-tip="The deck archetype used during tournament games. Try Midrange first — it's the most balanced and representative of typical Commander gameplay.">?</span>
|
||||
</label>
|
||||
<select id="tourney-style" class="form-select">
|
||||
<option value="midrange">Midrange</option>
|
||||
<option value="aggro">Aggro</option>
|
||||
|
||||
@@ -19,6 +19,8 @@ let isTraining = false;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
refreshDataStatus();
|
||||
refreshTrainingStatus();
|
||||
initIntroBanner();
|
||||
initTooltipFlip();
|
||||
});
|
||||
|
||||
async function refreshAll() {
|
||||
@@ -36,6 +38,51 @@ async function refreshAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Intro Banner
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
const INTRO_STORAGE_KEY = 'trainingIntroSeen';
|
||||
|
||||
function initIntroBanner() {
|
||||
const seen = localStorage.getItem(INTRO_STORAGE_KEY);
|
||||
if (!seen) {
|
||||
showIntroBanner();
|
||||
}
|
||||
}
|
||||
|
||||
function showIntroBanner() {
|
||||
const banner = document.getElementById('training-intro-banner');
|
||||
if (banner) banner.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function dismissIntroBanner() {
|
||||
const banner = document.getElementById('training-intro-banner');
|
||||
const dontShow = document.getElementById('intro-dont-show');
|
||||
if (banner) banner.classList.add('hidden');
|
||||
if (dontShow && dontShow.checked) {
|
||||
localStorage.setItem(INTRO_STORAGE_KEY, '1');
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Tooltip flip — prevent overflow at top
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
function initTooltipFlip() {
|
||||
document.querySelectorAll('.tip-icon').forEach(el => {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
// If less than 120px above the element, flip tooltip below
|
||||
if (rect.top < 120) {
|
||||
el.classList.add('tip-below');
|
||||
} else {
|
||||
el.classList.remove('tip-below');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Data Status
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
@@ -33,6 +33,24 @@ namespace CommanderAILab.Models
|
||||
[JsonProperty("roles")] public List<string> roles;
|
||||
[JsonProperty("source")] public string source;
|
||||
|
||||
// ── Battleground runtime state (not serialized to JSON) ───────────────
|
||||
[NonSerialized] public bool isTapped;
|
||||
[NonSerialized] public bool isSummoningSick;
|
||||
[NonSerialized] public int ownerSeat = -1;
|
||||
[NonSerialized] public BattleZone currentZone = BattleZone.Library;
|
||||
[NonSerialized] public Dictionary<string, int> counters = new();
|
||||
|
||||
public override string ToString() => $"{name} ({manaCost}) [{typeLine}]";
|
||||
}
|
||||
|
||||
public enum BattleZone
|
||||
{
|
||||
Library,
|
||||
Hand,
|
||||
Battlefield,
|
||||
Graveyard,
|
||||
Exile,
|
||||
CommandZone,
|
||||
Stack
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.UI;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Full live game state snapshot pushed by the server on state_update events.</summary>
|
||||
[Serializable]
|
||||
public class GameState
|
||||
{
|
||||
[JsonProperty("seats")] public List<SeatState> seats;
|
||||
[JsonProperty("active_seat")] public int activeSeat;
|
||||
[JsonProperty("turn_number")] public int turnNumber;
|
||||
[JsonProperty("phase")] public string phase;
|
||||
[JsonProperty("stack")] public List<StackZoneController.StackItem> stack;
|
||||
}
|
||||
|
||||
/// <summary>Per-seat state within a GameState snapshot.</summary>
|
||||
[Serializable]
|
||||
public class SeatState
|
||||
{
|
||||
[JsonProperty("seat_index")] public int seatIndex;
|
||||
[JsonProperty("player_name")] public string playerName;
|
||||
[JsonProperty("life")] public int life;
|
||||
[JsonProperty("hand")] public List<CardModel> hand;
|
||||
[JsonProperty("battlefield")] public List<CardModel> battlefield;
|
||||
[JsonProperty("graveyard")] public List<CardModel> graveyard;
|
||||
[JsonProperty("exile")] public List<CardModel> exile;
|
||||
[JsonProperty("commander")] public CardModel commander;
|
||||
[JsonProperty("commander_cast_count")] public int commanderCastCount;
|
||||
/// <summary>Cumulative commander damage received from each seat index (length 4).</summary>
|
||||
[JsonProperty("commander_damage_from")] public int[] commanderDamageFrom;
|
||||
[JsonProperty("poison_counters")] public int poisonCounters;
|
||||
[JsonProperty("mana_pool")] public Dictionary<string, int> manaPool;
|
||||
[JsonProperty("is_eliminated")] public bool isEliminated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Runtime data model for a token on the battlefield.</summary>
|
||||
[Serializable]
|
||||
public class TokenModel
|
||||
{
|
||||
public string id;
|
||||
public string name;
|
||||
public string power;
|
||||
public string toughness;
|
||||
/// <summary>Single letter: W U B R G C</summary>
|
||||
public string colorCode = "C";
|
||||
public int ownerSeat;
|
||||
public int qty = 1;
|
||||
public bool isTapped;
|
||||
public Dictionary<string, int> counters = new();
|
||||
|
||||
public string PTString =>
|
||||
(!string.IsNullOrEmpty(power) && !string.IsNullOrEmpty(toughness))
|
||||
? $"{power}/{toughness}" : "";
|
||||
}
|
||||
}
|
||||
@@ -283,5 +283,15 @@ namespace CommanderAILab.Services
|
||||
}
|
||||
onSuccess?.Invoke(request.downloadHandler.text);
|
||||
}
|
||||
|
||||
// -- Game (Live Battleground) ---------------------------------------
|
||||
|
||||
/// <summary>POST /api/game/action — submit a human player action during a live game.</summary>
|
||||
public Coroutine PostGameAction(string json, Action<string> onSuccess, Action<string> onError = null)
|
||||
=> PostRaw("/api/game/action", json, onSuccess, onError);
|
||||
|
||||
/// <summary>POST /api/game/start — start a new live game with the given lobby config.</summary>
|
||||
public Coroutine PostGameStart(string json, Action<string> onSuccess, Action<string> onError = null)
|
||||
=> PostRaw("/api/game/start", json, onSuccess, onError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.UI;
|
||||
|
||||
namespace CommanderAILab.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// WebSocket client for the live game backend.
|
||||
/// Connects to ws://{serverHost}/ws/game/{gameId}.
|
||||
///
|
||||
/// game_id is written to PlayerPrefs["GameId"] by LobbySetupModal
|
||||
/// after a successful POST /api/game/start response.
|
||||
///
|
||||
/// ServerUrl PlayerPref should be the HTTP base URL, e.g.
|
||||
/// "http://localhost:8080" — this client converts it to ws://.
|
||||
/// </summary>
|
||||
public class GameWebSocketClient : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string defaultServerUrl = "http://localhost:8080";
|
||||
[SerializeField] private float reconnectDelay = 3f;
|
||||
[SerializeField] private float pingIntervalSec = 15f;
|
||||
|
||||
private System.Net.WebSockets.ClientWebSocket _ws;
|
||||
private CancellationTokenSource _cts;
|
||||
private readonly ConcurrentQueue<(string type, string payload)> _mainThreadQueue = new();
|
||||
private bool _running;
|
||||
private float _pingTimer;
|
||||
private string _wsUrl;
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
_wsUrl = BuildWsUrl();
|
||||
if (string.IsNullOrEmpty(_wsUrl))
|
||||
{
|
||||
Debug.LogError("[GameWebSocketClient] Could not build WS URL — PlayerPrefs 'GameId' is missing. " +
|
||||
"LobbySetupModal must set it after /api/game/start.");
|
||||
return;
|
||||
}
|
||||
|
||||
_running = true;
|
||||
StartCoroutine(ConnectLoop());
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// Drain main-thread queue
|
||||
while (_mainThreadQueue.TryDequeue(out var msg))
|
||||
GameStateManager.Instance?.ApplyServerEvent(msg.type, msg.payload);
|
||||
|
||||
// Ping
|
||||
_pingTimer += Time.deltaTime;
|
||||
if (_pingTimer >= pingIntervalSec)
|
||||
{
|
||||
_pingTimer = 0f;
|
||||
_ = SendRawAsync("{\"type\":\"ping\"}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_running = false;
|
||||
_cts?.Cancel();
|
||||
_ws?.Dispose();
|
||||
}
|
||||
|
||||
// ── URL construction ────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// Builds ws://{host}/ws/game/{gameId} from PlayerPrefs.
|
||||
/// ServerUrl pref stores the HTTP base URL (e.g. http://localhost:8080).
|
||||
/// GameId pref stores the game_id returned by /api/game/start.
|
||||
/// </summary>
|
||||
private string BuildWsUrl()
|
||||
{
|
||||
string gameId = PlayerPrefs.GetString("GameId", "");
|
||||
if (string.IsNullOrEmpty(gameId))
|
||||
return string.Empty;
|
||||
|
||||
string httpBase = PlayerPrefs.GetString("ServerUrl", defaultServerUrl).TrimEnd('/');
|
||||
|
||||
// Convert http(s):// to ws(s)://
|
||||
string wsBase = httpBase
|
||||
.Replace("https://", "wss://")
|
||||
.Replace("http://", "ws://");
|
||||
|
||||
return $"{wsBase}/ws/game/{gameId}";
|
||||
}
|
||||
|
||||
// ── Connection loop ──────────────────────────────────────────────────
|
||||
private IEnumerator ConnectLoop()
|
||||
{
|
||||
while (_running)
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_ws = new System.Net.WebSockets.ClientWebSocket();
|
||||
|
||||
bool connected = false;
|
||||
var connectTask = _ws.ConnectAsync(new Uri(_wsUrl), _cts.Token);
|
||||
|
||||
yield return new WaitUntil(() => connectTask.IsCompleted);
|
||||
|
||||
if (!connectTask.IsFaulted && !connectTask.IsCanceled)
|
||||
{
|
||||
connected = true;
|
||||
Debug.Log("[GameWebSocketClient] Connected to " + _wsUrl);
|
||||
_ = ReceiveLoop();
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[GameWebSocketClient] Connect failed ({_wsUrl}): {connectTask.Exception?.Message}");
|
||||
}
|
||||
|
||||
if (!connected)
|
||||
{
|
||||
yield return new WaitForSeconds(reconnectDelay);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return new WaitUntil(() =>
|
||||
_ws.State != System.Net.WebSockets.WebSocketState.Open);
|
||||
|
||||
Debug.Log("[GameWebSocketClient] Disconnected — reconnecting...");
|
||||
yield return new WaitForSeconds(reconnectDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receive loop (background thread) ────────────────────────────────
|
||||
private async Task ReceiveLoop()
|
||||
{
|
||||
var buffer = new byte[8192];
|
||||
try
|
||||
{
|
||||
while (_ws.State == System.Net.WebSockets.WebSocketState.Open)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
System.Net.WebSockets.WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
result = await _ws.ReceiveAsync(
|
||||
new ArraySegment<byte>(buffer), _cts.Token);
|
||||
sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
|
||||
} while (!result.EndOfMessage);
|
||||
|
||||
if (result.MessageType ==
|
||||
System.Net.WebSockets.WebSocketMessageType.Close)
|
||||
break;
|
||||
|
||||
ParseAndEnqueue(sb.ToString());
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[GameWebSocketClient] ReceiveLoop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parse incoming JSON envelope ─────────────────────────────────────
|
||||
private void ParseAndEnqueue(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
var env = JsonConvert.DeserializeObject<WsEnvelope>(raw);
|
||||
if (env == null) return;
|
||||
if (env.type == "pong") return;
|
||||
_mainThreadQueue.Enqueue((env.type, env.payload ?? raw));
|
||||
}
|
||||
catch
|
||||
{
|
||||
Debug.LogWarning($"[GameWebSocketClient] Failed to parse: {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send (public for external callers) ───────────────────────────────
|
||||
public async Task SendRawAsync(string json)
|
||||
{
|
||||
if (_ws == null ||
|
||||
_ws.State != System.Net.WebSockets.WebSocketState.Open) return;
|
||||
try
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
await _ws.SendAsync(new ArraySegment<byte>(bytes),
|
||||
System.Net.WebSockets.WebSocketMessageType.Text,
|
||||
true, _cts?.Token ?? CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[GameWebSocketClient] Send error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── POCO ────────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
private class WsEnvelope
|
||||
{
|
||||
[JsonProperty("type")] public string type;
|
||||
[JsonProperty("payload")] public string payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace CommanderAILab.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton texture cache for card images.
|
||||
/// Downloads and caches Scryfall card art as Sprite objects.
|
||||
/// Downloads and caches Scryfall card art as Sprite or Texture2D.
|
||||
/// </summary>
|
||||
public class ImageCache : MonoBehaviour
|
||||
{
|
||||
public static ImageCache Instance { get; private set; }
|
||||
|
||||
private readonly Dictionary<string, Sprite> cache = new();
|
||||
private readonly Dictionary<string, Sprite> _spriteCache = new();
|
||||
private readonly Dictionary<string, Texture2D> _textureCache = new();
|
||||
[SerializeField] private int maxCacheSize = 500;
|
||||
|
||||
private void Awake()
|
||||
@@ -23,33 +26,67 @@ namespace CommanderAILab.Services
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
public void GetSprite(string url, System.Action<Sprite> callback)
|
||||
// ── Sprite (existing usage) ───────────────────────────────────────────
|
||||
public void GetSprite(string url, Action<Sprite> callback)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) { callback?.Invoke(null); return; }
|
||||
if (cache.TryGetValue(url, out var cached)) { callback?.Invoke(cached); return; }
|
||||
StartCoroutine(DownloadImage(url, callback));
|
||||
if (_spriteCache.TryGetValue(url, out var cached)) { callback?.Invoke(cached); return; }
|
||||
StartCoroutine(DownloadSprite(url, callback));
|
||||
}
|
||||
|
||||
private IEnumerator DownloadImage(string url, System.Action<Sprite> callback)
|
||||
private IEnumerator DownloadSprite(string url, Action<Sprite> callback)
|
||||
{
|
||||
using var request = UnityWebRequestTexture.GetTexture(url);
|
||||
yield return request.SendWebRequest();
|
||||
|
||||
if (request.result != UnityWebRequest.Result.Success)
|
||||
using var req = UnityWebRequestTexture.GetTexture(url);
|
||||
yield return req.SendWebRequest();
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ImageCache] Failed to load {url}: {request.error}");
|
||||
Debug.LogWarning($"[ImageCache] Sprite failed {url}: {req.error}");
|
||||
callback?.Invoke(null);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var tex = DownloadHandlerTexture.GetContent(request);
|
||||
var tex = DownloadHandlerTexture.GetContent(req);
|
||||
var sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), Vector2.one * 0.5f);
|
||||
|
||||
if (cache.Count >= maxCacheSize) cache.Clear();
|
||||
cache[url] = sprite;
|
||||
if (_spriteCache.Count >= maxCacheSize) _spriteCache.Clear();
|
||||
_spriteCache[url] = sprite;
|
||||
callback?.Invoke(sprite);
|
||||
}
|
||||
|
||||
public void ClearCache() => cache.Clear();
|
||||
// ── RawImage / Texture2D (battleground CardView + CommanderZoneWidget) ───
|
||||
/// <summary>Load a card image URL directly into a RawImage component.</summary>
|
||||
public IEnumerator LoadCard(string url, RawImage target)
|
||||
{
|
||||
if (target == null || string.IsNullOrEmpty(url)) yield break;
|
||||
if (_textureCache.TryGetValue(url, out var cached)) { target.texture = cached; yield break; }
|
||||
yield return DownloadTexture(url, tex => { if (target != null) target.texture = tex; });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load Scryfall art_crop into a RawImage by card name.
|
||||
/// URL: https://api.scryfall.com/cards/named?exact={name}&format=image&version=art_crop
|
||||
/// </summary>
|
||||
public IEnumerator LoadArtCrop(string cardName, RawImage target)
|
||||
{
|
||||
if (target == null || string.IsNullOrEmpty(cardName)) yield break;
|
||||
string url = $"https://api.scryfall.com/cards/named?exact={Uri.EscapeDataString(cardName)}&format=image&version=art_crop";
|
||||
yield return LoadCard(url, target);
|
||||
}
|
||||
|
||||
private IEnumerator DownloadTexture(string url, Action<Texture2D> callback)
|
||||
{
|
||||
using var req = UnityWebRequestTexture.GetTexture(url);
|
||||
yield return req.SendWebRequest();
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ImageCache] Texture failed {url}: {req.error}");
|
||||
callback?.Invoke(null);
|
||||
yield break;
|
||||
}
|
||||
var tex = DownloadHandlerTexture.GetContent(req);
|
||||
if (_textureCache.Count >= maxCacheSize) _textureCache.Clear();
|
||||
_textureCache[url] = tex;
|
||||
callback?.Invoke(tex);
|
||||
}
|
||||
|
||||
public void ClearCache() { _spriteCache.Clear(); _textureCache.Clear(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Models;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Master orchestrator for the Battleground scene.
|
||||
/// Reads the lobby config written by SimulationController (PlayerPrefs "BattleSeats"),
|
||||
/// wires up all seat panels, and opens the LobbySetupModal on start.
|
||||
/// </summary>
|
||||
public class BattlegroundController : MonoBehaviour
|
||||
{
|
||||
// ── Inspector ─────────────────────────────────────────────
|
||||
[Header("Seat Panels")]
|
||||
[SerializeField] private RectTransform[] seatPanels; // [0]=bottom,[1]=right,[2]=top,[3]=left
|
||||
|
||||
[Header("Center")]
|
||||
[SerializeField] private PhaseTrackerWidget phaseTracker;
|
||||
[SerializeField] private TurnOrderBar turnOrderBar;
|
||||
|
||||
[Header("Modals")]
|
||||
[SerializeField] private LobbySetupModal lobbyModal;
|
||||
|
||||
[Header("Navigation")]
|
||||
[SerializeField] private Button mainMenuButton;
|
||||
|
||||
// ── Seat rotations ────────────────────────────────────────
|
||||
private static readonly float[] SeatRotations = { 0f, -90f, 180f, 90f };
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
ApplySeatRotations();
|
||||
SubscribeEvents();
|
||||
|
||||
mainMenuButton.onClick.AddListener(() => SceneManager.LoadScene("MainMenu"));
|
||||
|
||||
// Pre-populate lobby from PlayerPrefs if arriving via SimulationController
|
||||
string savedSeats = PlayerPrefs.GetString("BattleSeats", "");
|
||||
if (lobbyModal != null)
|
||||
lobbyModal.Open(savedSeats);
|
||||
}
|
||||
|
||||
private void OnDestroy() => UnsubscribeEvents();
|
||||
|
||||
// ── Layout ─────────────────────────────────────────────
|
||||
private void ApplySeatRotations()
|
||||
{
|
||||
for (int i = 0; i < seatPanels.Length; i++)
|
||||
{
|
||||
if (seatPanels[i] == null) continue;
|
||||
seatPanels[i].localEulerAngles = new Vector3(0f, 0f, SeatRotations[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event subscriptions ──────────────────────────────────
|
||||
private void SubscribeEvents()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnTurnChanged += OnTurnChanged;
|
||||
GameStateManager.Instance.OnPhaseChanged += OnPhaseChanged;
|
||||
GameStateManager.Instance.OnFullStateSync += OnFullStateSync;
|
||||
}
|
||||
|
||||
private void UnsubscribeEvents()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnTurnChanged -= OnTurnChanged;
|
||||
GameStateManager.Instance.OnPhaseChanged -= OnPhaseChanged;
|
||||
GameStateManager.Instance.OnFullStateSync -= OnFullStateSync;
|
||||
}
|
||||
|
||||
private void OnTurnChanged(int seat) =>
|
||||
turnOrderBar?.SetActiveSeat(seat);
|
||||
|
||||
private void OnPhaseChanged(PhaseTrackerWidget.Phase phase, int seat, int turn) =>
|
||||
phaseTracker?.SetPhase(phase, seat, turn);
|
||||
|
||||
private void OnFullStateSync(GameState state)
|
||||
{
|
||||
// Delegate per-seat refresh to each SeatPanelController once those exist
|
||||
turnOrderBar?.SetActiveSeat(state.activeSeat);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Visual representation of a single card in a CardZone.
|
||||
/// Handles art loading via ImageCache, tapped rotation, and face-down state.
|
||||
/// </summary>
|
||||
public class CardView : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private RawImage artImage;
|
||||
[SerializeField] private TMP_Text nameLabel;
|
||||
[SerializeField] private TMP_Text ptLabel; // power/toughness
|
||||
[SerializeField] private TMP_Text manaCostLabel;
|
||||
[SerializeField] private GameObject tappedIndicator; // e.g. coloured strip
|
||||
[SerializeField] private GameObject summonSickOverlay; // semi-transparent overlay
|
||||
[SerializeField] private GameObject faceDownOverlay; // card back image object
|
||||
|
||||
public CardModel Model { get; private set; }
|
||||
private bool _faceDown;
|
||||
|
||||
// ── Binding ─────────────────────────────────────────────
|
||||
public void Bind(CardModel model, bool faceDown = false)
|
||||
{
|
||||
Model = model;
|
||||
_faceDown = faceDown;
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
public void SetFaceDown(bool faceDown)
|
||||
{
|
||||
_faceDown = faceDown;
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────
|
||||
public void SetTapped(bool tapped)
|
||||
{
|
||||
if (Model == null) return;
|
||||
Model.isTapped = tapped;
|
||||
// Rotate the RectTransform -90 degrees when tapped
|
||||
transform.localEulerAngles = tapped ? new Vector3(0f, 0f, -90f) : Vector3.zero;
|
||||
if (tappedIndicator) tappedIndicator.SetActive(tapped);
|
||||
}
|
||||
|
||||
public void SetSummonSick(bool sick)
|
||||
{
|
||||
if (Model == null) return;
|
||||
Model.isSummoningSick = sick;
|
||||
if (summonSickOverlay) summonSickOverlay.SetActive(sick);
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────────
|
||||
private void RefreshDisplay()
|
||||
{
|
||||
if (faceDownOverlay) faceDownOverlay.SetActive(_faceDown);
|
||||
if (artImage) artImage.gameObject.SetActive(!_faceDown);
|
||||
|
||||
if (nameLabel)
|
||||
nameLabel.text = _faceDown ? "?" : (Model?.name ?? "");
|
||||
|
||||
bool hasStats = !_faceDown
|
||||
&& Model != null
|
||||
&& !string.IsNullOrEmpty(Model.power)
|
||||
&& !string.IsNullOrEmpty(Model.toughness);
|
||||
|
||||
if (ptLabel)
|
||||
{
|
||||
ptLabel.text = hasStats ? $"{Model.power}/{Model.toughness}" : "";
|
||||
ptLabel.gameObject.SetActive(hasStats);
|
||||
}
|
||||
|
||||
if (manaCostLabel)
|
||||
{
|
||||
manaCostLabel.text = (!_faceDown && Model != null) ? Model.manaCost ?? "" : "";
|
||||
}
|
||||
|
||||
// Load art via ImageCache if visible and URL available
|
||||
if (!_faceDown && Model != null && !string.IsNullOrEmpty(Model.imageUri)
|
||||
&& artImage != null && ImageCache.Instance != null)
|
||||
{
|
||||
StartCoroutine(ImageCache.Instance.LoadCard(Model.imageUri, artImage));
|
||||
}
|
||||
|
||||
// Reset tapped/sick overlays on rebind
|
||||
if (tappedIndicator) tappedIndicator.SetActive(false);
|
||||
if (summonSickOverlay) summonSickOverlay.SetActive(false);
|
||||
transform.localEulerAngles = Vector3.zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using CommanderAILab.Models;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages a collection of CardView instances inside one battlefield zone
|
||||
/// (Hand, Battlefield, Graveyard, Exile, etc.).
|
||||
/// The cardContainer should have a HorizontalLayoutGroup or GridLayoutGroup.
|
||||
/// </summary>
|
||||
public class CardZone : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private BattleZone zoneType;
|
||||
[SerializeField] private GameObject cardPrefab; // CardPrefab.prefab
|
||||
[SerializeField] private Transform cardContainer; // layout group parent
|
||||
[SerializeField] private bool faceDown; // true for AI hand zones
|
||||
|
||||
private readonly List<CardView> _cards = new();
|
||||
|
||||
public BattleZone ZoneType => zoneType;
|
||||
public IReadOnlyList<CardView> Cards => _cards;
|
||||
public int Count => _cards.Count;
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public CardView AddCard(CardModel model)
|
||||
{
|
||||
var go = Instantiate(cardPrefab, cardContainer);
|
||||
var view = go.GetComponent<CardView>();
|
||||
if (view == null)
|
||||
{
|
||||
Debug.LogError("[CardZone] CardPrefab missing CardView component.");
|
||||
Destroy(go);
|
||||
return null;
|
||||
}
|
||||
model.currentZone = zoneType;
|
||||
view.Bind(model, faceDown);
|
||||
_cards.Add(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
public void RemoveCard(string cardId)
|
||||
{
|
||||
var view = _cards.Find(c => c.Model?.id == cardId);
|
||||
if (view == null) return;
|
||||
_cards.Remove(view);
|
||||
Destroy(view.gameObject);
|
||||
}
|
||||
|
||||
public void ClearAll()
|
||||
{
|
||||
foreach (var v in _cards) if (v != null) Destroy(v.gameObject);
|
||||
_cards.Clear();
|
||||
}
|
||||
|
||||
public void SetFaceDown(bool fd)
|
||||
{
|
||||
faceDown = fd;
|
||||
foreach (var v in _cards) v?.SetFaceDown(fd);
|
||||
}
|
||||
|
||||
public CardView FindById(string cardId) =>
|
||||
_cards.Find(c => c.Model?.id == cardId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Displays cumulative commander damage received from each opposing seat.
|
||||
/// Triggers elimination when any source reaches 21.
|
||||
/// Attach one to each seat panel; set SeatIndex in inspector.
|
||||
/// </summary>
|
||||
public class CommanderDamageRow : MonoBehaviour
|
||||
{
|
||||
/// <summary>The seat this row belongs to (receives damage).</summary>
|
||||
[SerializeField] private int seatIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Labels for damage received from each seat.
|
||||
/// fromSeatLabels[i] corresponds to global seat i.
|
||||
/// The label for this seat's own index will be hidden (can't deal commander damage to yourself).
|
||||
/// </summary>
|
||||
[SerializeField] private TMP_Text[] fromSeatLabels; // length 4
|
||||
|
||||
private readonly int[] _damage = new int[4];
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// Hide this seat's own label
|
||||
if (fromSeatLabels != null && seatIndex < fromSeatLabels.Length
|
||||
&& fromSeatLabels[seatIndex] != null)
|
||||
fromSeatLabels[seatIndex].gameObject.SetActive(false);
|
||||
|
||||
RefreshAllLabels();
|
||||
}
|
||||
|
||||
public void AddDamage(int fromSeat, int amount)
|
||||
{
|
||||
if (fromSeat < 0 || fromSeat >= 4 || fromSeat == seatIndex) return;
|
||||
_damage[fromSeat] = Mathf.Max(0, _damage[fromSeat] + amount);
|
||||
RefreshLabel(fromSeat);
|
||||
|
||||
if (_damage[fromSeat] >= 21)
|
||||
GameStateManager.Instance?.EliminatePlayer(seatIndex, "commander_damage");
|
||||
}
|
||||
|
||||
public void SetDamage(int fromSeat, int value)
|
||||
{
|
||||
if (fromSeat < 0 || fromSeat >= 4 || fromSeat == seatIndex) return;
|
||||
_damage[fromSeat] = Mathf.Max(0, value);
|
||||
RefreshLabel(fromSeat);
|
||||
}
|
||||
|
||||
public int GetDamage(int fromSeat) =>
|
||||
(fromSeat >= 0 && fromSeat < 4) ? _damage[fromSeat] : 0;
|
||||
|
||||
private void RefreshLabel(int fromSeat)
|
||||
{
|
||||
if (fromSeatLabels == null || fromSeat >= fromSeatLabels.Length) return;
|
||||
var lbl = fromSeatLabels[fromSeat];
|
||||
if (lbl == null) return;
|
||||
lbl.text = _damage[fromSeat] > 0 ? _damage[fromSeat].ToString() : "0";
|
||||
lbl.color = _damage[fromSeat] >= 21 ? Color.red : Color.white;
|
||||
}
|
||||
|
||||
private void RefreshAllLabels()
|
||||
{
|
||||
for (int i = 0; i < 4; i++) RefreshLabel(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-seat commander zone widget.
|
||||
/// Shows: art crop banner, commander name, cast count, commander tax.
|
||||
/// Requires ImageCache singleton in scene.
|
||||
/// </summary>
|
||||
public class CommanderZoneWidget : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private RawImage artCropImage;
|
||||
[SerializeField] private TMP_Text commanderNameLabel;
|
||||
[SerializeField] private TMP_Text castCountLabel;
|
||||
[SerializeField] private TMP_Text taxLabel;
|
||||
[SerializeField] private UnityEngine.UI.Button castCommanderBtn;
|
||||
|
||||
public CardModel CommanderCard { get; private set; }
|
||||
private int _castCount = 0;
|
||||
|
||||
// ── Binding ───────────────────────────────────────────
|
||||
public void Bind(CardModel commander)
|
||||
{
|
||||
CommanderCard = commander;
|
||||
_castCount = commander != null ? commander.counters.GetValueOrDefault("cast_count", 0) : 0;
|
||||
|
||||
if (commanderNameLabel)
|
||||
commanderNameLabel.text = commander?.name ?? "No Commander";
|
||||
|
||||
UpdateTaxDisplay();
|
||||
|
||||
// Load art crop from Scryfall
|
||||
if (commander != null && !string.IsNullOrEmpty(commander.name)
|
||||
&& artCropImage != null && ImageCache.Instance != null)
|
||||
{
|
||||
StartCoroutine(ImageCache.Instance.LoadArtCrop(commander.name, artCropImage));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cast tracking ──────────────────────────────────────
|
||||
public void OnCommanderCast()
|
||||
{
|
||||
_castCount++;
|
||||
UpdateTaxDisplay();
|
||||
}
|
||||
|
||||
public void SetCastCount(int count)
|
||||
{
|
||||
_castCount = Mathf.Max(0, count);
|
||||
UpdateTaxDisplay();
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────
|
||||
private void UpdateTaxDisplay()
|
||||
{
|
||||
if (castCountLabel)
|
||||
castCountLabel.text = _castCount == 0 ? "Not yet cast" : $"Cast #{_castCount}";
|
||||
|
||||
int tax = _castCount * 2;
|
||||
if (taxLabel)
|
||||
taxLabel.text = tax == 0 ? "No tax" : $"+{tax} ☀ tax";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the visual elimination sequence for a seat panel.
|
||||
/// On elimination: plays a flash animation, applies a grayscale material, and
|
||||
/// shows an elimination banner with cause of death text.
|
||||
/// Requires a URP ShaderGraph material "EliminatedGrayscale" in Resources/.
|
||||
/// </summary>
|
||||
public class EliminationHandler : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private int seatIndex;
|
||||
[SerializeField] private CanvasGroup seatCanvasGroup; // seat panel root CanvasGroup
|
||||
[SerializeField] private Image eliminationOverlay; // full-panel semi-transparent overlay
|
||||
[SerializeField] private TMP_Text eliminationLabel; // "ELIMINATED" text
|
||||
[SerializeField] private TMP_Text causeLabel; // cause of death
|
||||
[SerializeField] private Image[] grayscaleTargets; // RawImages to recolor to grayscale
|
||||
|
||||
private static readonly Color GrayscaleTint = new Color(0.4f, 0.4f, 0.4f, 1f);
|
||||
private bool _eliminated;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (eliminationOverlay) eliminationOverlay.gameObject.SetActive(false);
|
||||
if (eliminationLabel) eliminationLabel.gameObject.SetActive(false);
|
||||
if (causeLabel) causeLabel.gameObject.SetActive(false);
|
||||
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnPlayerEliminated += OnPlayerEliminated;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnPlayerEliminated -= OnPlayerEliminated;
|
||||
}
|
||||
|
||||
// ── Event ───────────────────────────────────────────────
|
||||
private void OnPlayerEliminated(int seat, string reason)
|
||||
{
|
||||
if (seat != seatIndex || _eliminated) return;
|
||||
_eliminated = true;
|
||||
StartCoroutine(PlayEliminationSequence(reason));
|
||||
}
|
||||
|
||||
// ── Animation ──────────────────────────────────────────
|
||||
private IEnumerator PlayEliminationSequence(string reason)
|
||||
{
|
||||
// 1. Red flash
|
||||
if (eliminationOverlay)
|
||||
{
|
||||
eliminationOverlay.gameObject.SetActive(true);
|
||||
eliminationOverlay.color = new Color(1f, 0f, 0f, 0f);
|
||||
yield return FadeImage(eliminationOverlay, 0f, 0.6f, 0.15f);
|
||||
yield return FadeImage(eliminationOverlay, 0.6f, 0f, 0.4f);
|
||||
eliminationOverlay.color = new Color(0f, 0f, 0f, 0.4f); // keep dark overlay
|
||||
}
|
||||
|
||||
// 2. Grayscale tint on card art panels
|
||||
foreach (var img in grayscaleTargets)
|
||||
if (img != null) img.color = GrayscaleTint;
|
||||
|
||||
// 3. Fade-down CanvasGroup to 60%
|
||||
if (seatCanvasGroup)
|
||||
{
|
||||
seatCanvasGroup.interactable = false;
|
||||
seatCanvasGroup.blocksRaycasts = false;
|
||||
yield return FadeCanvasGroup(seatCanvasGroup, seatCanvasGroup.alpha, 0.6f, 0.3f);
|
||||
}
|
||||
|
||||
// 4. Show banner
|
||||
if (eliminationLabel) eliminationLabel.gameObject.SetActive(true);
|
||||
if (causeLabel)
|
||||
{
|
||||
causeLabel.text = reason switch
|
||||
{
|
||||
"life" => "Life total reached 0",
|
||||
"commander_damage" => "Commander damage (21+)",
|
||||
"poison" => "Poison counters (10+)",
|
||||
"deck_empty" => "Failed to draw",
|
||||
_ => reason
|
||||
};
|
||||
causeLabel.gameObject.SetActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator FadeImage(Image img, float from, float to, float duration)
|
||||
{
|
||||
float t = 0;
|
||||
while (t < duration)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
var c = img.color;
|
||||
c.a = Mathf.Lerp(from, to, t / duration);
|
||||
img.color = c;
|
||||
yield return null;
|
||||
}
|
||||
var final = img.color;
|
||||
final.a = to;
|
||||
img.color = final;
|
||||
}
|
||||
|
||||
private IEnumerator FadeCanvasGroup(CanvasGroup cg, float from, float to, float duration)
|
||||
{
|
||||
float t = 0;
|
||||
while (t < duration) { t += Time.deltaTime; cg.alpha = Mathf.Lerp(from, to, t / duration); yield return null; }
|
||||
cg.alpha = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Models;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton event hub for the live battleground game state.
|
||||
/// All UI widgets subscribe to these events instead of polling.
|
||||
/// Receives parsed server events from GameWebSocketClient.
|
||||
/// </summary>
|
||||
public class GameStateManager : MonoBehaviour
|
||||
{
|
||||
public static GameStateManager Instance { get; private set; }
|
||||
|
||||
// ── Events ────────────────────────────────────────────────
|
||||
public event Action<int> OnTurnChanged; // activeSeat
|
||||
public event Action<PhaseTrackerWidget.Phase, int, int> OnPhaseChanged; // phase, seat, turnNumber
|
||||
public event Action<int> OnPriorityChanged; // seatIndex
|
||||
public event Action<int, int> OnLifeChanged; // seatIndex, newLife
|
||||
public event Action<int, string> OnPlayerEliminated; // seatIndex, reason
|
||||
public event Action<List<StackZoneController.StackItem>> OnStackUpdated;
|
||||
public event Action<GameState> OnFullStateSync;
|
||||
|
||||
// ── State ────────────────────────────────────────────────
|
||||
public int TurnNumber { get; private set; } = 1;
|
||||
private GameState _state;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
// ── Server event dispatch ───────────────────────────────────
|
||||
/// <summary>Called by GameWebSocketClient on the Unity main thread.</summary>
|
||||
public void ApplyServerEvent(string type, string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case "state_update":
|
||||
_state = JsonConvert.DeserializeObject<GameState>(payload);
|
||||
if (_state != null) OnFullStateSync?.Invoke(_state);
|
||||
break;
|
||||
|
||||
case "turn_start":
|
||||
var ts = JsonConvert.DeserializeObject<TurnStartEvent>(payload);
|
||||
if (ts != null) { TurnNumber = ts.turnNumber; OnTurnChanged?.Invoke(ts.seat); }
|
||||
break;
|
||||
|
||||
case "phase_change":
|
||||
var pc = JsonConvert.DeserializeObject<PhaseChangeEvent>(payload);
|
||||
if (pc != null)
|
||||
OnPhaseChanged?.Invoke((PhaseTrackerWidget.Phase)pc.phaseIndex, pc.seat, TurnNumber);
|
||||
break;
|
||||
|
||||
case "priority":
|
||||
var pv = JsonConvert.DeserializeObject<PriorityEvent>(payload);
|
||||
if (pv != null) OnPriorityChanged?.Invoke(pv.seat);
|
||||
break;
|
||||
|
||||
case "life_change":
|
||||
var lc = JsonConvert.DeserializeObject<LifeChangeEvent>(payload);
|
||||
if (lc != null) OnLifeChanged?.Invoke(lc.seat, lc.newLife);
|
||||
break;
|
||||
|
||||
case "stack_update":
|
||||
var su = JsonConvert.DeserializeObject<StackUpdateEvent>(payload);
|
||||
if (su?.items != null) OnStackUpdated?.Invoke(su.items);
|
||||
break;
|
||||
|
||||
case "player_elim":
|
||||
var pe = JsonConvert.DeserializeObject<EliminationEvent>(payload);
|
||||
if (pe != null) EliminatePlayer(pe.seat, pe.reason);
|
||||
break;
|
||||
|
||||
default:
|
||||
Debug.LogWarning($"[GameStateManager] Unknown event type: {type}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[GameStateManager] ApplyServerEvent failed ({type}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Trigger elimination (called by LifeTrackerWidget, CommanderDamageRow, or server event).</summary>
|
||||
public void EliminatePlayer(int seatIndex, string reason) =>
|
||||
OnPlayerEliminated?.Invoke(seatIndex, reason);
|
||||
|
||||
// ── Debug helpers (editor only) ─────────────────────────────
|
||||
#if UNITY_EDITOR
|
||||
private void OnGUI()
|
||||
{
|
||||
GUILayout.BeginArea(new Rect(10, 10, 220, 200));
|
||||
GUILayout.Label("[GSM Debug]");
|
||||
if (GUILayout.Button("Fake Turn 1 Start")) ApplyServerEvent("turn_start", "{\"seat\":0,\"turnNumber\":1}");
|
||||
if (GUILayout.Button("Advance Phase Main1")) ApplyServerEvent("phase_change","{\"phaseIndex\":3,\"seat\":0}");
|
||||
if (GUILayout.Button("Priority Seat 0")) ApplyServerEvent("priority", "{\"seat\":0}");
|
||||
if (GUILayout.Button("Elim Seat 2")) EliminatePlayer(2, "life");
|
||||
GUILayout.EndArea();
|
||||
}
|
||||
#endif
|
||||
|
||||
// ── Private event POCOs ───────────────────────────────────
|
||||
[Serializable] private class TurnStartEvent { public int seat; public int turnNumber; }
|
||||
[Serializable] private class PhaseChangeEvent { public int phaseIndex; public int seat; }
|
||||
[Serializable] private class PriorityEvent { public int seat; }
|
||||
[Serializable] private class LifeChangeEvent { public int seat; public int newLife; }
|
||||
[Serializable] private class StackUpdateEvent { public List<StackZoneController.StackItem> items; }
|
||||
[Serializable] private class EliminationEvent { public int seat; public string reason; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Human player action bar — only active when seat 0 has priority.
|
||||
/// Buttons post actions to /api/game/action.
|
||||
/// Subscribes to GameStateManager.OnPriorityChanged.
|
||||
/// </summary>
|
||||
public class HumanActionBar : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private ApiClient apiClient;
|
||||
[SerializeField] private GameObject actionBar; // root panel
|
||||
[SerializeField] private Button passPriorityBtn;
|
||||
[SerializeField] private Button endTurnBtn;
|
||||
[SerializeField] private Button declareAttackersBtn;
|
||||
[SerializeField] private Button declareBlockersBtn;
|
||||
[SerializeField] private TMP_Text priorityLabel;
|
||||
[SerializeField] private TMP_Text feedbackLabel;
|
||||
|
||||
private bool _hasPriority;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
passPriorityBtn ?.onClick.AddListener(() => PostAction("pass_priority"));
|
||||
endTurnBtn ?.onClick.AddListener(() => PostAction("end_turn"));
|
||||
declareAttackersBtn?.onClick.AddListener(() => PostAction("declare_attackers"));
|
||||
declareBlockersBtn ?.onClick.AddListener(() => PostAction("declare_blockers"));
|
||||
|
||||
if (GameStateManager.Instance != null)
|
||||
{
|
||||
GameStateManager.Instance.OnPriorityChanged += OnPriorityChanged;
|
||||
GameStateManager.Instance.OnPhaseChanged += OnPhaseChanged;
|
||||
}
|
||||
|
||||
SetPriority(false);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnPriorityChanged -= OnPriorityChanged;
|
||||
GameStateManager.Instance.OnPhaseChanged -= OnPhaseChanged;
|
||||
}
|
||||
|
||||
// ── Priority visibility ──────────────────────────────────
|
||||
private void OnPriorityChanged(int seatIndex)
|
||||
{
|
||||
SetPriority(seatIndex == 0);
|
||||
}
|
||||
|
||||
private void OnPhaseChanged(PhaseTrackerWidget.Phase phase, int seat, int turn)
|
||||
{
|
||||
// Only show combat buttons during combat phases
|
||||
bool inCombat = phase >= PhaseTrackerWidget.Phase.DeclareAttackers
|
||||
&& phase <= PhaseTrackerWidget.Phase.EndCombat;
|
||||
if (declareAttackersBtn) declareAttackersBtn.gameObject.SetActive(inCombat && _hasPriority);
|
||||
if (declareBlockersBtn) declareBlockersBtn.gameObject.SetActive(inCombat && _hasPriority);
|
||||
}
|
||||
|
||||
private void SetPriority(bool hasPriority)
|
||||
{
|
||||
_hasPriority = hasPriority;
|
||||
if (actionBar) actionBar.SetActive(hasPriority);
|
||||
if (priorityLabel) priorityLabel.text = hasPriority ? "Your Priority" : "";
|
||||
}
|
||||
|
||||
// ── Action posting ─────────────────────────────────────
|
||||
private void PostAction(string actionType)
|
||||
{
|
||||
var payload = JsonConvert.SerializeObject(new { action = actionType, seat = 0 });
|
||||
apiClient?.PostGameAction(payload,
|
||||
_ => ShowFeedback(actionType),
|
||||
err => ShowFeedback($"Error: {err}", isError: true));
|
||||
}
|
||||
|
||||
private void ShowFeedback(string msg, bool isError = false)
|
||||
{
|
||||
if (!feedbackLabel) return;
|
||||
StopCoroutine(nameof(FadeOutFeedback));
|
||||
feedbackLabel.text = msg;
|
||||
feedbackLabel.color = isError ? Color.red : Color.green;
|
||||
feedbackLabel.gameObject.SetActive(true);
|
||||
StartCoroutine(nameof(FadeOutFeedback));
|
||||
}
|
||||
|
||||
private IEnumerator FadeOutFeedback()
|
||||
{
|
||||
yield return new WaitForSeconds(1.5f);
|
||||
if (feedbackLabel) feedbackLabel.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-seat life total widget with +/- buttons, danger flash, and history log.
|
||||
/// Calls GameStateManager.EliminatePlayer when life reaches 0.
|
||||
/// </summary>
|
||||
public class LifeTrackerWidget : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private TMP_Text lifeLabel;
|
||||
[SerializeField] private Button plusOneBtn;
|
||||
[SerializeField] private Button minusOneBtn;
|
||||
[SerializeField] private Button plusFiveBtn;
|
||||
[SerializeField] private Button minusFiveBtn;
|
||||
[SerializeField] private GameObject dangerFlash; // red glow overlay, shown at <=5 life
|
||||
|
||||
[Header("Config")]
|
||||
[SerializeField] private int startingLife = 40;
|
||||
public int SeatIndex { get; set; }
|
||||
|
||||
private int _life;
|
||||
private readonly List<LifeHistoryEntry> _history = new();
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
SetLife(startingLife);
|
||||
plusOneBtn .onClick.AddListener(() => AdjustLife(+1, "manual"));
|
||||
minusOneBtn.onClick.AddListener(() => AdjustLife(-1, "manual"));
|
||||
plusFiveBtn .onClick.AddListener(() => AdjustLife(+5, "manual"));
|
||||
minusFiveBtn.onClick.AddListener(() => AdjustLife(-5, "manual"));
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public int Life => _life;
|
||||
|
||||
public void AdjustLife(int delta, string source)
|
||||
{
|
||||
_life = Mathf.Max(0, _life + delta);
|
||||
RefreshDisplay();
|
||||
|
||||
int turn = GameStateManager.Instance != null ? GameStateManager.Instance.TurnNumber : 0;
|
||||
_history.Add(new LifeHistoryEntry { delta = delta, source = source, turn = turn });
|
||||
|
||||
if (_life == 0)
|
||||
GameStateManager.Instance?.EliminatePlayer(SeatIndex, "life");
|
||||
}
|
||||
|
||||
public void SetLife(int value)
|
||||
{
|
||||
_life = Mathf.Max(0, value);
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
public List<LifeHistoryEntry> GetHistory() => _history;
|
||||
|
||||
// ── Internal ───────────────────────────────────────────
|
||||
private void RefreshDisplay()
|
||||
{
|
||||
if (lifeLabel) lifeLabel.text = _life.ToString();
|
||||
if (dangerFlash) dangerFlash.SetActive(_life <= 5 && _life > 0);
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class LifeHistoryEntry
|
||||
{
|
||||
public int delta;
|
||||
public string source;
|
||||
public int turn;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Modal shown at Battleground scene start.
|
||||
/// Configures 4 seats (Human vs AI, deck choice, AI style).
|
||||
/// On Confirm:
|
||||
/// 1. POSTs seat config to POST /api/game/start
|
||||
/// 2. Saves game_id + ws_url to PlayerPrefs
|
||||
/// 3. Loads Battleground scene
|
||||
/// </summary>
|
||||
public class LobbySetupModal : MonoBehaviour
|
||||
{
|
||||
[Header("Seat Rows (4)")]
|
||||
[SerializeField] private LobbyRowUI[] seatRows; // length 4
|
||||
|
||||
[Header("Buttons")]
|
||||
[SerializeField] private Button confirmButton;
|
||||
[SerializeField] private Button cancelButton;
|
||||
|
||||
[Header("Feedback")]
|
||||
[SerializeField] private TMP_Text errorLabel; // assign in Inspector; hidden by default
|
||||
|
||||
[SerializeField] private ApiClient apiClient;
|
||||
|
||||
private List<string> _deckNames = new();
|
||||
|
||||
// ── Open ────────────────────────────────────────────────
|
||||
public void Open(string savedSeatsJson)
|
||||
{
|
||||
gameObject.SetActive(true);
|
||||
SetError(null);
|
||||
|
||||
confirmButton.onClick.AddListener(OnConfirm);
|
||||
cancelButton.onClick.AddListener(() => SceneManager.LoadScene("MainMenu"));
|
||||
|
||||
// Load deck list for dropdowns
|
||||
apiClient?.GetDecks(
|
||||
json =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var w = JsonConvert.DeserializeObject<DecksWrapper>(json);
|
||||
_deckNames = new List<string>();
|
||||
if (w?.decks != null)
|
||||
foreach (var d in w.decks) _deckNames.Add(d.name);
|
||||
}
|
||||
catch { _deckNames = new List<string>(); }
|
||||
PopulateRows(savedSeatsJson);
|
||||
},
|
||||
_ => PopulateRows(savedSeatsJson));
|
||||
}
|
||||
|
||||
private void PopulateRows(string savedJson)
|
||||
{
|
||||
List<SeatConfig> saved = null;
|
||||
try { saved = JsonConvert.DeserializeObject<List<SeatConfig>>(savedJson); } catch { }
|
||||
|
||||
for (int i = 0; i < seatRows.Length; i++)
|
||||
{
|
||||
if (seatRows[i] == null) continue;
|
||||
var cfg = (saved != null && i < saved.Count) ? saved[i] : null;
|
||||
seatRows[i].Populate(i, _deckNames, cfg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Confirm → POST /api/game/start ──────────────────────
|
||||
private void OnConfirm()
|
||||
{
|
||||
SetError(null);
|
||||
confirmButton.interactable = false;
|
||||
|
||||
// Build local seat list
|
||||
var configs = new List<SeatConfig>();
|
||||
foreach (var row in seatRows)
|
||||
configs.Add(row?.GetConfig() ?? new SeatConfig());
|
||||
|
||||
// Save locally so Battleground scene can read seat metadata
|
||||
PlayerPrefs.SetString("BattleSeats", JsonConvert.SerializeObject(configs));
|
||||
PlayerPrefs.Save();
|
||||
|
||||
// Build backend request — maps local SeatConfig → StartGameRequest
|
||||
var apiSeats = new List<ApiSeatConfig>();
|
||||
for (int i = 0; i < configs.Count; i++)
|
||||
{
|
||||
var c = configs[i];
|
||||
apiSeats.Add(new ApiSeatConfig
|
||||
{
|
||||
seat_index = i,
|
||||
seat_type = c.isHuman ? "Human" : $"AI {c.aiStyle}",
|
||||
deck_name = c.deckName ?? "",
|
||||
player_name = c.isHuman ? "Player" : $"AI {i}",
|
||||
});
|
||||
}
|
||||
|
||||
var requestJson = JsonConvert.SerializeObject(new StartGameRequest { seats = apiSeats });
|
||||
|
||||
apiClient.PostGameStart(
|
||||
requestJson,
|
||||
onSuccess: responseJson =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = JsonConvert.DeserializeObject<StartGameResponse>(responseJson);
|
||||
if (resp == null || string.IsNullOrEmpty(resp.game_id))
|
||||
{
|
||||
OnStartError("Server returned an invalid response.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist game_id and ws_url so GameWebSocketClient can connect
|
||||
PlayerPrefs.SetString("GameId", resp.game_id);
|
||||
PlayerPrefs.SetString("WsUrl", resp.ws_url ?? $"/ws/game/{resp.game_id}");
|
||||
PlayerPrefs.Save();
|
||||
|
||||
gameObject.SetActive(false);
|
||||
SceneManager.LoadScene("Battleground");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnStartError($"Parse error: {ex.Message}");
|
||||
}
|
||||
},
|
||||
onError: err => OnStartError($"Failed to start game: {err}"));
|
||||
}
|
||||
|
||||
private void OnStartError(string message)
|
||||
{
|
||||
Debug.LogError("[LobbySetupModal] " + message);
|
||||
SetError(message);
|
||||
confirmButton.interactable = true;
|
||||
}
|
||||
|
||||
private void SetError(string message)
|
||||
{
|
||||
if (errorLabel == null) return;
|
||||
errorLabel.text = message ?? string.Empty;
|
||||
errorLabel.gameObject.SetActive(!string.IsNullOrEmpty(message));
|
||||
}
|
||||
|
||||
// ── Models ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>Local / PlayerPrefs representation of a seat.</summary>
|
||||
[Serializable]
|
||||
public class SeatConfig
|
||||
{
|
||||
public bool isHuman;
|
||||
public string deckName;
|
||||
public string aiStyle; // Aggro / Control / Combo / Political
|
||||
}
|
||||
|
||||
/// <summary>Matches routes/game.py SeatConfig schema.</summary>
|
||||
[Serializable]
|
||||
private class ApiSeatConfig
|
||||
{
|
||||
public int seat_index;
|
||||
public string seat_type;
|
||||
public string deck_name;
|
||||
public string player_name;
|
||||
}
|
||||
|
||||
/// <summary>Matches routes/game.py StartGameRequest schema.</summary>
|
||||
[Serializable]
|
||||
private class StartGameRequest
|
||||
{
|
||||
public List<ApiSeatConfig> seats;
|
||||
}
|
||||
|
||||
/// <summary>Matches routes/game.py StartGameResponse schema.</summary>
|
||||
[Serializable]
|
||||
private class StartGameResponse
|
||||
{
|
||||
public string game_id;
|
||||
public string status;
|
||||
public string ws_url;
|
||||
}
|
||||
|
||||
[Serializable] private class DecksWrapper { public List<DeckEntry> decks; }
|
||||
[Serializable] private class DeckEntry { public string name; }
|
||||
}
|
||||
|
||||
/// <summary>Per-seat row in the lobby modal (UI bindings are set in inspector).</summary>
|
||||
[Serializable]
|
||||
public class LobbyRowUI : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text seatLabel;
|
||||
[SerializeField] private Toggle humanToggle;
|
||||
[SerializeField] private TMP_Dropdown deckDropdown;
|
||||
[SerializeField] private TMP_Dropdown aiStyleDropdown;
|
||||
|
||||
private static readonly List<string> AiStyles = new() { "Aggro", "Control", "Combo", "Political" };
|
||||
|
||||
public void Populate(int seatIndex, List<string> deckNames, LobbySetupModal.SeatConfig saved)
|
||||
{
|
||||
if (seatLabel) seatLabel.text = seatIndex == 0 ? "Seat 0 (You)" : $"Seat {seatIndex} (AI)";
|
||||
|
||||
if (humanToggle)
|
||||
{
|
||||
humanToggle.isOn = saved?.isHuman ?? (seatIndex == 0);
|
||||
humanToggle.onValueChanged.AddListener(isHuman =>
|
||||
{
|
||||
if (aiStyleDropdown) aiStyleDropdown.gameObject.SetActive(!isHuman);
|
||||
});
|
||||
if (aiStyleDropdown) aiStyleDropdown.gameObject.SetActive(!humanToggle.isOn);
|
||||
}
|
||||
|
||||
if (deckDropdown)
|
||||
{
|
||||
deckDropdown.ClearOptions();
|
||||
var opts = new List<string> { "-- Select Deck --" };
|
||||
opts.AddRange(deckNames);
|
||||
deckDropdown.AddOptions(opts);
|
||||
if (!string.IsNullOrEmpty(saved?.deckName))
|
||||
{
|
||||
int idx = opts.IndexOf(saved.deckName);
|
||||
deckDropdown.value = idx >= 0 ? idx : 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (aiStyleDropdown)
|
||||
{
|
||||
aiStyleDropdown.ClearOptions();
|
||||
aiStyleDropdown.AddOptions(AiStyles);
|
||||
if (!string.IsNullOrEmpty(saved?.aiStyle))
|
||||
{
|
||||
int idx = AiStyles.IndexOf(saved.aiStyle);
|
||||
aiStyleDropdown.value = idx >= 0 ? idx : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public LobbySetupModal.SeatConfig GetConfig() => new()
|
||||
{
|
||||
isHuman = humanToggle?.isOn ?? false,
|
||||
deckName = deckDropdown != null && deckDropdown.value > 0
|
||||
? deckDropdown.options[deckDropdown.value].text : "",
|
||||
aiStyle = aiStyleDropdown != null
|
||||
? aiStyleDropdown.options[aiStyleDropdown.value].text : "Aggro"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Displays the mana pool for one seat as coloured symbol + count labels.
|
||||
/// Symbols are hidden when the mana amount is 0.
|
||||
/// Resets on each phase-change event from GameStateManager.
|
||||
/// </summary>
|
||||
public class ManaPoolDisplay : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private int seatIndex;
|
||||
|
||||
[Header("Mana Labels (W U B R G C order)")]
|
||||
[SerializeField] private TMP_Text[] manaLabels; // length 6
|
||||
[SerializeField] private GameObject[] manaSymbols; // parent GO per mana type (contains Image)
|
||||
|
||||
private static readonly string[] ManaKeys = { "W", "U", "B", "R", "G", "C" };
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
{
|
||||
GameStateManager.Instance.OnPhaseChanged += OnPhaseChanged;
|
||||
GameStateManager.Instance.OnFullStateSync += OnFullStateSync;
|
||||
}
|
||||
UpdatePool(null);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnPhaseChanged -= OnPhaseChanged;
|
||||
GameStateManager.Instance.OnFullStateSync -= OnFullStateSync;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public void UpdatePool(Dictionary<string, int> pool)
|
||||
{
|
||||
for (int i = 0; i < ManaKeys.Length; i++)
|
||||
{
|
||||
int amt = 0;
|
||||
pool?.TryGetValue(ManaKeys[i], out amt);
|
||||
|
||||
bool show = amt > 0;
|
||||
if (i < manaLabels.Length && manaLabels[i] != null)
|
||||
manaLabels[i].text = show ? amt.ToString() : "";
|
||||
if (i < manaSymbols.Length && manaSymbols[i] != null)
|
||||
manaSymbols[i].SetActive(show);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ─────────────────────────────────────
|
||||
private void OnPhaseChanged(PhaseTrackerWidget.Phase phase, int seat, int turn)
|
||||
{
|
||||
// Mana pool empties at the end of each phase
|
||||
if (phase == PhaseTrackerWidget.Phase.Cleanup)
|
||||
UpdatePool(null);
|
||||
}
|
||||
|
||||
private void OnFullStateSync(GameState state)
|
||||
{
|
||||
if (state?.seats == null || seatIndex >= state.seats.Count) return;
|
||||
UpdatePool(state.seats[seatIndex].manaPool);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// A single phase pill in the PhaseTrackerWidget strip.
|
||||
/// Changes color, font weight, and scale when activated.
|
||||
/// </summary>
|
||||
public class PhaseStepButton : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Image background;
|
||||
[SerializeField] private TMP_Text label;
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color activeColor = new Color(1f, 0.85f, 0f); // amber/yellow
|
||||
[SerializeField] private Color inactiveColor = new Color(0.2f, 0.2f, 0.2f); // dark grey
|
||||
|
||||
private Vector3 _baseScale;
|
||||
|
||||
private void Awake() => _baseScale = transform.localScale;
|
||||
|
||||
public void SetActive(bool active)
|
||||
{
|
||||
if (background) background.color = active ? activeColor : inactiveColor;
|
||||
if (label) label.fontStyle = active ? TMPro.FontStyles.Bold : TMPro.FontStyles.Normal;
|
||||
transform.localScale = active ? _baseScale * 1.1f : _baseScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Horizontal strip of 13 phase/step buttons in the BattlefieldCenter panel.
|
||||
/// Subscribes to GameStateManager.OnPhaseChanged.
|
||||
/// Test without backend: press Space in Play Mode (debug binding below).
|
||||
/// </summary>
|
||||
public class PhaseTrackerWidget : MonoBehaviour
|
||||
{
|
||||
public enum Phase
|
||||
{
|
||||
Untap = 0,
|
||||
Upkeep,
|
||||
Draw,
|
||||
Main1,
|
||||
BeginCombat,
|
||||
DeclareAttackers,
|
||||
DeclareBlockers,
|
||||
FirstStrikeDamage,
|
||||
Damage,
|
||||
EndCombat,
|
||||
Main2,
|
||||
EndStep,
|
||||
Cleanup
|
||||
}
|
||||
|
||||
[SerializeField] private PhaseStepButton[] stepButtons; // 13 buttons, one per Phase
|
||||
[SerializeField] private TMP_Text activePlayerLabel;
|
||||
[SerializeField] private TMP_Text turnNumberLabel;
|
||||
|
||||
private Phase _currentPhase;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnPhaseChanged += SetPhase;
|
||||
|
||||
SetPhase(Phase.Untap, 0, 1);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnPhaseChanged -= SetPhase;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public void SetPhase(Phase phase, int seatIndex, int turnNumber)
|
||||
{
|
||||
_currentPhase = phase;
|
||||
|
||||
for (int i = 0; i < stepButtons.Length; i++)
|
||||
stepButtons[i]?.SetActive(i == (int)phase);
|
||||
|
||||
if (activePlayerLabel)
|
||||
activePlayerLabel.text = seatIndex == 0 ? "Your Turn" : $"Seat {seatIndex}'s Turn";
|
||||
|
||||
if (turnNumberLabel)
|
||||
turnNumberLabel.text = $"Turn {turnNumber}";
|
||||
}
|
||||
|
||||
public Phase CurrentPhase => _currentPhase;
|
||||
|
||||
// ── Debug (editor only) ──────────────────────────────────
|
||||
#if UNITY_EDITOR
|
||||
private void Update()
|
||||
{
|
||||
if (Input.GetKeyDown(KeyCode.Space))
|
||||
{
|
||||
int next = ((int)_currentPhase + 1) % System.Enum.GetValues(typeof(Phase)).Length;
|
||||
SetPhase((Phase)next, 0, GameStateManager.Instance?.TurnNumber ?? 1);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks poison counters for a seat (0-10).
|
||||
/// Activates pip toggles and triggers elimination at 10.
|
||||
/// </summary>
|
||||
public class PoisonCounter : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private int seatIndex;
|
||||
[SerializeField] private Toggle[] pips; // 10 Toggle GameObjects
|
||||
[SerializeField] private TMP_Text countLabel;
|
||||
[SerializeField] private Button addPoisonBtn;
|
||||
[SerializeField] private Button removePoisonBtn;
|
||||
|
||||
private int _count = 0;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
addPoisonBtn?.onClick.AddListener(() => AddPoison(1));
|
||||
removePoisonBtn?.onClick.AddListener(() => AddPoison(-1));
|
||||
Refresh();
|
||||
}
|
||||
|
||||
public void AddPoison(int delta)
|
||||
{
|
||||
_count = Mathf.Clamp(_count + delta, 0, 10);
|
||||
Refresh();
|
||||
if (_count >= 10)
|
||||
GameStateManager.Instance?.EliminatePlayer(seatIndex, "poison");
|
||||
}
|
||||
|
||||
public void SetPoison(int value)
|
||||
{
|
||||
_count = Mathf.Clamp(value, 0, 10);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (countLabel) countLabel.text = _count.ToString();
|
||||
for (int i = 0; i < pips.Length; i++)
|
||||
if (pips[i] != null) pips[i].isOn = i < _count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Visual row for a single item on the stack.
|
||||
/// Shows card/ability name, caster seat color, stack position, and targets.
|
||||
/// </summary>
|
||||
public class StackItemView : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text cardNameLabel;
|
||||
[SerializeField] private TMP_Text casterLabel;
|
||||
[SerializeField] private TMP_Text targetsLabel;
|
||||
[SerializeField] private TMP_Text stackPositionLabel;
|
||||
[SerializeField] private Image seatColorStrip;
|
||||
|
||||
private static readonly Color[] SeatColors =
|
||||
{
|
||||
new Color(0.2f, 0.6f, 1f), // seat 0 — blue (human)
|
||||
new Color(1f, 0.4f, 0.4f), // seat 1 — red
|
||||
new Color(0.4f, 1f, 0.4f), // seat 2 — green
|
||||
new Color(1f, 0.85f, 0.2f) // seat 3 — gold
|
||||
};
|
||||
|
||||
public void Bind(StackZoneController.StackItem item, int displayPosition)
|
||||
{
|
||||
if (cardNameLabel)
|
||||
cardNameLabel.text = item.isAbility ? $"⚡ {item.cardName}" : item.cardName;
|
||||
|
||||
if (casterLabel)
|
||||
casterLabel.text = item.casterSeat == 0 ? "You" : $"Seat {item.casterSeat}";
|
||||
|
||||
if (seatColorStrip)
|
||||
{
|
||||
int seat = Mathf.Clamp(item.casterSeat, 0, SeatColors.Length - 1);
|
||||
seatColorStrip.color = SeatColors[seat];
|
||||
}
|
||||
|
||||
if (stackPositionLabel)
|
||||
stackPositionLabel.text = displayPosition == 0 ? "[TOP]" : $"[{displayPosition}]";
|
||||
|
||||
if (targetsLabel)
|
||||
{
|
||||
if (item.targets != null && item.targets.Count > 0)
|
||||
targetsLabel.text = "\u2192 " + string.Join(", ", item.targets);
|
||||
else
|
||||
targetsLabel.text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Displays the active spell/ability stack (LIFO order, top of stack shown first).
|
||||
/// Hidden when stack is empty. Subscribes to GameStateManager.OnStackUpdated.
|
||||
/// </summary>
|
||||
public class StackZoneController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private GameObject stackPanel;
|
||||
[SerializeField] private Transform stackItemParent; // VerticalLayoutGroup
|
||||
[SerializeField] private GameObject stackItemPrefab;
|
||||
[SerializeField] private TMP_Text stackCountLabel;
|
||||
|
||||
private readonly List<GameObject> _itemGOs = new();
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnStackUpdated += SetStack;
|
||||
SetStack(new List<StackItem>());
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnStackUpdated -= SetStack;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public void SetStack(List<StackItem> items)
|
||||
{
|
||||
// Clear existing rows
|
||||
foreach (var go in _itemGOs) if (go) Destroy(go);
|
||||
_itemGOs.Clear();
|
||||
|
||||
bool hasItems = items != null && items.Count > 0;
|
||||
if (stackPanel) stackPanel.SetActive(hasItems);
|
||||
|
||||
if (stackCountLabel)
|
||||
stackCountLabel.text = hasItems ? $"Stack ({items.Count})" : "";
|
||||
|
||||
if (!hasItems) return;
|
||||
|
||||
// LIFO — iterate in reverse so top of stack appears first
|
||||
for (int i = items.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var go = Instantiate(stackItemPrefab, stackItemParent);
|
||||
go.SetActive(true);
|
||||
var view = go.GetComponent<StackItemView>();
|
||||
view?.Bind(items[i], items.Count - 1 - i);
|
||||
_itemGOs.Add(go);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serializable stack item (shared with GameState) ───────────
|
||||
[Serializable]
|
||||
public class StackItem
|
||||
{
|
||||
[JsonProperty("card_name")] public string cardName;
|
||||
[JsonProperty("caster_seat")] public int casterSeat;
|
||||
[JsonProperty("targets")] public List<string> targets;
|
||||
[JsonProperty("is_ability")] public bool isAbility;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# Battleground UI — Task Tracker
|
||||
> Created: 2026-03-28
|
||||
> Updated: 2026-03-29 — all 14 issues complete
|
||||
> Branch: `feat/battleground-ui`
|
||||
|
||||
All issues below are part of the **4-player Commander Battleground** Unity 3D UI build.
|
||||
Build in the numbered order — each step is independently testable before the next.
|
||||
|
||||
---
|
||||
|
||||
## Build Order Checklist
|
||||
|
||||
| # | Issue | Script(s) | Needs Backend? | Status |
|
||||
|---|-------|-----------|----------------|--------|
|
||||
| 1 | [#138 — Battleground.unity scene + 4-seat layout](https://github.com/KoalaTrapLord/commander-ai-lab/issues/138) | `BattlegroundController.cs`, `GameStateManager.cs` | No | ✅ |
|
||||
| 2 | [#139 — LifeTrackerWidget + CommanderDamageRow + Poison](https://github.com/KoalaTrapLord/commander-ai-lab/issues/139) | `LifeTrackerWidget.cs`, `CommanderDamageRow.cs`, `PoisonCounter.cs` | No | ✅ |
|
||||
| 3 | [#140 — PhaseTrackerWidget + PhaseStepButton](https://github.com/KoalaTrapLord/commander-ai-lab/issues/140) | `PhaseTrackerWidget.cs`, `PhaseStepButton.cs` | No | ✅ |
|
||||
| 4 | [#141 — TurnOrderBar + TurnOrderPill](https://github.com/KoalaTrapLord/commander-ai-lab/issues/141) | `TurnOrderBar.cs`, `TurnOrderPill.cs` | No | ✅ |
|
||||
| 5 | [#142 — CardZone + CardView](https://github.com/KoalaTrapLord/commander-ai-lab/issues/142) | `CardZone.cs`, `CardView.cs` + `CardModel` runtime fields | ImageCache only | ✅ |
|
||||
| 6 | [#143 — CommanderZoneWidget](https://github.com/KoalaTrapLord/commander-ai-lab/issues/143) | `CommanderZoneWidget.cs` + `ImageCache.LoadArtCrop()` | ImageCache only | ✅ |
|
||||
| 7 | [#144 — Token System](https://github.com/KoalaTrapLord/commander-ai-lab/issues/144) | `TokenModel.cs`, `TokenPanelController.cs`, `TokenRowView.cs` | No | ✅ |
|
||||
| 8 | [#146 — StackZoneController + ManaPoolDisplay](https://github.com/KoalaTrapLord/commander-ai-lab/issues/146) | `StackZoneController.cs`, `ManaPoolDisplay.cs` | No (events only) | ✅ |
|
||||
| 9 | [#147 — LobbySetupModal + ZoneViewerModal](https://github.com/KoalaTrapLord/commander-ai-lab/issues/147) | `LobbySetupModal.cs`, `ZoneViewerModal.cs` | `/api/decks`, `/api/game/start` | ✅ |
|
||||
| 10 | [#148 — GameStateManager + GameState POCOs](https://github.com/KoalaTrapLord/commander-ai-lab/issues/148) | `GameStateManager.cs`, `GameState.cs` | No | ✅ |
|
||||
| 11 | [#149 — GameWebSocketClient](https://github.com/KoalaTrapLord/commander-ai-lab/issues/149) | `GameWebSocketClient.cs` | Yes — `/ws/game` | ✅ |
|
||||
| 12 | [#145 — HumanActionBar](https://github.com/KoalaTrapLord/commander-ai-lab/issues/145) | `HumanActionBar.cs`, `ApiClient.PostGameAction()` | Yes — `/api/game/action` | ✅ |
|
||||
| 13 | [#150 — EliminationHandler + Grayscale Shader](https://github.com/KoalaTrapLord/commander-ai-lab/issues/150) | `EliminationHandler.cs`, `EliminatedGrayscale` ShaderGraph | No | ✅ |
|
||||
| 14 | [#151 — SimulationController: Play Live button](https://github.com/KoalaTrapLord/commander-ai-lab/issues/151) | `SimulationController.cs` (modify) | No | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## All Files Committed
|
||||
|
||||
### `Assets/Scripts/UI/Battleground/` (new folder)
|
||||
- ✅ `BattlegroundController.cs` — scene orchestrator, seat rotations, event subscriptions
|
||||
- ✅ `GameStateManager.cs` — singleton event hub
|
||||
- ✅ `LifeTrackerWidget.cs` — life +/- with history + danger flash
|
||||
- ✅ `CommanderDamageRow.cs` — 4-pip commander damage tracker per seat
|
||||
- ✅ `PoisonCounter.cs` — 10-pip poison tracker
|
||||
- ✅ `PhaseTrackerWidget.cs` — 13-step phase strip, Space key debug advance
|
||||
- ✅ `PhaseStepButton.cs` — individual phase pill (color + scale)
|
||||
- ✅ `TurnOrderBar.cs` — animated seat border slider
|
||||
- ✅ `TurnOrderPill.cs` — per-seat pill with name, life, eliminated state
|
||||
- ✅ `CardZone.cs` — zone container (add/remove/clear CardView children)
|
||||
- ✅ `CardView.cs` — card visual (art, tapped rotation, face-down, summoning sick)
|
||||
- ✅ `CommanderZoneWidget.cs` — art crop, cast count, tax (+2 per cast)
|
||||
- ✅ `TokenPanelController.cs` — token creation panel
|
||||
- ✅ `TokenRowView.cs` — token row (qty, tap, counters, delete)
|
||||
- ✅ `StackZoneController.cs` — LIFO stack display, hidden when empty
|
||||
- ✅ `StackItemView.cs` — individual stack item row
|
||||
- ✅ `ManaPoolDisplay.cs` — 6-pip mana display, hides zero labels
|
||||
- ✅ `LobbySetupModal.cs` — 4-seat lobby config, loads decks from API, POSTs to /api/game/start
|
||||
- ✅ `ZoneViewerModal.cs` — fullscreen graveyard/exile viewer with search filter
|
||||
- ✅ `HumanActionBar.cs` — priority-gated action buttons + targeting mode
|
||||
- ✅ `EliminationHandler.cs` — overlay + grayscale material on elimination
|
||||
|
||||
### `Assets/Scripts/Models/`
|
||||
- ✅ `GameState.cs` — `GameState` + `SeatState` POCOs
|
||||
- ✅ `TokenModel.cs` — token data model
|
||||
- ✅ `CardModel.cs` — modified: added `[NonSerialized]` runtime state + `BattleZone` enum
|
||||
|
||||
### `Assets/Scripts/Services/`
|
||||
- ✅ `ImageCache.cs` — modified: added `LoadCard(RawImage)` + `LoadArtCrop(name, RawImage)`
|
||||
- ✅ `GameWebSocketClient.cs` — WS connect/reconnect, ping, ConcurrentQueue main-thread dispatch
|
||||
- ✅ `ApiClient.cs` — modified: added `PostGameAction()` + `PostGameStart()`
|
||||
|
||||
### `Assets/Scenes/`
|
||||
- ✅ `Battleground.unity` — new scene
|
||||
|
||||
### `Assets/Materials/`
|
||||
- ✅ `EliminatedGrayscale` — URP Shader Graph
|
||||
|
||||
### Modified Existing Files
|
||||
- ✅ `SimulationController.cs` — Play Live button + `OnPlayLive()` + `BuildSeatsJson()`
|
||||
- ✅ `MainMenuController.cs` — Play Live nav button
|
||||
|
||||
---
|
||||
|
||||
## Backend Endpoints Required
|
||||
|
||||
These Python routes must exist before end-to-end testing:
|
||||
|
||||
| Endpoint | Used By | Status |
|
||||
|----------|---------|--------|
|
||||
| `GET /api/decks` | `LobbySetupModal` | verify |
|
||||
| `POST /api/game/start` | `LobbySetupModal` | verify |
|
||||
| `POST /api/game/action` | `HumanActionBar` | verify |
|
||||
| `WS /ws/game` | `GameWebSocketClient` | verify (PR #107) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **`GameStateManager`** is the singleton event hub — all UI widgets subscribe to its events, never poll
|
||||
- **`GameWebSocketClient`** receives server push messages and routes them to `GameStateManager.ApplyServerEvent()`
|
||||
- **Canvas mode**: Screen Space — Camera (not World Space) for crisp TMP rendering
|
||||
- **Seat rotation**: AI panels rotated -90°, 180°, 90° via `localEulerAngles` so text/buttons face the right direction
|
||||
- **Backend WS**: connect to `ws://localhost:8080/ws/game` (configurable via `PlayerPrefs["ServerUrl"]`)
|
||||
- **Space key** in Play Mode advances the phase strip for isolated testing without WebSocket
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Token creation and tracking panel in the BattlefieldCenter.
|
||||
/// Human creates tokens manually; AI tokens are added via AddToken() from server events.
|
||||
/// </summary>
|
||||
public class TokenPanelController : MonoBehaviour
|
||||
{
|
||||
[Header("Creation Row")]
|
||||
[SerializeField] private TMP_InputField nameInput;
|
||||
[SerializeField] private TMP_InputField powerInput;
|
||||
[SerializeField] private TMP_InputField toughnessInput;
|
||||
[SerializeField] private TMP_InputField qtyInput;
|
||||
[SerializeField] private Button createBtn;
|
||||
|
||||
[Header("List")]
|
||||
[SerializeField] private Transform tokenListParent;
|
||||
[SerializeField] private GameObject tokenRowPrefab;
|
||||
|
||||
private readonly List<TokenModel> _tokens = new();
|
||||
private readonly List<TokenRowView> _rowViews = new();
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start() => createBtn.onClick.AddListener(OnCreateToken);
|
||||
|
||||
// ── Token management ────────────────────────────────────
|
||||
private void OnCreateToken()
|
||||
{
|
||||
string tokenName = nameInput?.text?.Trim();
|
||||
if (string.IsNullOrEmpty(tokenName)) return;
|
||||
|
||||
int.TryParse(qtyInput?.text, out int qty);
|
||||
qty = Mathf.Max(1, qty);
|
||||
|
||||
var token = new TokenModel
|
||||
{
|
||||
id = Guid.NewGuid().ToString(),
|
||||
name = tokenName,
|
||||
power = powerInput?.text?.Trim() ?? "",
|
||||
toughness = toughnessInput?.text?.Trim() ?? "",
|
||||
ownerSeat = 0,
|
||||
qty = qty
|
||||
};
|
||||
|
||||
AddToken(token);
|
||||
|
||||
// Clear inputs
|
||||
if (nameInput) nameInput.text = "";
|
||||
if (powerInput) powerInput.text = "";
|
||||
if (toughnessInput) toughnessInput.text = "";
|
||||
if (qtyInput) qtyInput.text = "1";
|
||||
}
|
||||
|
||||
public void AddToken(TokenModel token)
|
||||
{
|
||||
_tokens.Add(token);
|
||||
var go = Instantiate(tokenRowPrefab, tokenListParent);
|
||||
var view = go.GetComponent<TokenRowView>();
|
||||
if (view != null)
|
||||
{
|
||||
view.Bind(token, RemoveToken);
|
||||
_rowViews.Add(view);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveToken(TokenModel token)
|
||||
{
|
||||
int idx = _tokens.IndexOf(token);
|
||||
if (idx < 0) return;
|
||||
|
||||
_tokens.RemoveAt(idx);
|
||||
var view = _rowViews[idx];
|
||||
_rowViews.RemoveAt(idx);
|
||||
if (view != null) Destroy(view.gameObject);
|
||||
}
|
||||
|
||||
public IReadOnlyList<TokenModel> Tokens => _tokens;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// A single token row in TokenPanelController's list.
|
||||
/// Supports qty +/-, tap all, counter editing, and removal.
|
||||
/// </summary>
|
||||
public class TokenRowView : MonoBehaviour
|
||||
{
|
||||
[Header("Labels")]
|
||||
[SerializeField] private TMP_Text nameLabel;
|
||||
[SerializeField] private TMP_Text ptLabel;
|
||||
[SerializeField] private TMP_Text qtyLabel;
|
||||
[SerializeField] private TMP_Text tappedLabel;
|
||||
|
||||
[Header("Buttons")]
|
||||
[SerializeField] private Button qtyPlusBtn;
|
||||
[SerializeField] private Button qtyMinusBtn;
|
||||
[SerializeField] private Button tapAllBtn;
|
||||
[SerializeField] private Button deleteBtn;
|
||||
|
||||
[Header("Counter Editor")]
|
||||
[SerializeField] private GameObject counterEditorPanel;
|
||||
[SerializeField] private TMP_Dropdown counterTypeDropdown;
|
||||
[SerializeField] private Button counterAddBtn;
|
||||
[SerializeField] private Button counterRemoveBtn;
|
||||
[SerializeField] private TMP_Text counterSummaryLabel;
|
||||
[SerializeField] private Button toggleCounterEditorBtn;
|
||||
|
||||
private static readonly List<string> CounterTypes =
|
||||
new() { "+1/+1", "-1/-1", "loyalty", "charge", "quest", "time" };
|
||||
|
||||
private TokenModel _token;
|
||||
private Action<TokenModel> _onDelete;
|
||||
|
||||
// ── Binding ───────────────────────────────────────────
|
||||
public void Bind(TokenModel token, Action<TokenModel> onDelete)
|
||||
{
|
||||
_token = token;
|
||||
_onDelete = onDelete;
|
||||
|
||||
// Wire buttons
|
||||
qtyPlusBtn ?.onClick.AddListener(() => AdjustQty(+1));
|
||||
qtyMinusBtn?.onClick.AddListener(() => AdjustQty(-1));
|
||||
tapAllBtn ?.onClick.AddListener(TapAll);
|
||||
deleteBtn ?.onClick.AddListener(() => _onDelete?.Invoke(_token));
|
||||
toggleCounterEditorBtn?.onClick.AddListener(ToggleCounterEditor);
|
||||
counterAddBtn ?.onClick.AddListener(() => AdjustCounter(+1));
|
||||
counterRemoveBtn?.onClick.AddListener(() => AdjustCounter(-1));
|
||||
|
||||
// Populate counter type dropdown
|
||||
if (counterTypeDropdown != null)
|
||||
{
|
||||
counterTypeDropdown.ClearOptions();
|
||||
counterTypeDropdown.AddOptions(CounterTypes);
|
||||
}
|
||||
|
||||
if (counterEditorPanel) counterEditorPanel.SetActive(false);
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────
|
||||
private void AdjustQty(int delta)
|
||||
{
|
||||
_token.qty = Mathf.Max(0, _token.qty + delta);
|
||||
if (_token.qty == 0) { _onDelete?.Invoke(_token); return; }
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
private void TapAll()
|
||||
{
|
||||
_token.isTapped = !_token.isTapped;
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
private void ToggleCounterEditor()
|
||||
{
|
||||
if (counterEditorPanel)
|
||||
counterEditorPanel.SetActive(!counterEditorPanel.activeSelf);
|
||||
}
|
||||
|
||||
private void AdjustCounter(int delta)
|
||||
{
|
||||
if (counterTypeDropdown == null) return;
|
||||
string counterType = CounterTypes[Mathf.Clamp(counterTypeDropdown.value, 0, CounterTypes.Count - 1)];
|
||||
_token.counters.TryGetValue(counterType, out int current);
|
||||
int next = Mathf.Max(0, current + delta);
|
||||
if (next == 0) _token.counters.Remove(counterType);
|
||||
else _token.counters[counterType] = next;
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
// ── Display ────────────────────────────────────────────
|
||||
private void RefreshDisplay()
|
||||
{
|
||||
if (nameLabel) nameLabel.text = _token.name;
|
||||
if (ptLabel) ptLabel.text = _token.PTString;
|
||||
if (qtyLabel) qtyLabel.text = $"×{_token.qty}";
|
||||
if (tappedLabel) tappedLabel.text = _token.isTapped ? "[Tapped]" : "";
|
||||
|
||||
// Counter summary
|
||||
if (counterSummaryLabel)
|
||||
{
|
||||
var parts = new System.Text.StringBuilder();
|
||||
foreach (var kv in _token.counters)
|
||||
parts.Append($"{kv.Key}:{kv.Value} ");
|
||||
counterSummaryLabel.text = parts.Length > 0 ? parts.ToString().Trim() : "No counters";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Horizontal strip of 4 TurnOrderPills at the top of BattlefieldCenter.
|
||||
/// Animates an active border sliding to the current seat's pill on turn change.
|
||||
/// Subscribes to GameStateManager.OnTurnChanged.
|
||||
/// </summary>
|
||||
public class TurnOrderBar : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TurnOrderPill[] pills; // length 4
|
||||
[SerializeField] private RectTransform activeBorder; // sliding highlight rect
|
||||
[SerializeField] private float slideDuration = 0.25f;
|
||||
|
||||
private int _activeSeat = 0;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
{
|
||||
GameStateManager.Instance.OnTurnChanged += SetActiveSeat;
|
||||
GameStateManager.Instance.OnLifeChanged += OnLifeChanged;
|
||||
GameStateManager.Instance.OnPlayerEliminated += OnPlayerEliminated;
|
||||
}
|
||||
SetActiveSeat(0);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnTurnChanged -= SetActiveSeat;
|
||||
GameStateManager.Instance.OnLifeChanged -= OnLifeChanged;
|
||||
GameStateManager.Instance.OnPlayerEliminated -= OnPlayerEliminated;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public void SetActiveSeat(int seatIndex)
|
||||
{
|
||||
_activeSeat = seatIndex;
|
||||
for (int i = 0; i < pills.Length; i++)
|
||||
pills[i]?.SetActive(i == seatIndex);
|
||||
|
||||
AnimateBorderToPill(seatIndex);
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────
|
||||
private void AnimateBorderToPill(int seatIndex)
|
||||
{
|
||||
if (activeBorder == null || pills == null || seatIndex >= pills.Length) return;
|
||||
if (pills[seatIndex] == null) return;
|
||||
|
||||
var targetPos = (pills[seatIndex].transform as RectTransform)?.anchoredPosition
|
||||
?? pills[seatIndex].transform.localPosition;
|
||||
|
||||
StopAllCoroutines();
|
||||
StartCoroutine(SlideBorder(targetPos));
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerator SlideBorder(Vector2 targetPos)
|
||||
{
|
||||
var start = activeBorder.anchoredPosition;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < slideDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = Mathf.SmoothStep(0f, 1f, elapsed / slideDuration);
|
||||
activeBorder.anchoredPosition = Vector2.Lerp(start, targetPos, t);
|
||||
yield return null;
|
||||
}
|
||||
activeBorder.anchoredPosition = targetPos;
|
||||
}
|
||||
|
||||
private void OnLifeChanged(int seatIndex, int newLife)
|
||||
{
|
||||
if (seatIndex < pills.Length) pills[seatIndex]?.UpdateLife(newLife);
|
||||
}
|
||||
|
||||
private void OnPlayerEliminated(int seatIndex, string reason)
|
||||
{
|
||||
if (seatIndex < pills.Length) pills[seatIndex]?.SetEliminated();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// A single seat pill inside TurnOrderBar.
|
||||
/// Shows player name, current life, and highlights when it is that seat's turn.
|
||||
/// </summary>
|
||||
public class TurnOrderPill : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text playerNameLabel;
|
||||
[SerializeField] private TMP_Text lifeLabel;
|
||||
[SerializeField] private Image pillBackground;
|
||||
[SerializeField] private GameObject youBadge; // "YOU" label, only visible on seat 0
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color activeColor = new Color(1f, 0.85f, 0f);
|
||||
[SerializeField] private Color inactiveColor = new Color(0.25f, 0.25f, 0.25f);
|
||||
[SerializeField] private Color eliminatedColor = new Color(0.4f, 0.4f, 0.4f, 0.5f);
|
||||
|
||||
private bool _isEliminated;
|
||||
|
||||
public void SetPlayerName(string playerName)
|
||||
{
|
||||
if (playerNameLabel) playerNameLabel.text = playerName;
|
||||
}
|
||||
|
||||
public void UpdateLife(int life)
|
||||
{
|
||||
if (lifeLabel) lifeLabel.text = life.ToString();
|
||||
}
|
||||
|
||||
public void ShowYouBadge(bool show)
|
||||
{
|
||||
if (youBadge) youBadge.SetActive(show);
|
||||
}
|
||||
|
||||
public void SetActive(bool active)
|
||||
{
|
||||
if (_isEliminated) return;
|
||||
if (pillBackground) pillBackground.color = active ? activeColor : inactiveColor;
|
||||
}
|
||||
|
||||
public void SetEliminated()
|
||||
{
|
||||
_isEliminated = true;
|
||||
if (pillBackground) pillBackground.color = eliminatedColor;
|
||||
if (playerNameLabel)
|
||||
{
|
||||
playerNameLabel.fontStyle = TMPro.FontStyles.Strikethrough;
|
||||
playerNameLabel.color = Color.gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Full-screen overlay showing all cards in a particular zone (graveyard, exile, etc.).
|
||||
/// Opened by clicking zone count badges on each seat panel.
|
||||
/// </summary>
|
||||
public class ZoneViewerModal : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private TMP_Text titleLabel;
|
||||
[SerializeField] private Transform cardGrid; // GridLayoutGroup
|
||||
[SerializeField] private GameObject cardPrefab; // CardView prefab
|
||||
[SerializeField] private Button closeButton;
|
||||
|
||||
private readonly List<GameObject> _cardGOs = new();
|
||||
|
||||
private void Start() => closeButton?.onClick.AddListener(Close);
|
||||
|
||||
// ── Open ─────────────────────────────────────────────────
|
||||
public void Open(string zoneName, CardZone zone)
|
||||
{
|
||||
if (titleLabel) titleLabel.text = $"{zoneName} ({zone.Count} cards)";
|
||||
|
||||
foreach (var go in _cardGOs) if (go) Destroy(go);
|
||||
_cardGOs.Clear();
|
||||
|
||||
foreach (var view in zone.Cards)
|
||||
{
|
||||
if (view?.Model == null) continue;
|
||||
var go = Instantiate(cardPrefab, cardGrid);
|
||||
go.SetActive(true);
|
||||
var cv = go.GetComponent<CardView>();
|
||||
cv?.Bind(view.Model, false);
|
||||
_cardGOs.Add(go);
|
||||
}
|
||||
|
||||
gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
// ── Close ───────────────────────────────────────────────
|
||||
public void Close()
|
||||
{
|
||||
foreach (var go in _cardGOs) if (go) Destroy(go);
|
||||
_cardGOs.Clear();
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ namespace CommanderAILab.UI
|
||||
/// <summary>
|
||||
/// Simulator scene — configure and run multi-player simulations,
|
||||
/// poll status, display results per deck.
|
||||
/// Also provides a Play Live button that routes to the Battleground scene.
|
||||
/// </summary>
|
||||
public class SimulationController : MonoBehaviour
|
||||
{
|
||||
@@ -22,6 +23,8 @@ namespace CommanderAILab.UI
|
||||
[Header("Navigation")]
|
||||
[SerializeField] private Button backButton;
|
||||
[SerializeField] private Button startSimButton;
|
||||
/// <summary>Routes to the Battleground scene for a live 4-player game.</summary>
|
||||
[SerializeField] private Button playLiveButton;
|
||||
|
||||
// ── Config Panel ──────────────────────────────────────────────────────
|
||||
[Header("Config")]
|
||||
@@ -74,6 +77,9 @@ namespace CommanderAILab.UI
|
||||
startSimButton.onClick.AddListener(OnStartSim);
|
||||
retryButton.onClick.AddListener(OnStartSim);
|
||||
|
||||
// Play Live: save current deck selections and load Battleground scene
|
||||
playLiveButton?.onClick.AddListener(OnPlayLive);
|
||||
|
||||
numPlayersSlider.minValue = 2;
|
||||
numPlayersSlider.maxValue = 4;
|
||||
numPlayersSlider.wholeNumbers = true;
|
||||
@@ -83,12 +89,10 @@ namespace CommanderAILab.UI
|
||||
|
||||
errorPanel.SetActive(false);
|
||||
|
||||
// Check for a deck exported from DeckBuilder
|
||||
string simDeck = PlayerPrefs.GetString("SimDeck", "");
|
||||
if (!string.IsNullOrEmpty(simDeck))
|
||||
{
|
||||
PlayerPrefs.DeleteKey("SimDeck");
|
||||
// Pre-populate deck dropdown 0 after decks load — stored for later
|
||||
PlayerPrefs.SetString("_SimDeckPending", simDeck);
|
||||
}
|
||||
|
||||
@@ -96,6 +100,29 @@ namespace CommanderAILab.UI
|
||||
LoadAiProfiles();
|
||||
}
|
||||
|
||||
// ── Play Live ─────────────────────────────────────────────────────────
|
||||
private void OnPlayLive()
|
||||
{
|
||||
// Persist current deck selections so LobbySetupModal can pre-populate
|
||||
var seats = new List<object>();
|
||||
void AddSeat(TMP_Dropdown dd, bool isHuman, int seatIdx)
|
||||
{
|
||||
string deckName = "";
|
||||
if (dd != null && dd.value > 0 && (dd.value - 1) < _decks.Count)
|
||||
deckName = _decks[dd.value - 1].name;
|
||||
seats.Add(new { isHuman, deckName, aiStyle = "Aggro" });
|
||||
}
|
||||
|
||||
AddSeat(deckDropdown0, true, 0);
|
||||
AddSeat(deckDropdown1, false, 1);
|
||||
AddSeat(deckDropdown2, false, 2);
|
||||
AddSeat(deckDropdown3, false, 3);
|
||||
|
||||
PlayerPrefs.SetString("BattleSeats", JsonConvert.SerializeObject(seats));
|
||||
PlayerPrefs.Save();
|
||||
SceneManager.LoadScene("Battleground");
|
||||
}
|
||||
|
||||
// ── Player Count ──────────────────────────────────────────────────────
|
||||
private void OnNumPlayersChanged(float value)
|
||||
{
|
||||
@@ -103,7 +130,6 @@ namespace CommanderAILab.UI
|
||||
if (numPlayersLabel != null)
|
||||
numPlayersLabel.text = $"Players: {count}";
|
||||
|
||||
// Show/hide optional deck dropdowns based on count
|
||||
if (deckDropdown2 != null) deckDropdown2.gameObject.SetActive(count >= 3);
|
||||
if (deckDropdown3 != null) deckDropdown3.gameObject.SetActive(count >= 4);
|
||||
}
|
||||
@@ -143,14 +169,12 @@ namespace CommanderAILab.UI
|
||||
Fill(deckDropdown2);
|
||||
Fill(deckDropdown3);
|
||||
|
||||
// Apply any pending deck from DeckBuilder export
|
||||
string pending = PlayerPrefs.GetString("_SimDeckPending", "");
|
||||
if (!string.IsNullOrEmpty(pending))
|
||||
{
|
||||
PlayerPrefs.DeleteKey("_SimDeckPending");
|
||||
try
|
||||
{
|
||||
// Try to match by name from the JSON
|
||||
var exported = JsonConvert.DeserializeObject<LabDeckEntry>(pending);
|
||||
if (exported != null && !string.IsNullOrEmpty(exported.name))
|
||||
{
|
||||
@@ -179,7 +203,6 @@ namespace CommanderAILab.UI
|
||||
},
|
||||
err =>
|
||||
{
|
||||
// Non-fatal: profiles may not be available yet
|
||||
_profiles = new List<AiProfile>();
|
||||
PopulateProfileDropdown();
|
||||
});
|
||||
@@ -203,7 +226,6 @@ namespace CommanderAILab.UI
|
||||
|
||||
int numPlayers = Mathf.RoundToInt(numPlayersSlider.value);
|
||||
|
||||
// Collect selected deck names (index 0 = "-- Select Deck --")
|
||||
var selectedDecks = new List<string>();
|
||||
void TryAdd(TMP_Dropdown dd)
|
||||
{
|
||||
@@ -292,7 +314,6 @@ namespace CommanderAILab.UI
|
||||
|
||||
bool done = false;
|
||||
string errorMsg = null;
|
||||
SimStatusModel latestStatus = null;
|
||||
|
||||
_api.GetSimStatus(batchId,
|
||||
json =>
|
||||
@@ -301,7 +322,6 @@ namespace CommanderAILab.UI
|
||||
{
|
||||
var status = JsonConvert.DeserializeObject<SimStatusModel>(json);
|
||||
if (status == null) { done = true; errorMsg = "Empty status response."; return; }
|
||||
latestStatus = status;
|
||||
|
||||
if (!string.IsNullOrEmpty(status.error))
|
||||
{
|
||||
@@ -316,10 +336,10 @@ namespace CommanderAILab.UI
|
||||
: status.gamesCompleted;
|
||||
|
||||
float progress = total > 0 ? (float)completed / total : 0f;
|
||||
if (progressBar) progressBar.value = progress;
|
||||
if (progressText) progressText.text = $"{Mathf.RoundToInt(progress * 100)}%";
|
||||
if (statusText) statusText.text = $"Running... {completed}/{total} games";
|
||||
if (elapsedText) elapsedText.text = $"Elapsed: {status.elapsedMs / 1000f:F1}s";
|
||||
if (progressBar) progressBar.value = progress;
|
||||
if (progressText) progressText.text = $"{Mathf.RoundToInt(progress * 100)}%";
|
||||
if (statusText) statusText.text = $"Running... {completed}/{total} games";
|
||||
if (elapsedText) elapsedText.text = $"Elapsed: {status.elapsedMs / 1000f:F1}s";
|
||||
if (simsPerSecText) simsPerSecText.text = $"{status.simsPerSec:F1} sims/s";
|
||||
|
||||
if (!status.running) done = true;
|
||||
@@ -332,7 +352,6 @@ namespace CommanderAILab.UI
|
||||
},
|
||||
err => { done = true; errorMsg = err; });
|
||||
|
||||
// Wait one frame so callbacks have fired
|
||||
yield return null;
|
||||
|
||||
if (!string.IsNullOrEmpty(errorMsg))
|
||||
@@ -345,7 +364,7 @@ namespace CommanderAILab.UI
|
||||
|
||||
if (done)
|
||||
{
|
||||
if (statusText) statusText.text = "Simulation complete!";
|
||||
if (statusText) statusText.text = "Simulation complete!";
|
||||
if (progressBar) progressBar.value = 1f;
|
||||
if (progressText) progressText.text = "100%";
|
||||
startSimButton.interactable = true;
|
||||
@@ -412,9 +431,9 @@ namespace CommanderAILab.UI
|
||||
|
||||
private void SetResultsPanelVisible(bool visible)
|
||||
{
|
||||
if (resultsParent) resultsParent.gameObject.SetActive(visible);
|
||||
if (winnerText) winnerText.gameObject.SetActive(visible);
|
||||
if (historyButton) historyButton.gameObject.SetActive(visible);
|
||||
if (resultsParent) resultsParent.gameObject.SetActive(visible);
|
||||
if (winnerText) winnerText.gameObject.SetActive(visible);
|
||||
if (historyButton) historyButton.gameObject.SetActive(visible);
|
||||
}
|
||||
|
||||
// ── Error ─────────────────────────────────────────────────────────────
|
||||
@@ -424,19 +443,26 @@ namespace CommanderAILab.UI
|
||||
errorText.text = msg;
|
||||
}
|
||||
|
||||
// ── Helper Models (private inner classes) ─────────────────────────────
|
||||
[Serializable] private class DecksWrapper { public List<LabDeckEntry> decks; }
|
||||
[Serializable] private class LabDeckEntry { public string name; public string source; public string commander; }
|
||||
// ── Helper Models ─────────────────────────────────────────────────────
|
||||
[Serializable] private class DecksWrapper { public List<LabDeckEntry> decks; }
|
||||
[Serializable] private class LabDeckEntry { public string name; public string source; public string commander; }
|
||||
[Serializable] private class ProfilesWrapper { public List<AiProfile> profiles; }
|
||||
[Serializable] private class AiProfile { public string name; public string description; }
|
||||
[Serializable] private class AiProfile { public string name; public string description; }
|
||||
[Serializable] private class StartRequest
|
||||
{
|
||||
public List<string> decks; public int numGames; public int threads;
|
||||
public bool useLearnedPolicy; public string policyStyle;
|
||||
public int seed; public int clock;
|
||||
}
|
||||
[Serializable] private class SimResult { public Summary summary; }
|
||||
[Serializable] private class Summary { public List<ResultEntry> perDeck; }
|
||||
[Serializable] private class SimStartResponse { public string batchId; }
|
||||
[Serializable] private class SimStatusModel
|
||||
{
|
||||
public bool running; public int total; public int completed;
|
||||
public int totalGames; public int gamesCompleted;
|
||||
public float elapsedMs; public float simsPerSec; public string error;
|
||||
}
|
||||
[Serializable] private class SimResult { public Summary summary; }
|
||||
[Serializable] private class Summary { public List<ResultEntry> perDeck; }
|
||||
[Serializable] private class ResultEntry { public string deckName; public float winRate; public int wins; public int losses; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""WebSocket module for real-time game state streaming to Unity."""
|
||||
@@ -0,0 +1,165 @@
|
||||
"""WebSocket connection manager for game-state streaming.
|
||||
|
||||
Manages per-game-session WebSocket connections so the server can
|
||||
push state deltas to every connected Unity client in real time.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
log = logging.getLogger("ws.connection_manager")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientInfo:
|
||||
"""Metadata for a single connected WebSocket client."""
|
||||
ws: WebSocket
|
||||
client_id: str
|
||||
game_id: str
|
||||
connected_at: float = field(default_factory=time.time)
|
||||
last_ping: float = field(default_factory=time.time)
|
||||
messages_sent: int = 0
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections grouped by game_id.
|
||||
|
||||
Usage:
|
||||
manager = ConnectionManager()
|
||||
await manager.connect(websocket, game_id, client_id)
|
||||
await manager.broadcast_to_game(game_id, payload)
|
||||
manager.disconnect(websocket, game_id)
|
||||
"""
|
||||
|
||||
def __init__(self, heartbeat_interval: float = 30.0):
|
||||
# game_id -> set of ClientInfo
|
||||
self._games: Dict[str, Dict[str, ClientInfo]] = {}
|
||||
self._heartbeat_interval = heartbeat_interval
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# ── Connect / Disconnect ───────────────────────────────
|
||||
|
||||
async def connect(
|
||||
self, ws: WebSocket, game_id: str, client_id: str
|
||||
) -> ClientInfo:
|
||||
"""Accept and register a WebSocket connection."""
|
||||
await ws.accept()
|
||||
client = ClientInfo(ws=ws, client_id=client_id, game_id=game_id)
|
||||
async with self._lock:
|
||||
if game_id not in self._games:
|
||||
self._games[game_id] = {}
|
||||
self._games[game_id][client_id] = client
|
||||
log.info(
|
||||
"WS connected: client=%s game=%s (total=%d)",
|
||||
client_id, game_id, len(self._games[game_id]),
|
||||
)
|
||||
return client
|
||||
|
||||
async def disconnect(self, game_id: str, client_id: str) -> None:
|
||||
"""Remove a client from the game session."""
|
||||
async with self._lock:
|
||||
game = self._games.get(game_id)
|
||||
if game and client_id in game:
|
||||
del game[client_id]
|
||||
if not game:
|
||||
del self._games[game_id]
|
||||
log.info("WS disconnected: client=%s game=%s", client_id, game_id)
|
||||
|
||||
# ── Broadcasting ───────────────────────────────────────
|
||||
|
||||
async def broadcast_to_game(
|
||||
self, game_id: str, payload: dict
|
||||
) -> int:
|
||||
"""Send a JSON payload to every client in a game session.
|
||||
|
||||
Returns the number of clients that received the message.
|
||||
Silently removes clients whose connections are broken.
|
||||
"""
|
||||
async with self._lock:
|
||||
game = self._games.get(game_id)
|
||||
if not game:
|
||||
return 0
|
||||
clients = list(game.values())
|
||||
|
||||
sent = 0
|
||||
dead: List[str] = []
|
||||
for client in clients:
|
||||
try:
|
||||
await client.ws.send_json(payload)
|
||||
client.messages_sent += 1
|
||||
sent += 1
|
||||
except Exception:
|
||||
dead.append(client.client_id)
|
||||
|
||||
# Clean up broken connections
|
||||
if dead:
|
||||
async with self._lock:
|
||||
game = self._games.get(game_id)
|
||||
if game:
|
||||
for cid in dead:
|
||||
game.pop(cid, None)
|
||||
if not game:
|
||||
del self._games[game_id]
|
||||
log.warning(
|
||||
"Removed %d dead connection(s) from game %s",
|
||||
len(dead), game_id,
|
||||
)
|
||||
return sent
|
||||
|
||||
async def send_to_client(
|
||||
self, game_id: str, client_id: str, payload: dict
|
||||
) -> bool:
|
||||
"""Send a JSON payload to a specific client."""
|
||||
async with self._lock:
|
||||
game = self._games.get(game_id)
|
||||
if not game:
|
||||
return False
|
||||
client = game.get(client_id)
|
||||
if not client:
|
||||
return False
|
||||
try:
|
||||
await client.ws.send_json(payload)
|
||||
client.messages_sent += 1
|
||||
return True
|
||||
except Exception:
|
||||
await self.disconnect(game_id, client_id)
|
||||
return False
|
||||
|
||||
# ── Queries ────────────────────────────────────────────
|
||||
|
||||
def get_game_clients(self, game_id: str) -> List[str]:
|
||||
"""Return client IDs connected to a game."""
|
||||
game = self._games.get(game_id, {})
|
||||
return list(game.keys())
|
||||
|
||||
def active_games(self) -> List[str]:
|
||||
"""Return all game IDs with active connections."""
|
||||
return list(self._games.keys())
|
||||
|
||||
@property
|
||||
def total_connections(self) -> int:
|
||||
return sum(len(g) for g in self._games.values())
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Return connection statistics."""
|
||||
return {
|
||||
"active_games": len(self._games),
|
||||
"total_connections": self.total_connections,
|
||||
"games": {
|
||||
gid: {
|
||||
"clients": len(clients),
|
||||
"client_ids": list(clients.keys()),
|
||||
}
|
||||
for gid, clients in self._games.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
game_manager = ConnectionManager()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Game-state delta engine.
|
||||
|
||||
Compares successive game-state snapshots and produces minimal
|
||||
JSON deltas so that Unity only receives what changed — not
|
||||
the full 4-player board every tick.
|
||||
|
||||
Delta format sent over WebSocket:
|
||||
{
|
||||
"type": "delta" | "snapshot" | "decision" | "game_over",
|
||||
"seq": <monotonic int>,
|
||||
"game_id": "...",
|
||||
"turn": 5,
|
||||
"phase": "main_1",
|
||||
"timestamp": 1711400000.0,
|
||||
"changes": [
|
||||
{"path": "players.0.life", "op": "replace", "value": 37},
|
||||
{"path": "players.1.battlefield", "op": "add", "value": {"name": "Sol Ring", ...}},
|
||||
{"path": "stack", "op": "replace", "value": [...]},
|
||||
]
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
log = logging.getLogger("ws.game_state")
|
||||
|
||||
|
||||
def _deep_diff(
|
||||
old: Any, new: Any, path: str = ""
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Recursively diff two JSON-serialisable structures.
|
||||
|
||||
Returns a list of RFC-6902-style patch operations:
|
||||
{"op": "replace"|"add"|"remove", "path": "...", "value": ...}
|
||||
"""
|
||||
changes: List[Dict[str, Any]] = []
|
||||
|
||||
if old is None and new is not None:
|
||||
return [{"op": "add", "path": path, "value": new}]
|
||||
if old is not None and new is None:
|
||||
return [{"op": "remove", "path": path}]
|
||||
if type(old) != type(new):
|
||||
return [{"op": "replace", "path": path, "value": new}]
|
||||
|
||||
if isinstance(new, dict):
|
||||
all_keys = set(list(old.keys()) + list(new.keys()))
|
||||
for key in sorted(all_keys):
|
||||
child_path = f"{path}.{key}" if path else key
|
||||
if key not in old:
|
||||
changes.append({"op": "add", "path": child_path, "value": new[key]})
|
||||
elif key not in new:
|
||||
changes.append({"op": "remove", "path": child_path})
|
||||
else:
|
||||
changes.extend(_deep_diff(old[key], new[key], child_path))
|
||||
return changes
|
||||
|
||||
if isinstance(new, list):
|
||||
# For lists (battlefield, hand, graveyard, stack) we do a
|
||||
# simple length+element comparison. Zone lists change
|
||||
# frequently and a full LCS diff is overkill for game state.
|
||||
if old == new:
|
||||
return []
|
||||
# If the list is short or changed significantly, send whole list
|
||||
if len(old) != len(new) or _list_diff_ratio(old, new) > 0.5:
|
||||
return [{"op": "replace", "path": path, "value": new}]
|
||||
# Element-wise diff for same-length lists with few changes
|
||||
for i, (o, n) in enumerate(zip(old, new)):
|
||||
child_path = f"{path}.{i}"
|
||||
changes.extend(_deep_diff(o, n, child_path))
|
||||
return changes
|
||||
|
||||
# Scalar comparison
|
||||
if old != new:
|
||||
return [{"op": "replace", "path": path, "value": new}]
|
||||
return []
|
||||
|
||||
|
||||
def _list_diff_ratio(old: list, new: list) -> float:
|
||||
"""Fraction of elements that differ between two same-length lists."""
|
||||
if not old:
|
||||
return 0.0
|
||||
diffs = sum(1 for a, b in zip(old, new) if a != b)
|
||||
return diffs / len(old)
|
||||
|
||||
|
||||
class GameStateTracker:
|
||||
"""Tracks per-game state and emits deltas on each update.
|
||||
|
||||
Usage:
|
||||
tracker = GameStateTracker(game_id)
|
||||
delta = tracker.update(new_snapshot)
|
||||
# delta is a dict ready to send via WebSocket
|
||||
"""
|
||||
|
||||
def __init__(self, game_id: str):
|
||||
self.game_id = game_id
|
||||
self._last_snapshot: Optional[Dict] = None
|
||||
self._seq: int = 0
|
||||
self._created_at: float = time.time()
|
||||
|
||||
def update(self, snapshot: Dict) -> Dict:
|
||||
"""Compare snapshot with previous state, return delta message.
|
||||
|
||||
First call returns a full snapshot message (type="snapshot").
|
||||
Subsequent calls return delta messages (type="delta").
|
||||
"""
|
||||
self._seq += 1
|
||||
now = time.time()
|
||||
|
||||
if self._last_snapshot is None:
|
||||
# First update — send full snapshot
|
||||
self._last_snapshot = copy.deepcopy(snapshot)
|
||||
return {
|
||||
"type": "snapshot",
|
||||
"seq": self._seq,
|
||||
"game_id": self.game_id,
|
||||
"turn": snapshot.get("turn", 0),
|
||||
"phase": snapshot.get("phase", ""),
|
||||
"timestamp": now,
|
||||
"state": snapshot,
|
||||
}
|
||||
|
||||
# Compute delta
|
||||
changes = _deep_diff(self._last_snapshot, snapshot)
|
||||
self._last_snapshot = copy.deepcopy(snapshot)
|
||||
|
||||
if not changes:
|
||||
return {
|
||||
"type": "heartbeat",
|
||||
"seq": self._seq,
|
||||
"game_id": self.game_id,
|
||||
"timestamp": now,
|
||||
}
|
||||
|
||||
return {
|
||||
"type": "delta",
|
||||
"seq": self._seq,
|
||||
"game_id": self.game_id,
|
||||
"turn": snapshot.get("turn", 0),
|
||||
"phase": snapshot.get("phase", ""),
|
||||
"timestamp": now,
|
||||
"changes": changes,
|
||||
}
|
||||
|
||||
def make_decision_event(
|
||||
self,
|
||||
action: str,
|
||||
action_index: int,
|
||||
confidence: float,
|
||||
probabilities: Optional[Dict[str, float]] = None,
|
||||
inference_ms: float = 0.0,
|
||||
) -> Dict:
|
||||
"""Build a decision event message for the Unity client."""
|
||||
self._seq += 1
|
||||
return {
|
||||
"type": "decision",
|
||||
"seq": self._seq,
|
||||
"game_id": self.game_id,
|
||||
"timestamp": time.time(),
|
||||
"action": action,
|
||||
"action_index": action_index,
|
||||
"confidence": confidence,
|
||||
"probabilities": probabilities or {},
|
||||
"inference_ms": inference_ms,
|
||||
}
|
||||
|
||||
def make_game_over_event(
|
||||
self,
|
||||
winner_seat: int,
|
||||
reason: str = "",
|
||||
turns_played: int = 0,
|
||||
) -> Dict:
|
||||
"""Build a game-over event message."""
|
||||
self._seq += 1
|
||||
return {
|
||||
"type": "game_over",
|
||||
"seq": self._seq,
|
||||
"game_id": self.game_id,
|
||||
"timestamp": time.time(),
|
||||
"winner_seat": winner_seat,
|
||||
"reason": reason,
|
||||
"turns_played": turns_played,
|
||||
}
|
||||
|
||||
@property
|
||||
def sequence(self) -> int:
|
||||
return self._seq
|
||||
|
||||
@property
|
||||
def has_baseline(self) -> bool:
|
||||
return self._last_snapshot is not None
|
||||
|
||||
|
||||
# Per-game tracker registry
|
||||
_trackers: Dict[str, GameStateTracker] = {}
|
||||
|
||||
|
||||
def get_tracker(game_id: str) -> GameStateTracker:
|
||||
"""Get or create a state tracker for a game."""
|
||||
if game_id not in _trackers:
|
||||
_trackers[game_id] = GameStateTracker(game_id)
|
||||
return _trackers[game_id]
|
||||
|
||||
|
||||
def remove_tracker(game_id: str) -> None:
|
||||
"""Remove a game tracker (called on game end)."""
|
||||
_trackers.pop(game_id, None)
|
||||
Reference in new issue
Block a user