Compare commits

..
Author SHA1 Message Date
ClaudeandClaude Opus 4.6 a5955473d7 feat: centralized damage path with commander damage tracking (#86)
Add _apply_damage() to GameEngine as the single entry point for all
player damage (combat and non-combat). Tracks per-seat commander
damage and enforces the 21-damage lethal rule. Refactor _resolve_combat
to route through _apply_damage with source identity (commander vs
non-commander) preserved per-attacker. Add commander_damage_received
field to Player model. 23 targeted regression tests covering unit,
combat routing, non-combat, and full-game integration scenarios.

Refs #86 (deferred item 2: commander damage handling nuances)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 22:17:39 +00:00
98 changed files with 3322 additions and 12555 deletions

No files matched your search

+3 -7
View File
@@ -6,13 +6,9 @@
# Get yours at: https://app.ximilar.com/
XIMILAR_API_KEY=your-ximilar-api-key-here
# Anthropic API Key (AI deck generation & coaching with Claude Opus)
# Get yours at: https://console.anthropic.com/settings/keys
ANTHROPIC_API_KEY=your-anthropic-api-key-here
# Anthropic 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
# Perplexity API Key (AI deck research & generation)
# Get yours at: https://www.perplexity.ai/settings/api
PPLX_API_KEY=your-perplexity-api-key-here
# CORS allowed origins (comma-separated)
# Default: http://localhost:5173,http://localhost:8080
+20
View File
@@ -33,6 +33,26 @@ jobs:
- name: Run tests
run: pytest -v --tb=short tests/ || true
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Run frontend tests
working-directory: frontend
run: npx vitest run
java-tests:
runs-on: ubuntu-latest
steps:
+333 -132
View File
@@ -1,180 +1,381 @@
# Commander AI Lab
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 |
Headless batch simulation environment for 3-AI MTG Commander pods.
Run 100+ games, analyze win rates, and stress-test your decks — all from a web UI or CLI.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ 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 │
└────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────┘
```
## Quick Start (Windows)
**Prerequisites:** Java 17+, Python 3.10+, Maven 3.8+, [Forge](https://github.com/Card-Forge/forge) built at `D:\ForgeCommander\forge-repo`
### Prerequisites
- **Java 17+** (`java -version`)
- **Python 3.10+** (`python --version`)
- **Maven 3.8+** (`mvn --version`)
- **Forge repo** cloned and built at `D:\ForgeCommander\forge-repo`
### 1. Build Forge (one-time)
```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
### 2. Build Commander AI Lab
```powershell
cd D:\ForgeCommander\commander-ai-lab
mvn package "-DskipTests"
```
This produces `target/commander-ai-lab-1.0.0-SNAPSHOT.jar` (shaded fat JAR).
# 3. Install Python deps
pip install -e .
### 3. Install Python dependencies
```powershell
pip install fastapi uvicorn
```
# 4. Launch
### 4. Launch the Web UI
**Option A — One-click launcher:**
```powershell
.\start-lab.bat
# → open http://localhost:8080
```
Edit the paths at the top of `start-lab.bat` if needed.
## Overnight AFK Runner
Runs batch sims unattended, then updates card weights and trains the neural net:
**Option B — Manual launch:**
```powershell
python overnight-run.py # default: 11.5h DeepSeek sims
python overnight-run.py --batches 20 --games 50 # fixed batch count
python overnight-run.py --no-update-weights # skip weight update
python overnight-run.py --no-train # skip neural net training
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
```
**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
Open `http://localhost:8080` — select 3 decks, set game count, click **Run AI Sims**.
### 5. CLI-only usage (no web server)
```powershell
# 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
cd D:\ForgeCommander\forge-repo\forge-gui
# Reset weights to defaults
python -m ml.scripts.update_weights --reset
# Generate synthetic training data
python ml/scripts/generate_synthetic_data.py
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
```
Learned weights are clamped to `[-20.0, 20.0]` and stored in `src/commander_ai_lab/sim/learned_weights.json`.
## Scaling Tests
## 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}")
### 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
```
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.
### Multi-threaded (10 games, 2 threads)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 10 --threads 2 --output results/mt-10-t2.json
```
### Scale to 100 games (4 threads)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 100 --threads 4 --output results/scale-100-t4.json
```
### Maximum throughput test (100 games, 8 threads)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 100 --threads 8 --seed 42 --output results/scale-100-t8-seed42.json
```
### Performance comparison script
Run different thread counts and compare sims/sec:
```powershell
# Baseline: 1 thread
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 1 --seed 42 --output results/perf-t1.json
# 2 threads
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 2 --seed 42 --output results/perf-t2.json
# 4 threads
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 4 --seed 42 --output results/perf-t4.json
# 8 threads
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 8 --seed 42 --output results/perf-t8.json
```
Then compare the `simsPerSecond` values in each output JSON to see scaling behavior.
## API Reference
| Endpoint | Method | Description |
|---|---|---|
| `/api/lab/decks` | GET | List available Commander decks |
| `/api/lab/start` | POST | Start Forge batch (returns batchId) |
| `/api/lab/start-deepseek` | POST | Start Python/DeepSeek batch |
| `/api/lab/status?batchId=X` | GET | Poll batch progress |
| `/api/lab/result?batchId=X` | GET | Get completed batch JSON |
| `/api/lab/history` | GET | List past batch runs |
| `/api/ml/train` | POST | Start neural net training |
| `/api/ml/train/status` | GET | Poll training progress |
| `/api/coach/chat` | POST | LLM coaching session (streaming) |
| `/api/deck-builder/build` | POST | Build a 99-card Commander deck |
| `/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.
## Project Structure
```
commander-ai-lab/
├── overnight-run.py # AFK batch runner
├── lab_api.py # Python FastAPI server (web UI backend)
├── start-lab.bat # Windows one-click launcher
├── 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
├── 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
```
## Known Limitations
## Output Format
- **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
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
+1 -1
View File
@@ -1 +1 @@
# Coach clients — Anthropic Claude, LM Studio
# Coach clients — Perplexity, LM Studio
-401
View File
@@ -1,401 +0,0 @@
"""
Commander AI Lab — Anthropic Claude Structured Output Client
════════════════════════════════════════════════════════════
Uses the anthropic SDK to call Claude's messages endpoint
with structured JSON output enforced via system prompt.
Supports both sync and native async operation.
All substantive calls use .messages.stream() to avoid the SDK's
10-minute non-streaming timeout restriction on large max_tokens.
"""
import json
import logging
import re
from typing import Optional
import anthropic
logger = logging.getLogger("coach.anthropic")
# ── Anthropic Models ───────────────────────────────────────────
CLAUDE_OPUS = "claude-opus-4-0-20250514"
CLAUDE_SONNET = "claude-sonnet-4-5"
CLAUDE_HAIKU = "claude-haiku-4-5"
class AnthropicResponse:
"""Parsed response container from Anthropic API.
Drop-in replacement for PerplexityResponse — same attribute shape
so all existing call-sites work without changes.
"""
def __init__(
self,
content: str,
parsed_json: Optional[dict],
prompt_tokens: int,
completion_tokens: int,
model: str,
citations: list[str] = None,
):
self.content = content
self.parsed_json = parsed_json
self.prompt_tokens = prompt_tokens
self.completion_tokens = completion_tokens
self.model = model
self.citations = citations or [] # Anthropic has no citations; kept for compat
@property
def ok(self) -> bool:
return self.parsed_json is not None
class AnthropicClient:
"""
Anthropic Claude API client.
Drop-in replacement for PerplexityClient.
All substantive calls use .messages.stream() so the SDK never
enforces the 10-minute non-streaming timeout.
Usage:
client = AnthropicClient(api_key="sk-ant-...")
resp = client.chat_structured(
system_prompt="You are a deck builder.",
user_prompt="Build a deck for...",
json_schema=deck_schema,
schema_name="DeckList",
)
if resp.ok:
deck = resp.parsed_json
"""
def __init__(
self,
api_key: str,
model: str = CLAUDE_OPUS,
timeout: int = 120,
base_url: str = None,
):
if not api_key:
raise ValueError("Anthropic API key is required")
self.api_key = api_key
self.model = model
self.timeout = timeout
self._client = anthropic.Anthropic(
api_key=api_key,
base_url=base_url,
timeout=float(timeout),
)
self._async_client = anthropic.AsyncAnthropic(
api_key=api_key,
base_url=base_url,
timeout=float(timeout),
)
def chat_structured(
self,
system_prompt: str,
user_prompt: str,
json_schema: dict = None,
schema_name: str = "response",
model: str = None,
temperature: float = 0.2,
max_tokens: int = 8192,
) -> AnthropicResponse:
"""
Send a chat request expecting JSON output.
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
"""
use_model = model or self.model
effective_system = self._build_system(system_prompt, json_schema, schema_name)
try:
with self._client.messages.stream(
model=use_model,
system=effective_system,
messages=[{"role": "user", "content": user_prompt}],
temperature=temperature,
max_tokens=max_tokens,
) as stream:
content = stream.get_final_text()
final_msg = stream.get_final_message()
return self._parse_streamed(content, final_msg, use_model)
except Exception as e:
logger.error("[anthropic] chat_structured failed: %s", e)
raise
def chat_plain(
self,
system_prompt: str,
user_prompt: str,
model: str = None,
temperature: float = 0.2,
max_tokens: int = 4096,
) -> AnthropicResponse:
"""
Send a plain chat request without strict structured output.
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
"""
use_model = model or self.model
try:
with self._client.messages.stream(
model=use_model,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
temperature=temperature,
max_tokens=max_tokens,
) as stream:
content = stream.get_final_text()
final_msg = stream.get_final_message()
return self._parse_streamed(content, final_msg, use_model)
except Exception as e:
logger.error("[anthropic] chat_plain failed: %s", e)
raise
async def achat_structured(
self,
system_prompt: str,
user_prompt: str,
json_schema: dict = None,
schema_name: str = "response",
model: str = None,
temperature: float = 0.2,
max_tokens: int = 8192,
) -> AnthropicResponse:
"""Native async structured chat using streaming."""
use_model = model or self.model
effective_system = self._build_system(system_prompt, json_schema, schema_name)
try:
async with self._async_client.messages.stream(
model=use_model,
system=effective_system,
messages=[{"role": "user", "content": user_prompt}],
temperature=temperature,
max_tokens=max_tokens,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
return self._parse_streamed(content, final_msg, use_model)
except Exception as e:
logger.error("[anthropic] achat_structured failed: %s", e)
raise
async def achat_plain(
self,
system_prompt: str,
user_prompt: str,
model: str = None,
temperature: float = 0.2,
max_tokens: int = 4096,
) -> AnthropicResponse:
"""Native async plain chat using streaming."""
use_model = model or self.model
try:
async with self._async_client.messages.stream(
model=use_model,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
temperature=temperature,
max_tokens=max_tokens,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
return self._parse_streamed(content, final_msg, use_model)
except Exception as e:
logger.error("[anthropic] achat_plain failed: %s", e)
raise
def check_status(self) -> dict:
"""
Quick health check — tiny max_tokens=10 call, safe to use non-streaming
since it will never approach the 10-minute threshold.
"""
try:
response = self._client.messages.create(
model=self.model,
system="Reply with the word OK.",
messages=[{"role": "user", "content": "Status check."}],
max_tokens=10,
)
content = response.content[0].text if response.content else ""
return {
"connected": True,
"model": self.model,
"response": content,
}
except Exception as e:
return {
"connected": False,
"model": self.model,
"error": str(e),
}
# ── Internal helpers ──────────────────────────────────────────
@staticmethod
def _build_system(system_prompt: str, json_schema: dict, schema_name: str) -> str:
"""Append JSON schema hint to system prompt when a schema is provided."""
if not json_schema:
return system_prompt
schema_str = json.dumps(json_schema, indent=2)
return (
f"{system_prompt}\n\n"
f"You MUST respond with valid JSON only — no markdown fences, no prose.\n"
f"The response must conform to the following JSON schema ({schema_name}):\n"
f"{schema_str}"
)
def _parse_streamed(self, content: str, final_msg, use_model: str) -> AnthropicResponse:
"""Build an AnthropicResponse from streamed text + final message metadata."""
usage = getattr(final_msg, "usage", None)
prompt_tokens = usage.input_tokens if usage else 0
completion_tokens = usage.output_tokens if usage else 0
parsed = None
try:
parsed = json.loads(content)
except (json.JSONDecodeError, TypeError):
parsed = self._extract_json(content)
logger.info(
"[anthropic] model=%s tokens=(%d in, %d out) parsed=%s",
use_model,
prompt_tokens,
completion_tokens,
"yes" if parsed else "no",
)
return AnthropicResponse(
content=content,
parsed_json=parsed,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
model=use_model,
citations=[],
)
@staticmethod
def _extract_json(text: str) -> Optional[dict]:
"""Best-effort JSON extraction from possibly noisy text."""
try:
return json.loads(text.strip())
except (json.JSONDecodeError, TypeError):
pass
# Markdown fences
fence_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text)
if fence_match:
try:
return json.loads(fence_match.group(1).strip())
except (json.JSONDecodeError, TypeError):
pass
# Brace counting
start = text.find('{')
if start == -1:
return None
depth = 0
in_string = False
escape_next = False
for i in range(start, len(text)):
ch = text[i]
if escape_next:
escape_next = False
continue
if ch == '\\' and in_string:
escape_next = True
continue
if ch == '"':
in_string = not in_string
continue
if in_string:
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
try:
return json.loads(text[start:i + 1])
except (json.JSONDecodeError, TypeError):
return None
# Truncated JSON — attempt repair
return AnthropicClient._repair_truncated_json(text[start:])
@staticmethod
def _repair_truncated_json(text: str) -> Optional[dict]:
"""
Attempt to repair truncated JSON from max_tokens cutoff.
Strategy: find the last complete nested object, then close
all open brackets/braces.
"""
logger.info("Attempting truncated JSON repair (%d chars)...", len(text))
last_good = -1
depth = 0
in_string = False
escape_next = False
for i in range(len(text)):
ch = text[i]
if escape_next:
escape_next = False
continue
if ch == '\\' and in_string:
escape_next = True
continue
if ch == '"':
in_string = not in_string
continue
if in_string:
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth >= 1:
last_good = i
if last_good == -1:
return None
truncated = text[:last_good + 1]
close_depth = 0
in_arr = 0
in_str = False
esc = False
for ch in truncated:
if esc:
esc = False
continue
if ch == '\\' and in_str:
esc = True
continue
if ch == '"':
in_str = not in_str
continue
if in_str:
continue
if ch == '{':
close_depth += 1
elif ch == '}':
close_depth -= 1
elif ch == '[':
in_arr += 1
elif ch == ']':
in_arr -= 1
suffix = ']' * in_arr + '}' * close_depth
repaired = truncated + suffix
try:
result = json.loads(repaired)
card_count = len(result.get('cards', []))
logger.info("JSON repair succeeded: recovered %d cards", card_count)
return result
except (json.JSONDecodeError, TypeError) as e:
logger.warning("JSON repair failed: %s", e)
return None
+50 -324
View File
@@ -9,8 +9,6 @@ calls the LLM, parses responses, and persists coaching sessions.
import json
import logging
import uuid
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, List, Dict
@@ -18,16 +16,11 @@ from typing import Optional, List, Dict
from .config import (
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
COACH_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
UNDERPERFORMER_IMPACT_THRESHOLD, DEFAULT_MAX_TOKENS, ensure_dirs,
UNDERPERFORMER_IMPACT_THRESHOLD, ensure_dirs,
)
from .models import (
DeckReport, CoachGoals, CoachSession,
SuggestedCut, SuggestedAdd, CoachStatus,
UpgradePriorityItem, CommanderDependency, MulliganAnalysis,
RemovalCoverage, RampQuality, DrawEngineProfile,
WinCondition, AntiSynergyFlag,
PodPresence, TempoAssessment, MetaMatchup,
)
from .llm_client import LLMClient
from .embeddings import MTGEmbeddingIndex
@@ -35,18 +28,6 @@ from .prompt_template import build_system_prompt, build_user_prompt
logger = logging.getLogger("coach.service")
# SQLite persistence for coach sessions
try:
from services.database import (
save_coach_session as _db_save_session,
load_coach_session as _db_load_session,
list_coach_sessions as _db_list_sessions,
delete_coach_session as _db_delete_session,
)
_DB_AVAILABLE = True
except ImportError:
_DB_AVAILABLE = False
class CoachService:
"""
@@ -65,19 +46,23 @@ 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()):
if f.stem.lower() == deck_id.lower():
report_path = f
break
if not report_path.exists():
logger.warning("Deck report not found: %s", deck_id)
return None
try:
with open(report_path, "r") as f:
data = json.load(f)
@@ -92,60 +77,18 @@ class CoachService:
return []
return [f.stem for f in DECK_REPORTS_DIR.glob("*.json")]
# ── Coach Session I/O ──────────────────────────────
# ── Coach Session I/O ──────────────────────────────────────
def save_session(self, session: CoachSession) -> Path:
"""Persist a coaching session to disk and SQLite."""
"""Persist a coaching session to disk."""
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
with open(path, "w") as f:
f.write(session.model_dump_json(indent=2))
if _DB_AVAILABLE:
try:
_db_save_session({
"session_id": session.sessionId,
"deck_id": session.deckId,
"timestamp": session.timestamp,
"model_used": session.modelUsed or "",
"prompt_tokens": session.promptTokens or 0,
"completion_tokens": session.completionTokens or 0,
"summary": session.summary or "",
"goals_json": json.dumps(session.goals.model_dump() if session.goals else {}),
"cuts_json": json.dumps([c.model_dump() for c in session.suggestedCuts]),
"adds_json": json.dumps([a.model_dump() for a in session.suggestedAdds]),
"heuristic_hints_json": json.dumps(session.heuristicHints or []),
"mana_base_advice": session.manaBaseAdvice or "",
"raw_text": session.rawTextExplanation or "",
})
logger.info("Coach session persisted to SQLite: %s", session.sessionId)
except Exception as e:
logger.warning("Failed to persist session to SQLite: %s", e)
return path
def load_session(self, session_id: str) -> Optional[CoachSession]:
"""Load a coaching session from SQLite, falling back to disk."""
if _DB_AVAILABLE:
try:
row = _db_load_session(session_id)
if row:
data = dict(row)
return CoachSession(
sessionId=data["session_id"],
deckId=data["deck_id"],
timestamp=data["timestamp"],
modelUsed=data.get("model_used", ""),
promptTokens=data.get("prompt_tokens", 0),
completionTokens=data.get("completion_tokens", 0),
summary=data.get("summary", ""),
goals=json.loads(data["goals_json"]) if data.get("goals_json") and data["goals_json"] != "{}" else None,
suggestedCuts=[SuggestedCut(**c) for c in json.loads(data.get("cuts_json", "[]"))],
suggestedAdds=[SuggestedAdd(**a) for a in json.loads(data.get("adds_json", "[]"))],
heuristicHints=json.loads(data.get("heuristic_hints_json", "[]")),
manaBaseAdvice=data.get("mana_base_advice", ""),
rawTextExplanation=data.get("raw_text", ""),
)
except Exception as e:
logger.warning("SQLite load failed for %s: %s", session_id, e)
"""Load a coaching session from disk."""
path = COACH_SESSIONS_DIR / f"{session_id}.json"
if not path.exists():
return None
@@ -158,14 +101,7 @@ class CoachService:
return None
def list_sessions(self, deck_id: str = None) -> List[dict]:
"""List coaching sessions from SQLite, falling back to disk."""
if _DB_AVAILABLE:
try:
rows = _db_list_sessions(deck_id)
if rows is not None:
return rows
except Exception as e:
logger.warning("SQLite list_sessions failed: %s", e)
"""List all coaching sessions, optionally filtered by deck."""
if not COACH_SESSIONS_DIR.exists():
return []
sessions = []
@@ -187,24 +123,23 @@ 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 (streaming for Anthropic to avoid 10-min SDK timeout)
5. Call LLM
6. Parse response
7. Persist session
"""
# 0. Auto-load embeddings if not loaded
if not self.embeddings.loaded:
logger.info("Embeddings not loaded — attempting auto-load...")
try:
@@ -216,6 +151,7 @@ class CoachService:
except Exception as e:
logger.warning("Embeddings auto-load failed: %s", e)
# 1. Load deck report — try simulation report first, then fallback
report = self.load_deck_report(deck_id)
if report is None and fallback_report is not None:
report = fallback_report
@@ -223,18 +159,25 @@ class CoachService:
if report is None:
raise ValueError(f"Deck report not found for: {deck_id}")
# 2. Get deck card names (for exclusion from candidates)
deck_card_names = [c.name for c in report.cards]
# 3. Find replacement candidates for underperformers
candidates: Dict[str, List[dict]] = {}
underperformers = report.underperformers[:MAX_UNDERPERFORMERS]
if not underperformers:
# If no explicit underperformers, pick lowest impact cards
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
underperformers = [
c.name for c in sorted_cards[:MAX_UNDERPERFORMERS]
if c.impactScore < UNDERPERFORMER_IMPACT_THRESHOLD
]
# If still no underperformers and we have cards, pick bottom N by impact
if not underperformers and report.cards:
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
# Take the bottom 5 non-land cards as candidates for cuts
underperformers = [
c.name for c in sorted_cards[:5]
if not any(t in (c.tags or []) for t in [])
@@ -253,50 +196,17 @@ class CoachService:
else:
logger.warning("Embeddings not loaded — skipping candidate search")
# 4. Build prompts
system_prompt = build_system_prompt(report, goals)
user_prompt = build_user_prompt(report, candidates)
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
if COACH_PROVIDER == "anthropic":
import anthropic as _anthropic
anthropic_key = ANTHROPIC_API_KEY
if not anthropic_key:
raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.")
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
async with aclient.messages.stream(
model=ANTHROPIC_MODEL,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
max_tokens=DEFAULT_MAX_TOKENS,
temperature=0.7,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
usage = getattr(final_msg, "usage", None)
from types import SimpleNamespace
llm_response = SimpleNamespace(
content=content,
model=final_msg.model,
prompt_tokens=usage.input_tokens if usage else 0,
completion_tokens=usage.output_tokens if usage else 0,
parsed_json=None,
)
try:
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
llm_response.parsed_json = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
match = re.search(r'\{[\s\S]*\}', content)
if match:
try:
llm_response.parsed_json = json.loads(match.group(0))
except json.JSONDecodeError:
pass
else:
llm_response = await self.llm.chat(system_prompt, user_prompt)
# 5. Call LLM
logger.info("Calling LLM for deck: %s", deck_id)
llm_response = await self.llm.chat(system_prompt, user_prompt)
# 6. Parse response into CoachSession
session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
session = CoachSession(
sessionId=session_id,
deckId=deck_id,
@@ -310,103 +220,27 @@ 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}")
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, {}) # 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(
@@ -415,6 +249,7 @@ 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(
@@ -424,134 +259,25 @@ class CoachService:
synergyWith=add_data.get("synergyWith", []),
))
# 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 ─────────────────────────────────────────
# ── Status Check ───────────────────────────────────────────
def get_status(self) -> CoachStatus:
"""Check the health of all coach subsystems."""
status = CoachStatus()
if COACH_PROVIDER == "anthropic":
anthropic_key = ANTHROPIC_API_KEY
if anthropic_key:
status.llmConnected = True
status.llmModel = f"Claude Opus ({ANTHROPIC_MODEL})"
status.llmModels = [ANTHROPIC_MODEL]
else:
status.llmConnected = False
status.llmModel = None
status.llmModels = []
status.error = "Anthropic API key not configured. Set ANTHROPIC_API_KEY env var."
else:
llm_status = self.llm.check_connection()
status.llmConnected = llm_status.get("connected", False)
status.llmModel = llm_status.get("active_model")
status.llmModels = llm_status.get("models", [])
if not status.llmConnected:
status.error = llm_status.get("error", "Local LLM not reachable")
# Check LLM
llm_status = self.llm.check_connection()
status.llmConnected = llm_status.get("connected", False)
status.llmModel = llm_status.get("active_model")
status.llmModels = llm_status.get("models", [])
if not status.llmConnected:
status.error = llm_status.get("error", "LM Studio not reachable")
# Check embeddings
status.embeddingsLoaded = self.embeddings.loaded
status.embeddingCards = self.embeddings.card_count
# Check deck reports
status.deckReportsAvailable = len(self.list_deck_reports())
return status
+27 -30
View File
@@ -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,60 +8,57 @@ All paths are relative to the commander-ai-lab root directory.
import os
from pathlib import Path
# ── Base Paths ───────────────────────────────────────────────────────
# ── Base Paths ──────────────────────────────────────────────
# Resolve relative to wherever the lab is installed
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
# ── Ollama (OpenAI-compatible API) ────────────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
# ── Ollama (OpenAI-compatible API) ──────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://192.168.0.240: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 = 8
MAX_UNDERPERFORMERS = 8
MAX_CANDIDATES_PER_UNDERPERFORMER = 5
MAX_UNDERPERFORMERS = 4
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
# ── LLM Generation Settings (Coach — Ollama fallback) ─────────────
# ── LLM Generation Settings (Coach — Ollama) ──────────────
DEFAULT_TEMPERATURE = 0.7
DEFAULT_MAX_TOKENS = 16384
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
# so we need a generous limit to avoid truncation
DEFAULT_MAX_TOKENS = 8192
# ── Anthropic Claude Deck Generation Settings ──────────────────────
# Provider for deck generation: "anthropic" (Claude) or "local" (Ollama)
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "anthropic")
# Model used when DECK_GEN_PROVIDER="anthropic"
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "claude-opus-4-20250514")
# Anthropic API key
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
# Anthropic model for deck generation and coaching
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-20250514")
# Coach chat provider: "anthropic" (Claude API) or "local" (Ollama)
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "anthropic")
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://localhost:11434/v1")
# ── Perplexity Deck Generation Settings ───────────────────────
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "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")
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://192.168.0.240: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 Anthropic fallback
# ── Smart Substitution Settings ───────────────────────────────
# Minimum embedding similarity to accept a substitute without Perplexity fallback
SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY", "0.75"))
# Maximum number of alternatives to suggest per missing card
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
# Model for substitution fallback (claude-haiku-4-5 is fast + cheap for focused queries)
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "claude-haiku-4-5")
# Enable Anthropic fallback for low-confidence embedding matches
SUBSTITUTION_USE_ANTHROPIC_FALLBACK = os.environ.get("SUBSTITUTION_USE_ANTHROPIC_FALLBACK", "true").lower() == "true"
# Model for substitution fallback (sonar is fine — small focused queries)
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "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"
# ── Ensure directories exist ─────────────────────────────────────
# ── Ensure directories exist ───────────────────────────────
def ensure_dirs():
"""Create required directories if they don't exist."""
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
+41 -235
View File
@@ -9,11 +9,6 @@ Data source: https://huggingface.co/datasets/minimaxir/mtg-embeddings
Format: Parquet with 768-dim float32 embeddings per card (~32K cards)
Model: Alibaba-NLP/gte-modernbert-base
Unknown cards (e.g. newly printed sets) are fetched from Scryfall,
embedded on-the-fly using the same model, and persisted to a local
custom cache (embeddings/mtg_embeddings_custom.npz) so they survive
restarts without re-hitting Scryfall.
Uses NumPy brute-force cosine similarity — fast enough for ~32K cards (<100ms).
"""
@@ -24,7 +19,6 @@ import os
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.request import urlopen, Request
from urllib.parse import quote
import numpy as np
@@ -38,16 +32,8 @@ HF_PARQUET_URL = (
"resolve/main/mtg_embeddings.parquet"
)
# ── Scryfall API ───────────────────────────────────────────
SCRYFALL_NAMED_URL = "https://api.scryfall.com/cards/named?fuzzy={}"
# ── Custom cache for on-the-fly embedded cards ─────────────
CUSTOM_CACHE_PATH = EMBEDDINGS_DIR / "mtg_embeddings_custom.npz"
# ── Embedding model (same as minimaxir/mtg-embeddings) ─────
EMBEDDING_MODEL = "Alibaba-NLP/gte-modernbert-base"
# ── Color identity parsing ─────────────────────────────────
# Maps mana symbols like {W}, {U}, {B}, {R}, {G} to color letters
MANA_SYMBOL_COLORS = {
"W": "W", "U": "U", "B": "B", "R": "R", "G": "G"
}
@@ -60,6 +46,7 @@ def _parse_color_identity(mana_cost: str) -> str:
colors = set()
for match in re.finditer(r'\{([WUBRG])\}', mana_cost or ""):
colors.add(match.group(1))
# Sort in WUBRG order
order = "WUBRG"
return "".join(c for c in order if c in colors)
@@ -74,11 +61,12 @@ def _parse_mana_value(mana_cost: str) -> float:
if symbol in MANA_SYMBOL_COLORS:
total += 1.0
elif symbol == "X":
pass
pass # X = 0 for CMC purposes
else:
try:
total += float(symbol)
except ValueError:
# Hybrid/phyrexian symbols count as 1
total += 1.0
return total
@@ -87,42 +75,7 @@ def _is_color_subset(card_colors: str, deck_colors: List[str]) -> bool:
"""Check if card's color identity is within deck's color identity."""
deck_set = set(deck_colors)
card_set = set(card_colors)
return card_set.issubset(deck_set) or len(card_set) == 0
def _fetch_scryfall_card(card_name: str) -> Optional[dict]:
"""Fetch card data from Scryfall by fuzzy name match.
Returns a dict with keys: name, mana_cost, type_line, oracle_text,
color_identity. Returns None on any error.
"""
url = SCRYFALL_NAMED_URL.format(quote(card_name))
try:
req = Request(url, headers={"User-Agent": "commander-ai-lab/1.0"})
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
return {
"name": data.get("name", card_name),
"mana_cost": data.get("mana_cost", ""),
"type_line": data.get("type_line", ""),
"oracle_text": data.get("oracle_text", ""),
"color_identity": "".join(data.get("color_identity", [])),
}
except Exception as e:
logger.warning("Scryfall lookup failed for '%s': %s", card_name, e)
return None
def _build_card_text(card: dict) -> str:
"""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)
return card_set.issubset(deck_set) or len(card_set) == 0 # colorless always allowed
class CardMatch:
@@ -161,17 +114,15 @@ class MTGEmbeddingIndex:
"""
def __init__(self):
self.names: np.ndarray = None
self.colors: np.ndarray = None
self.mana_values: np.ndarray = None
self.types: np.ndarray = None
self.mana_costs: np.ndarray = None
self.texts: np.ndarray = None
self.vectors: np.ndarray = None
self.names: np.ndarray = None # (N,) string array
self.colors: np.ndarray = None # (N,) string array (e.g., "WU", "BRG")
self.mana_values: np.ndarray = None # (N,) float array
self.types: np.ndarray = None # (N,) string array
self.mana_costs: np.ndarray = None # (N,) string array
self.texts: np.ndarray = None # (N,) string array
self.vectors: np.ndarray = None # (N, 768) float32, L2-normalized
self._name_to_idx: dict = {}
self._loaded = False
self._sentence_model = None # lazy-loaded only when needed
self._custom_dirty = False # tracks whether custom cache needs saving
@property
def loaded(self) -> bool:
@@ -201,7 +152,10 @@ class MTGEmbeddingIndex:
return EMBEDDINGS_PARQUET
def convert_parquet_to_npz(self) -> Path:
"""Convert the Parquet file to a compact NPZ for fast loading."""
"""
Convert the Parquet file to a compact NPZ for fast loading.
Requires pandas and pyarrow.
"""
try:
import pandas as pd
except ImportError:
@@ -215,11 +169,14 @@ class MTGEmbeddingIndex:
types = df["type"].fillna("").values.astype(str)
texts = df["text"].fillna("").values.astype(str)
# Parse color identities from mana costs
colors = np.array([_parse_color_identity(mc) for mc in mana_costs])
mana_values = np.array([_parse_mana_value(mc) for mc in mana_costs], dtype=np.float32)
# Extract embedding vectors
embeddings = np.vstack(df["embedding"].to_numpy()).astype(np.float32)
# L2 normalize for cosine similarity via dot product
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
norms[norms == 0] = 1.0
embeddings = embeddings / norms
@@ -241,27 +198,25 @@ class MTGEmbeddingIndex:
return EMBEDDINGS_NPZ
def load(self, force_download: bool = False) -> bool:
"""Load embeddings from NPZ (fast) or fall back to downloading
"""
Load embeddings from NPZ (fast) or fall back to downloading
and converting from Parquet.
"""
if self._loaded and not force_download:
return True
# Try NPZ first (fast path)
if EMBEDDINGS_NPZ.exists() and not force_download:
ok = self._load_npz()
else:
try:
self.download_parquet(force=force_download)
self.convert_parquet_to_npz()
ok = self._load_npz()
except Exception as e:
logger.error("Failed to load embeddings: %s", e)
return False
return self._load_npz()
if ok:
self._load_custom_cache()
return ok
# Download and convert
try:
self.download_parquet(force=force_download)
self.convert_parquet_to_npz()
return self._load_npz()
except Exception as e:
logger.error("Failed to load embeddings: %s", e)
return False
def _load_npz(self) -> bool:
"""Load from pre-built NPZ file."""
@@ -275,6 +230,7 @@ class MTGEmbeddingIndex:
self.texts = data.get("texts", np.array([""] * len(self.names)))
self.vectors = data["vectors"]
# Build name lookup
self._name_to_idx = {
name.lower(): i for i, name in enumerate(self.names)
}
@@ -285,168 +241,12 @@ class MTGEmbeddingIndex:
logger.error("Failed to load NPZ: %s", e)
return False
# ── Custom cache ──────────────────────────────────────────
def _load_custom_cache(self) -> None:
"""Merge on-the-fly embedded cards from the custom cache into the index."""
if not CUSTOM_CACHE_PATH.exists():
return
try:
data = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
c_names = data["names"]
# Only add cards that aren't already in the main index
new_mask = np.array(
[n.lower() not in self._name_to_idx for n in c_names]
)
if not new_mask.any():
return
self._append_cards(
c_names[new_mask],
data["colors"][new_mask],
data["mana_values"][new_mask],
data["types"][new_mask],
data["mana_costs"][new_mask],
data["texts"][new_mask],
data["vectors"][new_mask],
)
logger.info("Loaded %d custom-cached card embeddings", new_mask.sum())
except Exception as e:
logger.warning("Failed to load custom embedding cache: %s", e)
def _save_custom_cache(self) -> None:
"""Persist the custom cache NPZ (only cards not in the base dataset)."""
if not self._custom_dirty:
return
try:
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
# Collect indices that correspond to custom-added cards
# We track these by loading the base count at load time — simpler:
# just re-save the full custom cache from CUSTOM_CACHE_PATH if it
# exists, merging with any new cards we've added this session.
existing_names: set = set()
existing: dict = {}
if CUSTOM_CACHE_PATH.exists():
try:
d = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
for n in d["names"]:
existing_names.add(n.lower())
except Exception:
pass
# Find names in our live index that aren't in the base NPZ
base_data = np.load(str(EMBEDDINGS_NPZ), allow_pickle=True)
base_names = set(n.lower() for n in base_data["names"])
custom_mask = np.array(
[n.lower() not in base_names for n in self.names]
)
if not custom_mask.any():
return
np.savez_compressed(
str(CUSTOM_CACHE_PATH),
names=self.names[custom_mask],
colors=self.colors[custom_mask],
mana_values=self.mana_values[custom_mask],
types=self.types[custom_mask],
mana_costs=self.mana_costs[custom_mask],
texts=self.texts[custom_mask],
vectors=self.vectors[custom_mask],
)
self._custom_dirty = False
logger.info("Saved custom embedding cache: %d cards", custom_mask.sum())
except Exception as e:
logger.warning("Failed to save custom embedding cache: %s", e)
def _append_cards(
self,
names: np.ndarray,
colors: np.ndarray,
mana_values: np.ndarray,
types: np.ndarray,
mana_costs: np.ndarray,
texts: np.ndarray,
vectors: np.ndarray,
) -> None:
"""Append new card arrays to the live in-memory index."""
start_idx = len(self.names)
self.names = np.concatenate([self.names, names])
self.colors = np.concatenate([self.colors, colors])
self.mana_values = np.concatenate([self.mana_values, mana_values])
self.types = np.concatenate([self.types, types])
self.mana_costs = np.concatenate([self.mana_costs, mana_costs])
self.texts = np.concatenate([self.texts, texts])
self.vectors = np.concatenate([self.vectors, vectors], axis=0)
for i, name in enumerate(names):
self._name_to_idx[name.lower()] = start_idx + i
# ── Sentence model (lazy) ─────────────────────────────────
def _get_sentence_model(self):
"""Lazy-load the sentence-transformers model on first cache miss."""
if self._sentence_model is None:
try:
from sentence_transformers import SentenceTransformer
logger.info("Loading embedding model %s (first cache miss)...", EMBEDDING_MODEL)
self._sentence_model = SentenceTransformer(EMBEDDING_MODEL)
logger.info("Embedding model loaded.")
except Exception as e:
logger.error("Failed to load SentenceTransformer: %s", e)
return None
return self._sentence_model
# ── On-the-fly Scryfall fallback ──────────────────────────
def _fetch_and_embed_card(self, card_name: str) -> Optional[np.ndarray]:
"""Fetch card from Scryfall, embed it, append to index, persist to cache.
Returns the L2-normalized embedding vector on success, None on failure.
"""
logger.info("Fetching unknown card from Scryfall: '%s'", card_name)
card_data = _fetch_scryfall_card(card_name)
if card_data is None:
return None
model = self._get_sentence_model()
if model is None:
return None
text = _build_card_text(card_data)
vec = model.encode(text, normalize_embeddings=True).astype(np.float32)
# Ensure shape (1, 768)
vec = vec.reshape(1, -1)
resolved_name = card_data["name"]
mana_cost = card_data.get("mana_cost", "")
color_identity = card_data.get("color_identity", "") or _parse_color_identity(mana_cost)
self._append_cards(
names=np.array([resolved_name]),
colors=np.array([color_identity]),
mana_values=np.array([_parse_mana_value(mana_cost)], dtype=np.float32),
types=np.array([card_data.get("type_line", "")]),
mana_costs=np.array([mana_cost]),
texts=np.array([card_data.get("oracle_text", "")]),
vectors=vec,
)
self._custom_dirty = True
self._save_custom_cache()
logger.info("Embedded and cached new card: '%s'", resolved_name)
return self.vectors[self._name_to_idx[resolved_name.lower()]]
# ── Public API ────────────────────────────────────────────
def get_card_vector(self, card_name: str) -> Optional[np.ndarray]:
"""Get the embedding vector for a card by name (case-insensitive).
On a cache miss, fetches from Scryfall and embeds on the fly.
"""
"""Get the embedding vector for a card by name (case-insensitive)."""
idx = self._name_to_idx.get(card_name.lower())
if idx is not None:
return self.vectors[idx]
# Cache miss — try Scryfall fallback
return self._fetch_and_embed_card(card_name)
return None
def search_similar(self, query_card: str,
color_filter: List[str] = None,
@@ -474,32 +274,38 @@ class MTGEmbeddingIndex:
query_vec = self.get_card_vector(query_card)
if query_vec is None:
logger.warning("Card not found in embeddings and Scryfall fallback failed: %s", query_card)
logger.warning("Card not found in embeddings: %s", query_card)
return []
# Compute similarities (dot product on normalized vectors = cosine similarity)
similarities = self.vectors @ query_vec
# Build exclusion set
exclude_set = set()
exclude_set.add(query_card.lower())
if exclude_cards:
exclude_set.update(name.lower() for name in exclude_cards)
# Filter and collect results
results = []
for idx in np.argsort(similarities)[::-1]:
name = str(self.names[idx])
if name.lower() in exclude_set:
continue
# Color filter
if color_filter:
card_colors = str(self.colors[idx])
if not _is_color_subset(card_colors, color_filter):
continue
# Type filter
if type_filter:
card_type = str(self.types[idx])
if type_filter.lower() not in card_type.lower():
continue
# Mana value range filter
if mana_range:
mv = float(self.mana_values[idx])
if mv < mana_range[0] or mv > mana_range[1]:
+130 -127
View File
@@ -1,25 +1,22 @@
"""
Commander AI Lab — Ollama LLM Client
Commander AI Lab — Ollama Client
════════════════════════════════════
Fully async HTTP client targeting Ollama's OpenAI-compatible
/v1/chat/completions endpoint. Every network call uses
httpx.AsyncClient so it NEVER blocks the FastAPI event loop.
Async HTTP client targeting Ollama's OpenAI-compatible
/v1/chat/completions endpoint. Includes retry logic for
non-JSON responses and configurable timeout.
"""
from __future__ import annotations
import json
import logging
import asyncio
from typing import Optional
import httpx
from urllib.request import urlopen, Request
from urllib.error import URLError
from .config import (
LLM_URL,
LLM_MODEL,
LLM_TIMEOUT,
LLM_MAX_RETRIES,
DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS,
LLM_URL, LLM_MODEL,
LLM_TIMEOUT, LLM_MAX_RETRIES,
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
)
logger = logging.getLogger("coach.llm")
@@ -27,15 +24,9 @@ logger = logging.getLogger("coach.llm")
class LLMResponse:
"""Parsed LLM response container."""
def __init__(
self,
content: str,
parsed_json: Optional[dict],
prompt_tokens: int,
completion_tokens: int,
model: str,
):
def __init__(self, content: str, parsed_json: Optional[dict],
prompt_tokens: int, completion_tokens: int,
model: str):
self.content = content
self.parsed_json = parsed_json
self.prompt_tokens = prompt_tokens
@@ -45,7 +36,7 @@ class LLMResponse:
class LLMClient:
"""
Fully async client for Ollama's OpenAI-compatible API.
Client for Ollama's OpenAI-compatible API.
Usage:
client = LLMClient()
@@ -54,25 +45,19 @@ 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: Optional[str] = None
self._resolved_model = None # actual model name from /models endpoint
# ── Model resolution ───────────────────────────────────────
async def _resolve_model(self) -> str:
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 = await self.check_connection()
status = self.check_connection()
if status.get("connected") and status.get("active_model"):
self._resolved_model = status["active_model"]
logger.info("Resolved Ollama model: %s", self._resolved_model)
@@ -81,18 +66,11 @@ class LLMClient:
pass
return self.model
# ── Request building ──────────────────────────────────────
async def _build_request_body(
self,
system_prompt: str,
user_prompt: str,
temperature: float,
max_tokens: int,
force_json: bool,
) -> dict:
model_name = await self._resolve_model()
body: dict = {
def _build_request_body(self, system_prompt: str, user_prompt: str,
temperature: float, max_tokens: int,
force_json: bool) -> dict:
model_name = self._resolve_model()
body = {
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
@@ -101,27 +79,32 @@ class LLMClient:
"temperature": temperature,
"max_tokens": max_tokens,
}
# Only request JSON format if using a model that supports it
# Some models (esp. smaller ones) don't support response_format
if force_json:
body["response_format"] = {"type": "json_object"}
return body
# ── Response parsing ──────────────────────────────────────
@staticmethod
def _strip_think_tags(text: str) -> str:
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks."""
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks from LLM output."""
import re
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
# Remove all <think>...</think> blocks (greedy, handles multiline)
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
return cleaned.strip()
@staticmethod
def _extract_json_object(text: str) -> Optional[dict]:
"""Find and parse the first complete JSON object via brace-counting."""
"""Find and parse the first complete JSON object in text.
Uses brace-counting to handle nested objects reliably."""
start = text.find('{')
if start == -1:
return None
depth = 0
in_string = False
escape_next = False
for i in range(start, len(text)):
ch = text[i]
if escape_next:
@@ -141,56 +124,66 @@ class LLMClient:
elif ch == '}':
depth -= 1
if depth == 0:
candidate = text[start : i + 1]
candidate = text[start:i+1]
try:
return json.loads(candidate)
except (json.JSONDecodeError, ValueError):
# Malformed — try next opening brace
next_start = text.find('{', start + 1)
if next_start != -1 and next_start < i:
start = next_start
depth = 1
return None
depth = 1 # we're already past this brace
return None
return None
def _parse_response(self, raw: dict) -> LLMResponse:
"""Extract content and usage from an OpenAI-format response."""
"""Extract content and usage from OpenAI-format response.
Handles DeepSeek-R1 <think> tags, markdown fences, and raw JSON."""
choice = raw.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "")
usage = raw.get("usage", {})
# Step 1: Strip <think>...</think> blocks (DeepSeek-R1)
cleaned = self._strip_think_tags(content)
parsed: Optional[dict] = None
# 1. Direct JSON parse
# Step 2: Try direct JSON parse on cleaned content
parsed = None
try:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, TypeError):
pass
# 2. Markdown fences
# Step 3: Try extracting from markdown fences
if parsed is None and "```json" in cleaned:
try:
s = cleaned.index("```json") + 7
e = cleaned.index("```", s)
parsed = json.loads(cleaned[s:e].strip())
start = cleaned.index("```json") + 7
end = cleaned.index("```", start)
parsed = json.loads(cleaned[start:end].strip())
except (json.JSONDecodeError, ValueError, IndexError):
pass
if parsed is None and "```" in cleaned:
try:
s = cleaned.index("```") + 3
e = cleaned.index("```", s)
parsed = json.loads(cleaned[s:e].strip())
start = cleaned.index("```") + 3
end = cleaned.index("```", start)
parsed = json.loads(cleaned[start:end].strip())
except (json.JSONDecodeError, ValueError, IndexError):
pass
# 3. Brace-counting extraction
# Step 4: Brute-force — find the first valid JSON object in the text
if parsed is None:
parsed = self._extract_json_object(cleaned)
# 4. Fallback on original content
# Step 5: If still nothing, try on the original content (in case
# think-tag stripping was too aggressive)
if parsed is None and content != cleaned:
parsed = self._extract_json_object(content)
if parsed is not None:
logger.info("Successfully parsed JSON from LLM response")
else:
logger.warning(
"Could not extract JSON from LLM response (%d chars)", len(content)
)
logger.warning("Could not extract JSON from LLM response (%d chars)",
len(content))
return LLMResponse(
content=content,
parsed_json=parsed,
@@ -199,84 +192,94 @@ class LLMClient:
model=raw.get("model", self.model),
)
# ── Public API ─────────────────────────────────────────────
async def chat(
self,
system_prompt: str,
user_prompt: str,
temperature: float = None,
max_tokens: int = None,
force_json: bool = True,
) -> LLMResponse:
def chat_sync(self, system_prompt: str, user_prompt: str,
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
"""
Async chat call to Ollama. Retries up to LLM_MAX_RETRIES.
Never blocks the event loop — uses httpx.AsyncClient throughout.
Synchronous chat call to Ollama.
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
"""
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
body = await self._build_request_body(
system_prompt, user_prompt, temp, tokens, force_json
)
last_error: Optional[Exception] = None
body = self._build_request_body(system_prompt, user_prompt,
temp, tokens, force_json)
last_error = None
for attempt in range(1, LLM_MAX_RETRIES + 1):
try:
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
async with httpx.AsyncClient(
timeout=httpx.Timeout(float(self.timeout))
) as client:
resp = await client.post(
f"{self.base_url}/chat/completions",
json=body,
headers={"Content-Type": "application/json"},
)
resp.raise_for_status()
raw = resp.json()
req = Request(
f"{self.base_url}/chat/completions",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(req, timeout=self.timeout) as resp:
raw = json.loads(resp.read().decode("utf-8"))
result = self._parse_response(raw)
# If we need JSON and didn't get it, retry
if force_json and result.parsed_json is None:
logger.warning(
"Attempt %d: LLM returned non-JSON, retrying", attempt
)
logger.warning("Attempt %d: LLM returned non-JSON, retrying", attempt)
last_error = ValueError("Non-JSON response from LLM")
continue
return result
except httpx.HTTPStatusError as e:
logger.warning("Attempt %d HTTP error %s", attempt, e.response.status_code)
last_error = e
# 400 with json format → retry without response_format constraint
if e.response.status_code == 400 and force_json:
logger.warning("Got 400 — retrying without response_format")
force_json = False
body = await self._build_request_body(
system_prompt, user_prompt, temp, tokens, False
)
except Exception as e:
logger.warning("Attempt %d failed: %s", attempt, e)
last_error = e
if attempt < LLM_MAX_RETRIES:
import asyncio
await asyncio.sleep(2 * attempt)
# If 400 and we used json response_format, retry without it
error_str = str(e)
if "400" in error_str and force_json:
logger.warning("Got 400 — retrying without response_format constraint")
force_json = False
body = self._build_request_body(
system_prompt, user_prompt, temp, tokens, False)
if attempt < LLM_MAX_RETRIES:
import time
time.sleep(2 * attempt) # Simple backoff
raise ConnectionError(
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
)
async def check_connection(self) -> dict:
"""Check if Ollama is reachable. Returns status dict."""
async def chat(self, system_prompt: str, user_prompt: str,
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
"""
Async chat call — runs the sync call in a thread executor
so it doesn't block the FastAPI event loop.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: self.chat_sync(system_prompt, user_prompt,
temperature, max_tokens, force_json)
)
def check_connection(self) -> dict:
"""
Check if Ollama is reachable. Returns status dict.
"""
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
resp = await client.get(
f"{self.base_url}/models",
headers={"Content-Type": "application/json"},
)
resp.raise_for_status()
data = resp.json()
models = data.get("data", [])
model_ids = [m.get("id", "unknown") for m in models]
return {
"connected": True,
"models": model_ids,
"active_model": model_ids[0] if model_ids else self.model,
}
req = Request(
f"{self.base_url}/models",
headers={"Content-Type": "application/json"},
method="GET",
)
with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8"))
models = data.get("data", [])
model_ids = [m.get("id", "unknown") for m in models]
return {
"connected": True,
"models": model_ids,
"active_model": model_ids[0] if model_ids else self.model,
}
except Exception as e:
return {
"connected": False,
+17 -140
View File
@@ -4,9 +4,12 @@ Commander AI Lab — Coach Data Models
Pydantic v2 models for deck reports, coach requests/responses,
and coaching session persistence.
"""
from datetime import datetime
from typing import List, Optional, Dict
from pydantic import BaseModel, Field
# ══════════════════════════════════════════════════════════════
# Deck Report Models (mirrors Java DeckReport)
# ══════════════════════════════════════════════════════════════
@@ -14,16 +17,16 @@ from pydantic import BaseModel, Field
class CardPerformance(BaseModel):
"""Per-card aggregated stats from simulation data."""
name: str
drawnRate: float = 0.0
castRate: float = 0.0
drawnRate: float = 0.0 # Fraction of games where card was drawn
castRate: float = 0.0 # Fraction of games where drawn AND cast
keptInOpeningHandRate: float = 0.0
deadCardRate: float = 0.0
impactScore: float = 0.0
synergyScore: float = 0.0
clunkinessScore: float = 0.0
deadCardRate: float = 0.0 # Drawn but never cast/used
impactScore: float = 0.0 # (winRateWhenCast - overallWinRate) * castRate
synergyScore: float = 0.0 # Co-occurrence uplift with other deck cards
clunkinessScore: float = 0.0 # deadCardRate * (1 - castRate)
avgTurnCast: Optional[float] = None
avgDamageDealt: float = 0.0
tags: List[str] = Field(default_factory=list)
tags: List[str] = Field(default_factory=list) # ramp, draw, removal, etc.
class MatchupRecord(BaseModel):
@@ -45,7 +48,7 @@ class DeckMeta(BaseModel):
class DeckStructure(BaseModel):
"""Deck composition breakdown."""
landCount: int = 0
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8)
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8) # CMC 0-7+
cardTypeCounts: Dict[str, int] = Field(default_factory=dict)
functionalCounts: Dict[str, int] = Field(default_factory=dict)
@@ -54,7 +57,7 @@ class ComboRecord(BaseModel):
"""Known combo with performance data."""
cardNames: List[str]
winRateWhenAssembled: float = 0.0
assemblyRate: float = 0.0
assemblyRate: float = 0.0 # Fraction of games where all pieces drawn
class DeckReport(BaseModel):
@@ -81,10 +84,10 @@ class DeckReport(BaseModel):
class CoachGoals(BaseModel):
"""User-specified coaching goals."""
targetPowerLevel: Optional[int] = None
metaFocus: Optional[str] = None
budget: Optional[str] = None
focusAreas: List[str] = Field(default_factory=list)
targetPowerLevel: Optional[int] = None # 1-10
metaFocus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focusAreas: List[str] = Field(default_factory=list) # e.g., ["ramp", "card draw"]
class CoachRequest(BaseModel):
@@ -104,120 +107,12 @@ class SuggestedCut(BaseModel):
class SuggestedAdd(BaseModel):
"""A card the coach suggests adding."""
cardName: str
role: str = ""
role: str = "" # ramp, draw, removal, finisher, etc.
reason: str = ""
synergyWith: List[str] = Field(default_factory=list)
estimatedManaValue: Optional[float] = None
# ══════════════════════════════════════════════════════════════
# Phase 2: Structured Analysis Models
# ══════════════════════════════════════════════════════════════
class UpgradePriorityItem(BaseModel):
"""A single prioritized cut/add swap ranked by expected impact."""
rank: int
cut: str
add: str
reasoning: str = ""
expectedImpact: str = ""
class CommanderDependency(BaseModel):
"""How reliant the deck is on having the commander in play."""
score: int = 5
dependentCards: List[str] = Field(default_factory=list)
recoveryPlan: str = ""
class MulliganAnalysis(BaseModel):
"""Opening hand keepability assessment."""
estimatedKeepRate: str = ""
worstOffenders: List[str] = Field(default_factory=list)
recommendation: str = ""
# ══════════════════════════════════════════════════════════════
# Phase 3: Qualitative Analysis Models
# ══════════════════════════════════════════════════════════════
class RemovalCoverage(BaseModel):
"""Categorized removal package by permanent type."""
creatures: str = ""
artifacts: str = ""
enchantments: str = ""
planeswalkers: str = ""
lands: str = ""
massRemoval: str = ""
gaps: List[str] = Field(default_factory=list)
class RampQuality(BaseModel):
"""Ramp package breakdown by subtype and fragility."""
manaRocks: str = ""
landFetch: str = ""
manaDorks: str = ""
costReducers: str = ""
fragility: str = ""
canReachFourByTurnThree: str = ""
class DrawEngineProfile(BaseModel):
"""Card draw sustainability analysis."""
burstDraw: List[str] = Field(default_factory=list)
repeatableEngines: List[str] = Field(default_factory=list)
assessment: str = ""
sustainability: str = ""
class WinCondition(BaseModel):
"""A named win condition with independence rating."""
name: str
cards: List[str] = Field(default_factory=list)
independence: str = ""
description: str = ""
class AntiSynergyFlag(BaseModel):
"""An internal conflict between cards in the deck."""
cards: List[str] = Field(default_factory=list)
conflict: str = ""
severity: str = ""
# ══════════════════════════════════════════════════════════════
# Phase 5: Pod Politics & Meta Awareness Models
# ══════════════════════════════════════════════════════════════
class PodPresence(BaseModel):
"""Commander pod politics and threat assessment."""
threatLevel: str = "" # early/medium/late/low
politicalTools: List[str] = Field(default_factory=list)
adversarialRating: str = "" # low/medium/high
recommendation: str = ""
class TempoAssessment(BaseModel):
"""When the deck 'comes online' and whether that's fast enough."""
peakTurn: int = 0
profile: str = "" # early/mid/late
survivalWindow: str = "" # Assessment of whether it survives to peak
assessment: str = ""
class MetaMatchup(BaseModel):
"""Strategic reasoning about deck vs a meta archetype."""
archetype: str # fast-combo, stax, control, aggro, etc.
assessment: str = ""
keyThreats: List[str] = Field(default_factory=list)
keyAnswers: List[str] = Field(default_factory=list)
overallRating: str = "" # favored/even/unfavored
# ══════════════════════════════════════════════════════════════
# Coach Session (persisted result)
# ══════════════════════════════════════════════════════════════
class CoachSession(BaseModel):
"""
Complete coaching session result.
@@ -236,24 +131,6 @@ class CoachSession(BaseModel):
promptTokens: int = 0
completionTokens: int = 0
goals: Optional[CoachGoals] = None
isQuickDigest: bool = False # True for quick digest, False for full analysis
# Phase 2: Structured analysis fields
upgradePriority: List[UpgradePriorityItem] = Field(default_factory=list)
commanderDependency: Optional[CommanderDependency] = None
mulliganAnalysis: Optional[MulliganAnalysis] = None
# Phase 3: Qualitative analysis fields
removalCoverage: Optional[RemovalCoverage] = None
rampQuality: Optional[RampQuality] = None
drawEngineProfile: Optional[DrawEngineProfile] = None
winConditions: List[WinCondition] = Field(default_factory=list)
antiSynergyFlags: List[AntiSynergyFlag] = Field(default_factory=list)
# Phase 5: Pod politics & meta
podPresence: Optional[PodPresence] = None
tempoAssessment: Optional[TempoAssessment] = None
metaMatchups: List[MetaMatchup] = Field(default_factory=list)
class ApplyRequest(BaseModel):
+132 -315
View File
@@ -6,6 +6,7 @@ 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
@@ -26,186 +27,40 @@ RULES:
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
- When suggesting replacements, prefer cards that fill the same functional role.
- IMPORTANT: Suggest 5-8 DIFFERENT cards to cut and 5-8 DIFFERENT cards to add.
- IMPORTANT: Suggest 3-5 DIFFERENT cards to cut and 3-5 DIFFERENT cards to add.
- Each suggested add MUST be a different card — never suggest the same card twice.
- Vary your suggestions across different functional roles (ramp, draw, removal, threats, utility).
- If the deck is missing a functional role (e.g., no card draw, no removal), prioritize adding cards for that role.
- If candidate replacements are provided from embeddings search, prefer those over generic suggestions.
- For each suggested cut, provide a DETAILED reason citing specific stats (impact score, cast rate, dead card rate, clunkiness, synergy score).
- For each suggested add, explain the SPECIFIC synergy with existing cards in the deck.
- When reasoning about cuts, also consider keptInOpeningHandRate (low = bad in opening hands) and avgTurnCast (high = too slow for the curve).
- When evaluating synergy, use the synergyScore: cards below 0.1 have weak co-occurrence with the rest of the deck.
- Use perArchetypeWinRates to identify which matchup types this deck struggles with and tailor suggestions accordingly.
- Provide at least 5 heuristic hints covering different strategic dimensions.
- Assess how dependent the deck is on the commander being in play.
- Evaluate opening hand quality and identify cards that cause bad mulligans.
- Rank your suggested swaps by priority so the user knows what to change first.
- Categorize the removal package by permanent type and flag coverage gaps.
- Evaluate the ramp package for fragility (creature/artifact-heavy vs land-based).
- Distinguish one-time draw from repeatable draw engines.
- Identify the top 3 win conditions and rate each for independence.
- Flag internal anti-synergies or card conflicts.
- Assess pod threat perception: does this deck draw too much hate too early?
- Evaluate when the deck comes online (tempo profile) and whether that's viable.
- Reason about matchups vs fast combo, stax/prison, and control archetypes.
{goals_section}
You MUST respond ONLY with valid JSON matching this exact structure:
{{
"summary": "4-6 sentence detailed overview of the deck's strengths, weaknesses, key synergies, and strategic identity. Reference specific win rates and simulation data.",
"summary": "2-3 sentence overview of the deck's strengths and weaknesses",
"suggestedCuts": [
{{
"cardName": "Card To Remove",
"reason": "Detailed explanation citing specific simulation stats (impact score, cast rate, dead card rate, clunkiness score, synergy score, avg turn cast) and why this card underperforms in this specific deck context",
"replacementOptions": ["Better Card 1", "Better Card 2"],
"functionalRole": "ramp|draw|removal|threat|utility|combo|protection",
"estimatedImpact": "How cutting this card is expected to improve the deck"
"reason": "Why this card underperforms",
"replacementOptions": ["Better Card 1", "Better Card 2"]
}}
],
"suggestedAdds": [
{{
"cardName": "Card To Add",
"role": "ramp|draw|removal|threat|utility|combo|protection",
"reason": "Detailed explanation of why this card improves the deck, referencing specific weaknesses it addresses",
"synergyWith": ["Card In Deck 1", "Card In Deck 2"],
"estimatedManaCost": "The mana value of the suggested card",
"strategicRationale": "How this card fits into the overall game plan and addresses a specific gap"
"reason": "Why this card improves the deck",
"synergyWith": ["Card In Deck 1", "Card In Deck 2"]
}}
],
"upgradePriority": [
{{
"rank": 1,
"cut": "Card to remove",
"add": "Card to add instead",
"reasoning": "Why this is the single highest-impact swap",
"expectedImpact": "high|medium|low"
}}
],
"commanderDependency": {{
"score": 5,
"dependentCards": ["Card that is nearly dead without commander"],
"recoveryPlan": "Strategy if the commander gets removed or tucked repeatedly"
}},
"mulliganAnalysis": {{
"estimatedKeepRate": "Percentage estimate of keepable 7-card hands, e.g. ~70%",
"worstOffenders": ["Card most responsible for unkeepable hands"],
"recommendation": "What to change to improve opening hand quality"
}},
"manaCurveAnalysis": {{
"currentAssessment": "Detailed analysis of the current mana curve distribution and its implications",
"recommendations": "Specific suggestions for curve adjustments with reasoning",
"idealRange": "What the curve should look like for this deck's strategy"
}},
"roleBreakdown": {{
"ramp": "Assessment of ramp package - count, quality, and suggestions",
"draw": "Assessment of card draw package",
"removal": "Assessment of removal package",
"threats": "Assessment of win conditions and threats",
"protection": "Assessment of protection and interaction"
}},
"synergyAnalysis": [
"Description of a key synergy chain in the deck and how well it performed in simulation"
],
"heuristicHints": [
"Detailed strategic tip explaining the reasoning and data behind the suggestion"
"Concise strategic tip based on data"
],
"manaBaseAdvice": "Specific mana base improvement suggestion including land count, color fixing, and utility lands, or null if mana base is solid",
"rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates, and (5) prioritized list of improvements ranked by expected impact on win rate",
"removalCoverage": {{
"creatures": "Assessment of creature removal",
"artifacts": "Assessment of artifact removal",
"enchantments": "Assessment of enchantment removal",
"planeswalkers": "Assessment of planeswalker removal",
"lands": "Assessment of land destruction",
"massRemoval": "Assessment of board wipes",
"gaps": ["Permanent types with zero or weak coverage"]
}},
"rampQuality": {{
"manaRocks": "2-mana rocks, signets, etc.",
"landFetch": "Cultivate, Kodama's Reach, etc.",
"manaDorks": "Llanowar Elves, Birds, etc.",
"costReducers": "Medallions, Helm of Awakening, etc.",
"fragility": "Overall fragility assessment",
"canReachFourByTurnThree": "yes/no with reasoning"
}},
"drawEngineProfile": {{
"burstDraw": ["One-time draw spells"],
"repeatableEngines": ["Sustained draw engines"],
"assessment": "Overall draw quality",
"sustainability": "Whether draw is sustainable or burst-only"
}},
"winConditions": [
{{
"name": "Name of win condition",
"cards": ["Key cards involved"],
"independence": "high|medium|low",
"description": "How this win con functions"
}}
],
"antiSynergyFlags": [
{{
"cards": ["Card A", "Card B"],
"conflict": "Description of conflict",
"severity": "high|medium|low"
}}
],
"podPresence": {{
"threatLevel": "early|medium|late|low",
"politicalTools": ["Pillowfort or group effect cards"],
"adversarialRating": "low|medium|high",
"recommendation": "How to manage threat perception in a pod"
}},
"tempoAssessment": {{
"peakTurn": 5,
"profile": "early|mid|late",
"survivalWindow": "Assessment of whether the deck survives to its power window",
"assessment": "Overall tempo evaluation"
}},
"metaMatchups": [
{{
"archetype": "fast-combo|stax|control|aggro|midrange",
"assessment": "How the deck fares against this archetype",
"keyThreats": ["Cards/strategies that threaten this deck"],
"keyAnswers": ["Cards in the deck that answer this archetype"],
"overallRating": "favored|even|unfavored"
}}
]
"manaBaseAdvice": "Specific mana base improvement suggestion or null",
"rawTextExplanation": "Detailed paragraph explaining the analysis and reasoning"
}}
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
# ══════════════════════════════════════════════════════════════
# Quick Digest Prompt
# ══════════════════════════════════════════════════════════════
QUICK_DIGEST_SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander deck coach.
Provide a FAST summary analysis of this deck. Be concise.
RULES:
- NEVER suggest cutting the commander ({commander}).
- Only suggest cards within the deck's color identity: {color_identity}.
{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
@@ -227,9 +82,6 @@ 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}
@@ -240,139 +92,114 @@ Avg Game Length: {avg_game_length:.1f} turns
{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 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."
"""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."
def _format_underperformers(cards: List[CardPerformance],
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."
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."
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)."
)
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)
"""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_matchups(report: DeckReport) -> str:
"""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)
"""Format matchup data."""
if not report.matchups:
return "No matchup data available."
lines = []
for m in report.matchups:
lines.append(f"- vs {m.opponentDeck}: {m.winRate:.1%} "
f"({m.gamesPlayed} games)")
return "\n".join(lines)
def _format_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)
# ══════════════════════════════════════════════════════════════
@@ -380,51 +207,41 @@ 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),
)
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),
)
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_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),
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 {}),
combo_section=_format_combos(report),
)
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),
)
-11
View File
@@ -1,9 +1,6 @@
"""
Commander AI Lab — Deck Generation System Prompt
═════════════════════════════════════════════════
Phase 1 changes:
- Added explicit 99-card count enforcement rule
- Clarified that commander counts separately (not in the 99)
"""
SYSTEM_PROMPT = """You are an elite Magic: The Gathering Commander (EDH) deck builder with encyclopedic knowledge of every card ever printed, current metagame trends, and Commander Rules Committee bracket guidelines.
@@ -12,8 +9,6 @@ Your task is to build a complete, legal 100-card Commander deck.
RULES:
- Exactly 100 cards total: 1 commander + 99 other cards
- The commander IS included in the cards list but counts as card #100
- The other 99 cards must exactly match the SLOT BUDGET provided in the user prompt
- No more than 1 copy of any card except basic lands
- All cards must share the commander's color identity
- All cards must be legal in Commander format
@@ -21,12 +16,6 @@ RULES:
- Include approximately: 10 ramp sources, 10 card draw sources, 8-10 targeted removal, 2-3 board wipes, 3-5 protection pieces
- Build a cohesive strategy around the commander's abilities
CARD COUNT ENFORCEMENT (critical):
- The user prompt will specify a SLOT BUDGET with exact per-category counts
- You MUST fill every slot — returning fewer cards than the budget specifies will cause a validation error
- If you cannot find a perfect fit for a slot, use the best available card for that role
- Basic lands count toward the Land slot total
BRACKET RULES (Commander Rules Committee):
- Bracket 1 (Casual): Precon-level. No tutors, no fast mana, no infinite combos, no mass land destruction. Game Changers: 0
- Bracket 2 (Upgraded Casual): Moderate power. Limited tutors (1-2 total), no 2-card infinite combos, no fast mana except Sol Ring. Game Changers: 0-1
+14 -43
View File
@@ -1,33 +1,11 @@
"""
Commander AI Lab — Deck Generation User Prompt Builder
═══════════════════════════════════════════════════════
Constructs the user message for LLM deck generation.
Phase 1 changes:
- slot_budget parameter injected into prompt so LLM targets exactly 99 cards
- Explicit card count requirement in prompt text
Constructs the user message for Perplexity deck generation
from a DeckGenV3Request.
"""
from typing import Dict, List, Optional
def build_slot_budget_segment(slot_budget: Dict[str, int]) -> str:
"""
Build the slot budget prompt segment.
Informs the LLM of exact per-category card counts so it never undershoots.
"""
total = sum(slot_budget.values())
lines = [f"SLOT BUDGET (you must fill EXACTLY {total} cards, excluding the commander):"]
for cat, count in slot_budget.items():
lines.append(f" {cat}: {count}")
lines.append(f" TOTAL: {total}")
lines.append("")
lines.append(
f"CRITICAL: Return exactly {total} cards in the JSON 'cards' array "
"(not counting the commander). Every slot in the budget above must be filled. "
"Do not return more or fewer cards."
)
return "\n".join(lines)
from typing import List, Optional
def build_user_prompt(
@@ -40,7 +18,6 @@ def build_user_prompt(
budget_mode: str = "total",
omit_cards: List[str] = None,
collection_summary: str = "",
slot_budget: Optional[Dict[str, int]] = None,
) -> str:
"""
Build the user prompt for deck generation.
@@ -49,19 +26,18 @@ def build_user_prompt(
commander: Commander card name
commander_type: Type line from Scryfall
color_identity: List of color letters, e.g. ["U", "B"]
strategy: User-specified strategy focus
strategy: User-specified strategy focus (e.g. "zombie tokens")
target_bracket: Target bracket level 1-4
budget_usd: Optional budget constraint
budget_mode: "total" for total deck cost, "per_card" for per-card max
omit_cards: Cards to exclude
collection_summary: Pre-built collection summary text block
slot_budget: Dict of category -> card count (must sum to 99)
"""
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
lines = [
"Build a complete 100-card Commander deck.",
"",
f"Build a complete 100-card Commander deck.",
f"",
f"COMMANDER: {commander}",
]
@@ -84,21 +60,16 @@ def build_user_prompt(
lines.append(f"OMIT LIST (do NOT include these cards): {', '.join(omit_cards)}")
if collection_summary:
lines.append("")
lines.append(f"")
lines.append(collection_summary)
lines.append("")
lines.append("STRONGLY PREFER cards from the collection when they fit the strategy.")
lines.append(f"")
lines.append(f"STRONGLY PREFER cards from the collection when they fit the strategy.")
else:
lines.append("")
lines.append("No collection data available — suggest the best cards regardless of ownership.")
lines.append(f"")
lines.append(f"No collection data available — suggest the best cards regardless of ownership.")
# Inject slot budget so LLM knows exact target counts
if slot_budget:
lines.append("")
lines.append(build_slot_budget_segment(slot_budget))
lines.append("")
lines.append("Build the deck as a structured JSON response. Include the commander in the cards list.")
lines.append("Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
lines.append(f"")
lines.append(f"Build the deck as a structured JSON response. Include the commander in the cards list.")
lines.append(f"Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
return "\n".join(lines)
+1 -1
View File
@@ -1,7 +1,7 @@
"""
Commander AI Lab — Substitution Prompt Templates
═════════════════════════════════════════════════
Prompts for the Claude Opus substitution pass.
Prompts for the Perplexity fallback substitution pass.
"""
+2 -2
View File
@@ -38,7 +38,7 @@ def generate_deck_reports(results_dir: str, reports_dir: str = None) -> List[str
output_path.mkdir(parents=True, exist_ok=True)
# Load all batch results
batch_files = sorted(results_path.glob("ml-decision-*.json"))
batch_files = sorted(results_path.glob("batch-*.json"))
if not batch_files:
logger.info("No batch result files found in %s", results_dir)
return []
@@ -108,7 +108,7 @@ def generate_single_deck_report(batch_json_path: str, reports_dir: str = None) -
# Also load existing batch results for comprehensive reports
results_dir = Path(batch_json_path).parent
all_batches = []
for bf in sorted(results_dir.glob("ml-decision-*.json")):
for bf in sorted(results_dir.glob("batch-*.json")):
try:
with open(bf, "r", encoding="utf-8") as f:
all_batches.append(json.load(f))
+2 -2
View File
@@ -1,7 +1,7 @@
"""
Commander AI Lab — Deck Generation JSON Schemas
═════════════════════════════════════════════════
Pydantic models and JSON Schema dicts for Claude Opus
Pydantic models and JSON Schema dicts for Perplexity
structured output deck generation.
"""
@@ -58,7 +58,7 @@ class GeneratedDeckList(BaseModel):
# ══════════════════════════════════════════════════════════════
# JSON Schema dicts (for Claude Opus response_format)
# JSON Schema dicts (for Perplexity response_format)
# ══════════════════════════════════════════════════════════════
DECK_CARD_SCHEMA = {
+5 -21
View File
@@ -3,10 +3,6 @@ Commander AI Lab — Substitution Schemas
════════════════════════════════════════
Pydantic models and JSON Schema dicts for the
Smart Substitution Engine output.
Phase 1 changes:
- SubstitutionBatchResult gains padded_slots field
- DeckCardWithStatus gains original_card field (was missing)
"""
from typing import List, Optional
@@ -22,17 +18,16 @@ class CardAlternative(BaseModel):
name: str = Field(description="Exact card name")
similarity_score: float = Field(default=0.0, description="Cosine similarity score (0-1)")
reason: str = Field(default="", description="Why this card is a good substitute")
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'anthropic'")
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'perplexity'")
role_overlap: List[str] = Field(default_factory=list, description="Shared functional roles")
cmc_delta: float = Field(default=0.0, description="CMC difference from original")
class DeckCardWithStatus(BaseModel):
"""A deck card enriched with ownership and substitution data."""
class Config:
extra = "ignore"
name: str
count: int = 1
category: str = ""
@@ -46,15 +41,9 @@ class DeckCardWithStatus(BaseModel):
owned_qty: int = 0
scryfall_id: str = ""
status: str = Field(default="owned", description="owned | substituted | missing")
original_card: Optional[str] = Field(
default=None,
description="Original card name before substitution (None if not substituted)"
)
original_card: Optional[str] = Field(default=None, description="Original card name if this is a substitution")
alternatives: List[CardAlternative] = Field(default_factory=list)
selected_substitute: Optional[str] = Field(
default=None,
description="The substitute card name chosen for this slot"
)
selected_substitute: Optional[str] = Field(default=None, description="User-selected substitute name")
class SubstitutionRequest(BaseModel):
@@ -72,16 +61,11 @@ class SubstitutionBatchResult(BaseModel):
owned_count: int = 0
substituted_count: int = 0
missing_count: int = 0
padded_slots: int = Field(
default=0,
description="Number of slots filled by the pad-to-99 circuit breaker. "
"Any value > 0 indicates a bug in the substitution pipeline."
)
cards: List[DeckCardWithStatus] = Field(default_factory=list)
# ══════════════════════════════════════════════════════════════
# JSON Schema dicts (for LLM fallback substitution)
# JSON Schema dicts (for Perplexity fallback substitution)
# ══════════════════════════════════════════════════════════════
SUBSTITUTION_RESPONSE_SCHEMA = {
+118 -427
View File
@@ -1,28 +1,19 @@
"""
Commander AI Lab — V3 Deck Generator Service
═════════════════════════════════════════════
Generates Commander decks using Anthropic Claude
Generates Commander decks using Perplexity Sonar
structured JSON output, then runs ownership check
and smart substitution.
Phase 1 changes:
- smart_substitute() non-destructive contract (never returns None)
- apply_substitutions() loop never silently drops cards
- _pad_to_99() circuit breaker before BuildResult validation
- Pre-validation count logging per category
- Startup SLOT_BUDGET assertion
"""
import json
import logging
import re
import sqlite3
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional, Tuple
from urllib.request import urlopen, Request
from ..clients.anthropic_client import AnthropicClient
from ..clients.perplexity_client import PerplexityClient
from ..schemas.deck_schema import DECK_LIST_SCHEMA, GeneratedDeckList
from ..schemas.substitution_schema import (
CardAlternative, DeckCardWithStatus, SubstitutionBatchResult,
@@ -35,261 +26,35 @@ from ..prompts.substitution_prompt import (
from ..config import (
DECK_GEN_MODEL, DECK_GEN_TEMPERATURE, DECK_GEN_MAX_TOKENS,
SUBSTITUTION_MIN_SIMILARITY, SUBSTITUTION_MAX_ALTERNATIVES,
SUBSTITUTION_MODEL, SUBSTITUTION_USE_ANTHROPIC_FALLBACK,
SUBSTITUTION_MODEL, SUBSTITUTION_USE_PPLX_FALLBACK,
)
logger = logging.getLogger("coach.deckgen")
# ══════════════════════════════════════════════════════════════
# Slot Budget — MUST sum to 99 (commander is the 100th card)
# ══════════════════════════════════════════════════════════════
SLOT_BUDGET: dict[str, int] = {
"Land": 36,
"Ramp": 10,
"Card Draw": 10,
"Removal": 8,
"Board Wipe": 3,
"Win Condition": 4,
"Protection": 4,
"Synergy": 20,
"Creature": 2,
"Utility": 2,
}
# ── Startup assertion — catches misconfigured budgets at boot time ──
_SLOT_BUDGET_TOTAL = sum(SLOT_BUDGET.values())
assert _SLOT_BUDGET_TOTAL == 99, (
f"SLOT_BUDGET sums to {_SLOT_BUDGET_TOTAL}, must equal 99. "
f"Current distribution: {SLOT_BUDGET}"
)
# ══════════════════════════════════════════════════════════════
# SubstitutionMethod — tracks HOW each card was resolved
# ══════════════════════════════════════════════════════════════
class SubstitutionMethod(str, Enum):
EXACT_MATCH = "exact_match" # Card found verbatim in collection
FUZZY_MATCH = "fuzzy_match" # Case/punctuation-normalised match
EMBEDDING_ANALOG = "embedding_analog" # Embedding similarity match
ANTHROPIC_FALLBACK = "anthropic_fallback" # LLM suggested replacement
ORIGINAL_KEPT = "original_kept" # Not in collection but Scryfall-valid
BASIC_LAND_FALLBACK = "basic_land_fallback" # Last resort — slot becomes a basic
@dataclass
class SubstitutionRecord:
"""Audit trail entry for a single card's resolution."""
original: str
resolved: str
method: SubstitutionMethod
in_collection: bool
# ══════════════════════════════════════════════════════════════
# Color → Basic Land mapping
# ══════════════════════════════════════════════════════════════
_COLOR_TO_BASIC: dict[str, str] = {
"W": "Plains",
"U": "Island",
"B": "Swamp",
"R": "Mountain",
"G": "Forest",
}
_BASIC_NAMES: set[str] = set(_COLOR_TO_BASIC.values()) | {"Wastes"}
def _get_basic_land(color_identity: List[str]) -> str:
"""Return the first appropriate basic land for the given color identity."""
for color in color_identity:
if color.upper() in _COLOR_TO_BASIC:
return _COLOR_TO_BASIC[color.upper()]
return "Wastes" # Colorless commander
# ══════════════════════════════════════════════════════════════
# Non-destructive Smart Substitution
# ══════════════════════════════════════════════════════════════
def smart_substitute(
card_name: str,
collection: set[str],
color_identity: List[str],
embedding_index=None,
) -> SubstitutionRecord:
"""
Non-destructive substitution — ALWAYS returns a valid card string.
Fallback chain:
1. Exact match in collection
2. Fuzzy match (case + punctuation normalised)
3. Embedding similarity match (if index available)
4. Keep original (Scryfall-valid, not in collection)
5. Basic land for the commander's colors (last resort — NEVER None)
"""
# 1. Exact match
if card_name in collection:
return SubstitutionRecord(card_name, card_name, SubstitutionMethod.EXACT_MATCH, True)
# 2. Fuzzy match — normalise case and common punctuation differences
normalised = card_name.lower().strip()
for owned in collection:
if owned.lower().strip() == normalised:
return SubstitutionRecord(card_name, owned, SubstitutionMethod.FUZZY_MATCH, True)
# 3. Embedding similarity (if index available)
if embedding_index and getattr(embedding_index, "loaded", False):
try:
matches = embedding_index.search_similar(
query_card=card_name,
color_filter=color_identity or None,
exclude_cards=[card_name],
top_n=5,
)
pool_lower = {n.lower() for n in collection}
for match in matches:
if match.name.lower() in pool_lower:
return SubstitutionRecord(
card_name, match.name, SubstitutionMethod.EMBEDDING_ANALOG, True
)
except Exception as e:
logger.warning("Embedding substitution failed for '%s': %s", card_name, e)
# 4. Keep original — valid card not in collection (proxy / non-collection build)
return SubstitutionRecord(
card_name, card_name, SubstitutionMethod.ORIGINAL_KEPT, False
)
def apply_substitutions(
card_list: List[DeckCardWithStatus],
collection: set[str],
color_identity: List[str],
embedding_index=None,
) -> Tuple[List[DeckCardWithStatus], List[SubstitutionRecord]]:
"""
Apply smart substitution to a card list.
Guarantees: output list is NEVER shorter than input list.
Logs a warning for every BASIC_LAND_FALLBACK.
"""
records: List[SubstitutionRecord] = []
result: List[DeckCardWithStatus] = []
for card in card_list:
rec = smart_substitute(card.name, collection, color_identity, embedding_index)
records.append(rec)
if rec.method == SubstitutionMethod.BASIC_LAND_FALLBACK:
logger.warning(
"[substitution] BASIC_LAND_FALLBACK for '%s' — slot filled with '%s'",
rec.original, rec.resolved,
)
# Update the card in-place — never drop it
updated = card.model_copy(update={
"name": rec.resolved,
"owned": rec.in_collection,
"status": "owned" if rec.in_collection else
("substituted" if rec.method != SubstitutionMethod.ORIGINAL_KEPT
else "missing"),
"original_card": card.name if rec.resolved != card.name else card.original_card,
})
result.append(updated)
fallback_count = sum(1 for r in records if r.method == SubstitutionMethod.BASIC_LAND_FALLBACK)
if fallback_count:
logger.error(
"[substitution] %d cards fell back to basic lands — investigate LLM output quality",
fallback_count,
)
return result, records
# ══════════════════════════════════════════════════════════════
# Pad-to-99 Circuit Breaker
# ══════════════════════════════════════════════════════════════
def _pad_to_99(
cards: List[DeckCardWithStatus],
color_identity: List[str],
target: int = 99,
) -> Tuple[List[DeckCardWithStatus], int]:
"""
Emergency safety pad — should NEVER fire if substitution contract holds.
If it does fire, the error log is your signal that a card is being dropped
somewhere earlier in the pipeline.
Returns (padded_cards, number_of_slots_padded).
"""
# Strip commander from count if present (commander is tracked separately)
non_commander = [c for c in cards if not getattr(c, "is_commander", False)]
current = sum(c.count for c in non_commander)
shortfall = target - current
if shortfall <= 0:
return cards, 0
logger.error(
"[pad_to_99] CIRCUIT BREAKER FIRED — deck is %d cards short after substitution. "
"Padding with basic lands. Check substitution pipeline for silent drops.",
shortfall,
)
basic = _get_basic_land(color_identity)
padding_card = DeckCardWithStatus(
name=basic,
count=shortfall,
category="Land",
role_tags=[],
reason="Emergency pad — basic land inserted by circuit breaker",
estimated_price_usd=0.10,
owned=True,
owned_qty=shortfall,
status="owned",
)
return cards + [padding_card], shortfall
def _log_pre_validation_counts(cards: List[DeckCardWithStatus]) -> None:
"""Log per-category card counts before Pydantic validation."""
by_cat: dict[str, int] = {}
for card in cards:
cat = card.category or "Uncategorized"
by_cat[cat] = by_cat.get(cat, 0) + card.count
total = sum(by_cat.values())
logger.info("[pre_validation] Category counts: %s", by_cat)
logger.info("[pre_validation] Total: %d/99 (delta: %d)", total, 99 - total)
# ══════════════════════════════════════════════════════════════
# DeckGeneratorV3
# ══════════════════════════════════════════════════════════════
class DeckGeneratorV3:
"""
V3 Deck Generator using Anthropic Claude structured output.
V3 Deck Generator using Perplexity structured output.
Pipeline:
1. Resolve commander via Scryfall
2. Build collection summary
3. Call Anthropic Claude with structured JSON schema
3. Call Perplexity with structured JSON schema
4. Validate card names via Scryfall
5. Check ownership against collection DB
6. Run Smart Substitution (non-destructive)
7. _pad_to_99 circuit breaker
8. Pre-validation count logging
9. Return finalized deck — guaranteed 99 cards
6. Run Smart Substitution (embedding + Perplexity fallback)
7. Return finalized deck with substitution data
"""
def __init__(self, anthropic_client: AnthropicClient, db_conn_factory,
def __init__(self, pplx_client: PerplexityClient, db_conn_factory,
embedding_index=None):
self.anthropic = anthropic_client
"""
Args:
pplx_client: Configured PerplexityClient instance
db_conn_factory: Callable that returns sqlite3.Connection
embedding_index: Optional MTGEmbeddingIndex for substitution
"""
self.pplx = pplx_client
self.db_conn_factory = db_conn_factory
self.embedding_index = embedding_index
@@ -312,12 +77,9 @@ class DeckGeneratorV3:
"mana_cost": data.get("mana_cost", ""),
"image_url": data.get("image_uris", {}).get("normal", ""),
"oracle_text": data.get("oracle_text", ""),
"scryfall_found": True,
}
except Exception as e:
logger.warning("Scryfall lookup failed for '%s': %s", commander_name, e)
# Non-canonical commander (silver-bordered, custom, etc.)
# Skip collection substitution for these — trust LLM output directly
return {
"name": commander_name,
"scryfall_id": "",
@@ -326,7 +88,6 @@ class DeckGeneratorV3:
"mana_cost": "",
"image_url": "",
"oracle_text": "",
"scryfall_found": False,
}
# ── Step 2: Build Collection Summary ─────────────────────
@@ -349,10 +110,16 @@ class DeckGeneratorV3:
if not rows:
return ""
# Filter by color identity
ci_set = set(color_identity) if color_identity else None
filtered = []
for row in rows:
name = row["name"]
type_line = row["type_line"] or ""
# Basic lands and colorless are always allowed
if ci_set:
# Simple color check from type_line and name
# For proper filtering we'd check mana cost but this is good enough for summary
card_colors = set()
oracle = row["oracle_text"] or ""
mana_cost = ""
@@ -370,6 +137,7 @@ class DeckGeneratorV3:
if not filtered:
return ""
# Group by card type
groups = {}
for row in filtered:
type_line = row["type_line"] or ""
@@ -391,12 +159,14 @@ class DeckGeneratorV3:
group = "Other"
groups.setdefault(group, []).append(row)
# Sort each group by popularity/price and build text
lines = [f"COLLECTION SUMMARY ({len(filtered)} cards in deck colors):"]
for group_name in ["Creatures", "Instants", "Sorceries", "Artifacts",
"Enchantments", "Planeswalkers", "Lands", "Other"]:
cards = groups.get(group_name, [])
if not cards:
continue
# Sort by price descending (higher value = likely better card)
cards.sort(key=lambda r: r["tcg_price"] or 0, reverse=True)
card_strs = []
for c in cards[:max_per_group]:
@@ -406,7 +176,7 @@ class DeckGeneratorV3:
return "\n".join(lines)
# ── Step 3: Generate Deck via Anthropic Claude ─────────────────
# ── Step 3: Generate Deck via Perplexity ─────────────────
def generate_deck(
self,
commander_name: str,
@@ -423,33 +193,24 @@ class DeckGeneratorV3:
Returns dict with:
- commander: dict with Scryfall data
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
- cards: list of DeckCardWithStatus (after ownership check + substitution)
- raw_deck: parsed GeneratedDeckList from Perplexity
- cards: list of DeckCardWithStatus (after ownership check)
- stats: deck statistics
- substitution_log: list of SubstitutionRecord dicts
- padded_slots: int > 0 means circuit breaker fired
- tokens_used: {prompt, completion}
"""
# Step 1: Resolve commander
cmdr = self.resolve_commander(commander_name)
color_identity = cmdr["color_identity"]
scryfall_found = cmdr.get("scryfall_found", True)
logger.info("Commander: %s, CI: %s, Scryfall: %s",
cmdr["name"], color_identity, scryfall_found)
logger.info("Commander: %s, CI: %s", cmdr["name"], color_identity)
# Step 2: Collection summary
collection_block = ""
if use_collection and scryfall_found:
if use_collection:
collection_block = self.build_collection_summary(color_identity)
logger.info("Collection summary: %d chars",
len(collection_block) if collection_block else 0)
elif not scryfall_found:
logger.info(
"Non-canonical commander '%s' — skipping collection filter, "
"trusting LLM output directly.", cmdr["name"]
)
# Step 3: Build prompts (with slot budget injected)
# Step 3: Build prompts
user_prompt = build_user_prompt(
commander=cmdr["name"],
commander_type=cmdr["type_line"],
@@ -460,14 +221,13 @@ class DeckGeneratorV3:
budget_mode=budget_mode,
omit_cards=omit_cards,
collection_summary=collection_block,
slot_budget=SLOT_BUDGET,
)
# Step 4: Call LLM with structured output
# Step 4: Call Perplexity with structured output
use_model = model or DECK_GEN_MODEL
logger.info("Calling LLM (%s) for deck generation...", use_model)
logger.info("Calling Perplexity (%s) for deck generation...", use_model)
response = self.anthropic.chat_structured(
response = self.pplx.chat_structured(
system_prompt=SYSTEM_PROMPT,
user_prompt=user_prompt,
json_schema=DECK_LIST_SCHEMA,
@@ -479,60 +239,24 @@ class DeckGeneratorV3:
if not response.ok:
raise ValueError(
f"LLM returned invalid response: {response.content[:300]}"
f"Perplexity returned invalid response: {response.content[:300]}"
)
raw_deck = response.parsed_json
card_count = len(raw_deck.get("cards", []))
truncated = card_count < 90
logger.info(
"Deck generated: %d cards, bracket %s%s",
card_count,
(raw_deck.get("bracket", {}).get("level", "?") if isinstance(raw_deck.get("bracket"), dict)
else raw_deck.get("bracket", "?")),
" (TRUNCATED — hit token limit)" if truncated else "",
)
truncated = card_count < 90 # Commander decks should have ~100 cards
logger.info("Deck generated: %d cards, bracket %d%s",
card_count,
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
" (TRUNCATED — response may have hit token limit)" if truncated else "")
# Step 5: Check ownership
# Step 5: Check ownership and build enriched card list
cards_with_status = self._check_ownership(raw_deck.get("cards", []))
# Step 5b: Fill basic lands to reach target_total=100
# Step 5b: Fill basic lands to hit exactly 100 cards
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
# Step 5c: Secondary deduplication
seen: dict[str, int] = {}
deduped: List[DeckCardWithStatus] = []
for c in cards_with_status:
key = c.name.lower()
if key in seen:
deduped[seen[key]].count += c.count
logger.warning("Secondary dedupe caught duplicate: '%s'", c.name)
else:
seen[key] = len(deduped)
deduped.append(c)
cards_with_status = deduped
# Step 6: Run non-destructive smart substitution
# For non-canonical commanders (e.g. Sonic), skip collection filter
owned_pool: set[str] = set()
if use_collection and scryfall_found:
conn = self.db_conn_factory()
rows = conn.execute(
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
).fetchall()
owned_pool = {r["name"] for r in rows}
cards_with_status, substitution_log = apply_substitutions(
cards_with_status, owned_pool, color_identity, self.embedding_index
)
# Step 7: Pad-to-99 circuit breaker
cards_with_status, padded_slots = _pad_to_99(cards_with_status, color_identity)
# Step 8: Pre-validation count logging
_log_pre_validation_counts(cards_with_status)
# Step 9: Compute stats
# Step 6: Compute stats
stats = self._compute_stats(cards_with_status)
return {
@@ -546,12 +270,6 @@ class DeckGeneratorV3:
"archetype": raw_deck.get("archetype", ""),
"reasoning": raw_deck.get("reasoning", {}),
"estimated_total_usd": raw_deck.get("estimated_total_usd", 0),
"substitution_log": [
{"original": r.original, "resolved": r.resolved,
"method": r.method.value, "in_collection": r.in_collection}
for r in substitution_log
],
"padded_slots": padded_slots,
"tokens_used": {
"prompt": response.prompt_tokens,
"completion": response.completion_tokens,
@@ -565,25 +283,17 @@ class DeckGeneratorV3:
def _check_ownership(self, cards: List[dict]) -> List[DeckCardWithStatus]:
"""
Cross-reference generated cards against the collection DB.
Deduplicates by name (case-insensitive).
Sets owned/scryfall_id/owned_qty and real price.
"""
conn = self.db_conn_factory()
enriched: List[DeckCardWithStatus] = []
seen_names: dict[str, int] = {}
enriched = []
for card in cards:
name = card.get("name", "")
if not name:
continue
name_lower = name.lower()
if name_lower in seen_names:
idx = seen_names[name_lower]
enriched[idx].count += card.get("count", 1)
logger.warning("Duplicate card '%s' merged", name)
continue
# Look up in collection
row = conn.execute(
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
@@ -608,14 +318,16 @@ class DeckGeneratorV3:
scryfall_id=row["scryfall_id"] if row else "",
status="owned" if is_owned else "missing",
)
seen_names[name_lower] = len(enriched)
enriched.append(enriched_card)
return enriched
# ── Step 5b: Basic Land Fill ──────────────────────────────
_COLOR_TO_BASIC = _COLOR_TO_BASIC
_BASIC_NAMES = _BASIC_NAMES
_COLOR_TO_BASIC = {
'W': 'Plains', 'U': 'Island', 'B': 'Swamp',
'R': 'Mountain', 'G': 'Forest',
}
_BASIC_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'}
def _fill_basic_lands(
self,
@@ -637,7 +349,7 @@ class DeckGeneratorV3:
ci = [c.upper() for c in color_identity]
basic_names = [self._COLOR_TO_BASIC[c] for c in ci if c in self._COLOR_TO_BASIC]
if not basic_names:
basic_names = ["Wastes"]
basic_names = ['Wastes']
per_color = basics_needed // len(basic_names)
remainder = basics_needed % len(basic_names)
@@ -648,20 +360,20 @@ class DeckGeneratorV3:
non_basics.append(DeckCardWithStatus(
name=bname,
count=qty,
category="Land",
category='Land',
role_tags=[],
reason="Basic land for mana fixing",
reason='Basic land for mana fixing',
estimated_price_usd=0.10,
owned=True,
owned_qty=qty,
status="owned",
status='owned',
))
logger.info("Basic land fill: %d basics added across %d color(s)",
basics_needed, len(basic_names))
basics_needed, len(basic_names))
return non_basics
# ── Step 6: Smart Substitution (legacy wrapper for route compatibility) ──
# ── Step 6: Smart Substitution ───────────────────────────
def run_substitution(
self,
cards: List[DeckCardWithStatus],
@@ -671,12 +383,15 @@ class DeckGeneratorV3:
"""
Run the Smart Substitution Engine on missing cards.
Uses the non-destructive apply_substitutions() under the hood.
Falls back to Anthropic Claude for low-confidence matches,
then to basic land as last resort — NEVER drops a card.
Pipeline:
a) Identify missing cards
b) For each: try embedding similarity (free, fast)
c) For low-confidence matches: batch Perplexity fallback
d) Auto-select best substitute per card
Returns SubstitutionBatchResult with all cards updated.
"""
color_identity = commander.get("color_identity", [])
scryfall_found = commander.get("scryfall_found", True)
missing = [c for c in cards if not c.owned]
owned = [c for c in cards if c.owned]
@@ -691,63 +406,47 @@ class DeckGeneratorV3:
logger.info("Substitution: %d missing / %d owned", len(missing), len(owned))
# Build owned pool
# Build list of owned card names for substitution pool
conn = self.db_conn_factory()
owned_pool_rows = conn.execute(
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
).fetchall()
owned_pool = {r["name"] for r in owned_pool_rows}
deck_names = {c.name.lower() for c in cards}
available_pool = {n for n in owned_pool if n.lower() not in deck_names}
owned_pool = [r["name"] for r in owned_pool_rows]
# a) Embedding pass for missing cards
needs_anthropic_fallback: List[DeckCardWithStatus] = []
# Exclude cards already in the deck
deck_names = set(c.name.lower() for c in cards)
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
# a) Embedding pass
needs_pplx_fallback = []
for card in missing:
embedding_alts = self._embedding_substitution(
card, color_identity, list(available_pool)
card, color_identity, available_pool
)
card.alternatives = embedding_alts
# Check if best match meets threshold
if embedding_alts and embedding_alts[0].similarity_score >= SUBSTITUTION_MIN_SIMILARITY:
card.selected_substitute = embedding_alts[0].name
card.status = "substituted"
card.owned = True
available_pool.discard(card.selected_substitute.lower())
# Remove selected from pool
if card.selected_substitute.lower() in [n.lower() for n in available_pool]:
available_pool = [n for n in available_pool
if n.lower() != card.selected_substitute.lower()]
else:
needs_anthropic_fallback.append(card)
needs_pplx_fallback.append(card)
# b) Anthropic Claude fallback
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
self._anthropic_substitution(
needs_anthropic_fallback, list(available_pool),
# b) Perplexity fallback for low-confidence matches
if needs_pplx_fallback and SUBSTITUTION_USE_PPLX_FALLBACK:
self._pplx_substitution(
needs_pplx_fallback, available_pool,
commander, strategy
)
# c) Any card STILL missing after both passes gets a basic land — never drop it
padded = 0
for card in missing:
if card.status == "missing":
basic = _get_basic_land(color_identity)
logger.warning(
"[substitution] '%s' unresolved after all passes — "
"replacing with basic land '%s'",
card.name, basic,
)
card.name = basic
card.category = "Land"
card.role_tags = []
card.owned = True
card.status = "substituted"
card.original_card = card.name
padded += 1
# Count results
substituted = sum(1 for c in cards if c.status == "substituted")
still_missing = sum(1 for c in cards if c.status == "missing")
# Validate count hasn't changed
assert len(cards) == len(owned) + len(missing), \
f"[substitution] Card count changed: {len(owned) + len(missing)} -> {len(cards)}"
return SubstitutionBatchResult(
total_cards=len(cards),
owned_count=len(owned),
@@ -766,37 +465,39 @@ class DeckGeneratorV3:
if not self.embedding_index or not self.embedding_index.loaded:
return []
try:
matches = self.embedding_index.search_similar(
query_card=card.name,
color_filter=color_identity if color_identity else None,
type_filter=card.category if card.category else None,
exclude_cards=[card.name],
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3,
)
except Exception as e:
logger.warning("Embedding search failed for '%s': %s", card.name, e)
return []
# Use the embeddings search to find similar cards
matches = self.embedding_index.search_similar(
query_card=card.name,
color_filter=color_identity if color_identity else None,
type_filter=card.category if card.category else None,
exclude_cards=[card.name],
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3, # Over-fetch, then filter to owned
)
pool_lower = {n.lower() for n in available_pool}
# Filter to only cards in the available pool
pool_lower = set(n.lower() for n in available_pool)
alternatives = []
for match in matches:
if match.name.lower() in pool_lower:
role_overlap = list(set(card.role_tags) & set(getattr(match, "role_tags", [])))
# Compute role overlap
match_roles = [] # Would need card's role_tags from DB
role_overlap = list(set(card.role_tags) & set(match_roles)) if match_roles else []
alternatives.append(CardAlternative(
name=match.name,
similarity_score=match.similarity,
reason=f"Similar to {card.name} (cosine sim: {match.similarity:.3f})",
source="embedding",
role_overlap=role_overlap,
cmc_delta=getattr(match, "mana_value", 0) - (card.estimated_price_usd or 0),
cmc_delta=match.mana_value - (card.estimated_price_usd or 0),
))
if len(alternatives) >= SUBSTITUTION_MAX_ALTERNATIVES:
break
return alternatives
def _anthropic_substitution(
def _pplx_substitution(
self,
missing_cards: List[DeckCardWithStatus],
available_pool: List[str],
@@ -804,14 +505,13 @@ class DeckGeneratorV3:
strategy: str,
):
"""
Batch Anthropic Claude fallback for cards with low embedding confidence.
Mutates cards in-place. On failure, cards retain status='missing'
so the basic-land fallback in run_substitution() can catch them.
Batch Perplexity fallback for cards with low embedding confidence.
Mutates the cards in-place with substitution data.
"""
if not available_pool:
logger.warning("[anthropic_substitution] No available pool — skipping")
return
# Build the request
missing_dicts = [
{
"name": c.name,
@@ -824,7 +524,7 @@ class DeckGeneratorV3:
user_prompt = build_substitution_prompt(
missing_cards=missing_dicts,
allowed_cards=available_pool[:200],
allowed_cards=available_pool[:200], # Limit to avoid token overflow
commander=commander.get("name", ""),
color_identity=commander.get("color_identity", []),
strategy=strategy,
@@ -832,7 +532,7 @@ class DeckGeneratorV3:
try:
from ..schemas.substitution_schema import SUBSTITUTION_RESPONSE_SCHEMA
response = self.anthropic.chat_structured(
response = self.pplx.chat_structured(
system_prompt=SUBSTITUTION_SYSTEM,
user_prompt=user_prompt,
json_schema=SUBSTITUTION_RESPONSE_SCHEMA,
@@ -843,16 +543,13 @@ class DeckGeneratorV3:
)
if not response.ok:
logger.warning(
"[anthropic_substitution] LLM returned non-JSON — "
"%d cards will fall through to basic land fallback",
len(missing_cards),
)
logger.warning("Perplexity substitution fallback failed — non-JSON response")
return
subs = response.parsed_json.get("substitutions", [])
logger.info("[anthropic_substitution] Received %d substitutions", len(subs))
logger.info("Perplexity suggested %d substitutions", len(subs))
# Apply substitutions
card_by_name = {c.name.lower(): c for c in missing_cards}
for sub in subs:
orig = sub.get("original", "").lower()
@@ -861,38 +558,32 @@ class DeckGeneratorV3:
if orig in card_by_name and substitute:
card = card_by_name[orig]
anthropic_alt = CardAlternative(
# Add as top alternative
pplx_alt = CardAlternative(
name=substitute,
similarity_score=0.80,
similarity_score=0.80, # Nominal score for Perplexity suggestions
reason=reason,
source="anthropic",
source="perplexity",
role_overlap=sub.get("role_overlap", []),
)
card.alternatives.insert(0, anthropic_alt)
# Insert at front
card.alternatives.insert(0, pplx_alt)
card.selected_substitute = substitute
card.status = "substituted"
card.owned = True
card.original_card = card.name
except Exception as e:
logger.error(
"[anthropic_substitution] Exception: %s — "
"%d cards will fall through to basic land fallback",
e, len(missing_cards),
)
# Do NOT re-raise — cards stay status='missing' and
# run_substitution()'s basic-land fallback will handle them
logger.error("Perplexity substitution fallback error: %s", e)
# ── Stats ────────────────────────────────────────────────
@staticmethod
def _compute_stats(cards: List[DeckCardWithStatus]) -> dict:
"""Compute deck statistics from the enriched card list."""
by_category: dict[str, int] = {}
by_status: dict[str, int] = {"owned": 0, "substituted": 0, "missing": 0}
by_category = {}
by_status = {"owned": 0, "substituted": 0, "missing": 0}
total_price = 0.0
owned_price = 0.0
missing_price = 0.0
role_counts: dict[str, int] = {}
role_counts = {}
for card in cards:
cat = card.category or "Other"
+1 -1
View File
@@ -137,7 +137,7 @@ if (-not (Test-Path $envFile)) {
# Uncomment and fill in the values you need:
# XIMILAR_API_KEY=your_ximilar_key_here
# ANTHROPIC_API_KEY=your_anthropic_api_key_here
# PPLX_API_KEY=your_perplexity_key_here
# FORGE_JAR=C:\Path\To\forge-gui-desktop.jar
# FORGE_DIR=C:\Path\To\Forge
# LAB_PORT=8080
+78 -87
View File
@@ -3,13 +3,9 @@
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:
@@ -26,66 +22,35 @@ 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 services.database import init_collection_db
from services.logging import setup_logging
from services.precon_service import PRECON_INDEX, download_precon_database
from routes.shared import (
CFG,
setup_logging,
init_collection_db,
download_precon_database,
load_commander_meta,
PRECON_INDEX,
COMMANDER_META,
)
from routes.collection import router as collection_router
from routes.deckbuilder import router as deckbuilder_router
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
log = logging.getLogger("commander_ai_lab.api")
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
# ── CORS origins (env-configurable) ────────────────────────────
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:8080,http://192.168.0.240:5173,http://192.168.0.240:8080"
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
# ── 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()
if not PRECON_INDEX:
download_precon_database()
if not COMMANDER_META:
load_commander_meta()
loop = asyncio.get_event_loop()
# RAG Phase 1: kick off Scryfall bulk download in background thread.
# run_in_executor returns a Future; we do NOT await it so startup
# completes immediately and the download runs concurrently.
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.
# Runs after Phase 1 completes inside ensure_rag_store() (it checks bulk_count > 0).
# Safe under both `uvicorn lab_api:app` and `python lab_api.py` — the
# _rag_ready flag + _rag_lock in rag_store.py make it idempotent.
from services.rag_store import ensure_rag_store
loop.run_in_executor(None, ensure_rag_store)
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 ────────────────────────────────────────────────────────
app = FastAPI(title="Commander AI Lab API", version="3.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
@@ -100,28 +65,34 @@ 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)
# ── 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"}
# ── 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"
@app.on_event("startup")
async def _on_startup():
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
import routes.shared as _shared
# Ensure precon_dir is resolved even when started via uvicorn directly
if not CFG.precon_dir:
CFG.precon_dir = _resolve_precon_dir()
_shared.init_collection_db()
if not _shared.PRECON_INDEX:
_shared.download_precon_database()
if not _shared.COMMANDER_META:
_shared.load_commander_meta()
# ── Static UI ──────────────────────────────────────────────────
_legacy_ui_dir = Path(__file__).parent / "ui"
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
if _legacy_ui_dir.exists():
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
@@ -129,7 +100,6 @@ 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
@@ -138,8 +108,7 @@ elif _spa_dir.exists():
return FileResponse(str(requested_file))
return FileResponse(str(_spa_dir / "index.html"))
# ── Argument parsing + auto-detection helpers ───────────────────────────────────
# ── Startup ────────────────────────────────────────────────────
def _parse_args():
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
@@ -149,11 +118,10 @@ 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("--anthropic-key", default=os.environ.get("ANTHROPIC_API_KEY", ""))
p.add_argument("--pplx-key", default=os.environ.get("PPLX_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":
@@ -171,35 +139,52 @@ def _resolve_forge_decks_dir() -> str:
return str(candidate)
return ""
def _resolve_forge_dir() -> str:
"""Auto-detect the Forge installation directory."""
"""Auto-detect the Forge installation directory.
The Forge dir is the root of a Forge install — it typically contains
a 'res' subdirectory with card data. We derive it from the JAR
location (parent of 'target/') or look in common install paths.
"""
import sys
# 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:
@@ -207,25 +192,32 @@ 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 [
@@ -239,7 +231,6 @@ 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():
@@ -254,25 +245,36 @@ def _resolve_lab_jar() -> str:
return str(jars[0])
return ""
def _resolve_precon_dir() -> str:
"""Auto-detect the precon-decks directory."""
"""Auto-detect the precon-decks directory.
The precon-decks dir lives in the project root and contains downloaded
.dck files plus precon-index.json. We check relative to this file
(lab_api.py) first, then common sibling paths.
"""
project_root = Path(__file__).parent
# 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)
@@ -284,9 +286,9 @@ def main():
CFG.precon_dir = args.precon_dir or _resolve_precon_dir()
CFG.port = args.port
CFG.ximilar_api_key = args.ximilar_key
CFG.anthropic_api_key = args.anthropic_key
CFG.pplx_api_key = args.pplx_key
from services.forge_runner import get_java17
from routes.shared import get_java17
j17 = get_java17()
log.info("Commander AI Lab — API Server v3.0.0")
@@ -298,7 +300,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" Anthropic: {'configured' if CFG.anthropic_api_key else 'NOT SET'}")
log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET'}")
log.info(f" Java 17: {j17}")
load_commander_meta()
@@ -306,19 +308,8 @@ def main():
init_collection_db()
init_coach_service()
# RAG Phase 1: synchronous boot-time check so logs are visible.
from services.scryfall_bulk import ensure_bulk_db
ensure_bulk_db()
# RAG Phase 2: synchronous boot-time ChromaDB build.
# The lifespan handler also fires this in a thread for uvicorn ASGI mode;
# calling here ensures it runs before the first request when using python lab_api.py.
from services.rag_store import ensure_rag_store
ensure_rag_store()
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
if __name__ == "__main__":
main()
+1 -25
View File
@@ -16,7 +16,6 @@ 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
@@ -286,17 +285,12 @@ 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(),
@@ -305,20 +299,7 @@ def run_generation_tournament(
ckpt_path = Path(checkpoint_dir)
if ckpt_path.exists():
gen_dirs = sorted(ckpt_path.glob("gen-*"))
# Apply top_n cutoff — keep only the latest N generation dirs
if top_n > 0 and len(gen_dirs) > top_n:
skipped = len(gen_dirs) - top_n
logger.info(
"top_n=%d: skipping %d older generation(s), using %s … %s",
top_n, skipped,
gen_dirs[-top_n].name,
gen_dirs[-1].name,
)
gen_dirs = gen_dirs[-top_n:]
for gen_dir in gen_dirs:
for gen_dir in sorted(ckpt_path.glob("gen-*")):
model_path = gen_dir / "best_policy.pt"
if model_path.exists():
name = gen_dir.name # e.g., "gen-001"
@@ -382,10 +363,6 @@ 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()
@@ -396,7 +373,6 @@ def main():
episodes_per_matchup=args.episodes,
playstyle=args.playstyle,
k_factor=args.k_factor,
top_n=args.top_n,
)
# Print results
-40
View File
@@ -1,40 +0,0 @@
"""
Commander AI Lab - Training Subpackage
==========================================
Provides RL training infrastructure including PPO, rollout buffers,
Forge episode generation, self-play, and distillation.
"""
from ml.training.rollout_buffer import RolloutBuffer
from ml.training.reward import compute_intermediate_reward, compute_terminal_reward, RewardConfig
from ml.training.decision_exporter import DecisionExporter
from ml.training.forge_episode_generator import (
ForgeTransition,
ForgeEpisode,
ForgeEpisodeQueue,
forge_producer,
ppo_consumer,
run_forge_pipeline,
parse_forge_jsonl,
snapshots_to_episode,
)
__all__ = [
# Rollout & Buffers
"RolloutBuffer",
# Rewards
"compute_intermediate_reward",
"compute_terminal_reward",
"RewardConfig",
# Decision export
"DecisionExporter",
# Forge episode generation (Phase 3)
"ForgeTransition",
"ForgeEpisode",
"ForgeEpisodeQueue",
"forge_producer",
"ppo_consumer",
"run_forge_pipeline",
"parse_forge_jsonl",
"snapshots_to_episode",
]
-352
View File
@@ -1,352 +0,0 @@
"""
Commander AI Lab — Forge Episode Generator (Phase 3.2)
Async producer-consumer pipeline: runs Forge batch games in parallel
with PPO training. Forge produces episodes → shared queue → PPO
trainer consumes.
Uses the existing watchdog (_global_watchdog_loop in forge_runner.py)
to monitor Forge worker processes for stalls.
"""
import asyncio
import json
import logging
import os
import time
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Deque, Dict, List, Optional, Tuple
import numpy as np
from ml.config.scope import (
ACTION_TO_IDX,
GAME_SCOPE,
IDX_TO_ACTION,
MacroAction,
NUM_ACTIONS,
STATE_DIMS,
)
from ml.training.reward import (
RewardConfig,
compute_intermediate_reward,
compute_terminal_reward,
)
logger = logging.getLogger("ml.forge_episode_generator")
# ══════════════════════════════════════════════════════════
# Episode / Trajectory data structures
# ══════════════════════════════════════════════════════════
@dataclass
class ForgeTransition:
"""Single (s, a, r, log_prob, value, done) tuple from a Forge game."""
state: np.ndarray
action: int
reward: float
log_prob: float
value: float
done: bool
@dataclass
class ForgeEpisode:
"""Complete trajectory from a single Forge game."""
game_id: str
transitions: List[ForgeTransition] = field(default_factory=list)
winner: Optional[int] = None
total_turns: int = 0
deck_names: List[str] = field(default_factory=list)
@property
def length(self) -> int:
return len(self.transitions)
@property
def total_reward(self) -> float:
return sum(t.reward for t in self.transitions)
# ══════════════════════════════════════════════════════════
# JSONL Parser — reads Forge ML decision snapshots
# ══════════════════════════════════════════════════════════
def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
"""Parse a JSONL file of Forge ML decision snapshots.
Each line is a JSON object with keys like:
game_id, turn, phase, active_seat, state, action, outcome
"""
snapshots = []
with open(path, "r") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
snapshots.append(json.loads(line))
except json.JSONDecodeError as e:
logger.warning("Skipping malformed line %d in %s: %s", lineno, path, e)
logger.info("Parsed %d snapshots from %s", len(snapshots), path)
return snapshots
def snapshots_to_episode(
snapshots: List[Dict[str, Any]],
reward_config: Optional[RewardConfig] = None,
) -> ForgeEpisode:
"""Convert a sequence of Forge snapshots into a ForgeEpisode.
Maps Forge action labels → MacroAction indices and computes
rewards from game outcomes using ml.training.reward.
"""
if not snapshots:
return ForgeEpisode(game_id="empty")
first = snapshots[0]
episode = ForgeEpisode(
game_id=first.get("game_id", "unknown"),
deck_names=first.get("deck_names", []),
)
rc = reward_config or RewardConfig()
for i, snap in enumerate(snapshots):
# State vector — zero-padded if not present
state_vec = np.zeros(STATE_DIMS.total_state_dim, dtype=np.float32)
if "state_vector" in snap:
raw = np.array(snap["state_vector"], dtype=np.float32)
state_vec[: min(len(raw), len(state_vec))] = raw[: len(state_vec)]
# Action mapping
action_label = snap.get("action", "pass").lower().replace(" ", "_")
try:
macro = MacroAction(action_label)
action_idx = ACTION_TO_IDX[macro]
except (ValueError, KeyError):
action_idx = ACTION_TO_IDX[MacroAction.PASS]
# Reward
is_done = i == len(snapshots) - 1
if is_done and "outcome" in snap:
reward = compute_terminal_reward(
won=snap["outcome"].get("won", False),
turns=snap.get("turn", 1),
life_remaining=snap["outcome"].get("life_remaining", 0),
config=rc,
)
else:
reward = compute_intermediate_reward(
life_delta=snap.get("life_delta", 0),
cards_drawn=snap.get("cards_drawn", 0),
creatures_played=snap.get("creatures_played", 0),
config=rc,
)
episode.transitions.append(ForgeTransition(
state=state_vec,
action=action_idx,
reward=reward,
log_prob=snap.get("log_prob", 0.0),
value=snap.get("value", 0.0),
done=is_done,
))
episode.total_turns = snapshots[-1].get("turn", len(snapshots))
if "outcome" in snapshots[-1]:
episode.winner = snapshots[-1]["outcome"].get("winner_seat")
return episode
# ══════════════════════════════════════════════════════════
# Async Episode Queue (producer-consumer)
# ══════════════════════════════════════════════════════════
class ForgeEpisodeQueue:
"""Thread-safe async queue bridging Forge producers and PPO consumer.
Forge batch workers produce ForgeEpisodes; the PPO trainer consumes
them as they become available. Backpressure is applied when the
queue reaches max_size.
"""
def __init__(self, max_size: int = 256):
self._queue: asyncio.Queue[ForgeEpisode] = asyncio.Queue(maxsize=max_size)
self._produced = 0
self._consumed = 0
self._closed = False
async def put(self, episode: ForgeEpisode) -> None:
await self._queue.put(episode)
self._produced += 1
async def get(self, timeout: float = 30.0) -> Optional[ForgeEpisode]:
try:
ep = await asyncio.wait_for(self._queue.get(), timeout=timeout)
self._consumed += 1
return ep
except asyncio.TimeoutError:
return None
def close(self) -> None:
self._closed = True
@property
def is_closed(self) -> bool:
return self._closed and self._queue.empty()
@property
def stats(self) -> Dict[str, int]:
return {
"produced": self._produced,
"consumed": self._consumed,
"pending": self._queue.qsize(),
}
# ══════════════════════════════════════════════════════════
# Forge Batch Producer
# ══════════════════════════════════════════════════════════
async def forge_producer(
queue: ForgeEpisodeQueue,
results_dir: str = "results",
batch_id: str = "forge-batch",
num_games: int = 64,
poll_interval: float = 2.0,
reward_config: Optional[RewardConfig] = None,
) -> int:
"""Producer coroutine: watches for Forge JSONL output files,
parses them into episodes, and pushes them onto the queue.
Returns total number of episodes produced.
"""
results_path = Path(results_dir)
pattern = f"ml-decisions-forge-{batch_id}*.jsonl"
seen_files: set = set()
total_produced = 0
logger.info("[PRODUCER] Watching %s for pattern %s", results_path, pattern)
while total_produced < num_games and not queue.is_closed:
for jsonl_file in sorted(results_path.glob(pattern)):
if jsonl_file in seen_files:
continue
seen_files.add(jsonl_file)
try:
snapshots = parse_forge_jsonl(jsonl_file)
if not snapshots:
continue
# Group by game_id
games: Dict[str, List] = {}
for snap in snapshots:
gid = snap.get("game_id", "unknown")
games.setdefault(gid, []).append(snap)
for gid, game_snaps in games.items():
episode = snapshots_to_episode(game_snaps, reward_config)
if episode.length > 0:
await queue.put(episode)
total_produced += 1
logger.debug("[PRODUCER] Queued episode %s (%d transitions)",
gid, episode.length)
except Exception as e:
logger.error("[PRODUCER] Error processing %s: %s", jsonl_file, e)
await asyncio.sleep(poll_interval)
logger.info("[PRODUCER] Done — produced %d episodes", total_produced)
return total_produced
async def ppo_consumer(
queue: ForgeEpisodeQueue,
buffer_size: int = 64,
on_batch_ready=None,
) -> int:
"""Consumer coroutine: pulls episodes from the queue and batches
them for PPO training updates.
Args:
queue: ForgeEpisodeQueue to consume from
buffer_size: number of episodes per training batch
on_batch_ready: callback(episodes) when a batch is full
Returns total episodes consumed.
"""
batch: List[ForgeEpisode] = []
total_consumed = 0
while not queue.is_closed:
episode = await queue.get(timeout=10.0)
if episode is None:
if queue.is_closed:
break
continue
batch.append(episode)
total_consumed += 1
if len(batch) >= buffer_size:
if on_batch_ready:
on_batch_ready(batch)
logger.info("[CONSUMER] Batch of %d episodes ready for PPO update", len(batch))
batch = []
# Flush remaining
if batch and on_batch_ready:
on_batch_ready(batch)
logger.info("[CONSUMER] Final batch of %d episodes flushed", len(batch))
logger.info("[CONSUMER] Done — consumed %d episodes", total_consumed)
return total_consumed
# ══════════════════════════════════════════════════════════
# Convenience runner
# ══════════════════════════════════════════════════════════
async def run_forge_pipeline(
results_dir: str = "results",
batch_id: str = "forge-batch",
num_games: int = 64,
buffer_size: int = 64,
max_queue: int = 256,
on_batch_ready=None,
reward_config: Optional[RewardConfig] = None,
) -> Dict[str, int]:
"""Run the full Forge producer → PPO consumer pipeline.
The Forge Java batch must be started separately (via forge_runner.py
or MultiThreadBatchRunner). This function handles the Python side:
reading JSONL output and feeding episodes into the PPO trainer.
"""
queue = ForgeEpisodeQueue(max_size=max_queue)
producer_task = asyncio.create_task(
forge_producer(queue, results_dir, batch_id, num_games,
reward_config=reward_config)
)
consumer_task = asyncio.create_task(
ppo_consumer(queue, buffer_size, on_batch_ready)
)
produced = await producer_task
queue.close()
consumed = await consumer_task
stats = {
"produced": produced,
"consumed": consumed,
**queue.stats,
}
logger.info("[PIPELINE] Complete: %s", stats)
return stats
-13
View File
@@ -17,15 +17,9 @@ that feed directly into the RolloutBuffer for PPO training.
For real game integration (Phase 9+), the Java sim would call the
policy server and feed back actual game states — this synthetic
version allows training to begin immediately.
.. deprecated:: Phase 3 (Issue #83)
Synthetic training mode is DEPRECATED for production training.
Use forge_episode_generator.py with real Forge engine games.
Retained ONLY for quick local testing and unit tests.
"""
import logging
import warnings
import random
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
@@ -48,12 +42,6 @@ from ml.training.decision_exporter import DecisionExporter
logger = logging.getLogger("ml.self_play")
_TRAINING_DEPRECATION_MSG = (
"self_play.py synthetic training is deprecated for production training (Issue #83). "
"Use forge_episode_generator.py with real Forge engine games instead. "
"Synthetic self-play is retained for local testing and unit tests only."
)
@dataclass
class PlayerState:
@@ -566,7 +554,6 @@ def collect_rollouts(
total_wins = 0
total_losses = 0
total_draws = 0
warnings.warn(_TRAINING_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
total_steps = 0
total_rewards = 0.0
+4 -75
View File
@@ -25,7 +25,6 @@ import json
import logging
import logging.handlers
import os
import subprocess
import sys
import time
import urllib.request
@@ -43,9 +42,6 @@ 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
@@ -233,52 +229,6 @@ 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", {
@@ -414,7 +364,6 @@ 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("")
@@ -491,17 +440,6 @@ 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:
@@ -544,17 +482,17 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Run DeepSeek sims for 11.5 hours, then update weights + train:
# Run DeepSeek sims for 11.5 hours, then train:
python overnight-run.py
# Run 20 batches of 50 games each, then update weights + train:
# Run 20 batches of 50 games each, then 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, skip both weight update and training:
python overnight-run.py --no-train --no-update-weights
# Just run sims, no training:
python overnight-run.py --no-train
# Custom training settings:
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
@@ -576,12 +514,6 @@ 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,
@@ -604,9 +536,6 @@ 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
-22
View File
@@ -25,28 +25,6 @@ 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"]
-12
View File
@@ -7,21 +7,9 @@ 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
beautifulsoup4>=4.12.0
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
+1 -1
View File
@@ -2,5 +2,5 @@
routes package — API routers for Commander AI Lab.
Each module defines a FastAPI APIRouter with real endpoint implementations.
Shared helpers live in services/, models live in models/.
All shared helpers, models, and state live in routes.shared.
"""
+563 -456
View File
File diff suppressed because it is too large. Load diff
+24 -134
View File
@@ -25,123 +25,35 @@ from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from models.state import CFG
from services.card_analysis import _detect_card_roles
from services.database import (
_get_db_conn, _row_to_dict, _add_image_url, _build_collection_filters,
_snake_to_camel, VALID_SORT_FIELDS, _JSON_FIELDS,
)
from services.deck_service import _classify_card_type
from services.import_service import (
_parse_finish, _parse_text_line, _parse_csv_content, _auto_infer_mapping,
)
from services.logging import log_collect, log_cache
from services.scryfall import (
SCRYFALL_CACHE_DB_PATH, SCRYFALL_CACHE_TTL_SECONDS, _API_HEADERS,
_scryfall_cache, _scryfall_rate_limit,
_enrich_from_scryfall, _fetch_scryfall_api,
from routes.shared import (
CFG,
_get_db_conn,
_row_to_dict,
_add_image_url,
_build_collection_filters,
_detect_card_roles,
_classify_card_type,
_enrich_from_scryfall,
_fetch_scryfall_api,
_parse_finish,
_parse_text_line,
_parse_csv_content,
_auto_infer_mapping,
_scryfall_cache,
_scryfall_rate_limit,
_snake_to_camel,
VALID_SORT_FIELDS,
_JSON_FIELDS,
SCRYFALL_CACHE_DB_PATH,
SCRYFALL_CACHE_TTL_SECONDS,
_API_HEADERS,
log_collect,
log_cache,
)
router = APIRouter(tags=["collection"])
@router.get("/api/collection/stats")
async def collection_stats():
"""Aggregate collection statistics for the dashboard."""
conn = _get_db_conn()
stats = {}
# Total cards and unique cards
row = conn.execute(
"SELECT COUNT(*) as total_rows, COALESCE(SUM(quantity),0) as total_cards, "
"COALESCE(SUM(tcg_price * quantity),0) as total_value "
"FROM collection_entries"
).fetchone()
stats["totalUniqueCards"] = row["total_rows"]
stats["totalCards"] = row["total_cards"]
stats["totalValue"] = round(row["total_value"], 2)
# CMC distribution (0-7+)
cmc_rows = conn.execute(
"SELECT CASE WHEN cmc >= 7 THEN 7 ELSE CAST(cmc AS INTEGER) END as cmc_bucket, "
"SUM(quantity) as count FROM collection_entries GROUP BY cmc_bucket ORDER BY cmc_bucket"
).fetchall()
stats["cmcCurve"] = {str(r["cmc_bucket"]): r["count"] for r in cmc_rows}
# Color identity distribution
color_rows = conn.execute(
"SELECT color_identity, SUM(quantity) as count FROM collection_entries GROUP BY color_identity"
).fetchall()
color_counts = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
for r in color_rows:
ci = r["color_identity"] or "[]"
try:
colors = json.loads(ci) if isinstance(ci, str) else ci
except (json.JSONDecodeError, TypeError):
colors = []
qty = r["count"] or 0
if not colors:
color_counts["C"] += qty
else:
for c in colors:
if c in color_counts:
color_counts[c] += qty
stats["colorDistribution"] = color_counts
# Type distribution
type_rows = conn.execute(
"SELECT type_line, SUM(quantity) as count FROM collection_entries GROUP BY type_line"
).fetchall()
type_counts = {}
for r in type_rows:
tl = r["type_line"] or ""
qty = r["count"] or 0
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
if t in tl:
type_counts[t] = type_counts.get(t, 0) + qty
stats["typeDistribution"] = type_counts
# Category/role distribution
cat_rows = conn.execute(
"SELECT category, SUM(quantity) as count FROM collection_entries "
"WHERE category != '' AND category != '[]' GROUP BY category"
).fetchall()
cat_counts = {}
for r in cat_rows:
raw = r["category"] or "[]"
qty = r["count"] or 0
try:
cats = json.loads(raw) if isinstance(raw, str) else raw
except (json.JSONDecodeError, TypeError):
cats = []
if isinstance(cats, list):
for c in cats:
cat_counts[c] = cat_counts.get(c, 0) + qty
stats["categoryDistribution"] = cat_counts
# Rarity distribution
rarity_rows = conn.execute(
"SELECT LOWER(rarity) as rarity, SUM(quantity) as count FROM collection_entries "
"WHERE rarity != '' GROUP BY LOWER(rarity)"
).fetchall()
stats["rarityDistribution"] = {r["rarity"]: r["count"] for r in rarity_rows}
# Top 10 most valuable cards
top_value = conn.execute(
"SELECT name, tcg_price, quantity FROM collection_entries "
"WHERE tcg_price > 0 ORDER BY tcg_price DESC LIMIT 10"
).fetchall()
stats["topValueCards"] = [{"name": r["name"], "price": r["tcg_price"], "qty": r["quantity"]} for r in top_value]
# Average CMC (excluding lands)
avg_row = conn.execute(
"SELECT AVG(cmc) as avg_cmc FROM collection_entries WHERE type_line NOT LIKE '%Land%'"
).fetchone()
stats["avgCmc"] = round(avg_row["avg_cmc"] or 0, 2)
return stats
@router.get("/api/collection/export")
async def export_collection(
format: str = "INTERNAL_CSV",
@@ -158,17 +70,6 @@ async def export_collection(
priceMin: Optional[float] = None,
priceMax: Optional[float] = None,
category: Optional[str] = None,
rarity: Optional[str] = None,
setCode: Optional[str] = None,
powerMin: Optional[str] = None,
powerMax: Optional[str] = None,
toughMin: Optional[str] = None,
toughMax: Optional[str] = None,
keyword: Optional[str] = None,
edhrecMin: Optional[int] = None,
edhrecMax: Optional[int] = None,
qtyMin: Optional[int] = None,
qtyMax: Optional[int] = None,
):
"""Export collection in various formats with optional filters."""
where_str, params = _build_collection_filters(
@@ -176,12 +77,6 @@ async def export_collection(
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
priceMin=priceMin, priceMax=priceMax, category=category,
rarity=rarity, setCode=setCode,
powerMin=powerMin, powerMax=powerMax,
toughMin=toughMin, toughMax=toughMax,
keyword=keyword,
edhrecMin=edhrecMin, edhrecMax=edhrecMax,
qtyMin=qtyMin, qtyMax=qtyMax,
)
sql = f"SELECT * FROM collection_entries {where_str} ORDER BY name ASC"
@@ -406,11 +301,6 @@ 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))
+78 -38
View File
@@ -36,25 +36,35 @@ from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
from fastapi.responses import JSONResponse
from models.state import CFG
from models.requests import (
CreateDeckRequest, UpdateDeckRequest, AddDeckCardRequest,
PatchDeckCardRequest, BulkAddRequest, BulkAddRecommendedRequest,
)
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, _classify_card_category, _to_edhrec_slug, _TYPE_TARGETS,
from routes.shared import (
CFG,
_get_db_conn,
_row_to_dict,
_add_image_url,
_get_deck_or_404,
_compute_deck_analysis,
_check_ratio_limit,
_classify_card_type,
_detect_card_roles,
_enrich_from_scryfall,
_fetch_scryfall_api,
_scryfall_rate_limit,
_to_edhrec_slug,
_edhrec_cache_get,
_edhrec_cache_set,
_fetch_edhrec_average,
_save_profile_to_dck,
)
from services.import_helpers import (
_fetch_edhrec_average, _parse_text_decklist,
_edhrec_cache_get, _edhrec_cache_set,
)
from services.logging import log_collect, log_deckgen
from services.scryfall import (
_API_HEADERS, _enrich_from_scryfall, _fetch_scryfall_api, _scryfall_rate_limit,
_parse_text_decklist,
_API_HEADERS,
_TYPE_TARGETS,
CreateDeckRequest,
UpdateDeckRequest,
AddDeckCardRequest,
PatchDeckCardRequest,
BulkAddRequest,
BulkAddRecommendedRequest,
log_collect,
log_deckgen,
)
router = APIRouter(tags=["deckbuilder"])
@@ -121,6 +131,7 @@ 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
@@ -129,7 +140,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)
_get_deck_or_404(deck_id) # ensure exists
conn = _get_db_conn()
updates = {}
@@ -157,7 +168,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)
_get_deck_or_404(deck_id) # ensure exists
conn = _get_db_conn()
conn.execute("DELETE FROM deck_cards WHERE deck_id = ?", (deck_id,))
conn.execute("DELETE FROM decks WHERE id = ?", (deck_id,))
@@ -180,7 +191,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 and card_records data."""
"""Get all cards in a deck with joined collection data."""
_get_deck_or_404(deck_id)
conn = _get_db_conn()
rows = conn.execute(
@@ -188,12 +199,7 @@ 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,
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.type_line, ce.cmc, ce.mana_cost, ce.color_identity, ce.oracle_text, ce.keywords,
ce.tcg_price, ce.quantity AS owned_qty, ce.is_legendary,
ce.salt_score, ce.is_game_changer
FROM deck_cards dc
@@ -204,11 +210,6 @@ 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
""",
@@ -217,6 +218,7 @@ 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:
@@ -227,8 +229,6 @@ 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)}
@@ -239,12 +239,14 @@ 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)
@@ -274,6 +276,7 @@ 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)
@@ -348,23 +351,28 @@ 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.get("deltas", {})
deltas = analysis["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()
@@ -374,9 +382,11 @@ 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,17 +403,22 @@ 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
@@ -424,6 +439,7 @@ 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"]
@@ -450,6 +466,7 @@ 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:
@@ -464,11 +481,13 @@ 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()
@@ -479,6 +498,7 @@ 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()
@@ -488,6 +508,7 @@ 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
@@ -496,6 +517,7 @@ 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", "")
@@ -508,7 +530,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,
"inclusion_pct": None, # EDHREC average doesn't expose % directly in this path
"synergy_score": None,
"owned": owned_info["owned"],
"owned_qty": owned_info["qty"],
@@ -557,6 +579,7 @@ 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,)
@@ -576,6 +599,7 @@ 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)
@@ -617,6 +641,7 @@ 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)
@@ -676,7 +701,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
continue # can't add without scryfall_id
scryfall_id = coll_info.get("scryfall_id", "")
if not scryfall_id:
continue
@@ -691,6 +716,7 @@ 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 = []
@@ -754,6 +780,7 @@ 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}")
@@ -778,6 +805,7 @@ 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}"
@@ -817,13 +845,14 @@ def _parse_decklist_text(text: str) -> dict:
"""
commanders = []
cards = []
section = 'main'
section = 'main' # default section
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'
@@ -835,12 +864,14 @@ 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))
@@ -849,6 +880,7 @@ 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:
@@ -890,6 +922,7 @@ 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()
@@ -903,6 +936,7 @@ 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)
@@ -912,6 +946,7 @@ 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)
@@ -931,9 +966,11 @@ 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,)
@@ -980,6 +1017,7 @@ 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'
@@ -992,6 +1030,7 @@ 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
@@ -1022,6 +1061,7 @@ 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 = ?',
+93 -198
View File
@@ -1,14 +1,14 @@
"""
routes/deckgen.py
=================
Deck generation endpoints (classic, Anthropic Claude V1, V3):
Deck generation endpoints (classic, Perplexity 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/anthropic/status
GET /api/pplx/status
GET /api/deck/v3/status
POST /api/deck/v3/generate
POST /api/deck/v3/commit
@@ -36,57 +36,18 @@ from fastapi.responses import JSONResponse, StreamingResponse
from models.requests import DeckResearchRequest, DeckGenerateAIRequest
import routes.coach as _coach # for _deck_gen_v3 / _deck_gen_v3_error module-level access
from models.state import CFG
from models.requests import (
from routes.shared import (
CFG,
log_deckgen, log_pplx,
_get_db_conn,
_scryfall_rate_limit, _API_HEADERS,
DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest,
DeckGenerationSourceConfig,
GeneratedDeckCard, DeckGenerationSourceConfig,
)
from models.responses import GeneratedDeckCard
from services.database import _get_db_conn
from services.logging import log_deckgen, log_pplx
from services.scryfall import _scryfall_rate_limit, _API_HEADERS
from services.deck_service import _write_dck_file, _build_dck_lines
router = APIRouter(tags=["deckgen"])
# ── V3 Result Cache (avoids re-generating on export) ─────────────
import hashlib
_v3_result_cache: dict[str, tuple[float, dict]] = {} # key -> (timestamp, result)
_V3_CACHE_TTL = 600 # 10 minutes
def _v3_cache_key(req) -> str:
"""Deterministic cache key from a DeckGenV3Request."""
blob = json.dumps({
'commander': req.commander_name.strip().lower(),
'strategy': req.strategy or '',
'bracket': req.target_bracket,
'budget': req.budget_usd,
'budget_mode': req.budget_mode,
'omit': sorted(req.omit_cards or []),
'collection': req.use_collection,
'model': req.model or '',
}, sort_keys=True)
return hashlib.sha256(blob.encode()).hexdigest()[:16]
def _v3_cache_get(key: str) -> dict | None:
"""Return cached result if within TTL, else None."""
entry = _v3_result_cache.get(key)
if entry and (time.time() - entry[0]) < _V3_CACHE_TTL:
return entry[1]
if entry:
del _v3_result_cache[key]
return None
def _v3_cache_put(key: str, result: dict):
"""Store result in cache with current timestamp."""
# Evict stale entries if cache grows large
if len(_v3_result_cache) > 50:
cutoff = time.time() - _V3_CACHE_TTL
stale = [k for k, (ts, _) in _v3_result_cache.items() if ts < cutoff]
for k in stale:
del _v3_result_cache[k]
_v3_result_cache[key] = (time.time(), result)
# ══════════════════════════════════════════════════════════════
# Auto Deck Generator
@@ -103,7 +64,7 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
# Try scryfall_id first
if req.commander_scryfall_id:
row = conn.execute(
"SELECT name, type_line, color_identity, mana_cost, scryfall_id, oracle_text FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
"SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
(req.commander_scryfall_id,)
).fetchone()
if row:
@@ -119,7 +80,6 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
"color_identity": ci,
"type_line": row["type_line"],
"mana_cost": row["mana_cost"] or "",
"oracle_text": row["oracle_text"] or "",
"image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal",
}
# Fallback to Scryfall API
@@ -137,7 +97,7 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
if req.commander_name:
name_lower = req.commander_name.strip().lower()
row = conn.execute(
"SELECT name, type_line, color_identity, mana_cost, scryfall_id, oracle_text FROM collection_entries WHERE LOWER(name) = ? LIMIT 1",
"SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE LOWER(name) = ? LIMIT 1",
(name_lower,)
).fetchone()
if row:
@@ -153,7 +113,6 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
"color_identity": ci,
"type_line": row["type_line"],
"mana_cost": row["mana_cost"] or "",
"oracle_text": row["oracle_text"] or "",
"image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal",
}
@@ -185,7 +144,6 @@ def _scryfall_to_commander(data: dict) -> dict:
"color_identity": ci,
"type_line": data.get("type_line", ""),
"mana_cost": data.get("mana_cost", ""),
"oracle_text": data.get("oracle_text", ""),
"image_url": image_uris.get("normal", ""),
}
@@ -236,31 +194,6 @@ def _get_collection_for_colors(color_identity: list) -> list:
return valid
# ── Oracle-text keyword extraction for scoring ────────────────────
_ORACLE_STOP_WORDS = frozenset({
'a', 'an', 'the', 'of', 'to', 'and', 'or', 'is', 'it', 'its',
'in', 'on', 'at', 'for', 'by', 'with', 'from', 'as', 'if',
'that', 'this', 'you', 'your', 'up', 'all', 'each', 'may',
'can', 'one', 'two', 'three', 'four', 'five', 'card', 'cards',
'target', 'player', 'creature', 'creatures', 'spell', 'spells',
'permanent', 'permanents', 'turn', 'end', 'step', 'phase',
'mana', 'color', 'pay', 'cost', 'tap', 'untap', 'put', 'get',
'has', 'have', 'are', 'be', 'been', 'was', 'were', 'do', 'does',
'any', 'no', 'not', 'only', 'also', 'then', 'when', 'whenever',
'where', 'which', 'who', 'them', 'they', 'their', 'other',
'more', 'less', 'than', 'into', 'under', 'over', 'until',
'would', 'could', 'instead', 'about', 'number', 'equal',
})
def _extract_oracle_keywords(oracle_text: str) -> set[str]:
"""Extract meaningful keywords from oracle text, filtering stop words."""
if not oracle_text:
return set()
cleaned = re.sub(r'\([^)]*\)', '', oracle_text)
tokens = re.findall(r'[a-z]{3,}', cleaned.lower())
return {t for t in tokens if t not in _ORACLE_STOP_WORDS}
def _generate_deck(req: DeckGenerationRequest) -> dict:
"""
Core deck generation algorithm.
@@ -279,8 +212,6 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
color_identity = req.color_identity or commander.get("color_identity", [])
log_deckgen.info(f" Commander: {commander['name']}, CI: {color_identity}")
commander_keywords = _extract_oracle_keywords(commander.get("oracle_text", ""))
log_deckgen.info(f" Commander keywords ({len(commander_keywords)}): {sorted(commander_keywords)[:15]}")
# 2. Load collection
collection = _get_collection_for_colors(color_identity)
@@ -347,7 +278,6 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
)
candidate_map[key] = {
"scryfall_id": card.get("scryfall_id", ""),
"oracle_text": card.get("oracle_text", ""),
"name": name,
"type_line": type_line,
"mana_cost": card.get("mana_cost", ""),
@@ -440,17 +370,6 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
score += 5
elif cmc <= 4:
score += 3
# Oracle-text keyword overlap with commander
card_oracle = card.get("oracle_text") or ""
if commander_keywords and card_oracle:
card_kw = _extract_oracle_keywords(card_oracle)
overlap = len(commander_keywords & card_kw)
if overlap >= 4:
score += 12
elif overlap >= 2:
score += 7
elif overlap >= 1:
score += 3
card["_score"] = score
cards.sort(key=lambda x: x["_score"], reverse=True)
@@ -724,7 +643,7 @@ async def deck_generator_commander_search(q: str = ""):
# ══════════════════════════════════════════════════════════════
# Anthropic Claude API — AI Deck Research & Generation
# Perplexity API — AI Deck Research & Generation
# ══════════════════════════════════════════════════════════════
@@ -847,62 +766,54 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
}
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).
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).
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, ANTHROPIC_MODEL
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_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.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 = {
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 = 'sonar'
headers = {
'Content-Type': 'application/json',
'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
'Authorization': f'Bearer {CFG.pplx_api_key}',
}
payload = {
'model': model,
'messages': user_msgs,
'messages': messages,
'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}/v1/messages',
f'{base_url}/chat/completions',
json=payload,
headers=headers,
)
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as e:
raise HTTPException(502, f'Anthropic Claude API returned {e.response.status_code}: {e.response.text[:200]}')
raise HTTPException(502, f'Perplexity API returned {e.response.status_code}: {e.response.text[:200]}')
except httpx.RequestError as e:
raise HTTPException(502, f'Anthropic Claude API call failed: {e}')
raise HTTPException(502, f'Perplexity API call failed: {e}')
content_blocks = data.get('content', [])
if not content_blocks:
raise HTTPException(502, 'Empty response from Anthropic Claude API')
content = content_blocks[0].get('text', '')
choices = data.get('choices', [])
if not choices:
raise HTTPException(502, 'Empty response from Perplexity API')
content = choices[0].get('message', {}).get('content', '')
usage = data.get('usage', {})
log_pplx.debug(
f'tokens: prompt={usage.get("input_tokens", "?")}, '
f'completion={usage.get("output_tokens", "?")}, '
f'tokens: prompt={usage.get("prompt_tokens", "?")}, '
f'completion={usage.get("completion_tokens", "?")}, '
f'model={data.get("model", "?")}'
)
return content
@@ -1036,11 +947,11 @@ def _fill_basic_lands(cards: list[dict], color_identity: list[str] | None = None
return non_basics
# ── Research Endpoint ───────────────────────────────────────────────────
# ── Research Endpoint ─────────────────────────────────────────
@router.post('/api/deck-research')
async def deck_research(req: DeckResearchRequest):
"""Analyze an existing deck using Anthropic Claude and suggest improvements."""
"""Analyze an existing deck using Perplexity AI and suggest improvements."""
conn = _get_db_conn()
# Load deck info
@@ -1165,7 +1076,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_anthropic_api([
content = await _call_pplx_api([
{'role': 'system', 'content': system_msg},
{'role': 'user', 'content': user_msg},
], max_tokens=8192)
@@ -1191,11 +1102,11 @@ Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4
return analysis
# ── Generate Endpoint ───────────────────────────────────────────────
# ── Generate Endpoint ─────────────────────────────────────────
@router.post('/api/deck-generate')
async def deck_generate_ai(req: DeckGenerateAIRequest):
"""Generate a full 100-card Commander deck using Anthropic Claude."""
"""Generate a full 100-card Commander deck using Perplexity AI."""
commander = req.commander.strip()
if not commander:
raise HTTPException(400, 'Commander name is required')
@@ -1275,7 +1186,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_anthropic_api([
content = await _call_pplx_api([
{'role': 'system', 'content': system_msg},
{'role': 'user', 'content': user_msg},
], max_tokens=8192, temperature=0.3)
@@ -1302,16 +1213,16 @@ Build the deck as JSON. Prioritize collection cards when they fit the strategy."
return result
@router.get('/api/anthropic/status')
async def anthropic_status():
"""Check if Anthropic Claude API is configured."""
@router.get('/api/pplx/status')
async def pplx_status():
"""Check if Perplexity API is configured."""
return {
'configured': bool(CFG.anthropic_api_key),
'configured': bool(CFG.pplx_api_key),
}
# ══════════════════════════════════════════════════════════════
# V3 Deck Generator (Anthropic Claude Structured Output)
# V3 Deck Generator (Perplexity Structured Output)
# ══════════════════════════════════════════════════════════════
@router.get('/api/deck/v3/status')
@@ -1319,9 +1230,9 @@ async def deck_gen_v3_status():
"""Check V3 deck generator status."""
return {
'initialized': _coach._deck_gen_v3 is not None,
'anthropic_configured': bool(CFG.anthropic_api_key),
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'anthropic_client', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
'embeddings_loaded': False,
'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,
'embeddings_loaded': False,
'embedding_cards': 0,
'error': _coach._deck_gen_v3_error,
}
@@ -1330,14 +1241,14 @@ async def deck_gen_v3_status():
@router.post('/api/deck/v3/generate')
async def deck_gen_v3_generate(req: DeckGenV3Request):
"""
V3 Deck Generation — Anthropic Claude Structured Output with Smart Substitution.
V3 Deck Generation — Perplexity structured output with Smart Substitution.
Pipeline:
1. Resolve commander via Scryfall
2. Build collection summary from DB
3. Call Anthropic Claude with JSON schema enforcement
3. Call Perplexity Sonar with JSON schema enforcement
4. Cross-reference cards with collection for ownership
5. Run Smart Substitution (embedding + Anthropic fallback)
5. Run Smart Substitution (embedding + Perplexity fallback)
6. Return complete deck with substitution data
"""
if _coach._deck_gen_v3 is None:
@@ -1378,9 +1289,6 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
from coach.services.deck_generator import DeckGeneratorV3
result['stats'] = DeckGeneratorV3._compute_stats(sub_result.cards)
# Cache result for export endpoints
_v3_cache_put(_v3_cache_key(req), result)
return result
except ValueError as e:
@@ -1498,20 +1406,17 @@ async def deck_gen_v3_export_csv(req: DeckGenV3Request):
if _coach._deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
_ck = _v3_cache_key(req)
result = _v3_cache_get(_ck)
if result is None:
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
_v3_cache_put(_ck, result)
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
cards = result.get('cards', [])
commander = result.get('commander', {})
@@ -1548,20 +1453,16 @@ async def deck_gen_v3_export_dck(req: DeckGenV3Request):
if _coach._deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
_ck = _v3_cache_key(req)
result = _v3_cache_get(_ck)
if result is None:
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
_v3_cache_put(_ck, result)
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
cards = result.get('cards', [])
commander = result.get('commander', {})
@@ -1587,20 +1488,16 @@ async def deck_gen_v3_export_moxfield(req: DeckGenV3Request):
if _coach._deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
_ck = _v3_cache_key(req)
result = _v3_cache_get(_ck)
if result is None:
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
_v3_cache_put(_ck, result)
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
cards = result.get('cards', [])
commander = result.get('commander', {})
@@ -1628,26 +1525,22 @@ async def deck_gen_v3_export_shopping(req: DeckGenV3Request):
if _coach._deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized.')
_ck = _v3_cache_key(req)
result = _v3_cache_get(_ck)
if result is None:
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
_v3_cache_put(_ck, result)
result = _coach._deck_gen_v3.generate_deck(
commander_name=req.commander_name.strip(),
strategy=req.strategy,
target_bracket=req.target_bracket,
budget_usd=req.budget_usd,
budget_mode=req.budget_mode,
omit_cards=req.omit_cards,
use_collection=req.use_collection,
model=req.model,
)
cards = result.get('cards', [])
shopping = []
total = 0.0
for card in cards:
if not card.get('from_collection', False) and card.get('status') != 'substituted':
if not card.get('owned', True) and card.get('status') != 'substituted':
price = card.get('estimated_price_usd', 0)
shopping.append({
'name': card.get('name', ''),
@@ -1664,3 +1557,5 @@ async def deck_gen_v3_export_shopping(req: DeckGenV3Request):
'total_missing': len(shopping),
'estimated_cost_usd': round(total, 2),
}
+69 -40
View File
@@ -28,18 +28,22 @@ from fastapi import APIRouter, HTTPException
from fastapi import Request as FastAPIRequest
from fastapi.responses import JSONResponse
from models.state import CFG
from services.database import _get_db_conn
from services.forge_runner import _get_deepseek_brain
from services.logging import log_sim, _ml_logging_enabled
from routes.shared import (
CFG, log_sim,
_get_db_conn,
_get_deepseek_brain,
_ml_logging_enabled,
)
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
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()
@@ -52,18 +56,22 @@ def _get_validator_brain() -> ValidatorBrain | None:
if _validator_brain is None:
cfg = ValidatorConfig(
api_base=os.environ.get("VALIDATOR_API_BASE", "http://localhost:11434"),
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:8b"),
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:14b"),
max_tokens=int(os.environ.get("VALIDATOR_MAX_TOKENS", "2048")),
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "300.0")),
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "120.0")),
)
_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')
@@ -120,6 +128,7 @@ 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}'
@@ -127,6 +136,7 @@ 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'):
@@ -170,6 +180,7 @@ 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,
@@ -199,6 +210,7 @@ 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 batch-*.json for coach / training dashboard ---
try:
deck_name = summary.get('deckName', 'Unknown')
opponent_name = summary.get('opponentName', 'Training Deck')
@@ -225,11 +237,12 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
}
results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
os.makedirs(results_dir, exist_ok=True)
batch_path = os.path.join(results_dir, f'ml-decision-sim-{sim_id}.json')
batch_path = os.path.join(results_dir, f'batch-sim-{sim_id}.json')
with open(batch_path, 'w') as f:
json.dump(batch_data, f, indent=2, default=str)
logger.info(f'Saved batch results to {batch_path}')
# 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')
@@ -243,6 +256,7 @@ 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 ---
if engine is not None and hasattr(engine, 'flush_ml_decisions'):
ml_decisions = engine.flush_ml_decisions()
if ml_decisions:
@@ -252,16 +266,20 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
for dec in ml_decisions:
mf.write(json.dumps(dec) + '\n')
logger.info(f'Wrote {len(ml_decisions)} ML decisions to {ml_path}')
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
@@ -288,6 +306,7 @@ 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
@@ -315,6 +334,7 @@ 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
@@ -330,7 +350,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()
validator = _get_validator_brain() # None if disabled
engine = DeepSeekGameEngine(
brain=brain, ai_player_index=1,
max_turns=25, record_log=record_logs, ml_log=True,
@@ -342,37 +362,34 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
game_id_prefix=f'ds-sim-{sim_id[:8]}',
archetype='midrange')
# Write ML decision JSONL for training pipeline
ml_decisions = engine.flush_ml_decisions()
if ml_decisions and brain and brain._connected:
if ml_decisions:
ml_path = os.path.join('results',
f'ml-decisions-sim-{sim_id[:8]}.jsonl')
os.makedirs('results', exist_ok=True)
with open(ml_path, 'w', encoding='utf-8') as mf:
for dec in ml_decisions:
mf.write(json.dumps(dec) + '\n')
logger.info(f'[ML Data] Wrote {len(ml_decisions)} decision snapshots to {ml_path}')
elif ml_decisions and (not brain or not brain._connected):
logger.warning(
f'[ML Data] Skipping {len(ml_decisions)} snapshots for sim {sim_id} — '
f'brain not connected, all actions are heuristic fallback '
f'(not useful for supervised training)'
)
else:
logger.warning(f'[ML Data] No decision snapshots captured for sim {sim_id} (0 decisions)')
ds_stats = brain.get_stats() if brain else {}
summary = _build_summary(
stats, deck_name, 'DeepSeek AI', num_games, elapsed,
opponentType='deepseek', deepseekStats=ds_stats)
_finish_sim(sim_id, summary, game_results, engine=None)
_finish_sim(sim_id, summary, game_results, engine=engine)
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
@@ -394,6 +411,7 @@ 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
@@ -463,7 +481,13 @@ 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] = {
@@ -485,42 +509,31 @@ def _fetch_deck_card_data(deck_id):
deck_name = row[0]
cur.execute("""
SELECT dc.card_name, dc.quantity,
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
ce.type_line, ce.cmc, ce.power, ce.toughness,
ce.oracle_text, ce.keywords, ce.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)
@@ -541,6 +554,7 @@ 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:
@@ -554,6 +568,7 @@ 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:
@@ -566,6 +581,7 @@ 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)
@@ -590,6 +606,7 @@ 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)
@@ -618,6 +635,7 @@ 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)
@@ -651,8 +669,14 @@ 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)
@@ -678,6 +702,7 @@ 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'})
@@ -686,6 +711,7 @@ 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:
@@ -726,6 +752,7 @@ 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)
@@ -756,10 +783,11 @@ async def deepseek_logs():
'flushedPath': flushed_path,
'logFiles': log_files[-20:],
})
@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,
@@ -769,6 +797,7 @@ 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:
+11 -7
View File
@@ -15,13 +15,17 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
from models.state import COMMANDER_META
from models.requests import ImportUrlRequest, ImportTextRequest, MetaFetchRequest
from services.import_helpers import (
_import_from_url, _parse_text_decklist, _save_profile_to_dck,
import routes.shared as _shared
from routes.shared import (
ImportUrlRequest,
ImportTextRequest,
MetaFetchRequest,
_import_from_url,
_parse_text_decklist,
_save_profile_to_dck,
_fetch_edhrec_average,
log,
)
from services.logging import log
router = APIRouter(tags=["import"])
@@ -70,7 +74,7 @@ async def import_from_text(req: ImportTextRequest):
async def list_meta_commanders():
"""List available commanders in the meta mapping."""
commanders = []
for name, entries in COMMANDER_META.items():
for name, entries in _shared.COMMANDER_META.items():
entry = entries[0] if entries else {}
commanders.append({
"name": name,
@@ -86,7 +90,7 @@ async def search_meta_commanders(q: str = ""):
"""Search commanders by partial name match."""
query = q.lower()
matches = []
for name, entries in COMMANDER_META.items():
for name, entries in _shared.COMMANDER_META.items():
if query in name.lower():
entry = entries[0] if entries else {}
matches.append({
+21 -13
View File
@@ -31,15 +31,23 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request as FastAPIRequest
from fastapi.responses import JSONResponse
from models.state import CFG, active_batches, BatchState, AI_PROFILES
from models.requests import StartRequest
from models.responses import StartResponse, StatusResponse
from services.database import _get_db_conn
from services.deck_service import parse_dck_file, _load_deck_cards_by_name
from services.forge_runner import (
build_java_command, run_batch_subprocess, _run_deepseek_batch_thread,
from routes.shared import (
CFG,
active_batches,
BatchState,
StartRequest,
StartResponse,
StatusResponse,
AI_PROFILES,
build_java_command,
run_batch_subprocess,
_run_deepseek_batch_thread,
parse_dck_file,
_get_db_conn,
_load_deck_cards_by_name,
log,
log_batch,
)
from services.logging import log, log_batch
router = APIRouter(tags=["lab"])
@@ -66,8 +74,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 > 32:
raise HTTPException(400, "threads must be 1-32")
if req.threads < 1 or req.threads > 16:
raise HTTPException(400, "threads must be 1-16")
if not os.path.exists(CFG.forge_jar):
raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}")
@@ -79,7 +87,7 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
active_batches[batch_id] = state
os.makedirs(CFG.results_dir, exist_ok=True)
output_path = os.path.join(CFG.results_dir, f"ml-decision-{batch_id}.json")
output_path = os.path.join(CFG.results_dir, f"batch-{batch_id}.json")
state.result_path = output_path
# Auto-export deckbuilder decks to .dck if not already exported
@@ -242,7 +250,7 @@ async def list_history():
if not results_dir.exists():
return {"results": []}
results = []
for f in sorted(results_dir.glob("ml-decision-*.json"), reverse=True):
for f in sorted(results_dir.glob("batch-*.json"), reverse=True):
try:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
@@ -375,7 +383,7 @@ async def get_deck_trends(deck_name: str):
if not results_dir.exists():
return {"deckName": deck_name, "history": []}
history = []
for f in sorted(results_dir.glob("ml-decision-*.json")):
for f in sorted(results_dir.glob("batch-*.json")):
try:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
+12 -22
View File
@@ -32,8 +32,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
from models.state import CFG
from services.logging import _ml_logging_enabled
from routes.shared import CFG, _ml_logging_enabled
log_ml = logging.getLogger("commander_ai_lab.ml")
@@ -122,7 +121,7 @@ def _get_policy_service():
@router.get("/api/ml/status")
async def ml_status():
"""Get ML decision logging status and available training data."""
import services.logging as _logging_mod
import routes.shared as _shared
lab_root = Path(__file__).resolve().parent.parent
results_dir = lab_root / CFG.results_dir
ml_files = []
@@ -141,7 +140,7 @@ async def ml_status():
})
total_decisions = sum(f["decisions"] for f in ml_files)
return {
"ml_logging_enabled": _logging_mod._ml_logging_enabled,
"ml_logging_enabled": _shared._ml_logging_enabled,
"training_files": ml_files,
"total_decisions": total_decisions,
"total_files": len(ml_files),
@@ -151,10 +150,10 @@ async def ml_status():
@router.post("/api/ml/toggle")
async def ml_toggle(enable: bool = True):
"""Enable or disable ML decision logging for future batch runs."""
import services.logging as _logging_mod
_logging_mod._ml_logging_enabled = enable
import routes.shared as _shared
_shared._ml_logging_enabled = enable
return {
"ml_logging_enabled": _logging_mod._ml_logging_enabled,
"ml_logging_enabled": _shared._ml_logging_enabled,
"message": f"ML decision logging {'enabled' if enable else 'disabled'} for future batches",
}
@@ -1097,7 +1096,7 @@ _elo_tournament_state = _EloTournamentState()
_elo_tournament_lock = threading.Lock()
def _run_elo_tournament_pipeline(episodes: int, playstyle: str, top_n: int):
def _run_elo_tournament_pipeline(episodes: int, playstyle: str):
"""Run ELO tournament in a background thread."""
try:
import sys
@@ -1108,7 +1107,7 @@ def _run_elo_tournament_pipeline(episodes: int, playstyle: str, top_n: int):
with _elo_tournament_lock:
_elo_tournament_state.running = True
_elo_tournament_state.phase = "running"
_elo_tournament_state.message = f"Running ELO tournament (top {top_n} generations)..."
_elo_tournament_state.message = "Running ELO tournament..."
_elo_tournament_state.error = None
_elo_tournament_state.result = None
@@ -1121,7 +1120,6 @@ def _run_elo_tournament_pipeline(episodes: int, playstyle: str, top_n: int):
checkpoint_dir=ckpt_dir,
episodes_per_matchup=episodes,
playstyle=playstyle,
top_n=top_n,
)
# Save to ELO history
@@ -1156,37 +1154,29 @@ def _run_elo_tournament_pipeline(episodes: int, playstyle: str, top_n: int):
@router.post("/api/ml/elo/tournament")
async def ml_start_elo_tournament(request: FastAPIRequest):
"""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)
"""
"""Start an ELO-rated tournament across all generation checkpoints."""
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 = f"Initializing ELO tournament (top {top_n} generations)..."
_elo_tournament_state.message = "Initializing ELO tournament..."
_elo_tournament_state.result = None
_elo_tournament_state.error = None
thread = threading.Thread(
target=_run_elo_tournament_pipeline,
args=(episodes, playstyle, top_n),
args=(episodes, playstyle),
daemon=True,
)
thread.start()
return {"status": "started", "episodes": episodes, "top_n": top_n}
return {"status": "started", "episodes": episodes}
@router.get("/api/ml/elo/tournament/status")
-321
View File
@@ -1,321 +0,0 @@
"""Commander AI Lab — Policy Decision Routes (Phase 2)
════════════════════════════════════════════════════
Live Forge ↔ policy server IPC endpoints for online training.
These endpoints are purpose-built for the real-time decision loop
where Forge's Java side calls the Python policy server at each
game decision point.
Endpoints:
POST /api/policy/decide — Accept full Forge game state, return action + log_prob + value
POST /api/policy/collect — Accept batch of (state, action, reward) tuples
POST /api/policy/reward — Submit end-of-game reward
GET /api/policy/health — Readiness check for the live IPC loop
GET /api/policy/stats — Online learning session statistics
Refs: Issue #83 Phase 2.2
"""
import logging
import time
from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
logger = logging.getLogger("routes.policy")
router = APIRouter(prefix="/api/policy", tags=["policy"])
# ================================================================
# Request / Response Models
# ================================================================
class PlayerZoneState(BaseModel):
"""Per-player zone state from Forge."""
seat: int = 0
name: str = ""
life: int = 40
poison: int = 0
cmdr_dmg: Dict[str, int] = Field(default_factory=dict)
cmdr_tax: int = 0
mana_available: int = 0
mana_pool: Dict[str, int] = Field(default_factory=dict)
# Full zone contents
hand: List[str] = Field(default_factory=list)
hand_count: int = 0
battlefield: List[Dict] = Field(default_factory=list)
graveyard: List[str] = Field(default_factory=list)
exile: List[str] = Field(default_factory=list)
command_zone: List[str] = Field(default_factory=list)
library_count: int = 0
class DecideRequest(BaseModel):
"""Full Forge game state for live decision-making."""
# Game metadata
game_id: str = ""
turn: int = 1
phase: str = "main_1"
active_player: int = 0
priority_player: int = 0
# Per-player state with full zones
players: List[PlayerZoneState] = Field(default_factory=list)
# Stack (spells/abilities currently resolving)
stack: List[Dict] = Field(default_factory=list)
# Legal actions from Forge
legal_actions: List[Dict] = Field(default_factory=list)
# Inference params
playstyle: str = "midrange"
greedy: bool = False
temperature: float = 1.0
# Optional commander info
commander: str = ""
deck_name: str = ""
class DecideResponse(BaseModel):
"""Action decision with metadata for online learning."""
action: str
action_index: int
confidence: float
log_prob: float = 0.0
value: float = 0.0
probabilities: Dict[str, float] = Field(default_factory=dict)
inference_ms: float = 0.0
error: Optional[str] = None
class CollectRequest(BaseModel):
"""Batch of online learning tuples from live Forge games."""
tuples: List[Dict] = Field(default_factory=list)
count: int = 0
class RewardRequest(BaseModel):
"""End-of-game reward submission."""
game_id: str
reward: float
winner_seat: int = -1
turns_played: int = 0
reason: str = "" # e.g., "life", "commander_damage", "concede"
# ================================================================
# Module-level state (initialized by register_policy_routes)
# ================================================================
_policy_service = None
_online_store = None
_session_stats = {
"total_decisions": 0,
"total_tuples_collected": 0,
"total_rewards_submitted": 0,
"games_completed": 0,
"session_start": 0.0,
"last_decision_ms": 0.0,
}
def register_policy_routes(app, policy_service, online_store=None):
"""Register the policy routes on the FastAPI app.
Args:
app: FastAPI application instance
policy_service: PolicyInferenceService from ml.serving.policy_server
online_store: Optional OnlineLearningStore for tuple collection
"""
global _policy_service, _online_store
_policy_service = policy_service
_online_store = online_store
_session_stats["session_start"] = time.time()
app.include_router(router)
logger.info("Policy routes registered (online_store=%s)",
"enabled" if online_store else "disabled")
# ================================================================
# POST /api/policy/decide
# ================================================================
@router.post("/decide", response_model=DecideResponse)
async def decide(req: DecideRequest):
"""Accept full Forge game state, run policy inference, return action.
This is the primary endpoint for live Forge IPC. The Java
PolicyClient calls this at each decision point during a real
Forge game.
Returns the macro-action plus log_prob and value estimate
needed for online PPO updates.
"""
if _policy_service is None or not _policy_service._loaded:
raise HTTPException(
503,
detail="Policy model not loaded"
)
t_start = time.time()
_session_stats["total_decisions"] += 1
# Convert request to snapshot dict for the encoder
snapshot = req.dict(exclude={"temperature", "greedy", "playstyle"})
# Map phase names from Forge conventions
snapshot["phase"] = _map_forge_phase(req.phase)
snapshot["active_seat"] = req.active_player
result = _policy_service.predict(
snapshot,
playstyle=req.playstyle,
temperature=req.temperature,
greedy=req.greedy,
)
elapsed_ms = (time.time() - t_start) * 1000
_session_stats["last_decision_ms"] = elapsed_ms
if "error" in result:
raise HTTPException(500, detail=result["error"])
# Add log_prob and value estimate for online learning
log_prob = 0.0
value = 0.0
try:
import math
conf = result.get("confidence", 0.5)
log_prob = math.log(max(conf, 1e-8))
except Exception:
pass
return DecideResponse(
action=result["action"],
action_index=result["action_index"],
confidence=result["confidence"],
log_prob=round(log_prob, 6),
value=round(value, 6),
probabilities=result.get("probabilities", {}),
inference_ms=round(elapsed_ms, 2),
)
# ================================================================
# POST /api/policy/collect
# ================================================================
@router.post("/collect")
async def collect_tuples(req: CollectRequest):
"""Accept a batch of (state, action, reward) tuples from live games
for online PPO updates.
"""
count = len(req.tuples)
_session_stats["total_tuples_collected"] += count
if _online_store is not None:
try:
_online_store.add_tuples(req.tuples)
except Exception as e:
logger.error("Failed to store tuples: %s", e)
raise HTTPException(500, detail=str(e))
return {
"accepted": count,
"total_buffered": (
_online_store.size() if _online_store else count
),
}
# ================================================================
# POST /api/policy/reward
# ================================================================
@router.post("/reward")
async def submit_reward(req: RewardRequest):
"""Submit end-of-game reward for all tuples in a game.
Propagates the terminal reward back through the game's trajectory.
"""
_session_stats["total_rewards_submitted"] += 1
_session_stats["games_completed"] += 1
if _online_store is not None:
try:
_online_store.assign_reward(
game_id=req.game_id,
reward=req.reward,
winner_seat=req.winner_seat,
)
except Exception as e:
logger.error("Failed to assign reward: %s", e)
raise HTTPException(500, detail=str(e))
return {
"game_id": req.game_id,
"reward": req.reward,
"status": "accepted",
}
# ================================================================
# GET /api/policy/health
# ================================================================
@router.get("/health")
async def policy_health():
"""Readiness check for the live Forge IPC loop."""
model_loaded = (
_policy_service is not None and _policy_service._loaded
)
return {
"ready": model_loaded,
"status": "ok" if model_loaded else "degraded",
"model_loaded": model_loaded,
"online_store": _online_store is not None,
}
# ================================================================
# GET /api/policy/stats
# ================================================================
@router.get("/stats")
async def policy_stats():
"""Online learning session statistics."""
uptime = time.time() - _session_stats["session_start"]
return {
**_session_stats,
"uptime_seconds": round(uptime, 1),
"decisions_per_second": (
round(_session_stats["total_decisions"] / max(uptime, 1), 2)
),
"online_store_size": (
_online_store.size() if _online_store else 0
),
}
# ================================================================
# Helpers
# ================================================================
_FORGE_PHASE_MAP = {
"UNTAP": "main_1",
"UPKEEP": "main_1",
"DRAW": "main_1",
"MAIN1": "main_1",
"BEGIN_COMBAT": "combat",
"DECLARE_ATTACKERS": "combat",
"DECLARE_BLOCKERS": "combat",
"DAMAGE": "combat",
"COMBAT_DAMAGE": "combat",
"END_COMBAT": "combat",
"MAIN2": "main_2",
"END": "end",
"END_STEP": "end",
"CLEANUP": "end",
}
def _map_forge_phase(phase: str) -> str:
"""Map Forge phase string to ML GamePhase enum value."""
return _FORGE_PHASE_MAP.get(phase.upper(), "main_1")
+3 -4
View File
@@ -15,9 +15,8 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from models.state import CFG
from services.precon_service import download_precon_database, PRECON_INDEX
from services.logging import log
import routes.shared as _shared # module reference, not value import
from routes.shared import CFG, download_precon_database, log
from services.precon_service import _get_precon_dir
router = APIRouter(tags=["precon"])
@@ -26,7 +25,7 @@ router = APIRouter(tags=["precon"])
@router.get("/api/lab/precons")
async def list_precons():
"""List all available precon decks."""
return {"precons": PRECON_INDEX} # reads current value every call
return {"precons": _shared.PRECON_INDEX} # reads current value every call
@router.post("/api/lab/precons/install")
-121
View File
@@ -1,121 +0,0 @@
"""
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)}
+7 -7
View File
@@ -22,13 +22,13 @@ from urllib.error import URLError
from fastapi import APIRouter, HTTPException
from fastapi import Request as FastAPIRequest
from models.state import CFG
from services.card_analysis import _detect_card_roles
from services.database import _get_db_conn, _row_to_dict
from services.deck_service import _to_edhrec_slug
from services.import_helpers import _edhrec_cache_get, _edhrec_cache_set
from services.logging import log_scan, log_deckgen
from services.scryfall import _API_HEADERS, _enrich_from_scryfall, _scryfall_fuzzy_lookup
from routes.shared import (
CFG, log_scan, log_deckgen,
_get_db_conn, _row_to_dict, _enrich_from_scryfall,
_scryfall_fuzzy_lookup, _detect_card_roles,
_to_edhrec_slug, _API_HEADERS,
_edhrec_cache_get, _edhrec_cache_set,
)
router = APIRouter(tags=["scanner"])
+204
View File
@@ -0,0 +1,204 @@
"""
routes/shared.py
================
Single source of truth for everything every router needs.
Contains:
- Config class + CFG singleton
- Logging setup + all named loggers
- FastAPI app instance
- In-memory state (active_batches, COMMANDER_META, PRECON_INDEX, AI_PROFILES)
- All Pydantic request/response models
- SQLite DB helpers (_get_db_conn, init_collection_db)
- Scryfall cache + enrichment helpers
- Collection helpers (_detect_card_roles, _classify_card_type,
_build_collection_filters, _parse_finish, _parse_csv_content,
_parse_text_line, _auto_infer_mapping, _row_to_dict, _add_image_url,
_snake_to_camel, VALID_SORT_FIELDS, _JSON_FIELDS)
- Deck helpers (_get_deck_or_404, _compute_deck_analysis, _check_ratio_limit,
_TYPE_TARGETS, _TYPE_PRIORITY, _classify_card_type)
- Import helpers (_import_from_url, _fetch_archidekt_deck,
_fetch_edhrec_average, _parse_text_decklist, _save_profile_to_dck,
_to_edhrec_slug, _http_get, _API_HEADERS, _edhrec_cache_get,
_edhrec_cache_set)
- Precon helpers (PRECON_DIR, PRECON_INDEX, download_precon_database,
load_precon_index, GITHUB_PRECON_URL, PRECON_CACHE_HOURS)
- Sim helpers (build_java_command, get_java17, BatchState, parse_dck_file,
AI_PROFILES, run_batch_subprocess, _run_process_blocking,
_run_deepseek_batch_thread, _load_deck_cards_by_name, _get_deepseek_brain)
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import io
import json
import logging
import logging.handlers
import os
import re
import sqlite3
import subprocess
import sys
import threading
import time
import uuid
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.request import urlopen, Request
from urllib.error import URLError
try:
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request as FastAPIRequest
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
except ImportError:
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
sys.exit(1)
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
# Configuration (imported from models/state)
from models.state import Config, CFG
# ══════════════════════════════════════════════════════════════
# Logging Setup
# ══════════════════════════════════════════════════════════════
_LOG_FORMAT = "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
_LOG_MAX_BYTES = int(os.environ.get("CAL_LOG_MAX_MB", "25")) * 1024 * 1024
_LOG_BACKUP_COUNT = int(os.environ.get("CAL_LOG_BACKUP_COUNT", "5"))
def setup_logging(level: int = logging.INFO) -> None:
_LOG_DIR.mkdir(parents=True, exist_ok=True)
root_logger = logging.getLogger("commander_ai_lab")
root_logger.setLevel(level)
if root_logger.handlers:
return
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATE_FMT)
console = logging.StreamHandler(sys.stdout)
console.setLevel(level)
console.setFormatter(formatter)
root_logger.addHandler(console)
file_handler = logging.handlers.RotatingFileHandler(
_LOG_DIR / "commander-ai-lab.log",
maxBytes=_LOG_MAX_BYTES,
backupCount=_LOG_BACKUP_COUNT,
encoding="utf-8",
)
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
log = logging.getLogger("commander_ai_lab.api")
log_batch = logging.getLogger("commander_ai_lab.batch")
log_sim = logging.getLogger("commander_ai_lab.sim")
log_coach = logging.getLogger("commander_ai_lab.coach")
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
log_collect = logging.getLogger("commander_ai_lab.collection")
log_scan = logging.getLogger("commander_ai_lab.scanner")
log_ml = logging.getLogger("commander_ai_lab.ml")
log_cache = logging.getLogger("commander_ai_lab.cache")
log_pplx = logging.getLogger("commander_ai_lab.pplx")
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
# In-Memory State (imported from models/state)
from models.state import (
BatchState, active_batches, COMMANDER_META,
BUILTIN_COMMANDERS, load_commander_meta,
)
# Pydantic Models (imported from models/)
from models.requests import (
StartRequest, ImportUrlRequest, ImportTextRequest, MetaFetchRequest,
CreateDeckRequest, UpdateDeckRequest, AddDeckCardRequest,
PatchDeckCardRequest, BulkAddRequest, BulkAddRecommendedRequest,
DeckGenerationSourceConfig, DeckGenerationRequest,
DeckGenV3Request, DeckGenV3SubstituteRequest,
)
from models.responses import (
StartResponse, StatusResponse, DeckInfo, GeneratedDeckCard,
)
# Collection Database (imported from services/database.py)
from services.database import (
COLLECTION_DB_PATH, _get_db_conn, init_collection_db,
_JSON_FIELDS, VALID_SORT_FIELDS,
_row_to_dict, _snake_to_camel, _add_image_url, _build_collection_filters,
)
# Card Role Detection (imported from services/card_analysis.py)
from services.card_analysis import _detect_card_roles
# ══════════════════════════════════════════════════════════════
# Deck Helpers (imported from services/deck_service.py)
from services.deck_service import (
_TYPE_PRIORITY, _TYPE_TARGETS, _classify_card_type, _get_deck_or_404,
_compute_deck_analysis, _check_ratio_limit, _to_edhrec_slug,
parse_dck_file, _save_profile_to_dck, _load_deck_cards_by_name,
)
# ══════════════════════════════════════════════════════════════
# Scryfall Cache (imported from services/scryfall.py)
from services.scryfall import (
SCRYFALL_CACHE_DB_PATH, SCRYFALL_CACHE_TTL_SECONDS, _API_HEADERS,
_ScryfallCache, _scryfall_cache, _scryfall_lock, _scryfall_last_call,
_scryfall_rate_limit, _fetch_scryfall_api, _enrich_from_scryfall, _scryfall_fuzzy_lookup,
)
# ══════════════════════════════════════════════════════════════
# Import Helpers (extracted to services/import_helpers.py)
from services.import_helpers import (
_http_get, _fetch_archidekt_deck, _fetch_edhrec_average,
_import_from_url, _parse_text_decklist, _save_profile_to_dck,
_edhrec_cache, _EDHREC_CACHE_TTL, _edhrec_cache_get, _edhrec_cache_set,
)
# ══════════════════════════════════════════════════════════════
# Collection Import Helpers (extracted to services/import_service.py)
from services.import_service import (
_parse_finish, _parse_text_line, _auto_infer_mapping, _parse_csv_content,
)
# ══════════════════════════════════════════════════════════════
# Precon Helpers (extracted to services/precon_service.py)
from services.precon_service import (
PRECON_DIR, PRECON_INDEX, GITHUB_PRECON_URL, PRECON_CACHE_HOURS,
load_precon_index, _sanitize_filename, _deck_to_dck, download_precon_database,
_get_precon_dir,
)
# ══════════════════════════════════════════════════════════════
# AI Profiles (imported from models/state)
from models.state import AI_PROFILES
# ══════════════════════════════════════════════════════════════
# Java / Sim Helpers (extracted to services/forge_runner.py)
from services.forge_runner import (
_find_java17, get_java17, build_java_command,
run_batch_subprocess, _run_process_blocking,
_run_deepseek_batch_thread, _get_deepseek_brain,
)
_ml_logging_enabled = True
-182
View File
@@ -1,182 +0,0 @@
"""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 logging
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:
# Re-send current state as full snapshot
import copy
snap = copy.deepcopy(tracker._last_snapshot)
tracker._seq += 1
import time
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.
Called by the Forge IPC bridge or policy server after each
state change. The delta engine diffs against the previous
state and broadcasts only what changed.
"""
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()
+1 -1
View File
@@ -85,7 +85,7 @@ def start_batch(base_url, decks, num_games, playstyle, threads):
def poll_batch(base_url, batch_id, poll_interval=5):
"""Poll batch status until complete. Returns final status."""
while True:
result = api_call(base_url, f"/api/lab/status?batchId={batch_id}")
result = api_call(base_url, f"/api/lab/status/{batch_id}")
if result is None:
time.sleep(poll_interval)
continue
-220
View File
@@ -1,220 +0,0 @@
"""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
-124
View File
@@ -136,28 +136,6 @@ def init_collection_db() -> None:
FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS coach_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
deck_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
model_used TEXT DEFAULT '',
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
summary TEXT DEFAULT '',
goals_json TEXT DEFAULT '{}',
cuts_json TEXT DEFAULT '[]',
adds_json TEXT DEFAULT '[]',
heuristic_hints_json TEXT DEFAULT '[]',
mana_base_advice TEXT DEFAULT '',
raw_text TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_cs_session_id ON coach_sessions(session_id);
CREATE INDEX IF NOT EXISTS idx_cs_deck_id ON coach_sessions(deck_id);
CREATE INDEX IF NOT EXISTS idx_cs_timestamp ON coach_sessions(timestamp);
CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id);
CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id);
CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id);
@@ -324,105 +302,3 @@ def _build_collection_filters(
conditions.append("quantity <= ?"); params.append(qtyMax)
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
return where_str, params
# ══════════════════════════════════════════════════════════════
# Coach Session DB Helpers
# ══════════════════════════════════════════════════════════════
def save_coach_session(session_data: dict) -> int:
"""Insert or update a coach session in SQLite. Returns the row id."""
conn = _get_db_conn()
existing = conn.execute(
"SELECT id FROM coach_sessions WHERE session_id = ?",
(session_data["session_id"],),
).fetchone()
if existing:
conn.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"],
),
)
conn.commit()
return existing["id"]
else:
cur = conn.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", ""),
),
)
conn.commit()
return cur.lastrowid
def load_coach_session(session_id: str) -> Optional[dict]:
"""Load a single coach session by session_id."""
conn = _get_db_conn()
row = conn.execute(
"SELECT * FROM coach_sessions WHERE session_id = ?", (session_id,)
).fetchone()
return dict(row) if row else None
def list_coach_sessions(deck_id: str = None) -> list:
"""List coach sessions, optionally filtered by deck_id."""
conn = _get_db_conn()
if deck_id:
rows = conn.execute(
"SELECT session_id, deck_id, timestamp, summary, created_at "
"FROM coach_sessions WHERE deck_id = ? ORDER BY timestamp DESC",
(deck_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT session_id, deck_id, timestamp, summary, created_at "
"FROM coach_sessions ORDER BY timestamp DESC"
).fetchall()
return [dict(r) for r in rows]
def delete_coach_session(session_id: str) -> bool:
"""Delete a coach session. Returns True if a row was deleted."""
conn = _get_db_conn()
cur = conn.execute(
"DELETE FROM coach_sessions WHERE session_id = ?", (session_id,)
)
conn.commit()
return cur.rowcount > 0
+266 -297
View File
@@ -1,297 +1,266 @@
"""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
"""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
+2 -3
View File
@@ -204,7 +204,6 @@ def build_java_command(
cmd += ['--aiSimplified']
if ai_think_time_ms > 0:
cmd += ['--aiThinkTimeMs', str(ai_think_time_ms)]
cmd += ['--ml-log']
if max_queue_depth > 0:
cmd += ['--maxQueueDepth', str(max_queue_depth)]
return cmd
@@ -317,7 +316,7 @@ def _get_deepseek_brain():
model=os.environ.get("BRAIN_MODEL", "gpt-oss:20b"),
temperature=float(os.environ.get("BRAIN_TEMPERATURE", "0.3")),
max_tokens=int(os.environ.get("BRAIN_MAX_TOKENS", "1024")),
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "300.0")),
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "120.0")),
)
cfg.log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'logs', 'decisions')
cfg.log_dir = os.path.normpath(cfg.log_dir)
@@ -361,7 +360,7 @@ def _run_deepseek_batch_thread(
results_dir = CFG.results_dir
if os.path.isdir(results_dir):
for fname in sorted(os.listdir(results_dir), reverse=True):
if fname.startswith('ml-decision-') and fname.endswith('.json'):
if fname.startswith('batch-') and fname.endswith('.json'):
try:
with open(os.path.join(results_dir, fname), 'r') as rf:
past = json.loads(rf.read())
-60
View File
@@ -1,60 +0,0 @@
"""
services/logging.py
====================
Single source of truth for application logging configuration and named loggers.
Moved from routes/shared.py so that every layer (routes, services, CLI
entry-points) can import logging without pulling in the full shared module.
"""
from __future__ import annotations
import logging
import logging.handlers
import os
import sys
from pathlib import Path
# ── Format / rotation constants ───────────────────────────────────────────────
_LOG_FORMAT = "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
_LOG_MAX_BYTES = int(os.environ.get("CAL_LOG_MAX_MB", "25")) * 1024 * 1024
_LOG_BACKUP_COUNT = int(os.environ.get("CAL_LOG_BACKUP_COUNT", "5"))
def setup_logging(level: int = logging.INFO) -> None:
_LOG_DIR.mkdir(parents=True, exist_ok=True)
root_logger = logging.getLogger("commander_ai_lab")
root_logger.setLevel(level)
if root_logger.handlers:
return
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATE_FMT)
console = logging.StreamHandler(sys.stdout)
console.setLevel(level)
console.setFormatter(formatter)
root_logger.addHandler(console)
file_handler = logging.handlers.RotatingFileHandler(
_LOG_DIR / "commander-ai-lab.log",
maxBytes=_LOG_MAX_BYTES,
backupCount=_LOG_BACKUP_COUNT,
encoding="utf-8",
)
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
# ── Named loggers ────────────────────────────────────────────────────────────
log = logging.getLogger("commander_ai_lab.api")
log_batch = logging.getLogger("commander_ai_lab.batch")
log_sim = logging.getLogger("commander_ai_lab.sim")
log_coach = logging.getLogger("commander_ai_lab.coach")
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
log_collect = logging.getLogger("commander_ai_lab.collection")
log_scan = logging.getLogger("commander_ai_lab.scanner")
log_ml = logging.getLogger("commander_ai_lab.ml")
log_cache = logging.getLogger("commander_ai_lab.cache")
log_pplx = logging.getLogger("commander_ai_lab.pplx")
# ── ML logging toggle ────────────────────────────────────────────────────────
_ml_logging_enabled = True
-518
View File
@@ -1,518 +0,0 @@
"""
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 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:
"""Build the text document to embed for a single card.
Format: Name | Mana Cost | Type | Oracle Text
Matches the structure used by coach/embeddings._build_card_text() so
vector similarity is consistent across both embedding systems.
"""
parts = [card.get("name", "")]
mana_cost = card.get("mana_cost", "")
if mana_cost:
parts.append(mana_cost)
type_line = card.get("type_line", "")
if type_line:
parts.append(type_line)
oracle = card.get("oracle_text", "")
if oracle:
parts.append(oracle)
return " | ".join(parts)
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
-372
View File
@@ -1,372 +0,0 @@
"""
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,
}
@@ -24,7 +24,7 @@ logger = logging.getLogger(__name__)
# Ollama exposes an OpenAI-compatible endpoint
DEFAULT_BASE_URL = "http://localhost:11434/v1"
DEFAULT_MODEL = "qwen2.5:7b"
DEFAULT_MODEL = "gpt-oss:20b"
def _get_client(base_url: str = DEFAULT_BASE_URL) -> OpenAI:
@@ -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,23 +154,13 @@ 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.
@@ -178,7 +168,7 @@ Commander: {commander_name}
Color identity: {', '.join(color_identity)}
Category: {category}
{strategy}
{exclude_str}{sim_block}
{exclude_str}
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.
@@ -210,7 +200,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],
@@ -252,28 +242,17 @@ 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}
@@ -286,7 +265,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.{sim_block}
The total across all categories must be exactly 99.
Return a JSON object with the same category keys, each mapping to an array of card name strings."""
@@ -305,7 +284,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,
@@ -1,124 +0,0 @@
"""
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,7 +13,6 @@ 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
@@ -38,7 +37,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
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:
@@ -51,17 +50,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
logger.info(f"Commander: {commander.name}, CI: {ci_list}")
sources.append("scryfall")
# 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 ─────────────────────────────
# ── 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:
@@ -70,7 +59,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),
@@ -81,7 +70,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
@@ -90,9 +79,7 @@ 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.
# sim_context is forwarded so the LLM factors in what traits have
# been statistically successful in our own game simulations.
# Ask Ollama to suggest cards for categories that need more
categories_to_fill = ["synergy", "protection", "wincon"]
ollama_suggestions: Dict[str, List[str]] = {}
@@ -104,7 +91,6 @@ 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)
@@ -112,7 +98,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
@@ -154,7 +140,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,
@@ -167,13 +153,10 @@ 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
@@ -182,52 +165,25 @@ 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")
# 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())
# Convert to CardEntry list via Scryfall batch lookup
all_card_names = []
for names in adjusted.values():
all_card_names.extend(names)
card_entries = scryfall.get_cards_by_names(all_card_names)
# 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 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
name_to_cat: Dict[str, str] = {}
for cat, names in adjusted.items():
for name in names:
name_to_cat[name] = cat
for entry in card_entries:
if entry.name in seen_names:
entry.category = seen_names[entry.name]
if entry.name in name_to_cat:
entry.category = name_to_cat[entry.name]
# Build the deck
elapsed = time.time() - start
+29 -35
View File
@@ -9,8 +9,8 @@ The LLM receives a structured game-state snapshot and returns a JSON
action describing what to do this turn (play land, cast spell, attack,
hold/pass, use removal, use board wipe, cast ramp, cast commander).
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at localhost:11434
ollama run deepseek-r1:8b
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at 192.168.0.240:11434
ollama run gpt-oss:20b
Also supports:
- LM Studio (OpenAI-compatible API) — set api_base to LM Studio URL
@@ -58,28 +58,28 @@ class DeepSeekConfig:
Example — LM Studio::
config = DeepSeekConfig(
api_base="http://localhost:11434",
api_base="http://192.168.0.240:11434",
model="deepseek-r1-distill-qwen-8b",
max_tokens=2048, # R1 needs room for <think> blocks
)
Example — Ollama (default)::
config = DeepSeekConfig() # points at localhost:11434 / deepseek-r1:8b
config = DeepSeekConfig() # points at 192.168.0.240:11434 / gpt-oss:20b
"""
# API endpoint — Ollama OpenAI-compatible base (NO /v1 suffix)
api_base: str = "http://localhost:11434"
api_base: str = "http://192.168.0.240:11434"
# Model name as registered in Ollama
model: str = "deepseek-r1:8b"
model: str = "gpt-oss:20b"
# Generation parameters
# GPT-OSS 20B does not emit <think> blocks so 1024 tokens is ample
temperature: float = 0.3
max_tokens: int = 4096
max_tokens: int = 1024
top_p: float = 0.9
# Timeouts
request_timeout: float = float(os.environ.get("BRAIN_TIMEOUT", "300.0")) # Ollama local inference; raise vs LM Studio
request_timeout: float = 30.0 # Ollama local inference; raise vs LM Studio
fallback_on_timeout: bool = True
# Caching
@@ -91,7 +91,7 @@ class DeepSeekConfig:
log_dir: str = ""
# Retry
max_retries: int = 2
max_retries: int = 1
# ══════════════════════════════════════════════════════════════
@@ -543,8 +543,9 @@ class DeepSeekBrain:
if models:
model_id = models[0].get("id", self.config.model)
if model_id:
logger.info("Available model: %s (keeping configured: %s)", model_id, self.config.model)
self.config.model = model_id
logger.info("Auto-detected model: %s", model_id)
logger.info("LLM brain connected to %s", self.config.api_base)
return True
except Exception as e:
logger.warning("LLM brain connection failed: %s", e)
@@ -590,31 +591,25 @@ class DeepSeekBrain:
if not self._connected:
return self._fallback_action(snapshot, t_start, "not_connected")
last_err = None
for attempt in range(1, self.config.max_retries + 1):
try:
result = self._call_llm(snapshot)
result["source"] = "llm"
result["latency_ms"] = round((time.time() - t_start) * 1000, 1)
try:
result = self._call_llm(snapshot)
result["source"] = "llm"
result["latency_ms"] = round((time.time() - t_start) * 1000, 1)
if self.config.cache_enabled:
self._put_cache(cache_key, result)
if self.config.cache_enabled:
self._put_cache(cache_key, result)
if self.config.log_decisions:
self._log_decision(snapshot, result, player_index)
if self.config.log_decisions:
self._log_decision(snapshot, result, player_index)
self._total_latency_ms += result["latency_ms"]
return result
self._total_latency_ms += result["latency_ms"]
return result
except Exception as e:
last_err = e
logger.warning("LLM call failed (turn %d, attempt %d/%d): %s", turn + 1, attempt, self.config.max_retries, e)
if attempt < self.config.max_retries:
continue
if self.config.fallback_on_timeout:
return self._fallback_action(snapshot, t_start, str(last_err))
raise last_err
except Exception as e:
logger.warning("LLM call failed (turn %d): %s", turn + 1, e)
if self.config.fallback_on_timeout:
return self._fallback_action(snapshot, t_start, str(e))
raise
def _call_llm(self, snapshot: dict) -> dict:
"""Send game state to LLM and parse response."""
@@ -659,10 +654,9 @@ class DeepSeekBrain:
text = re.sub(r'\s*```$', '', text)
text = text.strip()
start = text.find('{')
end = text.rfind('}')
if start != -1 and end != -1 and end > start:
text = text[start:end + 1]
json_match = re.search(r'\{[^{}]*\}', text, re.DOTALL)
if json_match:
text = json_match.group(0)
try:
parsed = json.loads(text)
+1 -1
View File
@@ -23,7 +23,7 @@ Usage::
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
brain = DeepSeekBrain(DeepSeekConfig(api_base="http://localhost:11434"))
brain = DeepSeekBrain(DeepSeekConfig(api_base="http://192.168.0.240:11434"))
brain.check_connection()
engine = DeepSeekGameEngine(brain=brain, ai_player_index=1)
result = engine.run(deck_a, deck_b, name_a="My Deck", name_b="DeepSeek AI")
+128 -498
View File
@@ -28,14 +28,10 @@ from typing import Optional
from commander_ai_lab.sim.models import (
Card,
CombatState,
GameResult,
Phase,
PHASE_ORDER,
Player,
PlayerResult,
PlayerStats,
SORCERY_PHASES,
SimState,
)
from commander_ai_lab.sim.rules import (
@@ -64,9 +60,6 @@ class GameEngine:
``weights`` explicitly to override (e.g. for A/B testing).
"""
# London mulligan: max times a player may mulligan before forced keep
MAX_MULLIGANS = 4
def __init__(
self,
max_turns: int = 25,
@@ -74,7 +67,6 @@ class GameEngine:
weights: Optional[dict] = None,
record_log: bool = False,
ml_log: bool = False,
mulligan_rule: str = "london",
):
self.max_turns = max_turns
self.starting_life = starting_life
@@ -83,7 +75,6 @@ class GameEngine:
self.record_log = record_log
self.ml_log = ml_log
self.ml_decisions: list[dict] = []
self.mulligan_rule = mulligan_rule
# ──────────────────────────────────────────────────────────
# Public API
@@ -124,7 +115,6 @@ class GameEngine:
final_turn = 0
for turn in range(sim.max_turns):
sim.turn = turn
turn_entry = {"turn": turn + 1, "phases": []}
for pi in range(len(sim.players)):
@@ -132,106 +122,59 @@ class GameEngine:
if p.eliminated:
continue
p.stats.turns_alive += 1
sim.active_player_index = pi
phase_events: list[str] = []
# ── Untap (correct MTG order: untap is first) ──
for c in sim.get_battlefield(pi):
c.tapped = False
c.damage_marked = 0 # clear damage each turn (MTG rule 514.2)
# ── Draw ──
if p.library:
drawn = p.library[-1]
p.hand.append(p.library.pop())
p.stats.cards_drawn += 1
if self.record_log:
phase_events.append(f"Drew {drawn.name}")
# ── Capture ML snapshot BEFORE turn actions ──
pre_snapshot = None
if self.ml_log:
pre_snapshot = self._capture_ml_snapshot(sim, pi, turn)
# Track spell/damage counts for ML logging
# ── Land drop ──
land_before = p.stats.lands_played
self._play_land(sim, pi)
played_land = p.stats.lands_played > land_before
if self.record_log and played_land:
last_land = next(
(c for c in reversed(sim.get_battlefield(pi))
if c.is_land()),
None,
)
if last_land:
phase_events.append(f"Played land: {last_land.name}")
# ── Available mana ──
available_mana = self._count_untapped_lands(sim, pi)
# ── Play spells (up to 2) ──
spells_before = p.stats.spells_cast
damage_before_turn = p.stats.damage_dealt
spells_budget = 2 # shared across main1 + main2
# ── Walk each phase in order ──
for phase in PHASE_ORDER:
sim.current_phase = phase
if phase == Phase.UNTAP:
for c in sim.get_battlefield(pi):
c.tapped = False
c.damage_marked = 0 # MTG rule 514.2
elif phase == Phase.UPKEEP:
pass # placeholder — triggers would fire here
elif phase == Phase.DRAW:
if p.library:
drawn = p.library[-1]
p.hand.append(p.library.pop())
p.stats.cards_drawn += 1
if self.record_log:
phase_events.append(f"Drew {drawn.name}")
elif phase == Phase.MAIN1:
# Land drop (only in main1, per MTG convention)
land_before = p.stats.lands_played
self._play_land(sim, pi)
played_land = p.stats.lands_played > land_before
if self.record_log and played_land:
last_land = next(
(c for c in reversed(sim.get_battlefield(pi))
if c.is_land()),
None,
)
if last_land:
phase_events.append(f"Played land: {last_land.name}")
# Play spells (shared budget across main1 + main2)
available_mana = self._count_untapped_lands(sim, pi)
cast = self._play_spells(
sim, pi, available_mana,
phase_events if self.record_log else None,
max_spells=spells_budget,
)
spells_budget -= cast
elif phase == Phase.BEGIN_COMBAT:
pass # placeholder — "beginning of combat" triggers
elif phase == Phase.DECLARE_ATTACKERS:
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
elif phase == Phase.DECLARE_BLOCKERS:
pass # blocking is handled inside _resolve_combat
elif phase == Phase.COMBAT_DAMAGE:
pass # damage is handled inside _resolve_combat
elif phase == Phase.END_COMBAT:
pass # placeholder — "end of combat" triggers
elif phase == Phase.MAIN2:
# Second main phase: use remaining spell budget
if spells_budget > 0:
available_mana = self._count_untapped_lands(sim, pi)
cast = self._play_spells(
sim, pi, available_mana,
phase_events if self.record_log else None,
max_spells=spells_budget,
)
spells_budget -= cast
elif phase == Phase.END_STEP:
pass # placeholder — "at end of turn" triggers
elif phase == Phase.CLEANUP:
pass # damage clearing already happened in untap for simplicity
# ── Post-phase bookkeeping ──
self._play_spells(sim, pi, available_mana, phase_events if self.record_log else None)
played_spell = p.stats.spells_cast > spells_before
attacked = p.stats.damage_dealt > damage_before_turn
# Track board size
# ── Track board size ──
board_size = len(sim.get_battlefield(pi))
if board_size > p.stats.max_board_size:
p.stats.max_board_size = board_size
# ── Combat ──
damage_before = p.stats.damage_dealt
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
attacked = p.stats.damage_dealt > damage_before
# ── Record ML decision snapshot ──
if self.ml_log and pre_snapshot is not None:
available_mana = self._count_untapped_lands(sim, pi)
if attacked:
action_type = "attack"
elif played_spell:
@@ -339,42 +282,6 @@ class GameEngine:
# Internal helpers
# ──────────────────────────────────────────────────────────
# ──────────────────────────────────────────────────────────
# Opening-hand helpers
# ──────────────────────────────────────────────────────────
@staticmethod
def _count_lands_in_hand(hand: list[Card]) -> int:
"""Count lands in a hand of cards."""
return sum(1 for c in hand if c.is_land())
@staticmethod
def _should_mulligan(hand: list[Card], mulligan_number: int) -> bool:
"""Heuristic: mulligan if opening hand has 0-1 lands or 6-7 lands.
Becomes more lenient with each successive mulligan — after 2+
mulligans, accept 1-land hands to avoid going too low on cards.
"""
lands = GameEngine._count_lands_in_hand(hand)
total = len(hand)
if mulligan_number >= 2:
# Desperate: keep anything with 1-5 lands
return lands < 1 or lands >= total
# Standard: keep 2-5 lands in a 7-card hand
return lands <= 1 or lands >= 6
@staticmethod
def _pick_bottom_cards(hand: list[Card], count: int, weights: dict) -> list[Card]:
"""Choose the worst *count* cards to put on the bottom of library.
Uses the AI scoring function so the least valuable cards are
bottomed — mirrors a real player's London mulligan decision.
"""
if count <= 0 or count >= len(hand):
return []
scored = sorted(hand, key=lambda c: score_card(c, weights))
return scored[:count]
def _create_state(
self,
decks: list[list[Card]],
@@ -383,11 +290,6 @@ class GameEngine:
) -> SimState:
"""Create initial simulation state with shuffled decks and opening hands (N players).
Implements the London mulligan rule: each player draws 7, decides
whether to keep, and if not shuffles back and redraws. After
keeping, they put N cards on the bottom of their library where N
is the number of times they mulliganed.
If commander_names is provided, the matching card is flagged
is_commander=True and placed in the player's command_zone
instead of the library/hand.
@@ -406,46 +308,16 @@ class GameEngine:
commander_card = c
else:
remaining.append(c)
# ── London mulligan loop ──
mulligan_count = 0
if self.mulligan_rule == "london":
for attempt in range(self.MAX_MULLIGANS):
random.shuffle(remaining)
hand = remaining[:7]
if not self._should_mulligan(hand, attempt):
break
mulligan_count += 1
else:
# Exhausted all mulligans — forced keep with last draw
random.shuffle(remaining)
hand = remaining[:7]
else:
# No mulligan (legacy behavior)
random.shuffle(remaining)
hand = remaining[:7]
random.shuffle(remaining)
hand = remaining[:7]
library = remaining[7:]
# ── Bottom N cards for London mulligan ──
if mulligan_count > 0:
bottom = self._pick_bottom_cards(hand, mulligan_count, self.weights)
for card in bottom:
hand.remove(card)
library.insert(0, card) # bottom of library
opening_lands = self._count_lands_in_hand(hand)
player = Player(
name=name,
life=self.starting_life,
owner_id=idx,
library=library,
hand=hand,
stats=PlayerStats(
cards_drawn=7,
mulligans=mulligan_count,
opening_hand_lands=opening_lands,
),
stats=PlayerStats(cards_drawn=7),
)
if commander_card:
player.command_zone.append(commander_card)
@@ -471,6 +343,53 @@ class GameEngine:
sim.add_to_battlefield(pi, land_card)
p.stats.lands_played += 1
# ──────────────────────────────────────────────────────────
# Centralized Damage
# ──────────────────────────────────────────────────────────
@staticmethod
def _apply_damage(
sim: SimState,
amount: int,
target_seat: int,
source_seat: int,
*,
source_is_commander: bool = False,
is_combat: bool = False,
) -> int:
"""Apply damage to a player through a single centralized path.
Handles:
- Life total reduction
- Commander damage tracking (when source_is_commander=True)
- Per-player damage stats
- Elimination by life<=0 or commander damage>=21
Returns the amount of damage actually applied (always ``amount``
unless the target is already eliminated, in which case 0).
"""
if amount <= 0:
return 0
target = sim.players[target_seat]
if target.eliminated:
return 0
target.life -= amount
target.stats.damage_received += amount
sim.players[source_seat].stats.damage_dealt += amount
if source_is_commander:
prev = target.commander_damage_received.get(source_seat, 0)
target.commander_damage_received[source_seat] = prev + amount
# Elimination checks: life<=0 OR 21+ commander damage from one source
if target.life <= 0:
target.eliminated = True
elif any(v >= 21 for v in target.commander_damage_received.values()):
target.eliminated = True
return amount
@staticmethod
def _send_to_graveyard(sim: SimState, card, owner_idx: int) -> None:
"""Route card to command zone if it's a commander, else graveyard."""
@@ -482,57 +401,6 @@ class GameEngine:
else:
owner.graveyard.append(card)
# ──────────────────────────────────────────────────────────
# Centralized Damage
# ──────────────────────────────────────────────────────────
@staticmethod
def deal_damage(
sim: SimState,
amount: int,
target_seat: int,
source_card: Card | None = None,
source_seat: int | None = None,
is_combat: bool = False,
events: list | None = None,
label: str = "",
) -> int:
"""Apply *amount* damage to a player through the centralized path.
Handles life reduction, stat tracking, commander damage tracking
(both per-seat aggregate and per-card breakdown from PR #89),
and elimination checks. Returns the actual damage dealt.
"""
if amount <= 0:
return 0
target = sim.players[target_seat]
if target.eliminated:
return 0
target.life -= amount
target.stats.damage_received += amount
if source_seat is not None:
sim.players[source_seat].stats.damage_dealt += amount
# Commander damage tracking — dual-dict model (PR #89):
# commander_damage_received[seat] = aggregate per-seat total
# commander_damage_by_card[(seat, card_name)] = per-card breakdown
if source_card and source_card.is_commander and source_seat is not None:
target.commander_damage_received[source_seat] = (
target.commander_damage_received.get(source_seat, 0) + amount
)
key = (source_seat, source_card.name)
target.commander_damage_by_card[key] = (
target.commander_damage_by_card.get(key, 0) + amount
)
if events is not None and label:
events.append(f"{label} ({target.name} now at {target.life} life)")
if target.life <= 0 or target.is_dead_to_commander_damage():
target.eliminated = True
return amount
def _count_untapped_lands(self, sim: SimState, pi: int) -> int:
"""Count untapped lands for a player."""
return sum(
@@ -541,15 +409,12 @@ class GameEngine:
if not c.tapped and c.is_land()
)
def _play_spells(self, sim: SimState, pi: int, available_mana: int, events: list | None = None, max_spells: int = 2) -> int:
"""Play up to *max_spells* spells from hand, prioritized by AI score.
Returns the number of spells actually played this call.
"""
def _play_spells(self, sim: SimState, pi: int, available_mana: int, events: list | None = None) -> None:
"""Play up to 2 spells from hand, prioritized by AI score."""
p = sim.players[pi]
w = self.weights
played = 0
while played < max_spells:
while played < 2:
playable = []
for i, card in enumerate(p.hand):
if card.is_land():
@@ -619,20 +484,6 @@ class GameEngine:
p.graveyard.append(card)
if events is not None:
events.append(f"Cast {card.name} (board wipe) \u2014 destroyed {len(wiped_names)} creatures")
elif card.is_direct_damage and card.direct_damage_amount > 0:
# Direct-damage spell (burn): route through centralized deal_damage()
target_seat = self._select_attack_target(sim, pi)
if target_seat != -1:
target_name = sim.players[target_seat].name
self.deal_damage(
sim, card.direct_damage_amount, target_seat,
source_card=card, source_seat=pi,
events=events,
label=f"{card.name} deals {card.direct_damage_amount} damage to {target_name}",
)
elif events is not None:
events.append(f"Cast {card.name} (burn, no targets)")
p.graveyard.append(card)
else:
sim.add_to_battlefield(pi, card)
if card.is_creature():
@@ -678,7 +529,6 @@ class GameEngine:
f"for {total_cost} mana (tax={tax})"
)
available_mana = self._count_untapped_lands(sim, pi)
return played
@staticmethod
def _select_attack_target(sim: SimState, pi: int) -> int:
@@ -753,11 +603,13 @@ class GameEngine:
if events is not None and attackers:
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
total_damage = 0
# Accumulate per-attacker player-damage buckets so we can route
# commander vs non-commander damage through _apply_damage separately.
commander_damage = 0 # damage dealt by commander creatures
normal_damage = 0 # damage dealt by non-commander creatures
used_blockers: set[int] = set()
combat_details: list[str] = []
# Per-attacker damage to route through deal_damage() individually
attacker_damage: list[tuple[Card, int]] = []
lifelink_total = 0 # lifelink healing for the attacker
for atk in attackers:
a_pow = atk.get_power()
a_tou = atk.get_toughness()
@@ -806,275 +658,53 @@ class GameEngine:
trample_over = 0
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
trample_over = a_pow - blocker.get_toughness()
attacker_damage.append((atk, trample_over))
total_damage += trample_over
if atk.is_commander:
commander_damage += trample_over
else:
normal_damage += trample_over
if atk.has_keyword("double strike"):
ds_dmg = a_pow if (a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch")) else trample_over
attacker_damage.append((atk, ds_dmg))
total_damage += ds_dmg
if atk.is_commander:
commander_damage += ds_dmg
else:
normal_damage += ds_dmg
if atk.has_keyword("lifelink"):
damage_dealt = min(a_pow, blocker.get_toughness()) + trample_over
if atk.has_keyword("double strike"):
damage_dealt *= 2
p.life += damage_dealt
lifelink_total += damage_dealt
if not blocked:
if atk.has_keyword("double strike"):
atk_dmg = a_pow * 2
if atk.has_keyword("lifelink"):
p.life += a_pow * 2
else:
atk_dmg = a_pow
if atk.has_keyword("lifelink"):
p.life += a_pow
attacker_damage.append((atk, atk_dmg))
total_damage += atk_dmg
# Route each attacker's damage through deal_damage() for
# centralized life/stats/commander tracking
for atk_card, dmg in attacker_damage:
if dmg > 0:
self.deal_damage(
sim, dmg, opp_idx,
source_card=atk_card, source_seat=pi,
is_combat=True,
)
if atk.is_commander:
commander_damage += atk_dmg
else:
normal_damage += atk_dmg
if atk.has_keyword("lifelink"):
lifelink_total += atk_dmg
# Route all player damage through the centralized path.
# Commander damage is applied first (tracks 21-damage rule).
total_damage = 0
if commander_damage > 0:
total_damage += self._apply_damage(
sim, commander_damage, opp_idx, pi,
source_is_commander=True, is_combat=True,
)
if normal_damage > 0:
total_damage += self._apply_damage(
sim, normal_damage, opp_idx, pi,
source_is_commander=False, is_combat=True,
)
# Apply lifelink healing
if lifelink_total > 0:
p.life += lifelink_total
if events is not None and total_damage > 0:
# Compute commander damage annotation from the per-card dict
total_cmd_damage = sum(
dmg for (atk_card, dmg) in attacker_damage
if atk_card.is_commander and dmg > 0
)
cmd_note = f" ({total_cmd_damage} cmdr)" if total_cmd_damage > 0 else ""
events.append(
f"Dealt {total_damage} combat damage{cmd_note} to {opp.name} (now {opp.life} life)"
)
events.append(f"Dealt {total_damage} combat damage to {opp.name} (now {opp.life} life)")
events.extend(combat_details)
# ──────────────────────────────────────────────────────────
# Split combat methods for turn_manager priority windows
# (Issue #86 Item 1). The original _resolve_combat() above
# remains as the backward-compatible atomic path for run_n().
# ──────────────────────────────────────────────────────────
def assign_attackers(
self, sim: SimState, pi: int, events: list | None = None,
) -> Optional[CombatState]:
"""Phase 1 of split combat: choose attackers, populate CombatState.
Returns a CombatState with attackers set and tapped, or None if
no valid attack is possible. Does NOT deal any damage.
"""
p = sim.players[pi]
opp_idx = self._select_attack_target(sim, pi)
if opp_idx == -1:
return None
opp = sim.players[opp_idx]
w = self.weights
my_creatures = [
c
for c in sim.get_battlefield(pi)
if (
c.is_creature()
and not c.tapped
and (c.turn_played < sim.turn or c.has_keyword("haste"))
)
]
if not my_creatures:
return None
opp_blockers = [
c
for c in sim.get_battlefield(opp_idx)
if c.is_creature() and not c.tapped
]
attackers = []
total_my_power = sum(c.get_power() for c in my_creatures)
for atk in my_creatures:
a_pow = atk.get_power()
a_tou = atk.get_toughness()
has_flying = atk.has_keyword("flying")
has_trample = atk.has_keyword("trample")
has_haste = atk.has_keyword("haste")
if has_flying or has_trample or has_haste or a_pow >= 3 or opp.life <= total_my_power:
attackers.append(atk)
atk.tapped = True
continue
can_die_profitably = any(
(
(not has_flying or b.has_keyword("flying") or b.has_keyword("reach"))
and (b.get_power() >= a_tou or b.has_keyword("deathtouch"))
and b.get_toughness() > a_pow
)
for b in opp_blockers
)
if not can_die_profitably or p.life > 25:
attackers.append(atk)
atk.tapped = True
if not attackers:
return None
if events is not None:
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
# Build and store CombatState
combat = CombatState(
defending_seat=opp_idx,
attackers={atk.id: opp_idx for atk in attackers},
active=True,
)
sim.combat = combat
return combat
def assign_blockers(
self, sim: SimState, pi: int, combat: CombatState,
events: list | None = None,
) -> None:
"""Phase 2 of split combat: assign blockers into CombatState.
Populates combat.blockers and combat.player_damage.
Does NOT apply any damage to players or creatures yet.
"""
opp_idx = combat.defending_seat
opp_blockers = [
c for c in sim.get_battlefield(opp_idx)
if c.is_creature() and not c.tapped
]
used_blockers: set[int] = set()
for atk_id in combat.attackers:
atk = next((c for bf in sim.battlefields for c in bf if c.id == atk_id), None)
if atk is None:
continue
a_pow = atk.get_power()
has_flying = atk.has_keyword("flying")
valid_blockers = [
b for b in opp_blockers
if b.id not in used_blockers
and (not has_flying or b.has_keyword("flying") or b.has_keyword("reach"))
]
blocker = None
if valid_blockers and (not atk.has_keyword("menace") or len(valid_blockers) >= 2):
if atk.has_keyword("menace") and len(valid_blockers) >= 2:
valid_blockers.sort(key=lambda b: -b.get_toughness())
blocker = valid_blockers[0]
used_blockers.add(valid_blockers[1].id)
else:
blocker = next(
(b for b in valid_blockers if b.get_toughness() > a_pow),
None,
)
opp = sim.players[opp_idx]
if blocker is None and opp.life <= a_pow * 2 and valid_blockers:
blocker = valid_blockers[0]
if blocker:
used_blockers.add(blocker.id)
combat.blockers[atk_id] = [blocker.id]
if events is not None:
events.append(f"{atk.name} blocked by {blocker.name}")
else:
# Unblocked — full damage goes to player
combat.player_damage[atk_id] = a_pow
def resolve_combat_damage(
self, sim: SimState, pi: int, combat: CombatState,
first_strike_only: bool = False,
events: list | None = None,
) -> None:
"""Phase 3 of split combat: apply damage from CombatState.
When first_strike_only=True, only processes creatures with
first_strike or double_strike. When False, processes all
remaining (skipping first-strikers if already resolved).
"""
opp_idx = combat.defending_seat
p = sim.players[pi]
total_damage = 0
attacker_damage: list[tuple[Card, int]] = []
combat_details: list[str] = []
for atk_id, def_seat in list(combat.attackers.items()):
atk = next((c for bf in sim.battlefields for c in bf if c.id == atk_id), None)
if atk is None:
continue # removed by instant before damage
is_fs = atk.has_keyword("first_strike") or atk.has_keyword("double_strike")
if first_strike_only and not is_fs:
continue
if not first_strike_only and is_fs and combat.first_strike_resolved:
if not atk.has_keyword("double_strike"):
continue # already dealt damage in FS step
a_pow = atk.get_power()
a_tou = atk.get_toughness()
blocker_ids = combat.blockers.get(atk_id, [])
if blocker_ids:
blocker = next((c for bf in sim.battlefields for c in bf if c.id == blocker_ids[0]), None)
if blocker is None:
# Blocker removed — attacker is still blocked but deals no combat damage to player
continue
b_pow = blocker.get_power()
blocker.damage_marked += a_pow
if blocker.damage_marked >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
sim.remove_from_battlefield(blocker.id)
self._send_to_graveyard(sim, blocker, opp_idx)
if events is not None:
combat_details.append(f" {blocker.name} dies")
atk.damage_marked += b_pow
if atk.damage_marked >= a_tou or blocker.has_keyword("deathtouch"):
sim.remove_from_battlefield(atk.id)
self._send_to_graveyard(sim, atk, pi)
if events is not None:
combat_details.append(f" {atk.name} dies")
# Trample over
trample_over = 0
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
trample_over = a_pow - blocker.get_toughness()
attacker_damage.append((atk, trample_over))
total_damage += trample_over
if atk.has_keyword("lifelink"):
p.life += min(a_pow, blocker.get_toughness()) + trample_over
else:
# Unblocked
atk_dmg = a_pow * 2 if atk.has_keyword("double_strike") else a_pow
if atk.has_keyword("lifelink"):
p.life += atk_dmg
attacker_damage.append((atk, atk_dmg))
total_damage += atk_dmg
# Route each attacker's damage through deal_damage()
for atk_card, dmg in attacker_damage:
if dmg > 0:
self.deal_damage(
sim, dmg, opp_idx,
source_card=atk_card, source_seat=pi,
is_combat=True,
)
if first_strike_only:
combat.first_strike_resolved = True
if events is not None and total_damage > 0:
opp = sim.players[opp_idx]
total_cmd_damage = sum(
dmg for (atk_card, dmg) in attacker_damage
if atk_card.is_commander and dmg > 0
)
cmd_note = f" ({total_cmd_damage} cmdr)" if total_cmd_damage > 0 else ""
events.append(
f"Dealt {total_damage} combat damage{cmd_note} to {opp.name} (now {opp.life} life)"
)
events.extend(combat_details)
def _build_result(
self,
sim: SimState,
@@ -1,152 +0,0 @@
"""
Forge Card Loader
=================
Parse Forge cardsfolder .txt files into structured data for sim enrichment.
Lazy-loaded and cached at module level.
"""
from __future__ import annotations
import logging
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from models.state import CFG
_log = logging.getLogger(__name__)
@dataclass
class ForgeCardData:
"""Structured representation of a single Forge .txt card definition."""
name: str = ""
mana_cost: str = ""
cmc: int = 0
type_line: str = ""
oracle_text: str = ""
pt: str = ""
keywords: list[str] = field(default_factory=list)
trigger_modes: list[str] = field(default_factory=list)
has_replacement_effect: bool = False
has_static_ability: bool = False
# -- Module-level lazy cache ---
_FORGE_CACHE: dict[str, ForgeCardData] = {}
_FORGE_CACHE_LOADED: bool = False
def _get_cards_folder() -> Path:
"""Resolve the Forge cardsfolder directory from CFG.forge_dir."""
if not CFG.forge_dir:
return Path("")
return Path(CFG.forge_dir) / "forge-gui" / "res" / "cardsfolder"
def _parse_forge_file(filepath: Path) -> Optional[ForgeCardData]:
"""Parse a single Forge .txt card file into ForgeCardData."""
try:
text = filepath.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
data = ForgeCardData()
lines = text.splitlines()
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
# Name
if line.startswith("Name:"):
data.name = line.split(":", 1)[1].strip()
# ManaCost
elif line.startswith("ManaCost:"):
raw = line.split(":", 1)[1].strip()
data.mana_cost = raw
data.cmc = _calc_cmc(raw)
# Types
elif line.startswith("Types:"):
data.type_line = line.split(":", 1)[1].strip()
# PT
elif line.startswith("PT:"):
data.pt = line.split(":", 1)[1].strip()
# Oracle
elif line.startswith("Oracle:"):
data.oracle_text = line.split(":", 1)[1].strip()
# K -- Keywords
elif line.startswith("K:"):
kw = line.split(":", 1)[1].strip()
if kw and kw not in data.keywords:
data.keywords.append(kw)
# T -- Trigger modes (e.g. T:Mode$ ChangesZone)
elif line.startswith("T:"):
mode_match = re.search(r"Mode\$\s*(\w+)", line)
if mode_match:
mode = mode_match.group(1)
if mode not in data.trigger_modes:
data.trigger_modes.append(mode)
# R -- Replacement effects
elif line.startswith("R:"):
data.has_replacement_effect = True
# S -- Static abilities
elif line.startswith("S:"):
data.has_static_ability = True
# SVar (secondary -- grab Oracle from SVar if not set)
elif line.startswith("SVar:") and not data.oracle_text:
if "Oracle:" in line:
oracle_part = line.split("Oracle:", 1)[1].strip()
data.oracle_text = oracle_part
return data if data.name else None
def _calc_cmc(mana_cost: str) -> int:
"""Calculate CMC from Forge mana cost string like '2 W U'."""
total = 0
for part in mana_cost.split():
part = part.strip().upper()
if not part:
continue
if part.isdigit():
total += int(part)
elif part == "X":
continue
else:
total += 1
return total
def _load_forge_cache() -> None:
"""Walk the Forge cardsfolder and cache every card by lowercase name."""
global _FORGE_CACHE_LOADED
if _FORGE_CACHE_LOADED:
return
cards_folder = _get_cards_folder()
if not cards_folder.is_dir():
_log.warning("Forge cardsfolder not found at %s", cards_folder)
_FORGE_CACHE_LOADED = True
return
count = 0
for root, _dirs, files in os.walk(cards_folder):
for fname in files:
if not fname.endswith(".txt"):
continue
fpath = Path(root) / fname
parsed = _parse_forge_file(fpath)
if parsed and parsed.name:
_FORGE_CACHE[parsed.name.lower()] = parsed
count += 1
_log.info("Forge card cache loaded: %d cards from %s", count, cards_folder)
_FORGE_CACHE_LOADED = True
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())
+13 -79
View File
@@ -15,7 +15,7 @@ import json
from dataclasses import dataclass, field
from typing import Optional
from commander_ai_lab.sim.models import Card, CombatState, Phase, Player, SimState
from commander_ai_lab.sim.models import Card, Player, SimState
# ── Mana Pool ────────────────────────────────────────────────
@@ -95,6 +95,9 @@ class CommanderPlayer:
# Whether this player has drawn their card this turn
drawn_this_turn: bool = False
# Commander damage received from each opponent (keyed by seat index)
commander_damage_received: dict[int, int] = field(default_factory=dict)
# ── Convenience pass-throughs to base Player ─────────────
@property
@@ -133,18 +136,13 @@ class CommanderPlayer:
def library(self) -> list[Card]:
return self.base.library
@property
def commander_damage_received(self) -> dict[int, int]:
"""Delegate to base Player so headless engine writes are visible."""
return self.base.commander_damage_received
def commander_tax(self) -> int:
"""Additional mana cost (2 per cast from command zone)."""
return self.commander_cast_count * 2
def is_dead_to_commander_damage(self) -> bool:
"""Returns True if any single commander has dealt 21+ damage."""
return self.base.is_dead_to_commander_damage()
"""Returns True if any single opponent has dealt 21+ commander damage."""
return any(v >= 21 for v in self.commander_damage_received.values())
def to_dict(self) -> dict:
return {
@@ -196,7 +194,7 @@ class CommanderGameState:
# Phase tracking
# Phases: untap, upkeep, draw, main1, combat_begin, combat_declare_attackers,
# combat_declare_blockers, combat_damage, combat_end, main2, end, cleanup
current_phase: str | Phase = Phase.MAIN1
current_phase: str = "main1"
# Turn number (mirrors sim_state.turn)
turn: int = 0
@@ -232,65 +230,13 @@ class CommanderGameState:
# Bug 9 fix: stub get_legal_moves() and apply_move() so turn_manager
# doesn't crash. These delegate to the rules engine once implemented.
def get_legal_moves(self, seat: int) -> list[dict]:
"""Return legal moves for a seat.
"""Return legal moves for a seat. Stub — returns pass-only."""
return [{"id": 0, "category": "pass_priority", "description": "Pass"}]
During DECLARE_BLOCKERS phase, emits block moves for defending
creatures against each attacker in sim_state.combat.
Otherwise returns pass-only (stub for remaining rules engine).
"""
moves: list[dict] = [{"id": 0, "category": "pass_priority", "description": "Pass"}]
combat = self.sim_state.combat
# Emit block moves during declare_blockers phase
if (
combat
and combat.active
and self.current_phase in (Phase.DECLARE_BLOCKERS, "declare_blockers")
and seat != self.active_player_seat
):
move_id = 1000
my_creatures = [
c for c in self.sim_state.get_battlefield(seat)
if c.is_creature() and not c.tapped
]
for atk_id in combat.attackers:
atk = next(
(c for bf in self.sim_state.battlefields for c in bf if c.id == atk_id),
None,
)
if atk is None:
continue
for blocker in my_creatures:
moves.append({
"id": move_id,
"category": "block",
"blocker_id": blocker.id,
"attacker_id": atk_id,
"description": f"Block {atk.name} with {blocker.name}",
})
move_id += 1
return moves
def apply_move(self, seat: int, move_id: int) -> None:
"""Apply a chosen move.
"""Apply a chosen move. Stub — no-op until rules engine is wired."""
pass
Handles 'block' moves by updating sim_state.combat.blockers.
Other categories are no-op stubs until the rules engine is wired.
"""
# Find the move dict
all_moves = self.get_legal_moves(seat)
move = next((m for m in all_moves if m["id"] == move_id), None)
if move is None:
return
if move.get("category") == "block":
combat = self.sim_state.combat
if combat and combat.active:
atk_id = move["attacker_id"]
blocker_id = move["blocker_id"]
if atk_id not in combat.blockers:
combat.blockers[atk_id] = []
combat.blockers[atk_id].append(blocker_id)
def living_players(self) -> list[CommanderPlayer]:
return [p for p in self.commander_players if not p.eliminated]
@@ -301,25 +247,13 @@ class CommanderGameState:
return p.commander_damage_received.get(from_seat, 0)
return 0
def deal_commander_damage(
self, from_seat: int, to_seat: int, amount: int,
commander_name: str | None = None,
) -> None:
"""Record commander damage and reduce life total.
If *commander_name* is provided the per-card breakdown is also
updated (needed for partner-correct 21-damage checks).
"""
def deal_commander_damage(self, from_seat: int, to_seat: int, amount: int) -> None:
"""Record commander damage and reduce life total."""
if to_seat < len(self.commander_players):
p = self.commander_players[to_seat]
p.commander_damage_received[from_seat] = (
p.commander_damage_received.get(from_seat, 0) + amount
)
if commander_name is not None:
key = (from_seat, commander_name)
p.base.commander_damage_by_card[key] = (
p.base.commander_damage_by_card.get(key, 0) + amount
)
p.life -= amount
# ── Serialization ────────────────────────────────────────
+3 -98
View File
@@ -11,73 +11,12 @@ SimState.battlefields is per-player: battlefields[seat_index] holds that player'
from __future__ import annotations
import copy
import enum
import json
import re
from dataclasses import dataclass, field
from typing import Optional
# ── Turn Phases ──────────────────────────────────────────────
class Phase(str, enum.Enum):
"""MTG turn phases in order.
Using ``str`` as a mixin allows direct comparison with plain strings
and clean JSON serialization (``phase.value`` is a string).
"""
UNTAP = "untap"
UPKEEP = "upkeep"
DRAW = "draw"
MAIN1 = "main1"
BEGIN_COMBAT = "begin_combat"
DECLARE_ATTACKERS = "declare_attackers"
DECLARE_BLOCKERS = "declare_blockers"
COMBAT_DAMAGE = "combat_damage"
END_COMBAT = "end_combat"
MAIN2 = "main2"
END_STEP = "end_step"
CLEANUP = "cleanup"
# Ordered tuple for iteration
PHASE_ORDER: tuple[Phase, ...] = tuple(Phase)
# Phases where sorcery-speed actions (land drops, casting creatures) are legal
SORCERY_PHASES: frozenset[Phase] = frozenset({Phase.MAIN1, Phase.MAIN2})
# ── Combat State ───────────────────────────────────────────
@dataclass
class CombatState:
"""Transient combat state shared across combat sub-phases.
Created at BEGIN_COMBAT, cleared to None at END_COMBAT.
Allows priority windows between attacker declaration, blocker
declaration, and damage resolution (Issue #86 Item 1).
"""
# Seat index of the defending player (multiplayer: chosen target)
defending_seat: int = -1
# attacker card_id -> defending seat
attackers: dict[int, int] = field(default_factory=dict)
# attacker card_id -> list of blocker card_ids
blockers: dict[int, list[int]] = field(default_factory=dict)
# Damage routed to the defending player per-attacker (for unblocked / trample)
player_damage: dict[int, int] = field(default_factory=dict)
# Whether the first-strike damage sub-step has already resolved
first_strike_resolved: bool = False
# Whether combat is currently active
active: bool = False
@dataclass
class Card:
"""A single card in the simulation."""
@@ -98,15 +37,6 @@ class Card:
is_removal: bool = False
is_board_wipe: bool = False
is_commander: bool = False
is_direct_damage: bool = False
direct_damage_amount: int = 0
# Forge enrichment fields (set by forge_card_loader)
forge_enriched: bool = False
forge_keywords: list[str] = field(default_factory=list)
forge_trigger_modes: list[str] = field(default_factory=list)
has_replacement_effect: bool = False
has_static_ability: bool = False
# Runtime fields (set during game)
id: int = 0
@@ -170,8 +100,6 @@ class PlayerStats:
"""Per-game statistics tracked for a player."""
cards_drawn: int = 7 # starting hand
mulligans: int = 0
opening_hand_lands: int = 0
lands_played: int = 0
spells_cast: int = 0
creatures_played: int = 0
@@ -187,8 +115,6 @@ class PlayerStats:
def to_dict(self) -> dict:
return {
"cardsDrawn": self.cards_drawn,
"mulligans": self.mulligans,
"openingHandLands": self.opening_hand_lands,
"landsPlayed": self.lands_played,
"spellsCast": self.spells_cast,
"creaturesPlayed": self.creatures_played,
@@ -226,29 +152,13 @@ class Player:
# p.commander_tax[name] = tax + 2
commander_tax: dict = field(default_factory=dict)
# Commander damage received from each opponent, keyed by seat index.
# This is the aggregate per-seat total (used by GUI matrix and prompt
# builder). For games where a player controls partner commanders
# the per-card breakdown lives in commander_damage_by_card.
# Commander damage received from each opponent seat.
# Keyed by opponent seat index; value is cumulative damage.
# A player is eliminated when any single value reaches 21.
commander_damage_received: dict[int, int] = field(default_factory=dict)
# Per-commander breakdown: {(seat, card_name): damage}.
# In MTG rules, lethal commander damage (21+) is tracked per individual
# commander card, not per player. This dict captures that.
commander_damage_by_card: dict[tuple[int, str], int] = field(default_factory=dict)
stats: PlayerStats = field(default_factory=PlayerStats)
def is_dead_to_commander_damage(self) -> bool:
"""Returns True if any single commander has dealt 21+ damage.
Checks per-card tracking first (partner-correct); falls back to
per-seat totals for backward compatibility with older game states.
"""
if self.commander_damage_by_card:
return any(v >= 21 for v in self.commander_damage_by_card.values())
return any(v >= 21 for v in self.commander_damage_received.values())
@dataclass
class SimState:
@@ -264,11 +174,6 @@ class SimState:
turn: int = 0
max_turns: int = 25
next_card_id: int = 90000
current_phase: Phase = Phase.MAIN1
active_player_index: int = 0
# Combat state (transient, set during combat phases)
combat: Optional[CombatState] = None
# ── Battlefield helpers ──────────────────────────────────
+4 -147
View File
@@ -19,8 +19,6 @@ import re
from typing import Optional
from commander_ai_lab.sim.models import Card
# Forge enrichment (sibling module inside sim/)
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card, ForgeCardData
# ══════════════════════════════════════════════════════════════
@@ -141,47 +139,6 @@ _KNOWN_CARDS: list[tuple[re.Pattern, dict]] = [
]
def _apply_forge_data(card: Card, forge_data: ForgeCardData) -> None:
"""
Apply Forge card data onto a Card, merging keywords into both fields.
ISSUE 3 FIX: Writes to both card.keywords AND card.forge_keywords
so existing score_card() checks like `if "flying" in kw_list` work.
ISSUE 4 FIX: Sets card.oracle_text BEFORE returning, so the caller
can safely call _apply_oracle_flags(card) afterward.
"""
# Forge-specific tracking fields
card.forge_keywords = list(forge_data.keywords)
card.forge_trigger_modes = list(forge_data.trigger_modes)
card.has_replacement_effect = forge_data.has_replacement_effect
card.has_static_ability = forge_data.has_static_ability
# ISSUE 3: Merge keywords into card.keywords (set union, no dupes)
existing = {k.lower() for k in (card.keywords or [])}
for kw in forge_data.keywords:
if kw.lower() not in existing:
card.keywords.append(kw)
existing.add(kw.lower())
# Overwrite type/cost/pt only if Forge has data AND card doesn't
if forge_data.type_line and (not card.type_line or len(card.type_line) < 3):
card.type_line = forge_data.type_line
if forge_data.mana_cost and not card.mana_cost:
card.mana_cost = forge_data.mana_cost
card.cmc = forge_data.cmc
if forge_data.pt and not card.pt:
card.pt = forge_data.pt
parts = forge_data.pt.split("/", 1)
if len(parts) == 2:
card.power = parts[0].strip()
card.toughness = parts[1].strip()
# ISSUE 4: Set oracle_text so _apply_oracle_flags sees Forge text
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.
@@ -192,19 +149,6 @@ def enrich_card(card: Card) -> Card:
has_real_data = card.cmc > 0 or (card.type_line and len(card.type_line) > 5)
# Try Forge enrichment first for cards without Scryfall data
if not has_real_data:
forge_data = lookup_forge_card(card.name)
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
has_real_data = card.cmc > 0 or (card.type_line and len(card.type_line) > 5)
if has_real_data:
_apply_oracle_flags(card)
return card
# Otherwise fall through so known-cards / default heuristics can fill gaps
if not has_real_data:
# Basic lands
if name_lower in _BASIC_LAND_COLORS:
@@ -223,21 +167,6 @@ def enrich_card(card: Card) -> Card:
_apply_oracle_flags(card)
return card
# ── Nonbasic land name guard (Issue #118) ──────────────────
# Catch common nonbasic lands BEFORE the creature fallback
_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|"
r"command tower|path of ancestry|evolving wilds|terramorphic|expanse|"
r"exotic orchard|mana confluence|city of brass|reflecting pool|"
r"fabled passage|prismatic vista)\b", re.IGNORECASE)
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).
@@ -279,10 +208,6 @@ def _apply_oracle_flags(card: Card) -> None:
]):
card.is_removal = True
# Direct-damage spells (burn): "deals N damage to target/any target"
if not card.is_direct_damage:
_detect_direct_damage(card, oracle)
# Bug 17 fix: evaluate ramp independently — cards can be both removal and ramp
# (e.g. Nature's Claim, Beast Within, Abrupt Decay)
if not card.is_ramp:
@@ -299,64 +224,6 @@ def _apply_oracle_flags(card: Card) -> None:
card.is_ramp = True
# ── 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(
r"deals?\s+(\d+)\s+damage\s+to\s+(?:target|any\s+target)",
re.IGNORECASE,
)
# Well-known burn spells with fixed damage amounts for reliable detection
_KNOWN_BURN: dict[str, int] = {
"lightning bolt": 3,
"shock": 2,
"lightning strike": 3,
"lava spike": 3,
"rift bolt": 3,
"searing blaze": 3,
"chain lightning": 3,
"flame rift": 4,
"exquisite firecraft": 4,
"lightning helix": 3,
"warleader's helix": 4,
"boros charm": 4,
"searing spear": 3,
"incinerate": 3,
"magma jet": 2,
"searing blood": 2,
"firebolt": 2,
"galvanic blast": 2,
"skullcrack": 3,
"flames of the firebrand": 3,
"stoke the flames": 4,
}
def _detect_direct_damage(card: Card, oracle: str) -> None:
"""Detect direct-damage (burn) spells and set card flags."""
# Check known-burn table first (most reliable)
name_lower = (card.name or "").lower().strip()
if name_lower in _KNOWN_BURN:
card.is_direct_damage = True
card.direct_damage_amount = _KNOWN_BURN[name_lower]
return
# Fall back to regex detection from oracle text
if not oracle:
return
# Skip board wipes and each-creature effects
if card.is_board_wipe:
return
if "each creature" in oracle or "all creature" in oracle:
return
m = _DIRECT_DAMAGE_RE.search(oracle)
if m:
card.is_direct_damage = True
card.direct_damage_amount = int(m.group(1))
# ── Multi-opponent scaling factors ────────────────────────────
# Keywords / effects that become more valuable in larger pods.
# Each entry maps a weight key → the extra bonus *per additional
@@ -451,21 +318,11 @@ def score_card(
score += w.get("kw_cascade", 3.0)
if "defender" in text or "defender" in kw_list:
score += w.get("kw_defender", -2.0)
# NEW
_has_etb = False
if card.forge_trigger_modes and "ChangesZone" in card.forge_trigger_modes:
_has_etb = True
elif "when" in text and "enters" in text:
_has_etb = True
if _has_etb:
# ── Triggered ability bonuses ──
if "when" in text and "enters" in text:
score += w.get("trig_etb", 2.0) + _MULTI_OPP_SCALING.get("trig_etb", 0.0) * extra_opps
# NEW
_has_attack_trig = False
if card.forge_trigger_modes and "Attacks" in card.forge_trigger_modes:
_has_attack_trig = True
elif "whenever" in text and "attack" in text:
_has_attack_trig = True
if _has_attack_trig:
if "whenever" in text and "attack" in text:
score += w.get("trig_attack", 1.5)
if "when" in text and "dies" in text:
score += w.get("trig_dies", 1.0) + _MULTI_OPP_SCALING.get("trig_dies", 0.0) * extra_opps
+21 -69
View File
@@ -46,29 +46,38 @@ from __future__ import annotations
import asyncio
import logging
import os
import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Awaitable, Callable, Optional, Set
from commander_ai_lab.sim.models import Phase, PHASE_ORDER, SORCERY_PHASES, CombatState
from commander_ai_lab.sim.game_state import CommanderGameState, CommanderPlayer
from commander_ai_lab.sim.ai_opponent import AIOpponent
from commander_ai_lab.sim.threat_assessor import assess_threats, ThreatScore
logger = logging.getLogger("turn_manager")
# Re-export for backward compatibility; canonical source is models.py
PHASES = [p.value for p in PHASE_ORDER]
# Commander turn phases in order
PHASES = [
"untap",
"upkeep",
"draw",
"main1",
"begin_combat",
"declare_attackers",
"declare_blockers",
"combat_damage",
"end_combat",
"main2",
"end_step",
"cleanup",
]
# Phases where a player can take an action (play land, cast spell, etc.)
ACTION_PHASES = {p.value for p in SORCERY_PHASES}
ACTION_PHASES = {"main1", "main2"}
# Phases where instant-speed responses can happen
RESPONSE_PHASES = {
Phase.UPKEEP.value,
Phase.END_STEP.value,
}
RESPONSE_PHASES = {"upkeep", "begin_combat", "end_combat", "end_step"}
@dataclass
@@ -89,7 +98,7 @@ class GameEvent:
class TurnManagerConfig:
"""Tunable parameters for the turn manager."""
max_turns: int = 40
ai_decision_timeout: float = float(os.environ.get("BRAIN_TIMEOUT", "300.0")) # seconds before fallback
ai_decision_timeout: float = 30.0 # seconds before fallback
ai_narration_enabled: bool = True
ai_decision_delay: float = 0.0 # cosmetic pause (set > 0 for UI readability)
priority_pass_limit: int = 8 # max APNAP passes per phase before auto-advance
@@ -353,13 +362,7 @@ class CommanderTurnManager:
await self._apnap_priority_window(active_seat, turn_num, phase)
async def _phase_combat(self, active_seat: int, turn_num: int) -> None:
"""Full combat sequence with priority windows at each sub-phase (Issue #86)."""
sim = self.gs.sim_state
# 1. Beginning of combat — priority window BEFORE attackers chosen
await self._apnap_priority_window(active_seat, turn_num, "begin_combat")
# 2. Declare attackers
"""Declare attackers phase."""
legal_moves = self.get_legal_moves(active_seat)
attack_moves = [m for m in legal_moves if m.get("category") == "attack"]
if not attack_moves:
@@ -388,44 +391,9 @@ class CommanderTurnManager:
narration=narration,
))
# 3. After-attackers priority window
await self._apnap_priority_window(active_seat, turn_num, "declare_attackers")
# 4. Declare blockers — each defending player in APNAP order
for defender_seat in [s for s in range(len(self.gs.players))
if s != active_seat
and not self.gs.players[s].eliminated]:
block_moves = [m for m in self.get_legal_moves(defender_seat)
if m.get("category") == "block"]
if block_moves:
block_id = await self._get_action(defender_seat, block_moves, turn_num)
if block_id is not None:
self.gs.apply_move(defender_seat, block_id)
# 5. After-blockers priority window
# Defending players get priority to declare blockers
await self._apnap_priority_window(active_seat, turn_num, "declare_blockers")
# 6. First-strike damage sub-step (if applicable)
if self._has_first_strikers():
# Resolve first-strike damage via engine split method
if sim.combat and sim.combat.active:
from commander_ai_lab.sim.engine import GameEngine
engine = GameEngine()
engine.resolve_combat_damage(sim, active_seat, sim.combat, first_strike_only=True)
await self._apnap_priority_window(active_seat, turn_num, "first_strike_damage")
# 7. Normal combat damage
if sim.combat and sim.combat.active:
from commander_ai_lab.sim.engine import GameEngine
engine = GameEngine()
engine.resolve_combat_damage(sim, active_seat, sim.combat, first_strike_only=False)
# 8. End of combat priority window
await self._apnap_priority_window(active_seat, turn_num, "end_combat")
# 9. Clear combat state
sim.combat = None
async def _phase_cleanup(self, active_seat: int) -> None:
"""Discard to hand size (7), remove end-of-turn effects."""
player = self.gs.players[active_seat]
@@ -438,22 +406,6 @@ class CommanderTurnManager:
player.name, discarded.name,
)
def _has_first_strikers(self) -> bool:
"""Check if any creature in active combat has first_strike or double_strike."""
sim = self.gs.sim_state
combat = sim.combat
if not combat or not combat.active:
return False
all_combat_ids = (
list(combat.attackers.keys()) +
[bid for blist in combat.blockers.values() for bid in blist]
)
for card_id in all_combat_ids:
card = next((c for bf in sim.battlefields for c in bf if c.id == card_id), None)
if card and (card.has_keyword("first_strike") or card.has_keyword("double_strike")):
return True
return False
# ── Priority Passing (APNAP) ──────────────────────────────────────────────
async def _apnap_priority_window(
+3 -3
View File
@@ -50,7 +50,7 @@ class ValidatorConfig:
Example -- LM Studio::
cfg = ValidatorConfig(
api_base="http://localhost:1234",
api_base="http://192.168.0.240:1234",
model="deepseek-r1-distill-qwen-14b",
)
"""
@@ -58,7 +58,7 @@ class ValidatorConfig:
model: str = "deepseek-r1:14b"
temperature: float = 0.0
max_tokens: int = 2048
request_timeout: float = 300.0
request_timeout: float = 60.0
enabled: bool = True
@classmethod
@@ -70,7 +70,7 @@ class ValidatorConfig:
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:14b"),
temperature=float(os.environ.get("VALIDATOR_TEMPERATURE", "0.0")),
max_tokens=int(os.environ.get("VALIDATOR_MAX_TOKENS", "2048")),
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "300.0")),
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "60.0")),
enabled=(enabled_str == "true"),
)
@@ -1,359 +0,0 @@
package commanderailab.ai;
import commanderailab.ml.LearnedPolicy;
import commanderailab.ml.MacroActionExecutor;
import commanderailab.ml.PolicyServerClient;
import commanderailab.schema.DecisionSnapshot;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentLinkedQueue;
/**
* PolicyClient — Phase 2 bridge between Forge decision points and the
* Python policy server for live online training.
*
* This class extends the existing LearnedPolicy/PolicyServerClient
* infrastructure with two key Phase 2 capabilities:
*
* 1. Calls the new /api/policy/decide endpoint (purpose-built for live
* IPC with richer state including all zones, stack, and priority)
* instead of the batch-oriented /api/ml/predict.
*
* 2. Collects (state, action, reward) tuples in real-time during live
* Forge games and flushes them to the policy server for online PPO
* updates via POST /api/policy/collect.
*
* Falls back to ForgeBuiltinPolicy heuristics if the policy server
* is unreachable or returns an error.
*
* Usage:
* PolicyClient client = new PolicyClient("http://localhost:8080");
* if (client.connect()) {
* // At each Forge decision point:
* String action = client.decide(fullGameState);
* // After game ends:
* client.submitReward(gameId, reward);
* client.flushCollectedTuples();
* }
*
* Refs: Issue #83 Phase 2.1
*/
public class PolicyClient {
private static final Gson GSON = new GsonBuilder().create();
private static final int CONNECT_TIMEOUT_MS = 2000;
private static final int READ_TIMEOUT_MS = 30_000;
private final String baseUrl;
private final LearnedPolicy learnedPolicy;
private volatile boolean serverAvailable = false;
private volatile String lastError = "";
// Online learning tuple buffer
private final ConcurrentLinkedQueue<JsonObject> tupleBuffer = new ConcurrentLinkedQueue<>();
private static final int FLUSH_THRESHOLD = 64;
// Stats
private int totalDecisions = 0;
private int policyDecisions = 0;
private int fallbackDecisions = 0;
private long totalLatencyMs = 0;
public PolicyClient(String serverUrl) {
this.baseUrl = serverUrl.endsWith("/")
? serverUrl.substring(0, serverUrl.length() - 1)
: serverUrl;
this.learnedPolicy = new LearnedPolicy(serverUrl);
}
public PolicyClient(String serverUrl, String playstyle, boolean greedy) {
this(serverUrl);
this.learnedPolicy.setPlaystyle(playstyle);
this.learnedPolicy.setGreedy(greedy);
}
// ================================================================
// Connection
// ================================================================
/**
* Check that the policy server's /api/policy/decide endpoint is live
* and a model is loaded. Falls back to checking /api/ml/model if the
* new endpoint isn't available yet.
*/
public boolean connect() {
try {
String json = httpGet(baseUrl + "/api/policy/health");
serverAvailable = json.contains("\"ready\":true")
|| json.contains("\"status\":\"ok\"");
} catch (Exception e) {
// Fall back to legacy health check
serverAvailable = learnedPolicy.connect();
}
if (serverAvailable) {
System.out.println("[PolicyClient] Connected to policy server");
} else {
System.out.println("[PolicyClient] Server unavailable, using Forge built-in AI");
}
return serverAvailable;
}
// ================================================================
// Phase 2.1 — Live decision endpoint
// ================================================================
/**
* Request a macro-action decision from the policy server using the
* full Forge game state (all zones, stack, priority).
*
* This calls POST /api/policy/decide which is purpose-built for the
* live IPC loop, as opposed to /api/ml/predict which was designed
* for batch inference.
*
* @param gameState Full game state JSON with all zones and priority info
* @return macro-action name (e.g., "cast_creature"), or "pass" on failure
*/
public String decide(JsonObject gameState) {
totalDecisions++;
long t0 = System.currentTimeMillis();
if (!serverAvailable) {
fallbackDecisions++;
return MacroActionExecutor.PASS;
}
try {
// Add inference params
gameState.addProperty("playstyle", learnedPolicy.getPlaystyle());
gameState.addProperty("greedy", learnedPolicy.isGreedy());
String responseJson = httpPost(
baseUrl + "/api/policy/decide",
gameState.toString()
);
JsonObject resp = GSON.fromJson(responseJson, JsonObject.class);
long elapsed = System.currentTimeMillis() - t0;
totalLatencyMs += elapsed;
if (resp.has("action") && !resp.has("error")) {
policyDecisions++;
String action = resp.get("action").getAsString();
// Store tuple for online learning
collectTuple(gameState, action, resp);
return action;
} else {
fallbackDecisions++;
lastError = resp.has("error")
? resp.get("error").getAsString()
: "Unknown error";
return MacroActionExecutor.PASS;
}
} catch (Exception e) {
fallbackDecisions++;
lastError = e.getMessage();
return MacroActionExecutor.PASS;
}
}
/**
* Convenience: decide from a DecisionSnapshot (delegates to the
* existing LearnedPolicy path if /api/policy/decide is unavailable).
*/
public String decide(DecisionSnapshot snapshot) {
return learnedPolicy.decideAction(snapshot);
}
/**
* Resolve a decision to a concrete Forge action.
*/
public MacroActionExecutor.ActionResolution resolveAction(
JsonObject gameState,
List<String> hand,
List<String> battlefield,
List<String> commandZone,
int manaAvailable,
String commanderName,
int commanderTax
) {
String macroAction = decide(gameState);
return MacroActionExecutor.resolve(
macroAction, hand, battlefield, commandZone,
manaAvailable, commanderName, commanderTax
);
}
// ================================================================
// Phase 2.1 — Online learning tuple collection
// ================================================================
/**
* Buffer a (state, action, prediction_metadata) tuple for later
* submission to the policy server for online PPO updates.
*/
private void collectTuple(JsonObject state, String action, JsonObject prediction) {
JsonObject tuple = new JsonObject();
tuple.add("state", state);
tuple.addProperty("action", action);
if (prediction.has("action_index")) {
tuple.addProperty("action_index", prediction.get("action_index").getAsInt());
}
if (prediction.has("log_prob")) {
tuple.addProperty("log_prob", prediction.get("log_prob").getAsDouble());
}
if (prediction.has("value")) {
tuple.addProperty("value", prediction.get("value").getAsDouble());
}
tuple.addProperty("timestamp", System.currentTimeMillis());
tupleBuffer.add(tuple);
// Auto-flush when buffer is full
if (tupleBuffer.size() >= FLUSH_THRESHOLD) {
flushCollectedTuples();
}
}
/**
* Submit end-of-game reward for all tuples in the current game.
* Call this after a Forge game completes.
*/
public void submitReward(String gameId, double reward) {
try {
JsonObject body = new JsonObject();
body.addProperty("game_id", gameId);
body.addProperty("reward", reward);
httpPost(baseUrl + "/api/policy/reward", body.toString());
} catch (Exception e) {
System.err.println("[PolicyClient] Failed to submit reward: " + e.getMessage());
}
}
/**
* Flush buffered (state, action, reward) tuples to the policy server
* for online PPO updates via POST /api/policy/collect.
*/
public void flushCollectedTuples() {
if (tupleBuffer.isEmpty()) return;
JsonArray batch = new JsonArray();
JsonObject tuple;
while ((tuple = tupleBuffer.poll()) != null) {
batch.add(tuple);
}
try {
JsonObject body = new JsonObject();
body.add("tuples", batch);
body.addProperty("count", batch.size());
httpPost(baseUrl + "/api/policy/collect", body.toString());
System.out.println("[PolicyClient] Flushed " + batch.size() + " tuples");
} catch (Exception e) {
System.err.println("[PolicyClient] Failed to flush tuples: " + e.getMessage());
// Re-queue on failure
for (int i = 0; i < batch.size(); i++) {
tupleBuffer.add(batch.get(i).getAsJsonObject());
}
}
}
// ================================================================
// Accessors
// ================================================================
public boolean isServerAvailable() { return serverAvailable; }
public String getLastError() { return lastError; }
public int getTotalDecisions() { return totalDecisions; }
public int getPolicyDecisions() { return policyDecisions; }
public String getBaseUrl() { return baseUrl; }
/** Decision result record for GameSession integration. */
public record PolicyDecision(String action, double confidence) {}
/**
* Decide from a JSON string + seat index. Used by GameSession.
* Converts to JsonObject and delegates to decide(JsonObject).
*/
public PolicyDecision decide(String stateJson, int seat) {
JsonObject state = GSON.fromJson(stateJson, JsonObject.class);
state.addProperty("seat", seat);
String action = decide(state);
return new PolicyDecision(action, 1.0);
}
public int getFallbackDecisions() { return fallbackDecisions; }
public int getPendingTuples() { return tupleBuffer.size(); }
public double getAverageLatencyMs() {
return policyDecisions > 0
? (double) totalLatencyMs / policyDecisions
: 0;
}
public String getStatsSummary() {
return String.format(
"PolicyClient[decisions=%d, policy=%d, fallback=%d, avg=%.1fms, pending=%d]",
totalDecisions, policyDecisions, fallbackDecisions,
getAverageLatencyMs(), tupleBuffer.size()
);
}
// ================================================================
// HTTP helpers
// ================================================================
private String httpGet(String url) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
conn.setRequestProperty("Accept", "application/json");
int code = conn.getResponseCode();
if (code != 200) {
throw new IOException("HTTP " + code + ": " + readStream(conn.getErrorStream()));
}
return readStream(conn.getInputStream());
}
private String httpPost(String url, String jsonBody) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "application/json");
try (OutputStream os = conn.getOutputStream()) {
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
}
int code = conn.getResponseCode();
if (code != 200) {
throw new IOException("HTTP " + code + ": " + readStream(conn.getErrorStream()));
}
return readStream(conn.getInputStream());
}
private String readStream(InputStream stream) throws IOException {
if (stream == null) return "";
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
return sb.toString();
}
}
}
@@ -17,12 +17,6 @@ import java.util.concurrent.atomic.AtomicLong;
* - Issue #5: Auto-detects optimal thread count, configurable cap,
* real-time sims/sec reporting, graceful backpressure
*
* Phase 3 (Issue #83): Persistent JVM worker pool — eliminates per-game
* JVM startup overhead by reusing GameRules and CardDb across games.
* - Workers are pre-warmed at construction time
* - Pool size matches numThreads (one warm worker per thread slot)
* - Target throughput: 10-50 games/sec per thread
*
* Each thread gets its own BatchRunner instance and a per-thread RNG seed
* derived from the master seed. Results are merged after all threads complete.
*/
@@ -49,17 +43,6 @@ public class MultiThreadBatchRunner {
private boolean useSimplifiedAi = false;
private int aiThinkTimeMs = -1;
// ── ML Decision Logging ──────────────────────────────────────
private boolean mlLogEnabled = false;
private String mlOutputDir = "results";
private String mlBatchId;
// ── Phase 3: Persistent JVM worker pool ──────────────────────
// Pre-warmed BatchRunner instances — one per thread slot.
// Reusing them avoids per-game JVM startup cost (GameRules, CardDb init).
private final List<BatchRunner> workerPool = new ArrayList<>();
private boolean poolWarmed = false;
public MultiThreadBatchRunner(String forgeJarPath, String forgeWorkDir,
List<DeckInfo> decks, AiPolicy policy, int numThreads) {
this(forgeJarPath, forgeWorkDir, decks, policy, numThreads, true, 120, "java");
@@ -84,104 +67,65 @@ public class MultiThreadBatchRunner {
this.javaPath = (javaPath != null && !javaPath.isEmpty()) ? javaPath : "java";
}
// ── Configuration setters ────────────────────────────────────
/**
* Set progress callback for real-time reporting (Issue #5).
*/
public void setProgressCallback(BatchRunner.ProgressCallback callback) {
this.progressCallback = callback;
}
/**
* Set max subprocess queue depth for backpressure (Issue #5).
* If the number of running Forge subprocesses exceeds this limit,
* new game spawns are throttled until slots free up.
*
* @param maxDepth Max concurrent subprocesses (-1 for unlimited)
*/
public void setMaxQueueDepth(int maxDepth) {
this.maxQueueDepth = maxDepth;
}
/**
* Configure AI optimization flags (Issue #4).
*/
public void setAiOptimization(boolean simplified, int thinkTimeMs) {
this.useSimplifiedAi = simplified;
this.aiThinkTimeMs = thinkTimeMs;
}
public void enableMlLogging(String outputDir, String batchId) {
this.mlLogEnabled = true;
this.mlOutputDir = outputDir;
this.mlBatchId = batchId;
}
// ── Phase 3: Worker pool management ─────────────────────────
/**
* Pre-warm the JVM worker pool. Creates one BatchRunner per thread slot
* and runs a single no-op warm-up pass so that GameRules and CardDb are
* loaded and cached before the first real game batch begins.
*
* Call this once after construction, before runBatch(). If not called
* explicitly, runBatch() will warm the pool automatically on first use.
*
* Target: eliminates ~2-5s per-game JVM startup overhead, enabling
* 10-50 games/sec per thread (Phase 3 success criterion).
*/
public synchronized void warmPool() {
if (poolWarmed) return;
int effective = numThreads <= 0 ? detectOptimalThreads() : numThreads;
System.out.printf("[POOL] Pre-warming %d JVM worker(s)...%n", effective);
long warmStart = System.currentTimeMillis();
workerPool.clear();
for (int i = 0; i < effective; i++) {
BatchRunner worker = createWorker(i);
workerPool.add(worker);
}
long warmMs = System.currentTimeMillis() - warmStart;
System.out.printf("[POOL] Worker pool ready (%d workers) in %.1fs%n",
effective, warmMs / 1000.0);
poolWarmed = true;
}
/**
* Shut down the worker pool and release resources.
* Call after all runBatch() calls are complete.
*/
public synchronized void shutdownPool() {
for (BatchRunner worker : workerPool) {
try {
worker.closeMlLogger();
} catch (Exception ignored) {}
}
workerPool.clear();
poolWarmed = false;
System.out.println("[POOL] Worker pool shut down.");
}
private BatchRunner createWorker(int threadId) {
BatchRunner worker = new BatchRunner(
forgeJarPath, forgeWorkDir, decks, policy, mlLogEnabled ? false : quiet, clockSeconds, javaPath);
worker.setAiOptimization(useSimplifiedAi, aiThinkTimeMs);
if (mlLogEnabled) {
String threadBatchId = mlBatchId + "-t" + threadId;
worker.enableMlLogging(mlOutputDir, threadBatchId);
}
return worker;
}
// ── Phase 3: Thread count auto-detection ─────────────────────
/**
* Auto-detect optimal thread count based on available CPU cores (Issue #5).
*
* Uses physical core count (not logical/hyperthreaded) when possible.
* Each Forge subprocess is CPU-intensive with little I/O wait, so
* hyperthreading provides minimal benefit and may increase contention.
*
* @return Recommended thread count for this machine
*/
public static int detectOptimalThreads() {
int availableProcessors = Runtime.getRuntime().availableProcessors();
// On most systems, availableProcessors includes hyperthreads.
// For CPU-bound Forge sims, physical cores are what matter.
// Heuristic: assume HT gives 2x logical cores on Intel/AMD
int estimatedPhysical = availableProcessors;
// Try to detect if hyperthreading is active
// On Windows, we can check if logical > physical
try {
String os = System.getProperty("os.name", "").toLowerCase();
if (os.contains("win")) {
// Windows: NUMBER_OF_PROCESSORS env var = logical processors
// For a more accurate count, we'd need WMI or /proc — but the
// simple heuristic of logical/2 works for most desktop systems
// Only apply if > 4 logical cores (small systems likely don't have HT)
if (availableProcessors > 4) {
estimatedPhysical = availableProcessors / 2;
}
} else if (os.contains("linux")) {
java.io.File topology = new java.io.File(
"/sys/devices/system/cpu/cpu0/topology/thread_siblings_list");
// Linux: try reading /sys/devices/system/cpu/cpu0/topology/thread_siblings_list
// If the file exists and shows 2 threads per core, halve the count
java.io.File topology = new java.io.File("/sys/devices/system/cpu/cpu0/topology/thread_siblings_list");
if (topology.exists()) {
String content = java.nio.file.Files.readString(topology.toPath()).trim();
if (content.contains(",") || content.contains("-")) {
@@ -193,54 +137,47 @@ public class MultiThreadBatchRunner {
// Ignore — use availableProcessors as-is
}
// Reserve 1 core for the OS/JVM overhead, minimum 1 worker
int optimal = Math.max(1, estimatedPhysical - 1);
System.out.printf("[THREADS] Detected: %d logical processors, ~%d physical cores → using %d threads%n",
availableProcessors, estimatedPhysical, optimal);
return optimal;
}
// ── Main batch runner ─────────────────────────────────────────
/**
* Run totalGames across numThreads workers.
*
* Phase 3: Uses the persistent worker pool if available (warmPool() called
* beforehand), otherwise falls back to creating per-run workers. Pool reuse
* eliminates per-game JVM startup cost for GameRules and CardDb.
* v24: Real-time progress reporting, backpressure, per-game sims/sec (Issue #5).
*/
public List<GameResult> runBatch(int totalGames, Long masterSeed)
throws InterruptedException, ExecutionException {
// Auto-detect if threads=0 or threads=-1
public List<GameResult> runBatch(int totalGames, Long masterSeed) throws InterruptedException, ExecutionException {
// Issue #5: Auto-detect if threads=0 or threads=-1 (auto mode)
if (numThreads <= 0) {
numThreads = detectOptimalThreads();
}
// Phase 3: auto-warm pool on first runBatch() if not already warmed
if (!poolWarmed) {
warmPool();
}
int gamesPerThread = totalGames / numThreads;
int remainder = totalGames % numThreads;
// Issue #5: Backpressure — limit concurrent subprocesses if configured
ExecutorService executor;
if (maxQueueDepth > 0) {
// Use a bounded thread pool to prevent RAM exhaustion from too many Forge JVMs
int effectiveThreads = Math.min(numThreads, maxQueueDepth);
executor = Executors.newFixedThreadPool(effectiveThreads);
System.out.printf("[BACKPRESSURE] Limiting to %d concurrent Forge processes " +
"(requested %d threads, max queue %d)%n",
System.out.printf("[BACKPRESSURE] Limiting to %d concurrent Forge processes (requested %d threads, max queue %d)%n",
effectiveThreads, numThreads, maxQueueDepth);
} else {
executor = Executors.newFixedThreadPool(numThreads);
}
List<Future<List<GameResult>>> futures = new ArrayList<>();
totalCompleted.set(0);
batchStartMs.set(System.currentTimeMillis());
System.out.printf("[BATCH] Starting %d games across %d pooled workers " +
"(%.1f games/worker avg)%n",
System.out.printf("[BATCH] Starting %d games across %d threads (%.1f games/thread avg)%n",
totalGames, numThreads, (double) totalGames / numThreads);
int gameOffset = 0;
@@ -248,37 +185,36 @@ public class MultiThreadBatchRunner {
int chunkSize = gamesPerThread + (t < remainder ? 1 : 0);
if (chunkSize == 0) continue;
Long threadSeed = (masterSeed != null)
? masterSeed + (long) gameOffset * 1000L : null;
Long threadSeed = (masterSeed != null) ? masterSeed + (long) gameOffset * 1000L : null;
int startIndex = gameOffset;
int threadId = t;
// Phase 3: retrieve pre-warmed worker from pool
BatchRunner worker = (threadId < workerPool.size())
? workerPool.get(threadId)
: createWorker(threadId);
BatchRunner runner = new BatchRunner(forgeJarPath, forgeWorkDir, decks, policy, quiet, clockSeconds, javaPath);
// Per-thread progress callback that aggregates across all threads
worker.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
// Issue #4: Forward AI optimization settings
runner.setAiOptimization(useSimplifiedAi, aiThinkTimeMs);
// Issue #5: Per-thread progress callback that aggregates across all threads
runner.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
int globalCompleted = totalCompleted.incrementAndGet();
long elapsed = System.currentTimeMillis() - batchStartMs.get();
double globalSimsPerSec = elapsed > 0
? (double) globalCompleted / (elapsed / 1000.0) : 0.0;
? (double) globalCompleted / (elapsed / 1000.0)
: 0.0;
int globalPct = (int) ((double) globalCompleted * 100.0 / totalGames);
System.out.printf("[PROGRESS] %d/%d games (%.1f%%) — %.3f sims/sec " +
"(worker %d: %d/%d)%n",
System.out.printf("[PROGRESS] %d/%d games (%.1f%%) — %.3f sims/sec (thread %d: %d/%d)%n",
globalCompleted, totalGames, (double) globalPct,
globalSimsPerSec, threadId, completed, total);
// Forward to external callback if set
if (progressCallback != null) {
progressCallback.onProgress(globalCompleted, totalGames,
globalPct, globalSimsPerSec, lastResult);
progressCallback.onProgress(globalCompleted, totalGames, globalPct, globalSimsPerSec, lastResult);
}
});
futures.add(executor.submit(() -> {
List<GameResult> results = worker.runBatchSingleThread(chunkSize, threadSeed);
List<GameResult> results = runner.runBatchSingleThread(chunkSize, threadSeed);
for (int i = 0; i < results.size(); i++) {
results.get(i).gameIndex = startIndex + i;
}
@@ -304,10 +240,8 @@ public class MultiThreadBatchRunner {
// Final throughput report
long totalElapsed = System.currentTimeMillis() - batchStartMs.get();
double finalSimsPerSec = totalElapsed > 0
? (double) merged.size() / (totalElapsed / 1000.0) : 0.0;
System.out.printf("[BATCH] Completed %d games in %.1fs — %.3f sims/sec " +
"(across %d pooled workers)%n",
double finalSimsPerSec = totalElapsed > 0 ? (double) merged.size() / (totalElapsed / 1000.0) : 0.0;
System.out.printf("[BATCH] Completed %d games in %.1fs — %.3f sims/sec (across %d threads)%n",
merged.size(), totalElapsed / 1000.0, finalSimsPerSec, numThreads);
return merged;
@@ -4,7 +4,6 @@ import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import commanderailab.ai.PolicyClient;
import java.util.*;
import java.util.concurrent.BlockingQueue;
@@ -14,11 +13,10 @@ import java.util.function.Consumer;
/**
* GameSession — holds state for one live interactive Commander game.
*
* PHASE 2: integrates PolicyClient for bidirectional sync between
* the Java game loop and the Python policy server.
* PHASE 1 STUB: generates synthetic game state so you can verify the full
* WebSocket contract round-trips correctly before wiring in real Forge IPC.
*
* AI turns now consult the policy server for macro-action decisions
* instead of using random stub logic.
* Replace run() in Phase 2 once the contract is verified end-to-end.
*/
public class GameSession {
@@ -29,7 +27,6 @@ public class GameSession {
private final String forgeJarPath;
private final String forgeWorkDir;
private final BlockingQueue<String> humanActionQueue;
private final PolicyClient policyClient;
private volatile boolean running = false;
private volatile int turnNumber = 1;
@@ -59,21 +56,11 @@ public class GameSession {
public GameSession(List<String> deckNames, Long seed,
String forgeJarPath, String forgeWorkDir,
BlockingQueue<String> humanActionQueue) {
this(deckNames, seed, forgeJarPath, forgeWorkDir, humanActionQueue,
new PolicyClient("http://localhost:8080"));
}
@SuppressWarnings("unchecked")
public GameSession(List<String> deckNames, Long seed,
String forgeJarPath, String forgeWorkDir,
BlockingQueue<String> humanActionQueue,
PolicyClient policyClient) {
this.deckNames = deckNames;
this.seed = seed;
this.forgeJarPath = forgeJarPath;
this.forgeWorkDir = forgeWorkDir;
this.humanActionQueue = humanActionQueue;
this.policyClient = policyClient;
int seats = Math.min(deckNames.size(), 4);
life = new int[seats];
@@ -87,8 +74,8 @@ public class GameSession {
for (int i = 0; i < seats; i++) {
life[i] = 40;
hands[i] = new ArrayList<>(List.of(
"card_stub_1", "card_stub_2", "card_stub_3",
"card_stub_4", "card_stub_5", "card_stub_6", "card_stub_7"
"card_stub_1", "card_stub_2", "card_stub_3",
"card_stub_4", "card_stub_5", "card_stub_6", "card_stub_7"
));
battlefields[i] = new ArrayList<>();
graveyards[i] = new ArrayList<>();
@@ -98,27 +85,28 @@ public class GameSession {
public boolean isRunning() { return running; }
public int getTurnNumber() { return turnNumber; }
public void stop() { running = false; }
public void stop() {
running = false;
}
/**
* Run the game loop. Calls onStateChange after every action resolves.
* Blocks until the game ends or stop() is called.
*
* PHASE 2: AI turns now consult the PolicyClient for decisions.
* The policy server returns a macro-action which is applied to game state.
* Falls back to PASS_PRIORITY if the policy server is unreachable.
* PHASE 1 STUB: advances through phases automatically; on MAIN1 of seat 0's
* turn, pauses and waits for the human client to send an action.
*/
public void run(Consumer<Map<String, Object>> onStateChange) throws InterruptedException {
public void run(Consumer<Object> onStateChange) throws InterruptedException {
running = true;
Random rng = seed != null ? new Random(seed) : new Random();
System.out.println("[Session] Game started. Decks: " + deckNames);
System.out.println("[Session] PolicyClient endpoint: " + policyClient.getBaseUrl());
while (running && !isGameOver()) {
String currentPhase = PHASES[phaseIndex];
phase = currentPhase;
activePlayer = (turnNumber - 1) % deckNames.size();
boolean isHumanTurn = (activePlayer == 0);
boolean isInteractivePhase = currentPhase.equals("MAIN1") || currentPhase.equals("MAIN2");
@@ -129,6 +117,7 @@ public class GameSession {
System.out.println("[Session] Awaiting human action for " + currentPhase + "...");
String actionJson = humanActionQueue.poll(120, TimeUnit.SECONDS);
if (actionJson == null) {
System.out.println("[Session] Human timed out — auto passing.");
} else {
@@ -136,18 +125,13 @@ public class GameSession {
System.out.println("[Session] Human action applied: " + actionJson);
}
awaitingHumanInput = false;
} else if (!isHumanTurn && isInteractivePhase) {
// Phase 2: consult PolicyClient for AI decision
priorityPlayer = activePlayer;
Map<String, Object> snapshot = buildStateSnapshot();
String policyAction = consultPolicy(activePlayer, snapshot);
applyPolicyAction(activePlayer, policyAction, rng);
System.out.println("[Session] AI-" + activePlayer + " policy action: " + policyAction);
} else {
// Non-interactive phase — small delay for readability
// AI turn — small delay for readability
Thread.sleep(300);
// Simulate AI occasionally dealing damage to human
if (!isHumanTurn && currentPhase.equals("DAMAGE") && life[0] > 5) {
life[0] -= rng.nextInt(4);
}
}
// Advance phase
@@ -156,6 +140,7 @@ public class GameSession {
phaseIndex = 0;
turnNumber++;
}
onStateChange.accept(buildStateSnapshot());
}
@@ -163,70 +148,11 @@ public class GameSession {
System.out.println("[Session] Game ended after " + turnNumber + " turns.");
}
// ---------------------------------------------------------
// Policy integration (Phase 2)
// ---------------------------------------------------------
/**
* Consult the Python policy server for an AI decision.
* Sends the current game snapshot and returns the recommended macro-action.
* Falls back to PASS_PRIORITY on error.
*/
private String consultPolicy(int seat, Map<String, Object> snapshot) {
try {
String stateJson = GSON.toJson(snapshot);
PolicyClient.PolicyDecision decision = policyClient.decide(stateJson, seat);
if (decision != null && decision.action() != null) {
System.out.println("[Session] Policy decision for seat " + seat
+ ": " + decision.action()
+ " (confidence=" + String.format("%.2f", decision.confidence()) + ")");
return decision.action();
}
} catch (Exception e) {
System.err.println("[Session] Policy server error for seat " + seat + ": " + e.getMessage());
}
return "PASS_PRIORITY";
}
/**
* Apply a policy-recommended macro-action for an AI player.
*/
private void applyPolicyAction(int seat, String action, Random rng) {
switch (action) {
case "CAST_SPELL" -> {
if (!hands[seat].isEmpty()) {
String card = hands[seat].remove(0);
battlefields[seat].add(card);
System.out.println("[Session] AI-" + seat + " cast: " + card);
}
}
case "PLAY_LAND" -> {
if (!hands[seat].isEmpty()) {
String card = hands[seat].remove(hands[seat].size() - 1);
battlefields[seat].add(card);
}
}
case "ATTACK" -> {
int target = (seat + 1) % life.length;
if (life[target] > 0) {
int dmg = rng.nextInt(3) + 1;
life[target] -= dmg;
System.out.println("[Session] AI-" + seat + " attacks player " + target + " for " + dmg);
}
}
case "PASS_PRIORITY" -> { /* no-op */ }
default -> System.out.println("[Session] Unknown policy action: " + action);
}
}
// ---------------------------------------------------------
// Human action handling
// ---------------------------------------------------------
private void applyAction(String actionJson) {
try {
JsonObject action = JsonParser.parseString(actionJson).getAsJsonObject();
String type = action.has("type") ? action.get("type").getAsString() : "PASS_PRIORITY";
switch (type) {
case "PLAY_LAND", "CAST_SPELL" -> {
String cardId = action.has("cardId") ? action.get("cardId").getAsString() : null;
@@ -246,6 +172,7 @@ public class GameSession {
private boolean isGameOver() {
int alive = 0;
for (int lp : life) if (lp > 0) alive++;
// Phase 1 stub: end after 50 turns or only 1 player alive
return alive <= 1 || turnNumber > 50;
}
@@ -283,7 +210,7 @@ public class GameSession {
p.put("poison", poison[i]);
p.put("commanderTax", commanderTax[i]);
p.put("handCount", hands[i].size());
p.put("hand", i == 0 ? hands[i] : List.of());
p.put("hand", i == 0 ? hands[i] : List.of()); // hide opponent hands
p.put("battlefield", battlefields[i]);
p.put("graveyard", graveyards[i]);
p.put("commandZone", commandZones[i]);
+1 -7
View File
@@ -90,7 +90,7 @@ public class LabCli implements Callable<Integer> {
description = "Master RNG seed for reproducibility (blank for random)")
private String seed;
@Option(names = {"--output", "-o"}, defaultValue = "results/ml-decision-latest.json",
@Option(names = {"--output", "-o"}, defaultValue = "results/batch-latest.json",
description = "Output JSON file path")
private String outputPath;
@@ -344,12 +344,6 @@ public class LabCli implements Callable<Integer> {
mtRunner.setAiOptimization(aiSimplified, aiThinkTimeMs);
if (maxQueueDepth > 0) {
mtRunner.setMaxQueueDepth(maxQueueDepth);
}
// v4: Enable ML decision logging if requested
if (mlLog) {
String batchId = UUID.randomUUID().toString().substring(0, 8);
mtRunner.enableMlLogging("results", batchId);
System.out.println("[ML] Decision logging enabled (multi-thread) — data will be saved to results/");
}
games = mtRunner.runBatch(numGames, masterSeed);
} else {
@@ -33,7 +33,7 @@ public class PolicyServerClient {
private static final Gson GSON = new GsonBuilder().create();
private static final int CONNECT_TIMEOUT_MS = 2000;
private static final int READ_TIMEOUT_MS = 300_000;
private static final int READ_TIMEOUT_MS = 5000;
private final String baseUrl;
private volatile boolean available = false;
@@ -31,7 +31,7 @@ public class JsonExporter {
* Write a BatchResult to a JSON file.
*
* @param result The batch result to export
* @param filePath Output file path (e.g., "results/ml-decision-20260306-001.json")
* @param filePath Output file path (e.g., "results/batch-20260306-001.json")
* @throws IOException if file cannot be written
*/
public static void writeToFile(BatchResult result, String filePath) throws IOException {
+44 -42
View File
@@ -1,110 +1,112 @@
@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
set FORGE_DIR=D:\ForgeCommander\forge-repo\forge-gui
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 ANTHROPIC_API_KEY=your-anthropic-api-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 PPLX_API_KEY=your-perplexity-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"=="ANTHROPIC_API_KEY" set ANTHROPIC_API_KEY=%%B
if "%%A"=="PPLX_API_KEY" set PPLX_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 ANTHROPIC_API_KEY (
echo Claude Opus: 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 PPLX_API_KEY (
echo Perplexity: Configured
) else (
echo Claude Opus: Not configured ^(set ANTHROPIC_API_KEY env var for AI features^)
echo Perplexity: Not configured ^(set PPLX_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 ANTHROPIC_API_KEY (
if defined PPLX_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%" ^
--anthropic-key "%ANTHROPIC_API_KEY%"
--pplx-key "%PPLX_API_KEY%"
) else (
python "%~dp0lab_api.py" ^
--forge-jar "%FORGE_JAR%" ^
+387
View File
@@ -0,0 +1,387 @@
"""
Regression tests for centralized commander damage tracking.
============================================================
Covers:
- _apply_damage centralized path (life, stats, commander tracking, elimination)
- Combat damage routing through _apply_damage (commander vs non-commander)
- Commander damage 21-lethal elimination rule
- Non-combat commander damage (direct _apply_damage calls)
- Multi-seat / partner commander edge cases
Run with: pytest tests/sim/test_commander_damage.py -v
"""
import pytest
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.engine import GameEngine
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_sim(num_players: int = 2, starting_life: int = 40) -> SimState:
"""Create a minimal SimState with empty battlefields."""
sim = SimState(max_turns=25)
for i in range(num_players):
sim.players.append(Player(
name=f"P{i}",
life=starting_life,
owner_id=i,
stats=PlayerStats(cards_drawn=0),
))
sim.init_battlefields(num_players)
return sim
def _creature(name: str, power: int, toughness: int, **kwargs) -> Card:
"""Create a creature card with given stats."""
c = Card(
name=name,
type_line="Creature",
pt=f"{power}/{toughness}",
power=str(power),
toughness=str(toughness),
**kwargs,
)
return enrich_card(c)
def _land(name: str = "Forest") -> Card:
c = Card(name=name, type_line="Basic Land — Forest", cmc=0)
return enrich_card(c)
def _place_on_battlefield(sim: SimState, seat: int, card: Card) -> Card:
"""Place a card onto a seat's battlefield with a unique id."""
card.owner_id = seat
card.id = sim.next_card_id
sim.next_card_id += 1
card.turn_played = -1 # played in a previous turn (not summoning sick)
sim.add_to_battlefield(seat, card)
return card
# ===========================================================================
# _apply_damage unit tests
# ===========================================================================
class TestApplyDamage:
"""Tests for the centralized _apply_damage static method."""
def test_basic_damage_reduces_life(self):
sim = _make_sim()
applied = GameEngine._apply_damage(sim, 5, target_seat=1, source_seat=0)
assert applied == 5
assert sim.players[1].life == 35
assert sim.players[1].stats.damage_received == 5
assert sim.players[0].stats.damage_dealt == 5
def test_zero_damage_is_noop(self):
sim = _make_sim()
applied = GameEngine._apply_damage(sim, 0, target_seat=1, source_seat=0)
assert applied == 0
assert sim.players[1].life == 40
def test_negative_damage_is_noop(self):
sim = _make_sim()
applied = GameEngine._apply_damage(sim, -3, target_seat=1, source_seat=0)
assert applied == 0
assert sim.players[1].life == 40
def test_damage_to_eliminated_player_is_noop(self):
sim = _make_sim()
sim.players[1].eliminated = True
applied = GameEngine._apply_damage(sim, 10, target_seat=1, source_seat=0)
assert applied == 0
assert sim.players[1].life == 40 # unchanged
def test_lethal_damage_eliminates(self):
sim = _make_sim()
GameEngine._apply_damage(sim, 40, target_seat=1, source_seat=0)
assert sim.players[1].life == 0
assert sim.players[1].eliminated is True
def test_overkill_damage_eliminates(self):
sim = _make_sim()
GameEngine._apply_damage(sim, 50, target_seat=1, source_seat=0)
assert sim.players[1].life == -10
assert sim.players[1].eliminated is True
def test_non_commander_damage_does_not_track_commander_damage(self):
sim = _make_sim()
GameEngine._apply_damage(
sim, 10, target_seat=1, source_seat=0,
source_is_commander=False,
)
assert sim.players[1].commander_damage_received == {}
def test_commander_damage_tracks_per_seat(self):
sim = _make_sim(num_players=3)
GameEngine._apply_damage(
sim, 7, target_seat=2, source_seat=0,
source_is_commander=True,
)
GameEngine._apply_damage(
sim, 5, target_seat=2, source_seat=1,
source_is_commander=True,
)
assert sim.players[2].commander_damage_received == {0: 7, 1: 5}
assert sim.players[2].life == 40 - 7 - 5
def test_commander_damage_accumulates(self):
sim = _make_sim()
GameEngine._apply_damage(
sim, 10, target_seat=1, source_seat=0,
source_is_commander=True,
)
GameEngine._apply_damage(
sim, 8, target_seat=1, source_seat=0,
source_is_commander=True,
)
assert sim.players[1].commander_damage_received[0] == 18
def test_21_commander_damage_eliminates(self):
"""Commander damage rule: 21+ from a single source eliminates."""
sim = _make_sim()
sim.players[1].life = 100 # high life — shouldn't save them
GameEngine._apply_damage(
sim, 21, target_seat=1, source_seat=0,
source_is_commander=True,
)
assert sim.players[1].commander_damage_received[0] == 21
assert sim.players[1].eliminated is True
assert sim.players[1].life == 79 # life was reduced but still positive
def test_incremental_commander_damage_to_21_eliminates(self):
"""Gradual commander damage reaching 21 triggers elimination."""
sim = _make_sim()
sim.players[1].life = 100
for _ in range(3):
GameEngine._apply_damage(
sim, 7, target_seat=1, source_seat=0,
source_is_commander=True,
)
assert sim.players[1].commander_damage_received[0] == 21
assert sim.players[1].eliminated is True
def test_split_commander_damage_from_two_sources_no_elimination(self):
"""20 commander damage from two different sources should NOT eliminate."""
sim = _make_sim(num_players=3)
sim.players[2].life = 100
GameEngine._apply_damage(
sim, 10, target_seat=2, source_seat=0,
source_is_commander=True,
)
GameEngine._apply_damage(
sim, 10, target_seat=2, source_seat=1,
source_is_commander=True,
)
assert sim.players[2].commander_damage_received == {0: 10, 1: 10}
assert sim.players[2].eliminated is False
def test_stats_update_on_both_players(self):
sim = _make_sim()
GameEngine._apply_damage(sim, 12, target_seat=1, source_seat=0)
assert sim.players[0].stats.damage_dealt == 12
assert sim.players[1].stats.damage_received == 12
# ===========================================================================
# Combat damage routing through _apply_damage
# ===========================================================================
class TestCombatCommanderDamageRouting:
"""Verify _resolve_combat routes commander creature damage correctly."""
def _setup_combat(self, *, commander_power=5, defender_life=40):
"""Set up a 2-player sim with one commander attacker and no blockers."""
engine = GameEngine(max_turns=25)
sim = _make_sim(num_players=2, starting_life=defender_life)
# Attacker: a commander creature
cmd = _creature("Prossh", commander_power, 5, is_commander=True)
_place_on_battlefield(sim, 0, cmd)
# Defender: only lands (no blockers)
for _ in range(3):
_place_on_battlefield(sim, 1, _land())
return engine, sim
def test_unblocked_commander_tracks_damage(self):
engine, sim = self._setup_combat(commander_power=5)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].commander_damage_received.get(0, 0) == 5
assert sim.players[1].life == 35
def test_unblocked_noncommander_does_not_track(self):
engine = GameEngine(max_turns=25)
sim = _make_sim(num_players=2)
# Non-commander attacker
bear = _creature("Grizzly Bears", 2, 2)
_place_on_battlefield(sim, 0, bear)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].commander_damage_received == {}
assert sim.players[1].life == 38
def test_commander_combat_21_eliminates(self):
"""Commander dealing 21+ combat damage in one hit should eliminate."""
engine, sim = self._setup_combat(commander_power=21, defender_life=100)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].eliminated is True
assert sim.players[1].commander_damage_received[0] == 21
def test_mixed_commander_and_noncommander_attackers(self):
"""Commander + non-commander attack together; only commander damage tracked."""
engine = GameEngine(max_turns=25)
sim = _make_sim(num_players=2)
cmd = _creature("Prossh", 3, 3, is_commander=True)
bear = _creature("Grizzly Bears", 2, 2)
_place_on_battlefield(sim, 0, cmd)
_place_on_battlefield(sim, 0, bear)
engine._resolve_combat(sim, 0, turn=1)
# Total damage: 3 + 2 = 5
assert sim.players[1].life == 35
# Only commander damage tracked
assert sim.players[1].commander_damage_received.get(0, 0) == 3
def test_commander_trample_over_blocker_tracks_damage(self):
"""Trample damage from a commander going over a blocker should track."""
engine = GameEngine(max_turns=25)
# Low life forces the AI to assign a blocker via the
# "opp.life <= a_pow * 2" heuristic even when toughness < power.
sim = _make_sim(num_players=2, starting_life=10)
cmd = _creature("Prossh", 7, 7, is_commander=True, keywords=["trample"])
_place_on_battlefield(sim, 0, cmd)
blocker = _creature("Wall", 0, 3)
_place_on_battlefield(sim, 1, blocker)
engine._resolve_combat(sim, 0, turn=1)
# Trample over: 7 - 3 = 4 damage to player (all from commander)
assert sim.players[1].commander_damage_received.get(0, 0) == 4
assert sim.players[1].life == 6
# ===========================================================================
# Non-combat commander damage (direct _apply_damage usage)
# ===========================================================================
class TestNonCombatCommanderDamage:
"""
Validates that _apply_damage correctly handles non-combat commander damage
scenarios. These represent cases like commander abilities, ETB triggers,
or spell effects that deal damage where the source is a commander.
"""
def test_direct_commander_ability_damage(self):
"""Simulates a commander dealing direct damage (e.g. Purphoros trigger)."""
sim = _make_sim(num_players=4)
# Commander at seat 0 deals 2 to each opponent via ability
for target in [1, 2, 3]:
GameEngine._apply_damage(
sim, 2, target_seat=target, source_seat=0,
source_is_commander=True,
)
for target in [1, 2, 3]:
assert sim.players[target].commander_damage_received[0] == 2
assert sim.players[target].life == 38
def test_repeated_noncombat_commander_damage_reaches_21(self):
"""Repeated non-combat commander triggers accumulate to lethal 21."""
sim = _make_sim()
sim.players[1].life = 100
# 11 triggers of 2 damage each = 22 total
for _ in range(11):
GameEngine._apply_damage(
sim, 2, target_seat=1, source_seat=0,
source_is_commander=True,
)
assert sim.players[1].commander_damage_received[0] == 22
assert sim.players[1].eliminated is True
def test_noncombat_noncommander_spell_damage(self):
"""Non-commander spell damage: reduces life, no commander tracking."""
sim = _make_sim()
GameEngine._apply_damage(
sim, 3, target_seat=1, source_seat=0,
source_is_commander=False,
)
assert sim.players[1].life == 37
assert sim.players[1].commander_damage_received == {}
def test_mixed_combat_and_noncombat_commander_damage(self):
"""Both combat and non-combat commander damage should accumulate together."""
engine = GameEngine(max_turns=25)
sim = _make_sim(num_players=2)
sim.players[1].life = 100
# Non-combat commander damage: 15
GameEngine._apply_damage(
sim, 15, target_seat=1, source_seat=0,
source_is_commander=True,
)
assert sim.players[1].commander_damage_received[0] == 15
assert sim.players[1].eliminated is False
# Combat commander damage: 6 (pushes to 21)
cmd = _creature("Prossh", 6, 6, is_commander=True)
_place_on_battlefield(sim, 0, cmd)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].commander_damage_received[0] == 21
assert sim.players[1].eliminated is True
# ===========================================================================
# Full game integration
# ===========================================================================
class TestFullGameCommanderDamage:
"""Integration test: run a full game with commanders and verify tracking."""
def _make_commander_deck(self, commander_name: str, seed: int = 42) -> list[Card]:
"""Build a small test deck with a named commander."""
import random
rng = random.Random(seed)
cards = []
# Commander card
cmd = Card(
name=commander_name,
type_line="Legendary Creature",
cmc=5,
pt="5/5",
power="5",
toughness="5",
)
cards.append(enrich_card(cmd))
# Fill with lands and creatures
for i in range(24):
cards.append(enrich_card(Card(
name=f"Forest_{i}",
type_line="Basic Land — Forest",
cmc=0,
)))
for i in range(35):
cards.append(enrich_card(Card(
name=f"Bear_{i}",
type_line="Creature — Bear",
cmc=2,
pt="2/2",
power="2",
toughness="2",
)))
rng.shuffle(cards)
return cards
def test_commander_damage_tracked_in_full_game(self):
"""In a full game with commanders, commander_damage_received is populated."""
engine = GameEngine(max_turns=15)
deck_a = self._make_commander_deck("Prossh, Skyraider of Kher", seed=1)
deck_b = self._make_commander_deck("Kaalia of the Vast", seed=2)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
name_a="Alice",
name_b="Bob",
commander_names=["Prossh, Skyraider of Kher", "Kaalia of the Vast"],
)
assert result is not None
assert result.turns > 0
# The game completed without errors — commander damage tracking is wired
-539
View File
@@ -1,539 +0,0 @@
"""Regression tests for direct-damage spell routing (Issue #86).
Tests that burn spells and similar direct-damage effects are routed
through the centralized deal_damage() path, correctly update
life totals / stats, and integrate with commander damage tracking.
Adapted to work with the dual-dict commander damage model from PR #89:
- commander_damage_received[seat] = per-seat aggregate
- commander_damage_by_card[(seat, card_name)] = per-card breakdown
"""
import pytest
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.engine import GameEngine
# ── Helpers ──────────────────────────────────────────────────
def _make_creature(name="Bear", pt="2/2", cmc=2, oracle_text="", **kwargs) -> Card:
"""Build a creature Card with optional oracle text."""
c = Card(
name=name,
type_line="Creature",
cmc=cmc,
pt=pt,
power=pt.split("/")[0],
toughness=pt.split("/")[1],
oracle_text=oracle_text,
**kwargs,
)
enrich_card(c)
return c
def _make_land(name="Forest") -> Card:
c = Card(name=name)
enrich_card(c)
return c
def _make_burn(name="Lightning Bolt", cmc=1, oracle_text="Lightning Bolt deals 3 damage to any target.") -> Card:
c = Card(name=name, type_line="Instant", cmc=cmc, oracle_text=oracle_text)
enrich_card(c)
return c
def _setup_two_player(
p0_hand=None, p1_hand=None, p0_life=40, p1_life=40,
p0_battlefield=None, p1_battlefield=None,
) -> tuple[GameEngine, SimState]:
"""Set up a 2-player SimState with controlled hands/battlefields."""
engine = GameEngine(max_turns=5)
sim = SimState(max_turns=5)
for i, (hand, life, bf) in enumerate([
(p0_hand or [], p0_life, p0_battlefield or []),
(p1_hand or [], p1_life, p1_battlefield or []),
]):
p = Player(
name=f"Player {i}",
life=life,
owner_id=i,
hand=list(hand),
library=[_make_land() for _ in range(30)],
stats=PlayerStats(cards_drawn=7),
)
sim.players.append(p)
sim.init_battlefields(2)
for seat, bf_cards in enumerate([p0_battlefield or [], p1_battlefield or []]):
for c in bf_cards:
c.owner_id = seat
c.id = sim.next_card_id
sim.next_card_id += 1
c.turn_played = -1
sim.add_to_battlefield(seat, c)
return engine, sim
def _setup_four_player(
hands=None, lives=None, battlefields=None,
) -> tuple[GameEngine, SimState]:
"""Set up a 4-player SimState."""
engine = GameEngine(max_turns=5)
sim = SimState(max_turns=5)
hands = hands or [[] for _ in range(4)]
lives = lives or [40, 40, 40, 40]
battlefields = battlefields or [[] for _ in range(4)]
for i in range(4):
p = Player(
name=f"Player {i}",
life=lives[i],
owner_id=i,
hand=list(hands[i]),
library=[_make_land() for _ in range(30)],
stats=PlayerStats(cards_drawn=7),
)
sim.players.append(p)
sim.init_battlefields(4)
for seat, bf_cards in enumerate(battlefields):
for c in bf_cards:
c.owner_id = seat
c.id = sim.next_card_id
sim.next_card_id += 1
c.turn_played = -1
sim.add_to_battlefield(seat, c)
return engine, sim
# ══════════════════════════════════════════════════════════════
# deal_damage() centralized method tests
# ══════════════════════════════════════════════════════════════
class TestDealDamage:
"""Tests for GameEngine.deal_damage() static method."""
def test_basic_damage_reduces_life(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
assert dealt == 5
assert sim.players[1].life == 15
def test_zero_damage_is_noop(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, 0, target_seat=1)
assert dealt == 0
assert sim.players[1].life == 20
def test_negative_damage_is_noop(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, -3, target_seat=1)
assert dealt == 0
assert sim.players[1].life == 20
def test_damage_updates_stats(self):
engine, sim = _setup_two_player()
engine.deal_damage(sim, 7, target_seat=1, source_seat=0)
assert sim.players[0].stats.damage_dealt == 7
assert sim.players[1].stats.damage_received == 7
def test_damage_eliminates_at_zero(self):
engine, sim = _setup_two_player(p1_life=3)
engine.deal_damage(sim, 3, target_seat=1, source_seat=0)
assert sim.players[1].life == 0
assert sim.players[1].eliminated is True
def test_damage_eliminates_below_zero(self):
engine, sim = _setup_two_player(p1_life=2)
engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
assert sim.players[1].life == -3
assert sim.players[1].eliminated is True
def test_damage_to_eliminated_player_is_noop(self):
engine, sim = _setup_two_player(p1_life=0)
sim.players[1].eliminated = True
dealt = engine.deal_damage(sim, 10, target_seat=1)
assert dealt == 0
assert sim.players[1].life == 0
def test_events_logged(self):
engine, sim = _setup_two_player(p1_life=20)
events: list[str] = []
engine.deal_damage(sim, 3, target_seat=1, source_seat=0,
events=events, label="Bolt deals 3")
assert len(events) == 1
assert "Bolt deals 3" in events[0]
assert "Player 1 now at 17 life" in events[0]
def test_no_label_means_no_event(self):
engine, sim = _setup_two_player(p1_life=20)
events: list[str] = []
engine.deal_damage(sim, 3, target_seat=1, events=events)
assert len(events) == 0
# ══════════════════════════════════════════════════════════════
# Commander damage tracking through deal_damage()
# ══════════════════════════════════════════════════════════════
class TestCommanderDamageTracking:
"""Tests for commander damage tracked by deal_damage() using
the dual-dict model: per-seat aggregate + per-card breakdown."""
def test_commander_damage_tracked_both_dicts(self):
engine, sim = _setup_two_player()
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0, is_combat=True)
# Per-seat aggregate
assert sim.players[1].commander_damage_received[0] == 4
# Per-card breakdown
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 4
def test_commander_damage_accumulates(self):
engine, sim = _setup_two_player()
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0)
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0)
assert sim.players[1].commander_damage_received[0] == 8
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 8
def test_commander_21_damage_eliminates(self):
engine, sim = _setup_two_player()
cmd = _make_creature("Atraxa", pt="7/7", is_commander=True)
for _ in range(3):
engine.deal_damage(sim, 7, target_seat=1, source_card=cmd, source_seat=0)
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 21
assert sim.players[1].eliminated is True
def test_partner_commanders_tracked_separately(self):
engine, sim = _setup_two_player()
cmd_a = _make_creature("Thrasios", pt="1/3", is_commander=True)
cmd_b = _make_creature("Tymna", pt="2/2", is_commander=True)
# 15 from Thrasios, 15 from Tymna — neither hits 21 alone
for _ in range(15):
engine.deal_damage(sim, 1, target_seat=1, source_card=cmd_a, source_seat=0)
for _ in range(15):
engine.deal_damage(sim, 1, target_seat=1, source_card=cmd_b, source_seat=0)
# Per-card: each under 21
assert sim.players[1].commander_damage_by_card[(0, "Thrasios")] == 15
assert sim.players[1].commander_damage_by_card[(0, "Tymna")] == 15
# Per-seat aggregate: 30 total
assert sim.players[1].commander_damage_received[0] == 30
# Life is 10, but not eliminated by commander damage (no single source >= 21)
assert sim.players[1].life == 10
assert sim.players[1].is_dead_to_commander_damage() is False
def test_non_commander_damage_not_tracked(self):
engine, sim = _setup_two_player()
regular = _make_creature("Bear", pt="2/2")
engine.deal_damage(sim, 2, target_seat=1, source_card=regular, source_seat=0)
assert sim.players[1].commander_damage_received == {}
assert sim.players[1].commander_damage_by_card == {}
def test_is_dead_to_commander_damage(self):
p = Player(name="Test", life=40)
assert p.is_dead_to_commander_damage() is False
p.commander_damage_by_card[(0, "Kozilek")] = 20
p.commander_damage_received[0] = 20
assert p.is_dead_to_commander_damage() is False
p.commander_damage_by_card[(0, "Kozilek")] = 21
p.commander_damage_received[0] = 21
assert p.is_dead_to_commander_damage() is True
def test_commander_damage_eliminates_despite_high_life(self):
"""A player at 100 life should still die to 21 commander damage."""
engine, sim = _setup_two_player(p1_life=100)
cmd = _make_creature("Voltron", pt="21/21", is_commander=True)
engine.deal_damage(sim, 21, target_seat=1, source_card=cmd, source_seat=0)
assert sim.players[1].life == 79
assert sim.players[1].eliminated is True
# ══════════════════════════════════════════════════════════════
# Direct-damage spell detection (rules.py)
# ══════════════════════════════════════════════════════════════
class TestDirectDamageDetection:
"""Tests for is_direct_damage detection in enrich_card/rules."""
def test_lightning_bolt_detected(self):
c = Card(name="Lightning Bolt", type_line="Instant", cmc=1,
oracle_text="Lightning Bolt deals 3 damage to any target.")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 3
def test_shock_detected(self):
c = Card(name="Shock", type_line="Instant", cmc=1,
oracle_text="Shock deals 2 damage to any target.")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 2
def test_known_burn_by_name(self):
"""Known burn spells should be detected even without oracle text."""
c = Card(name="Lightning Bolt")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 3
def test_chain_lightning_detected(self):
c = Card(name="Chain Lightning")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 3
def test_boros_charm_detected(self):
c = Card(name="Boros Charm")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 4
def test_oracle_text_detection(self):
"""An unknown card with direct-damage oracle text should be detected."""
c = Card(
name="Custom Burn Spell",
type_line="Instant",
cmc=2,
oracle_text="Custom Burn Spell deals 5 damage to target creature or player.",
)
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 5
def test_board_wipe_not_flagged(self):
"""Board wipes should not be flagged as direct damage."""
c = Card(name="Blasphemous Act", type_line="Sorcery", cmc=9,
oracle_text="Blasphemous Act deals 13 damage to each creature.")
enrich_card(c)
assert c.is_board_wipe is True
assert c.is_direct_damage is False
def test_creature_not_flagged(self):
"""A vanilla creature should not be flagged as direct damage."""
c = _make_creature("Grizzly Bears", pt="2/2")
assert c.is_direct_damage is False
def test_removal_spell_not_flagged_as_direct_damage(self):
"""Pure removal (destroy target) should not become direct damage."""
c = Card(name="Murder", type_line="Instant", cmc=3,
oracle_text="Destroy target creature.")
enrich_card(c)
assert c.is_removal is True
assert c.is_direct_damage is False
# ══════════════════════════════════════════════════════════════
# Direct-damage routing through _play_spells()
# ══════════════════════════════════════════════════════════════
class TestDirectDamageSpellRouting:
"""Tests that burn spells resolve through deal_damage() during gameplay."""
def test_burn_spell_reduces_opponent_life(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
p1_life=20,
)
events: list[str] = []
engine._play_spells(sim, 0, available_mana=3, events=events)
# Lightning Bolt: 3 damage
assert sim.players[1].life == 17
assert sim.players[0].stats.damage_dealt == 3
assert sim.players[1].stats.damage_received == 3
def test_burn_spell_goes_to_graveyard(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
)
engine._play_spells(sim, 0, available_mana=3)
# Bolt should be in graveyard
assert any(c.name == "Lightning Bolt" for c in sim.players[0].graveyard)
def test_burn_spell_eliminates_low_life_opponent(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
p1_life=2,
)
engine._play_spells(sim, 0, available_mana=3)
assert sim.players[1].life == -1
assert sim.players[1].eliminated is True
def test_burn_targets_weakest_opponent(self):
"""In multiplayer, burn should target the weakest opponent."""
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_four_player(
hands=[[bolt], [], [], []],
lives=[40, 30, 10, 20],
battlefields=[lands, [], [], []],
)
engine._play_spells(sim, 0, available_mana=3)
# Player 2 (life=10) is weakest, should be targeted
assert sim.players[2].life == 7
def test_burn_events_logged(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
p1_life=20,
)
events: list[str] = []
engine._play_spells(sim, 0, available_mana=3, events=events)
burn_events = [e for e in events if "Lightning Bolt" in e and "damage" in e]
assert len(burn_events) >= 1
def test_burn_spell_taps_lands(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
)
engine._play_spells(sim, 0, available_mana=3)
tapped_lands = [c for c in sim.get_battlefield(0) if c.is_land() and c.tapped]
assert len(tapped_lands) == 1 # CMC 1
def test_shock_deals_2(self):
shock = _make_burn("Shock", cmc=1, oracle_text="Shock deals 2 damage to any target.")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[shock],
p0_battlefield=lands,
p1_life=20,
)
engine._play_spells(sim, 0, available_mana=3)
assert sim.players[1].life == 18
# ══════════════════════════════════════════════════════════════
# Combat damage routing through deal_damage()
# ══════════════════════════════════════════════════════════════
class TestCombatDamageRouting:
"""Tests that combat damage routes through deal_damage()."""
def test_unblocked_creature_deals_damage(self):
bear = _make_creature("Bear", pt="2/2")
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[bear] + lands,
p1_life=20,
)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].life == 18
assert sim.players[0].stats.damage_dealt == 2
assert sim.players[1].stats.damage_received == 2
def test_commander_combat_damage_tracked(self):
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[cmd] + lands,
p1_life=40,
)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].life == 36
# Per-seat aggregate
assert sim.players[1].commander_damage_received[0] == 4
# Per-card breakdown
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 4
def test_non_commander_combat_damage_not_tracked_as_commander(self):
bear = _make_creature("Bear", pt="3/3")
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[bear] + lands,
p1_life=40,
)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].life == 37
assert sim.players[1].commander_damage_received == {}
assert sim.players[1].commander_damage_by_card == {}
def test_commander_lethal_at_21(self):
"""A commander dealing 21 damage should eliminate even at high life."""
cmd = _make_creature("Voltron", pt="7/7", is_commander=True)
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[cmd] + lands,
p1_life=100,
)
# Simulate 3 combat rounds
for t in range(3):
if sim.players[1].eliminated:
break
# Reset attacker for next combat
cmd.tapped = False
cmd.turn_played = -1
engine._resolve_combat(sim, 0, turn=t + 1)
assert sim.players[1].commander_damage_by_card[(0, "Voltron")] == 21
assert sim.players[1].eliminated is True
# ══════════════════════════════════════════════════════════════
# Full-game integration with burn spells
# ══════════════════════════════════════════════════════════════
class TestFullGameWithBurn:
"""Integration tests running full games with burn spells in decks."""
def test_game_with_burn_completes(self):
"""A game with burn spells in decks should complete without error."""
deck_a = (
[Card(name="Lightning Bolt") for _ in range(8)]
+ [Card(name="Shock") for _ in range(8)]
+ [Card(name="Forest") for _ in range(24)]
+ [Card(name="Grizzly Bears") for _ in range(20)]
)
deck_b = (
[Card(name="Forest") for _ in range(24)]
+ [Card(name="Serra Angel") for _ in range(10)]
+ [Card(name="Grizzly Bears") for _ in range(20)]
+ [Card(name="Murder") for _ in range(6)]
)
for c in deck_a + deck_b:
enrich_card(c)
engine = GameEngine(max_turns=25, record_log=True)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
name_a="Burn",
name_b="Midrange",
)
assert result is not None
assert result.turns > 0
assert result.winner_seat in (-1, 0, 1)
def test_burn_damage_appears_in_stats(self):
"""Burn spell damage should be reflected in player stats."""
deck_a = (
[Card(name="Lightning Bolt") for _ in range(15)]
+ [Card(name="Mountain") for _ in range(45)]
)
deck_b = [Card(name="Forest") for _ in range(60)]
for c in deck_a + deck_b:
enrich_card(c)
engine = GameEngine(max_turns=25)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
name_a="Burn",
name_b="Lands",
)
# Player A should have dealt some damage from burn spells
pa_stats = result.player_a_stats
assert pa_stats is not None
assert pa_stats.damage_dealt > 0
-163
View File
@@ -1,163 +0,0 @@
"""Tests for Forge card enrichment -- parser, enrich_card(), and score_card()."""
from __future__ import annotations
import textwrap
from pathlib import Path
from unittest.mock import patch
import pytest
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.rules import enrich_card, score_card
FORGE_BASIC = textwrap.dedent("""\
Name:Elvish Mystic
ManaCost:G
Types:Creature Elf Druid
PT:1/1
Oracle:{T}: Add {G}.
""")
FORGE_ETB = textwrap.dedent("""\
Name:Llanowar Elves
ManaCost:G
Types:Creature Elf Druid
PT:1/1
T:Mode$ChangesZone $ Origin$Graveyard $ Destination$Battlefield
""")
FORGE_KEYWORDS = textwrap.dedent("""\
Name:Serra Angel
ManaCost:3 W W
Types:Creature Angel
PT:4/4
K:Flying
K:Vigilance
""")
FORGE_STATIC = textwrap.dedent("""\
Name:Glorious Anthem
ManaCost:1 W W
Types:Enchantment
S:Mode$Continuous $ Affected$Creature.YouCtrl
""")
FORGE_REPLACEMENT = textwrap.dedent("""\
Name:Leyline of the Void
ManaCost:2 B B
Types:Enchantment
R:Mode$ReplaceEvent $ Event$MovedToGraveyard
""")
# -- Parser unit tests --
class TestParseForgeFile:
def _parse(self, txt: str, tmp_path: Path):
from commander_ai_lab.sim.forge_card_loader import _parse_forge_file
fpath = tmp_path / "card.txt"
fpath.write_text(txt, encoding="utf-8")
return _parse_forge_file(fpath)
def test_basic_fields(self, tmp_path):
d = self._parse(FORGE_BASIC, tmp_path)
assert d.name == "Elvish Mystic"
assert d.cmc == 1
assert d.pt == "1/1"
assert "{T}: Add {G}" in d.oracle_text
def test_keywords_parsed(self, tmp_path):
d = self._parse(FORGE_KEYWORDS, tmp_path)
assert "Flying" in d.keywords and "Vigilance" in d.keywords
def test_cmc_generic_plus_colored(self, tmp_path):
d = self._parse(FORGE_KEYWORDS, tmp_path)
assert d.cmc == 5 # 3 + W + W
def test_etb_trigger_mode(self, tmp_path):
d = self._parse(FORGE_ETB, tmp_path)
assert "ChangesZone" in d.trigger_modes
def test_static_flag(self, tmp_path):
d = self._parse(FORGE_STATIC, tmp_path)
assert d.has_static_ability is True
assert d.has_replacement_effect is False
def test_replacement_flag(self, tmp_path):
d = self._parse(FORGE_REPLACEMENT, tmp_path)
assert d.has_replacement_effect is True
def test_empty_file_returns_none(self, tmp_path):
from commander_ai_lab.sim.forge_card_loader import _parse_forge_file
fpath = tmp_path / "empty.txt"
fpath.write_text("", encoding="utf-8")
assert _parse_forge_file(fpath) is None
# -- enrich_card() integration tests --
class TestEnrichCardForge:
def _forge(self, **kwargs):
from commander_ai_lab.sim.forge_card_loader import ForgeCardData
base = dict(
name="X", mana_cost="2 G", cmc=3, type_line="Creature Elf",
oracle_text="", pt="2/2", keywords=["Flying"],
trigger_modes=[], has_replacement_effect=False,
has_static_ability=False,
)
base.update(kwargs)
return ForgeCardData(**base)
def test_forge_applied_to_empty_card(self):
card = Card(name="X")
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=self._forge()):
r = enrich_card(card)
assert r.forge_enriched and r.cmc == 3 and r.power == "2"
assert "Flying" in r.keywords
def test_skipped_when_has_real_data(self):
card = Card(name="Sol Ring", cmc=1, type_line="Artifact")
with patch("commander_ai_lab.sim.rules.lookup_forge_card") as m:
enrich_card(card)
m.assert_not_called()
def test_miss_falls_through_to_known_cards(self):
card = Card(name="Sol Ring")
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=None):
r = enrich_card(card)
assert r.type_line == "Artifact" and r.is_ramp is True
def test_oracle_flags_from_forge_text(self):
card = Card(name="X")
fd = self._forge(oracle_text="Destroy target creature.", keywords=[])
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=fd):
r = enrich_card(card)
assert r.is_removal is True
def test_no_duplicate_keywords(self):
card = Card(name="X", keywords=["Flying"])
fd = self._forge(keywords=["Flying", "Haste"])
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=fd):
r = enrich_card(card)
assert sum(1 for k in r.keywords if k.lower() == "flying") == 1
assert any(k.lower() == "haste" for k in r.keywords)
# -- score_card() trigger detection tests --
class TestScoreCardForge:
def test_etb_via_forge_mode_equals_text_detection(self):
forge = Card(name="A", type_line="Creature", cmc=3, pt="3/3",
forge_trigger_modes=["ChangesZone"])
text = Card(name="B", type_line="Creature", cmc=3, pt="3/3",
oracle_text="When this enters the battlefield, gain 1 life.")
none = Card(name="C", type_line="Creature", cmc=3, pt="3/3")
sf, st, sn = score_card(forge), score_card(text), score_card(none)
assert sf > sn and abs(sf - st) < 0.01
def test_attack_trig_via_forge_mode(self):
forge = Card(name="A", type_line="Creature", cmc=3, pt="3/3",
forge_trigger_modes=["Attacks"])
none = Card(name="B", type_line="Creature", cmc=3, pt="3/3")
assert score_card(forge) > score_card(none)
-315
View File
@@ -1,315 +0,0 @@
"""Tests for London mulligan and opening-hand validation (#86)."""
import random
import pytest
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.models import Card, PlayerStats
from commander_ai_lab.sim.rules import enrich_card
# ── Helpers ───────────────────────────────────────────────────
def _land(name: str = "Forest") -> Card:
return enrich_card(Card(name=name))
def _spell(name: str = "Grizzly Bears") -> Card:
return enrich_card(Card(name=name))
def _make_deck(lands: int = 24, spells: int = 36) -> list[Card]:
"""Build a deck with an exact land/spell split."""
deck: list[Card] = []
for _ in range(lands):
deck.append(_land())
for _ in range(spells):
deck.append(_spell())
return deck
def _all_lands_deck(size: int = 60) -> list[Card]:
"""Deck of nothing but lands — forces 7-land opening hand."""
return [_land() for _ in range(size)]
def _no_lands_deck(size: int = 60) -> list[Card]:
"""Deck with zero lands — forces 0-land opening hand."""
return [_spell(f"Spell_{i}") for i in range(size)]
# ── PlayerStats fields ────────────────────────────────────────
class TestPlayerStatsMultiganFields:
def test_default_mulligans_zero(self):
stats = PlayerStats()
assert stats.mulligans == 0
def test_default_opening_hand_lands_zero(self):
stats = PlayerStats()
assert stats.opening_hand_lands == 0
def test_to_dict_includes_mulligan_keys(self):
stats = PlayerStats(mulligans=2, opening_hand_lands=3)
d = stats.to_dict()
assert d["mulligans"] == 2
assert d["openingHandLands"] == 3
# ── _should_mulligan heuristic ────────────────────────────────
class TestShouldMulligan:
def test_zero_lands_mulligans(self):
hand = [_spell() for _ in range(7)]
assert GameEngine._should_mulligan(hand, 0) is True
def test_one_land_mulligans(self):
hand = [_land()] + [_spell() for _ in range(6)]
assert GameEngine._should_mulligan(hand, 0) is True
def test_two_lands_keeps(self):
hand = [_land(), _land()] + [_spell() for _ in range(5)]
assert GameEngine._should_mulligan(hand, 0) is False
def test_five_lands_keeps(self):
hand = [_land() for _ in range(5)] + [_spell(), _spell()]
assert GameEngine._should_mulligan(hand, 0) is False
def test_six_lands_mulligans(self):
hand = [_land() for _ in range(6)] + [_spell()]
assert GameEngine._should_mulligan(hand, 0) is True
def test_seven_lands_mulligans(self):
hand = [_land() for _ in range(7)]
assert GameEngine._should_mulligan(hand, 0) is True
def test_desperate_keeps_one_land(self):
"""After 2+ mulligans, accept a 1-land hand."""
hand = [_land()] + [_spell() for _ in range(6)]
assert GameEngine._should_mulligan(hand, 2) is False
# ── _count_lands_in_hand ──────────────────────────────────────
class TestCountLandsInHand:
def test_empty_hand(self):
assert GameEngine._count_lands_in_hand([]) == 0
def test_mixed_hand(self):
hand = [_land(), _spell(), _land(), _spell()]
assert GameEngine._count_lands_in_hand(hand) == 2
def test_all_lands(self):
hand = [_land() for _ in range(7)]
assert GameEngine._count_lands_in_hand(hand) == 7
# ── _pick_bottom_cards ────────────────────────────────────────
class TestPickBottomCards:
def test_returns_correct_count(self):
hand = [_land(), _land(), _spell(), _spell(), _spell()]
bottom = GameEngine._pick_bottom_cards(hand, 2, {})
assert len(bottom) == 2
def test_zero_count_returns_empty(self):
hand = [_land(), _spell()]
assert GameEngine._pick_bottom_cards(hand, 0, {}) == []
def test_count_gte_hand_returns_empty(self):
hand = [_land()]
assert GameEngine._pick_bottom_cards(hand, 1, {}) == []
def test_bottoms_lowest_scored_cards(self):
"""The card with the worst AI score should be picked to bottom."""
good = enrich_card(Card(name="Serra Angel", type_line="Creature", cmc=5, pt="4/4"))
bad = enrich_card(Card(name="Forest"))
hand = [good, bad, _spell()]
bottom = GameEngine._pick_bottom_cards(hand, 1, {})
# Lands score very low compared to creatures
assert bottom[0].name == "Forest"
# ── London mulligan integration ───────────────────────────────
class TestLondonMulliganIntegration:
def test_good_hand_no_mulligan(self):
"""A well-balanced deck should usually keep on the first try."""
random.seed(42)
deck = _make_deck(lands=24, spells=36)
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# At least one player should have kept without mulligan
any_kept = any(
p.stats.mulligans == 0 for p in result.players
)
# With a 24-land deck, most seeds give keepable hands
assert result.turns > 0 # game still works
def test_all_lands_deck_triggers_mulligan(self):
"""A 60-land deck should always mulligan (7 lands in hand)."""
random.seed(99)
deck_lands = _all_lands_deck()
deck_normal = _make_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck_lands],
deck_b=[c.clone() for c in deck_normal],
)
# Player A has an all-land deck; must have mulliganed at least once
pa_stats = result.players[0].stats
assert pa_stats.mulligans > 0
def test_no_lands_deck_triggers_mulligan(self):
"""A 0-land deck should always mulligan (0 lands in hand)."""
random.seed(77)
deck_empty = _no_lands_deck()
deck_normal = _make_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck_empty],
deck_b=[c.clone() for c in deck_normal],
)
pa_stats = result.players[0].stats
assert pa_stats.mulligans > 0
def test_mulligan_caps_at_max(self):
"""Even a terrible deck can mulligan at most MAX_MULLIGANS times."""
random.seed(11)
deck_empty = _no_lands_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck_empty],
deck_b=[c.clone() for c in deck_empty],
)
for p in result.players:
assert p.stats.mulligans <= GameEngine.MAX_MULLIGANS
def test_hand_size_after_mulligan(self):
"""After N mulligans, opening hand should be 7 - N cards."""
random.seed(55)
deck_lands = _all_lands_deck()
deck_normal = _make_deck()
engine = GameEngine(mulligan_rule="london")
# We need to test the internal state, so use _create_state directly
sim = engine._create_state(
decks=[[c.clone() for c in deck_lands], [c.clone() for c in deck_normal]],
names=["LandPlayer", "NormalPlayer"],
)
for p in sim.players:
expected = 7 - p.stats.mulligans
assert len(p.hand) == expected, (
f"{p.name}: hand={len(p.hand)}, mulligans={p.stats.mulligans}, "
f"expected={expected}"
)
def test_opening_hand_lands_tracked(self):
"""opening_hand_lands should reflect final kept hand."""
random.seed(42)
deck = _make_deck()
engine = GameEngine(mulligan_rule="london")
sim = engine._create_state(
decks=[[c.clone() for c in deck], [c.clone() for c in deck]],
names=["P1", "P2"],
)
for p in sim.players:
actual_lands = sum(1 for c in p.hand if c.is_land())
assert p.stats.opening_hand_lands == actual_lands
def test_total_cards_preserved(self):
"""Mulligan must not create or destroy cards."""
random.seed(33)
deck = _make_deck(lands=24, spells=36)
engine = GameEngine(mulligan_rule="london")
for _ in range(10):
sim = engine._create_state(
decks=[[c.clone() for c in deck], [c.clone() for c in deck]],
names=["A", "B"],
)
for p in sim.players:
total = len(p.hand) + len(p.library) + len(p.command_zone)
assert total == 60, f"Card count mismatch: {total}"
# ── mulligan_rule="none" (legacy) ─────────────────────────────
class TestNoMulliganMode:
def test_no_mulligan_always_keeps_seven(self):
"""With mulligan_rule='none', always get 7 cards, 0 mulligans."""
random.seed(42)
deck = _all_lands_deck()
engine = GameEngine(mulligan_rule="none")
sim = engine._create_state(
decks=[[c.clone() for c in deck]],
names=["P1"],
)
p = sim.players[0]
assert len(p.hand) == 7
assert p.stats.mulligans == 0
# ── N-player mulligan ─────────────────────────────────────────
class TestNPlayerMulligan:
def test_four_player_mulligan(self):
"""Each player in a 4-player game resolves mulligans independently."""
random.seed(42)
decks = [_make_deck() for _ in range(4)]
engine = GameEngine(mulligan_rule="london")
result = engine.run_n(
decks=[[c.clone() for c in d] for d in decks],
names=["A", "B", "C", "D"],
)
assert len(result.players) == 4
for p in result.players:
assert p.stats.mulligans >= 0
assert p.stats.mulligans <= GameEngine.MAX_MULLIGANS
# ── Commander + mulligan interaction ──────────────────────────
class TestCommanderMulligan:
def test_commander_stays_in_command_zone_through_mulligans(self):
"""Commander should remain in command zone regardless of mulligans."""
random.seed(42)
# Build a deck with a known commander
deck = _make_deck(lands=24, spells=35)
commander = Card(name="Kenrith, the Returned King", type_line="Creature", cmc=5, pt="5/5")
deck.append(commander)
engine = GameEngine(mulligan_rule="london")
sim = engine._create_state(
decks=[[c.clone() for c in deck]],
names=["P1"],
commander_names=["Kenrith, the Returned King"],
)
p = sim.players[0]
assert len(p.command_zone) == 1
assert p.command_zone[0].name == "Kenrith, the Returned King"
assert p.command_zone[0].is_commander is True
# Commander should not appear in hand or library
assert all(not c.is_commander for c in p.hand)
assert all(not c.is_commander for c in p.library)
# ── Serialization round-trip ──────────────────────────────────
class TestMulliganSerialization:
def test_result_dict_includes_mulligans(self):
"""to_dict() output should include mulligan stats."""
import json
random.seed(42)
deck = _make_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
d = result.to_dict()
# Verify JSON-serializable
json.dumps(d)
for pr in d["playerResults"]:
assert "mulligans" in pr["stats"]
assert "openingHandLands" in pr["stats"]
-515
View File
@@ -1,515 +0,0 @@
"""Regression tests for explicit turn-structure and phase progression.
Validates that the headless engine walks through all MTG phases in order,
tracks the current phase on SimState, supports a shared spell budget across
main1+main2, and preserves all prior behavior (combat, elimination, logging).
"""
import random
import pytest
from commander_ai_lab.sim.models import (
Card,
Phase,
PHASE_ORDER,
SORCERY_PHASES,
SimState,
)
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.engine import GameEngine
# ── Helpers ──────────────────────────────────────────────────
def _make_deck(seed: int, size: int = 60) -> list[Card]:
"""Deterministic test deck."""
rng = random.Random(seed)
pool = [
Card(name="Forest"),
Card(name="Sol Ring"),
Card(name="Cultivate"),
Card(name="Murder"),
Card(name="Wrath of God"),
Card(name="Grizzly Bears"),
Card(name="Serra Angel"),
]
for c in pool:
enrich_card(c)
deck = [pool[i % len(pool)].clone() for i in range(size)]
rng.shuffle(deck)
return deck
def _land_heavy_deck(size: int = 60) -> list[Card]:
"""Deck with many lands (ensures mana for spells in both main phases)."""
cards = []
for i in range(size):
if i < 30:
c = Card(name="Forest")
elif i < 40:
c = Card(name="Grizzly Bears")
elif i < 50:
c = Card(name="Serra Angel")
else:
c = Card(name="Sol Ring")
enrich_card(c)
cards.append(c)
random.Random(42).shuffle(cards)
return cards
# ══════════════════════════════════════════════════════════════
# Phase Enum Tests
# ══════════════════════════════════════════════════════════════
class TestPhaseEnum:
"""Tests for the Phase enum and related constants."""
def test_phase_count(self):
"""There should be exactly 12 phases in MTG turn structure."""
assert len(Phase) == 12
def test_phase_order_matches_mtg(self):
"""Phases must follow the MTG turn order."""
expected = [
"untap", "upkeep", "draw", "main1",
"begin_combat", "declare_attackers", "declare_blockers",
"combat_damage", "end_combat",
"main2", "end_step", "cleanup",
]
assert [p.value for p in PHASE_ORDER] == expected
def test_phase_is_str_subclass(self):
"""Phase enum values should compare directly with plain strings."""
assert Phase.MAIN1 == "main1"
assert Phase.CLEANUP == "cleanup"
def test_sorcery_phases(self):
"""Only main1 and main2 are sorcery-speed phases."""
assert SORCERY_PHASES == frozenset({Phase.MAIN1, Phase.MAIN2})
def test_phase_iteration(self):
"""PHASE_ORDER is iterable and matches Phase members."""
assert list(PHASE_ORDER) == list(Phase)
# ══════════════════════════════════════════════════════════════
# SimState Phase Tracking
# ══════════════════════════════════════════════════════════════
class TestSimStatePhaseTracking:
"""Tests that SimState tracks phase and active player index."""
def test_default_phase(self):
"""SimState defaults to MAIN1."""
sim = SimState()
assert sim.current_phase == Phase.MAIN1
def test_default_active_player(self):
"""SimState defaults to player 0 as active."""
sim = SimState()
assert sim.active_player_index == 0
def test_phase_updates_during_game(self):
"""After a game, current_phase should have been set (not stuck at default)."""
engine = GameEngine(max_turns=1, mulligan_rule="none")
deck_a = _make_deck(seed=10)
deck_b = _make_deck(seed=11)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
)
# Game completed successfully
assert result.turns >= 1
# ══════════════════════════════════════════════════════════════
# Phase Progression in Engine
# ══════════════════════════════════════════════════════════════
class TestPhaseProgression:
"""Tests that the engine walks through phases correctly."""
def test_game_completes_with_phase_structure(self):
"""Game should complete without errors using the new phase loop."""
engine = GameEngine(max_turns=10)
result = engine.run(
deck_a=[c.clone() for c in _make_deck(1)],
deck_b=[c.clone() for c in _make_deck(2)],
name_a="Alice",
name_b="Bob",
)
assert result is not None
assert result.turns > 0
assert result.winner_seat in (-1, 0, 1)
def test_four_player_game_with_phases(self):
"""4-player game should complete with the new phase loop."""
engine = GameEngine(max_turns=10)
decks = [_make_deck(seed=i) for i in range(4)]
result = engine.run_n(
decks=[[c.clone() for c in d] for d in decks],
names=["A", "B", "C", "D"],
)
assert result is not None
assert result.turns > 0
assert len(result.players) == 4
def test_untap_happens_each_turn(self):
"""Creatures tapped for combat are untapped at start of next turn."""
engine = GameEngine(max_turns=5, record_log=True, mulligan_rule="none")
# Build deck with creatures that will attack
deck = []
for i in range(60):
if i < 25:
deck.append(Card(name="Forest"))
else:
c = Card(name="Grizzly Bears")
enrich_card(c)
deck.append(c)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# Game should complete — untap logic is working
assert result.turns > 0
def test_draw_happens_after_untap(self):
"""Players draw one card each turn after the first."""
engine = GameEngine(max_turns=3, mulligan_rule="none")
deck_a = _make_deck(seed=20, size=60)
deck_b = _make_deck(seed=21, size=60)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
)
# Both players should have drawn cards (starting 7 + draws)
for pr in result.players:
assert pr.stats.cards_drawn >= 7
def test_land_drop_in_main1(self):
"""Land drops should still happen (in main1)."""
engine = GameEngine(max_turns=5, mulligan_rule="none")
deck = _make_deck(seed=30)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# At least one player should have played lands
total_lands = sum(pr.stats.lands_played for pr in result.players)
assert total_lands > 0
def test_combat_in_declare_attackers_phase(self):
"""Combat damage should still resolve correctly."""
engine = GameEngine(max_turns=25, mulligan_rule="none")
# Use creature-heavy deck so combat is guaranteed
deck = []
for i in range(60):
if i < 24:
deck.append(Card(name="Forest"))
else:
c = Card(name="Grizzly Bears")
enrich_card(c)
deck.append(c)
random.Random(40).shuffle(deck)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
total_damage = sum(pr.stats.damage_dealt for pr in result.players)
# With creature-heavy decks over 25 turns, combat must happen
assert total_damage > 0
# ══════════════════════════════════════════════════════════════
# Spell Budget Across Main Phases
# ══════════════════════════════════════════════════════════════
class TestSpellBudget:
"""Tests that the 2-spell budget is shared across main1 and main2."""
def test_max_two_spells_per_turn(self):
"""No player should cast more than 2 non-commander spells per turn."""
engine = GameEngine(max_turns=5, mulligan_rule="none")
deck = _land_heavy_deck()
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# Both players played spells
for pr in result.players:
# spells_cast across all turns; with 5 turns max 10 spells each
assert pr.stats.spells_cast <= 10 # 2 per turn * 5 turns
def test_spells_can_split_across_main_phases(self):
"""Engine supports casting in main2 if budget allows.
We verify by checking that _play_spells returns 0 when budget is 0.
"""
engine = GameEngine()
# Create a minimal sim state with one player
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
p = Player(
name="Test",
life=40,
owner_id=0,
hand=[],
stats=PlayerStats(),
)
sim.players.append(p)
sim.init_battlefields(1)
# With no cards in hand, should play 0 spells
result = engine._play_spells(sim, 0, available_mana=5, max_spells=2)
assert result == 0
def test_play_spells_respects_max_spells_zero(self):
"""_play_spells with max_spells=0 plays nothing."""
engine = GameEngine()
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
p = Player(
name="Test",
life=40,
owner_id=0,
hand=[Card(name="Grizzly Bears", cmc=2, type_line="Creature")],
stats=PlayerStats(),
)
sim.players.append(p)
sim.init_battlefields(1)
# Add 5 untapped lands
for _ in range(5):
land = Card(name="Forest", type_line="Land", tapped=False)
sim.add_to_battlefield(0, land)
result = engine._play_spells(sim, 0, available_mana=5, max_spells=0)
assert result == 0
# Card should still be in hand
assert len(p.hand) == 1
def test_play_spells_respects_max_spells_one(self):
"""_play_spells with max_spells=1 plays at most one spell."""
engine = GameEngine()
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
bear1 = Card(name="Grizzly Bears", cmc=2, type_line="Creature — Bear", pt="2/2")
bear2 = Card(name="Grizzly Bears", cmc=2, type_line="Creature — Bear", pt="2/2")
enrich_card(bear1)
enrich_card(bear2)
p = Player(
name="Test",
life=40,
owner_id=0,
hand=[bear1, bear2],
stats=PlayerStats(),
)
sim.players.append(p)
sim.init_battlefields(1)
for _ in range(5):
land = Card(name="Forest", type_line="Land", tapped=False)
sim.add_to_battlefield(0, land)
result = engine._play_spells(sim, 0, available_mana=5, max_spells=1)
assert result == 1
assert len(p.hand) == 1 # one spell remaining
# ══════════════════════════════════════════════════════════════
# Game Log with Phase Structure
# ══════════════════════════════════════════════════════════════
class TestGameLogPhases:
"""Tests that the game log still works with the new phase structure."""
def test_game_log_has_turns(self):
"""record_log should produce a log with turn entries."""
engine = GameEngine(max_turns=3, record_log=True, mulligan_rule="none")
deck = _make_deck(seed=50)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
assert result.game_log is not None
assert len(result.game_log) > 0
def test_game_log_turn_structure(self):
"""Each log entry should have turn number and phases list."""
engine = GameEngine(max_turns=2, record_log=True, mulligan_rule="none")
deck = _make_deck(seed=51)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
for entry in result.game_log:
assert "turn" in entry
assert "phases" in entry
assert isinstance(entry["phases"], list)
def test_game_log_phase_entries(self):
"""Phase entries should have player and events."""
engine = GameEngine(max_turns=2, record_log=True, mulligan_rule="none")
deck = _make_deck(seed=52)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
for entry in result.game_log:
for phase in entry["phases"]:
assert "player" in phase
assert "playerId" in phase
assert "events" in phase
# ══════════════════════════════════════════════════════════════
# Backward Compatibility
# ══════════════════════════════════════════════════════════════
class TestBackwardCompat:
"""Ensure existing behavior is preserved."""
def test_two_player_still_works(self):
"""The 2-player run() entry point should still work."""
engine = GameEngine(max_turns=10)
result = engine.run(
deck_a=[c.clone() for c in _make_deck(60)],
deck_b=[c.clone() for c in _make_deck(61)],
)
assert result.winner_seat in (-1, 0, 1)
def test_n_player_still_works(self):
"""The N-player run_n() entry point should still work."""
engine = GameEngine(max_turns=10)
decks = [_make_deck(seed=i + 70) for i in range(3)]
result = engine.run_n(
decks=[[c.clone() for c in d] for d in decks],
names=["X", "Y", "Z"],
)
assert result.winner_seat in range(3)
assert len(result.players) == 3
def test_mulligan_rule_still_works(self):
"""London mulligan should still function with the phase refactor."""
engine = GameEngine(max_turns=5, mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in _make_deck(80)],
deck_b=[c.clone() for c in _make_deck(81)],
)
assert result.turns > 0
def test_no_mulligan_rule_still_works(self):
"""Legacy mulligan='none' should still function."""
engine = GameEngine(max_turns=5, mulligan_rule="none")
result = engine.run(
deck_a=[c.clone() for c in _make_deck(90)],
deck_b=[c.clone() for c in _make_deck(91)],
)
assert result.turns > 0
def test_commander_damage_still_tracked(self):
"""Commander damage tracking should still work through deal_damage."""
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
for i in range(2):
sim.players.append(Player(
name=f"P{i}", life=40, owner_id=i, stats=PlayerStats(),
))
sim.init_battlefields(2)
commander = Card(name="TestCommander", is_commander=True)
GameEngine.deal_damage(
sim, 5, target_seat=1,
source_card=commander, source_seat=0,
is_combat=True,
)
assert sim.players[1].commander_damage_received[0] == 5
assert sim.players[1].commander_damage_by_card[(0, "TestCommander")] == 5
assert sim.players[1].life == 35
def test_direct_damage_still_works(self):
"""Burn spells should still route through deal_damage."""
sim = SimState(max_turns=1)
from commander_ai_lab.sim.models import Player, PlayerStats
for i in range(2):
sim.players.append(Player(
name=f"P{i}", life=40, owner_id=i, stats=PlayerStats(),
))
sim.init_battlefields(2)
burn = Card(name="Lightning Bolt", is_direct_damage=True, direct_damage_amount=3)
events: list[str] = []
GameEngine.deal_damage(
sim, 3, target_seat=1,
source_card=burn, source_seat=0,
events=events,
label="Lightning Bolt deals 3",
)
assert sim.players[1].life == 37
assert len(events) == 1
def test_elimination_still_works(self):
"""Player elimination should still trigger when life <= 0."""
engine = GameEngine(max_turns=25, mulligan_rule="none")
# Build aggro-heavy decks
deck = []
for i in range(60):
if i < 20:
deck.append(Card(name="Mountain"))
else:
c = Card(name="Lightning Bolt")
enrich_card(c)
deck.append(c)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
# At least one player should be eliminated (or game timed out)
assert result.turns > 0
def test_ml_log_still_works(self):
"""ML logging should still capture decisions."""
engine = GameEngine(max_turns=3, ml_log=True, mulligan_rule="none")
deck = _make_deck(seed=100)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
decisions = engine.flush_ml_decisions()
assert isinstance(decisions, list)
# With turns happening, there should be some decisions
if result.turns > 0:
assert len(decisions) > 0
def test_result_serializable(self):
"""Game result should still serialize cleanly."""
import json
engine = GameEngine(max_turns=3, record_log=True, mulligan_rule="none")
deck = _make_deck(seed=110)
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
d = result.to_dict()
json.dumps(d) # should not raise
# ══════════════════════════════════════════════════════════════
# Turn Manager Phase Constants
# ══════════════════════════════════════════════════════════════
class TestTurnManagerPhaseCompat:
"""Ensure turn_manager.py PHASES/ACTION_PHASES/RESPONSE_PHASES are consistent."""
def test_turn_manager_phases_match_model(self):
"""turn_manager.PHASES should match the canonical PHASE_ORDER."""
from commander_ai_lab.sim.turn_manager import PHASES as TM_PHASES
assert TM_PHASES == [p.value for p in PHASE_ORDER]
def test_turn_manager_action_phases(self):
"""ACTION_PHASES should be main1 and main2."""
from commander_ai_lab.sim.turn_manager import ACTION_PHASES
assert ACTION_PHASES == {"main1", "main2"}
def test_turn_manager_response_phases(self):
"""RESPONSE_PHASES should be upkeep, begin_combat, end_combat, end_step."""
from commander_ai_lab.sim.turn_manager import RESPONSE_PHASES
assert RESPONSE_PHASES == {"upkeep", "begin_combat", "end_combat", "end_step"}
-134
View File
@@ -1,134 +0,0 @@
"""
tests/test_check_ownership_dedup.py
====================================
Unit tests for duplicate-card deduplication in
DeckGeneratorV3._check_ownership().
Uses an in-memory SQLite database so no real collection DB is required.
"""
from __future__ import annotations
import logging
import sqlite3
from unittest.mock import MagicMock
import pytest
from coach.services.deck_generator import DeckGeneratorV3
# ── Helpers ──────────────────────────────────────────────────────────────────
def _make_conn() -> sqlite3.Connection:
"""Return a seeded in-memory DB matching the collection_entries schema."""
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE collection_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
scryfall_id TEXT DEFAULT '',
quantity INTEGER DEFAULT 0,
tcg_price REAL DEFAULT 0,
type_line TEXT DEFAULT '',
cmc REAL DEFAULT 0
)
""")
cards = [
("Cultivate", "cult-001", 2, 1.50, "Sorcery", 3),
("Sol Ring", "sol-001", 1, 5.00, "Artifact", 0),
("Kodama's Reach", "kr-001", 1, 0.80, "Sorcery", 3),
]
conn.executemany(
"INSERT INTO collection_entries (name, scryfall_id, quantity, tcg_price, type_line, cmc) "
"VALUES (?, ?, ?, ?, ?, ?)",
cards,
)
conn.commit()
return conn
def _make_generator(conn: sqlite3.Connection) -> DeckGeneratorV3:
"""Build a DeckGeneratorV3 with a mock Anthropic Claude Opus client."""
return DeckGeneratorV3(
anthropic_client=MagicMock(),
db_conn_factory=lambda: conn,
)
# ── Tests ────────────────────────────────────────────────────────────────────
class TestCheckOwnershipDedup:
def setup_method(self):
self.conn = _make_conn()
self.gen = _make_generator(self.conn)
def test_duplicate_cards_merged(self):
"""Duplicate card names should be merged into a single entry with summed count."""
cards = [
{"name": "Cultivate", "count": 1, "category": "Ramp"},
{"name": "Sol Ring", "count": 1, "category": "Ramp"},
{"name": "Cultivate", "count": 1, "category": "Ramp"},
]
result = self.gen._check_ownership(cards)
names = [c.name for c in result]
# No duplicate names (case-insensitive)
assert len(names) == len(set(n.lower() for n in names)), (
f"Duplicate names found in output: {names}"
)
# Only 2 unique cards
assert len(result) == 2
# Merged count should be 2
cultivate = next(c for c in result if c.name.lower() == "cultivate")
assert cultivate.count == 2
def test_case_insensitive_dedup(self):
"""Deduplication should be case-insensitive."""
cards = [
{"name": "cultivate", "count": 1, "category": "Ramp"},
{"name": "CULTIVATE", "count": 1, "category": "Ramp"},
{"name": "Cultivate", "count": 1, "category": "Ramp"},
]
result = self.gen._check_ownership(cards)
assert len(result) == 1
assert result[0].count == 3
def test_warning_logged_on_duplicate(self, caplog):
"""A WARNING log should be emitted when a duplicate is merged."""
cards = [
{"name": "Cultivate", "count": 1, "category": "Ramp"},
{"name": "Cultivate", "count": 1, "category": "Ramp"},
]
with caplog.at_level(logging.WARNING, logger="coach.deckgen"):
self.gen._check_ownership(cards)
assert any("Duplicate" in msg or "merged" in msg for msg in caplog.messages), (
f"Expected WARNING about duplicate/merged, got: {caplog.messages}"
)
def test_no_duplicates_passes_through(self):
"""When there are no duplicates, all cards pass through normally."""
cards = [
{"name": "Cultivate", "count": 1, "category": "Ramp"},
{"name": "Sol Ring", "count": 1, "category": "Ramp"},
{"name": "Kodama's Reach", "count": 1, "category": "Ramp"},
]
result = self.gen._check_ownership(cards)
assert len(result) == 3
assert all(c.count == 1 for c in result)
def test_empty_name_skipped(self):
"""Cards with empty names should be silently skipped."""
cards = [
{"name": "", "count": 1},
{"name": "Sol Ring", "count": 1, "category": "Ramp"},
]
result = self.gen._check_ownership(cards)
assert len(result) == 1
assert result[0].name == "Sol Ring"
-160
View File
@@ -1,160 +0,0 @@
"""Tests for combat priority checkpoints (Issue #86 Item 1).
Validates CombatState lifecycle, split combat methods,
priority windows, first-strike ordering, and instant-speed
interaction during combat.
"""
import pytest
from commander_ai_lab.sim.models import (
Card, CombatState, Phase, Player, PlayerStats, SimState,
)
from commander_ai_lab.sim.engine import GameEngine
# ── Helpers ──────────────────────────────────────────────────────
def _make_creature(name, power, toughness, card_id, owner=0, keywords=None, is_commander=False):
"""Build a minimal creature Card for testing."""
return Card(
name=name,
type_line="Creature",
power=str(power),
toughness=str(toughness),
pt=f"{power}/{toughness}",
id=card_id,
owner_id=owner,
keywords=keywords or [],
is_commander=is_commander,
turn_played=-1,
)
def _two_player_sim(p0_creatures, p1_creatures, p0_life=40, p1_life=40):
"""Build a minimal 2-player SimState with creatures on the battlefield."""
sim = SimState(
players=[
Player(name="Attacker", life=p0_life, owner_id=0, stats=PlayerStats()),
Player(name="Defender", life=p1_life, owner_id=1, stats=PlayerStats()),
],
battlefields=[list(p0_creatures), list(p1_creatures)],
turn=2,
)
return sim
# ── Tests ───────────────────────────────────────────────────────
class TestCombatState:
"""CombatState dataclass lifecycle."""
def test_combat_state_defaults(self):
cs = CombatState()
assert cs.defending_seat == -1
assert cs.attackers == {}
assert cs.blockers == {}
assert cs.player_damage == {}
assert cs.first_strike_resolved is False
assert cs.active is False
def test_combat_state_on_simstate(self):
sim = SimState()
assert sim.combat is None
sim.combat = CombatState(active=True)
assert sim.combat.active is True
sim.combat = None
assert sim.combat is None
class TestAssignAttackers:
"""Engine.assign_attackers() populates CombatState."""
def test_assigns_and_taps(self):
atk = _make_creature("Bear", 3, 3, 100)
sim = _two_player_sim([atk], [])
engine = GameEngine()
combat = engine.assign_attackers(sim, 0)
assert combat is not None
assert combat.active is True
assert 100 in combat.attackers
assert atk.tapped is True
assert sim.combat is combat
def test_no_attack_returns_none(self):
sim = _two_player_sim([], []) # no creatures
engine = GameEngine()
assert engine.assign_attackers(sim, 0) is None
assert sim.combat is None
class TestResolveCombatDamage:
"""Engine.resolve_combat_damage() applies damage from CombatState."""
def test_unblocked_attacker_deals_damage(self):
atk = _make_creature("Bear", 3, 3, 100)
sim = _two_player_sim([atk], [], p1_life=20)
combat = CombatState(defending_seat=1, attackers={100: 1}, active=True)
sim.combat = combat
engine = GameEngine()
engine.resolve_combat_damage(sim, 0, combat)
assert sim.players[1].life == 17 # 20 - 3
def test_attacker_removed_before_damage_deals_nothing(self):
"""Simulates instant-speed removal of attacker after declare attackers."""
atk = _make_creature("Bear", 3, 3, 100)
sim = _two_player_sim([atk], [], p1_life=20)
combat = CombatState(defending_seat=1, attackers={100: 1}, active=True)
sim.combat = combat
# Simulate instant-speed removal: remove attacker before damage
sim.remove_from_battlefield(100)
engine = GameEngine()
engine.resolve_combat_damage(sim, 0, combat)
assert sim.players[1].life == 20 # no damage dealt
def test_first_strike_only_skips_non_fs(self):
fs = _make_creature("Knight", 2, 2, 200, keywords=["first_strike"])
normal = _make_creature("Bear", 3, 3, 201)
sim = _two_player_sim([fs, normal], [], p1_life=20)
combat = CombatState(
defending_seat=1,
attackers={200: 1, 201: 1},
active=True,
)
sim.combat = combat
engine = GameEngine()
# First-strike step: only the knight deals damage
engine.resolve_combat_damage(sim, 0, combat, first_strike_only=True)
assert sim.players[1].life == 18 # 20 - 2 (knight only)
assert combat.first_strike_resolved is True
# Normal damage step: bear deals damage, knight skipped
engine.resolve_combat_damage(sim, 0, combat, first_strike_only=False)
assert sim.players[1].life == 15 # 18 - 3 (bear only)
def test_combat_state_cleared_after_end_combat(self):
"""Verifies sim.combat is None after combat cleanup."""
sim = SimState()
sim.combat = CombatState(active=True)
# Simulate end-of-combat cleanup
sim.combat = None
assert sim.combat is None
def test_commander_damage_attributed(self):
"""Commander attacker routes damage through commander_damage_by_card."""
cmd = _make_creature("Atraxa", 4, 4, 300, is_commander=True)
sim = _two_player_sim([cmd], [], p1_life=40)
combat = CombatState(defending_seat=1, attackers={300: 1}, active=True)
sim.combat = combat
engine = GameEngine()
engine.resolve_combat_damage(sim, 0, combat)
assert sim.players[1].life == 36 # 40 - 4
assert sim.players[1].commander_damage_by_card.get((0, "Atraxa"), 0) == 4
class TestResponsePhasesCleanup:
"""Verify BEGIN_COMBAT and END_COMBAT removed from RESPONSE_PHASES."""
def test_no_duplicate_apnap_begin_end_combat(self):
from commander_ai_lab.sim.turn_manager import RESPONSE_PHASES
assert "begin_combat" not in RESPONSE_PHASES
assert "end_combat" not in RESPONSE_PHASES
assert "upkeep" in RESPONSE_PHASES
assert "end_step" in RESPONSE_PHASES
-410
View File
@@ -1,410 +0,0 @@
"""
Commander Damage Regression Tests
==================================
Run with: pytest tests/test_commander_damage.py -v
Covers:
- Commander source identity during combat
- Trample overflow counts as commander damage
- Double strike commander damage
- Partner / multi-commander per-card tracking
- 21-damage lethal check (per-card, not per-seat)
- Commander damage through CommanderGameState helper
- Non-commander creatures do NOT generate commander damage
- Lifelink + commander damage interaction
"""
from __future__ import annotations
import pytest
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.game_state import (
CommanderGameState,
CommanderPlayer,
)
# ── Helpers ──────────────────────────────────────────────────
def _make_creature(
name: str,
power: str = "3",
toughness: str = "3",
keywords: list[str] | None = None,
is_commander: bool = False,
) -> Card:
c = Card(
name=name,
type_line="Legendary Creature" if is_commander else "Creature",
cmc=3,
power=power,
toughness=toughness,
keywords=keywords or [],
is_commander=is_commander,
)
return c
def _make_land(name: str = "Mountain") -> Card:
return Card(name=name, type_line="Basic Land", cmc=0)
def _setup_combat(
attacker_creatures: list[Card],
blocker_creatures: list[Card] | None = None,
attacker_life: int = 40,
defender_life: int = 40,
) -> tuple[GameEngine, SimState, int, int]:
"""Set up a 2-player SimState ready for _resolve_combat.
Returns (engine, sim, attacker_seat, defender_seat).
Attacker is seat 0, defender is seat 1.
All creatures are pre-placed on battlefield, untapped, played on a previous turn.
"""
engine = GameEngine(max_turns=25, starting_life=40, record_log=True)
sim = SimState(max_turns=25)
sim.turn = 5 # so creatures played earlier pass summoning sickness check
p0 = Player(name="Attacker", life=attacker_life, owner_id=0, stats=PlayerStats())
p1 = Player(name="Defender", life=defender_life, owner_id=1, stats=PlayerStats())
sim.players = [p0, p1]
sim.init_battlefields(2)
card_id = 90000
for c in attacker_creatures:
c.owner_id = 0
c.tapped = False
c.turn_played = 1 # played on an earlier turn
c.id = card_id
card_id += 1
sim.add_to_battlefield(0, c)
for c in (blocker_creatures or []):
c.owner_id = 1
c.tapped = False
c.turn_played = 1
c.id = card_id
card_id += 1
sim.add_to_battlefield(1, c)
sim.next_card_id = card_id
return engine, sim, 0, 1
# ── Tests: Basic commander damage tracking ───────────────────
class TestCommanderDamageBasic:
"""Commander creatures attacking unblocked should generate
commander damage tracked on the defending player."""
def test_unblocked_commander_deals_commander_damage(self):
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Life reduced
assert opp.life == 35
# Per-seat aggregate
assert opp.commander_damage_received[pi] == 5
# Per-card breakdown
assert opp.commander_damage_by_card[(pi, "Krenko")] == 5
def test_unblocked_non_commander_no_commander_damage(self):
creature = _make_creature("Goblin", "4", "4", is_commander=False)
engine, sim, pi, oi = _setup_combat([creature])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
assert opp.life == 36
assert opp.commander_damage_received == {}
assert opp.commander_damage_by_card == {}
def test_mixed_attackers_only_commander_tracked(self):
cmd = _make_creature("Krenko", "3", "3", is_commander=True)
grunt = _make_creature("Goblin", "2", "2", is_commander=False)
engine, sim, pi, oi = _setup_combat([cmd, grunt])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Total damage = 3 + 2 = 5
assert opp.life == 35
# Only commander damage tracked
assert opp.commander_damage_received[pi] == 3
assert opp.commander_damage_by_card[(pi, "Krenko")] == 3
def test_cumulative_commander_damage_across_combats(self):
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
# First combat
engine._resolve_combat(sim, pi, sim.turn, [])
assert sim.players[oi].commander_damage_received[pi] == 5
# Reset tapped state for second combat
cmd.tapped = False
sim.turn += 1
engine._resolve_combat(sim, pi, sim.turn, [])
assert sim.players[oi].commander_damage_received[pi] == 10
assert sim.players[oi].commander_damage_by_card[(pi, "Krenko")] == 10
# ── Tests: Commander damage lethal (21+) ─────────────────────
class TestCommanderDamageLethal:
def test_21_damage_eliminates(self):
cmd = _make_creature("Krenko", "7", "7", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd], defender_life=100)
# Deal 7 per combat, need 3 combats = 21
for _ in range(3):
cmd.tapped = False
engine._resolve_combat(sim, pi, sim.turn, [])
sim.turn += 1
opp = sim.players[oi]
assert opp.commander_damage_received[pi] == 21
assert opp.eliminated
def test_20_damage_not_lethal(self):
"""20 commander damage should NOT eliminate."""
p = Player(name="Test", life=40)
p.commander_damage_received = {0: 20}
p.commander_damage_by_card = {(0, "Krenko"): 20}
assert not p.is_dead_to_commander_damage()
def test_21_per_card_not_per_seat(self):
"""With partners, 21 must come from a SINGLE commander, not combined."""
p = Player(name="Test", life=40)
# Two partner commanders from seat 0, 11 each = 22 total but neither is 21
p.commander_damage_by_card = {
(0, "Brallin"): 11,
(0, "Shabraz"): 11,
}
p.commander_damage_received = {0: 22}
assert not p.is_dead_to_commander_damage()
def test_21_from_one_partner_is_lethal(self):
"""If one partner reaches 21, that's lethal even if the other has 0."""
p = Player(name="Test", life=40)
p.commander_damage_by_card = {
(0, "Brallin"): 21,
(0, "Shabraz"): 3,
}
p.commander_damage_received = {0: 24}
assert p.is_dead_to_commander_damage()
def test_backward_compat_no_by_card(self):
"""If commander_damage_by_card is empty, fall back to per-seat check."""
p = Player(name="Test", life=40)
p.commander_damage_received = {0: 21}
# No by_card data (older game state)
assert p.is_dead_to_commander_damage()
# ── Tests: Trample commander damage ──────────────────────────
class TestCommanderDamageTrample:
def test_trample_overflow_is_commander_damage(self):
"""When a commander with trample is chump-blocked, overflow to
the player should count as commander damage."""
cmd = _make_creature("Ghalta", "12", "12", keywords=["trample"], is_commander=True)
# Defender life low enough that AI decides to chump-block
# (opp.life <= a_pow * 2 ⇒ 20 <= 24)
blocker = _make_creature("Bear", "2", "2")
engine, sim, pi, oi = _setup_combat([cmd], [blocker], defender_life=20)
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Bear (2/2) chump-blocks Ghalta (12/12 trample).
# Trample overflow = 12 - 2 = 10 damage to player.
assert opp.commander_damage_received[pi] == 10
assert opp.commander_damage_by_card[(pi, "Ghalta")] == 10
assert opp.life == 10
def test_fully_blocked_commander_no_player_damage(self):
"""Commander fully absorbed by a survivable blocker — no commander
damage to the defending player."""
cmd = _make_creature("Krenko", "3", "3", is_commander=True)
# Blocker toughness > attacker power so AI selects it
blocker = _make_creature("Wall", "0", "5")
engine, sim, pi, oi = _setup_combat([cmd], [blocker])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# No damage to player (absorbed by blocker, no trample)
assert opp.life == 40
assert opp.commander_damage_received == {}
# ── Tests: Double strike commander damage ────────────────────
class TestCommanderDamageDoubleStrike:
def test_unblocked_double_strike_commander(self):
cmd = _make_creature("Rafiq", "5", "5", keywords=["double strike"], is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Double strike unblocked = power * 2
assert opp.life == 30
assert opp.commander_damage_received[pi] == 10
assert opp.commander_damage_by_card[(pi, "Rafiq")] == 10
# ── Tests: Partner / multi-commander ─────────────────────────
class TestPartnerCommanderDamage:
def test_two_partner_commanders_tracked_separately(self):
cmd_a = _make_creature("Brallin", "4", "4", is_commander=True)
cmd_b = _make_creature("Shabraz", "3", "3", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd_a, cmd_b])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Total damage = 4 + 3 = 7
assert opp.life == 33
# Aggregate per-seat
assert opp.commander_damage_received[pi] == 7
# Per-card individual
assert opp.commander_damage_by_card[(pi, "Brallin")] == 4
assert opp.commander_damage_by_card[(pi, "Shabraz")] == 3
def test_partner_21_check_per_card(self):
"""Even with partners, only 21 from ONE commander kills."""
cmd_a = _make_creature("Brallin", "7", "7", is_commander=True)
cmd_b = _make_creature("Shabraz", "7", "7", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd_a, cmd_b], defender_life=100)
# 3 combats: 7*3=21 for Brallin, 7*3=21 for Shabraz
# After 2 combats: 14 each — not lethal
for _ in range(2):
cmd_a.tapped = False
cmd_b.tapped = False
engine._resolve_combat(sim, pi, sim.turn, [])
sim.turn += 1
opp = sim.players[oi]
assert not opp.eliminated # 14 from each, neither is 21
# 3rd combat pushes both to 21
cmd_a.tapped = False
cmd_b.tapped = False
engine._resolve_combat(sim, pi, sim.turn, [])
assert opp.eliminated
# ── Tests: CommanderGameState integration ────────────────────
class TestCommanderGameStateDamage:
def test_deal_commander_damage_with_name(self):
sim = SimState()
sim.init_battlefields(2)
for i in range(2):
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i))
gs = CommanderGameState.from_sim_state(sim)
gs.deal_commander_damage(0, 1, 10, commander_name="Krenko")
cp = gs.commander_players[1]
assert cp.commander_damage_received[0] == 10
assert cp.base.commander_damage_by_card[(0, "Krenko")] == 10
assert cp.life == 30
def test_deal_commander_damage_without_name_backward_compat(self):
sim = SimState()
sim.init_battlefields(2)
for i in range(2):
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i))
gs = CommanderGameState.from_sim_state(sim)
gs.deal_commander_damage(0, 1, 10)
cp = gs.commander_players[1]
assert cp.commander_damage_received[0] == 10
assert cp.base.commander_damage_by_card == {}
assert cp.life == 30
def test_commander_player_property_delegates_to_base(self):
"""CommanderPlayer.commander_damage_received should be the same
object as base.commander_damage_received."""
p = Player(name="Test", life=40)
cp = CommanderPlayer(base=p)
# Write through base
p.commander_damage_received[0] = 15
# Read through wrapper
assert cp.commander_damage_received[0] == 15
# Write through wrapper
cp.commander_damage_received[1] = 7
assert p.commander_damage_received[1] == 7
# ── Tests: Event log annotation ──────────────────────────────
class TestCommanderDamageEvents:
def test_event_log_includes_cmdr_annotation(self):
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
# Should mention commander damage in the event text
damage_event = [e for e in events if "combat damage" in e]
assert len(damage_event) == 1
assert "5 cmdr" in damage_event[0]
def test_no_cmdr_annotation_for_non_commanders(self):
grunt = _make_creature("Goblin", "3", "3", is_commander=False)
engine, sim, pi, oi = _setup_combat([grunt])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
damage_event = [e for e in events if "combat damage" in e]
assert len(damage_event) == 1
assert "cmdr" not in damage_event[0]
# ── Tests: Lifelink + commander interaction ──────────────────
class TestCommanderDamageLifelink:
def test_commander_with_lifelink_heals_and_tracks(self):
cmd = _make_creature("Oloro", "5", "5", keywords=["lifelink"], is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
attacker = sim.players[pi]
opp = sim.players[oi]
# Commander damage tracked
assert opp.commander_damage_received[pi] == 5
# Lifelink healed attacker
assert attacker.life == 45
# Defender took damage
assert opp.life == 35
# ── Tests: Player model ─────────────────────────────────────
class TestPlayerCommanderDamageModel:
def test_fresh_player_has_empty_tracking(self):
p = Player(name="Test")
assert p.commander_damage_received == {}
assert p.commander_damage_by_card == {}
assert not p.is_dead_to_commander_damage()
def test_is_dead_prefers_by_card(self):
"""When by_card data exists, per-seat aggregate is ignored for lethal check."""
p = Player(name="Test")
p.commander_damage_received = {0: 25} # aggregate says lethal
p.commander_damage_by_card = {
(0, "CmdA"): 12,
(0, "CmdB"): 13,
}
# Per-card: neither commander has 21 → NOT lethal
assert not p.is_dead_to_commander_damage()
+2 -2
View File
@@ -92,9 +92,9 @@ class TestCommanderPlayer:
def test_commander_damage_death(self):
cp = CommanderPlayer(base=Player(name="Test"))
cp.base.commander_damage_received = {0: 20, 1: 5}
cp.commander_damage_received = {0: 20, 1: 5}
assert not cp.is_dead_to_commander_damage()
cp.base.commander_damage_received[0] = 21
cp.commander_damage_received[0] = 21
assert cp.is_dead_to_commander_damage()
def test_to_dict_json_serializable(self):
-374
View File
@@ -1,374 +0,0 @@
"""
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
+2 -3
View File
@@ -165,9 +165,8 @@ const AiLab = (() => {
<option value="2">2</option>
<option value="4" selected>4</option>
<option value="8">8</option>
<option value="16">16</option>
<option value="24">24</option>
<option value="32">32</option>
<option value="12">12</option>
<option value="16">16</option>
</select>
</div>
<div class="lab-control-group">
+1 -5
View File
@@ -1132,11 +1132,7 @@
height: auto;
border-radius: 10px;
}
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 {
td[data-card-name] .coach-card-name {
cursor: pointer;
border-bottom: 1px dashed var(--text-secondary, #888);
}
+12 -32
View File
@@ -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">Claude Opus</span>
<span class="status-label">LM Studio</span>
<span class="status-value" id="status-llm-value">Checking...</span>
</div>
<div class="coach-status-item" id="status-embeddings">
@@ -58,10 +58,7 @@
<!-- Power Level Slider -->
<div class="form-group">
<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>
<label class="form-label" for="coach-power-level">Target Power Level</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>
@@ -71,10 +68,7 @@
<!-- Meta Focus -->
<div class="form-group">
<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>
<label class="form-label" for="coach-meta-focus">Meta Focus</label>
<select id="coach-meta-focus" class="form-select">
<option value="">Any / Balanced</option>
<option value="aggro">Aggro</option>
@@ -87,10 +81,7 @@
<!-- Budget -->
<div class="form-group">
<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>
<label class="form-label" for="coach-budget">Budget</label>
<select id="coach-budget" class="form-select">
<option value="">No Limit</option>
<option value="budget">Budget (&lt; $2 per card)</option>
@@ -101,10 +92,7 @@
<!-- Focus Areas -->
<div class="form-group coach-field-wide">
<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>
<label class="form-label">Focus Areas (optional)</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>
@@ -120,8 +108,9 @@
<!-- Action Buttons Row -->
<div class="coach-run-row">
<button id="coach-quick-btn" class="btn btn-secondary" disabled>&#9889; Quick Digest</button>
<button id="coach-run-btn" class="btn btn-primary" disabled>&#128300; Full Analysis</button>
<button id="coach-run-btn" class="btn btn-primary btn-lg" disabled>
<span class="coach-run-icon">&#9889;</span> Run Coach
</button>
<span class="coach-run-hint" id="coach-run-hint">Select a deck to begin</span>
</div>
@@ -132,7 +121,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>
@@ -154,7 +143,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>
@@ -174,10 +163,7 @@
<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
<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>
<label class="form-label">Results</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>
@@ -187,7 +173,7 @@
</div>
</section>
<!-- One-Shot Results Section -->
<!-- ═══ One-Shot Results Section ═══ -->
<section id="coach-results" class="coach-results" style="display:none;">
<!-- Apply Suggestions Bar -->
@@ -205,12 +191,6 @@
</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>&#9889; 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 -142
View File
@@ -1,9 +1,8 @@
/**
* Commander AI Lab — Deck Coach UI v29
* Commander AI Lab — Deck Coach UI v28
* ═════════════════════════════════════
* Multi-turn chat with SSE streaming, apply suggestions,
* cards-like search, enhanced session history.
* v29: Quick Digest button + Top Priority Swap card.
*/
(function () {
@@ -11,7 +10,7 @@
const API = window.location.origin;
// ── State ────────────────────────────────────────────
// ── State ────────────────────────────────────────────────
let chatHistory = []; // [{role, content}]
let isStreaming = false;
let currentSessionId = null;
@@ -44,8 +43,7 @@
.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(() => {});
@@ -57,7 +55,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() {
@@ -65,20 +63,19 @@
}
function _attachCardHover(container) {
container.querySelectorAll('[data-card-name]').forEach(td => {
container.querySelectorAll('td[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');
@@ -109,8 +106,6 @@
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');
@@ -132,7 +127,7 @@
const applyDeckSelect = document.getElementById('coach-apply-deck-select');
const applyCountEl = document.getElementById('coach-apply-count');
// ── Init ──────────────────────────────────────────────
// ── Init ────────────────────────────────────────────────
init();
@@ -143,13 +138,11 @@
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);
@@ -205,7 +198,7 @@
]);
}
// ── Goals Helper ────────────────────────────────────
// ── Goals Helper ────────────────────────────────────────
function getGoals() {
const goals = {};
@@ -221,7 +214,7 @@
return goals;
}
// ── Status Check ────────────────────────────────────
// ── Status Check ────────────────────────────────────────
async function checkStatus() {
try {
@@ -261,15 +254,18 @@
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');
@@ -279,6 +275,7 @@
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();
@@ -288,9 +285,11 @@
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 + ')' : '');
@@ -311,7 +310,9 @@
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');
@@ -345,14 +346,11 @@
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) + quickBadge + '</span>' +
'<span class="coach-session-deck">' + escHtml(s.deckId) + '</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>' +
@@ -364,12 +362,14 @@
'<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,19 +383,23 @@
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');
sessionCache[sessionId] = await res.json();
renderSessionDetail(sessionId, sessionCache[sessionId]);
const session = await res.json();
sessionCache[sessionId] = session;
renderSessionDetail(sessionId, session);
} catch (e) {
detailEl.innerHTML = '<div class="coach-session-detail-error">Failed to load session details</div>';
}
@@ -417,30 +421,44 @@
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"><span class="coach-session-detail-label">Cuts:</span><div class="coach-session-pills">';
html += '<div class="coach-session-detail-group">';
html += '<span class="coach-session-detail-label">Cuts:</span>';
html += '<div class="coach-session-pills">';
for (const cut of cuts) {
html += '<span class="coach-pill coach-pill-cut" data-card-name="' + escHtml(cut.cardName) + '" title="' + escHtml(cut.reason || '') + '">' + escHtml(cut.cardName) + '</span>';
html += '<span class="coach-pill coach-pill-cut" title="' + escHtml(cut.reason || '') + '">' + escHtml(cut.cardName) + '</span>';
}
html += '</div></div>';
}
// Adds
if (adds.length > 0) {
html += '<div class="coach-session-detail-group"><span class="coach-session-detail-label">Adds:</span><div class="coach-session-pills">';
html += '<div class="coach-session-detail-group">';
html += '<span class="coach-session-detail-label">Adds:</span>';
html += '<div class="coach-session-pills">';
for (const add of adds) {
html += '<span class="coach-pill coach-pill-add" data-card-name="' + escHtml(add.cardName) + '" title="' + escHtml(add.reason || '') + '">' + escHtml(add.cardName) + '</span>';
html += '<span class="coach-pill coach-pill-add" 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></div>';
detailEl.innerHTML = html;
_attachCardHover(detailEl);
html += '</div>';
html += '</div>';
detailEl.innerHTML = html;
// Bind buttons
detailEl.querySelector('.coach-load-chat-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
loadSessionInChat(sessionId);
@@ -455,7 +473,10 @@
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) {
@@ -465,24 +486,30 @@
}
}
}
// 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();
@@ -500,57 +527,14 @@
}
}
// ── 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 ───────────────────────────────────────
// ── Run Coach (one-shot) ────────────────────────────────
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.';
@@ -563,12 +547,14 @@
const res = await fetch(API + '/api/coach/decks/' + encodeURIComponent(deckId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ goals }),
body: JSON.stringify({ goals: 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);
@@ -579,45 +565,11 @@
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) {
@@ -627,21 +579,18 @@
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.isQuickDigest) addMetaChip('Mode', '\u26A1 Quick Digest');
// Top Priority Swap
displayTopPriority(session.upgradePriority || []);
if (session.deckId) addMetaChip('Deck', session.deckId);
if (session.timestamp) addMetaChip('Time', formatTime(session.timestamp));
// 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 =
@@ -650,7 +599,7 @@
'<td>' + escHtml(cut.reason) + '</td>' +
'<td><div class="coach-replacements">' +
(cut.replacementOptions || []).map(r =>
'<span class="coach-replacement-chip" data-card-name="' + escHtml(r) + '">' + escHtml(r) + '</span>'
'<span class="coach-replacement-chip">' + escHtml(r) + '</span>'
).join('') +
'</div></td>' +
'<td>' + formatImpact(cut.currentImpactScore) + '</td>';
@@ -663,6 +612,7 @@
addsCount.textContent = adds.length;
addsBody.innerHTML = '';
addsAllCheck.checked = false;
for (const add of adds) {
const tr = document.createElement('tr');
tr.innerHTML =
@@ -672,7 +622,7 @@
'<td>' + escHtml(add.reason) + '</td>' +
'<td><div class="coach-replacements">' +
(add.synergyWith || []).map(s =>
'<span class="coach-synergy-chip" data-card-name="' + escHtml(s) + '">' + escHtml(s) + '</span>'
'<span class="coach-synergy-chip">' + escHtml(s) + '</span>'
).join('') +
'</div></td>';
tr.querySelector('.coach-card-name').parentElement.dataset.cardName = add.cardName;
@@ -701,8 +651,10 @@
manaCard.style.display = 'none';
}
// Raw text
rawText.textContent = session.rawTextExplanation || session.summary || '(empty)';
_attachCardHover(cutsBody);
_attachCardHover(addsBody);
updateApplyCount();
@@ -713,23 +665,31 @@
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 = (cutChecked.length + addChecked.length) === 0 || !applyDeckSelect.value;
applyBtn.disabled = total === 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 => {
if (cb.dataset.card) acceptedCuts.push(cb.dataset.card);
const name = cb.dataset.card;
if (name) acceptedCuts.push(name);
});
const acceptedAdds = [];
addsBody.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
if (cb.dataset.card) acceptedAdds.push(cb.dataset.card);
const name = cb.dataset.card;
if (name) acceptedAdds.push(name);
});
if (acceptedCuts.length === 0 && acceptedAdds.length === 0) {
@@ -751,14 +711,17 @@
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 {
@@ -768,17 +731,19 @@
}
}
// ── 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;
@@ -793,16 +758,20 @@
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 = '';
@@ -810,14 +779,18 @@
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) {
@@ -825,13 +798,17 @@
contentEl.innerHTML = formatMarkdown(fullContent);
scrollChatToBottom();
}
} catch (_) {}
} catch (e) {
// skip malformed chunks
}
}
}
// Finalize
chatHistory.push({ role: 'assistant', content: fullContent });
contentEl.innerHTML = formatMarkdown(fullContent);
scrollChatToBottom();
} catch (e) {
typingEl.remove();
appendChatBubble('system', 'Error: ' + e.message);
@@ -842,14 +819,18 @@
}
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;
@@ -866,7 +847,9 @@
return el;
}
function scrollChatToBottom() { chatMessages.scrollTop = chatMessages.scrollHeight; }
function scrollChatToBottom() {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function clearChat() {
chatHistory = [];
@@ -879,6 +862,7 @@
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>')
@@ -886,7 +870,7 @@
.replace(/\n/g, '<br>');
}
// ── Cards-Like Search ──────────────────────────────────
// ── Cards-Like Search ───────────────────────────────────
async function searchCardsLike() {
const card = cardslikeInput.value.trim();
@@ -896,13 +880,16 @@
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) {
@@ -922,14 +909,16 @@
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" data-card-name="' + escHtml(card.name) + '">' + escHtml(card.name) + '</span>' +
'<span class="coach-cl-name">' + escHtml(card.name) + '</span>' +
'<span class="coach-cl-sim">' + simPct + '%</span>' +
'</div>' +
'<div class="coach-cl-meta">' +
@@ -942,24 +931,26 @@
'<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');
@@ -967,6 +958,7 @@
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');
@@ -974,7 +966,7 @@
}, 4000);
}
// ── Helpers ───────────────────────────────────────────
// ── Helpers ─────────────────────────────────────────────
function addMetaChip(label, value) {
const chip = document.createElement('span');
@@ -984,19 +976,26 @@
}
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>';
if (score > 0.01) return '<span class="coach-impact coach-impact-positive">+' + fixed + '</span>';
return '<span class="coach-impact coach-impact-neutral">' + fixed + '</span>';
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>';
}
}
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) {
-391
View File
@@ -2841,394 +2841,3 @@
height: auto;
border-radius: 12px;
}
/* ── 17. Stats Dashboard ─────────────────────────────────────── */
.coll-stats-btn {
background: rgba(99,102,241,0.15);
border: 1px solid rgba(99,102,241,0.4);
color: var(--accent, #6366f1);
border-radius: 6px;
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s;
}
.coll-stats-btn:hover {
background: rgba(99,102,241,0.3);
border-color: var(--accent, #6366f1);
}
.coll-stats-panel {
background: var(--surface, #161b22);
border: 1px solid var(--border, #30363d);
border-radius: 10px;
padding: 20px 24px;
margin: 0 0 12px 0;
animation: coll-fade-in 0.2s ease;
}
.coll-stats-loading {
color: var(--text-secondary, #8b949e);
padding: 16px;
text-align: center;
}
.coll-stats-grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
/* Summary KPI cards */
.coll-stats-summary {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.coll-stat-card {
flex: 1;
min-width: 100px;
background: var(--bg, #0d1117);
border: 1px solid var(--border, #30363d);
border-radius: 8px;
padding: 12px 16px;
text-align: center;
}
.coll-stat-num {
font-size: 1.5rem;
font-weight: 700;
color: var(--text, #e6edf3);
line-height: 1.2;
}
.coll-stat-label {
font-size: 0.72rem;
color: var(--text-secondary, #8b949e);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Section headers */
.coll-stats-section {
border-top: 1px solid var(--border, #30363d);
padding-top: 14px;
}
.coll-stats-section h4 {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary, #8b949e);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 10px 0;
}
/* CMC vertical bar chart */
.coll-stat-bar-chart {
display: flex;
align-items: flex-end;
gap: 4px;
height: 64px;
padding-bottom: 20px;
position: relative;
}
.coll-stat-bar-col {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
height: 100%;
justify-content: flex-end;
position: relative;
}
.coll-stat-bar {
width: 100%;
background: var(--accent, #6366f1);
border-radius: 3px 3px 0 0;
min-height: 2px;
transition: height 0.3s ease;
}
.coll-stat-bar-label {
position: absolute;
bottom: -18px;
font-size: 0.68rem;
color: var(--text-secondary, #8b949e);
}
/* Horizontal bar rows (colors, types, rarity, categories) */
.coll-stat-color-bar,
.coll-stat-type-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
}
.coll-stat-color-pip {
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: 700;
flex-shrink: 0;
}
.coll-stat-type-label {
width: 90px;
font-size: 0.75rem;
color: var(--text, #e6edf3);
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.coll-stat-hbar-track {
flex: 1;
height: 10px;
background: var(--bg, #0d1117);
border-radius: 5px;
overflow: hidden;
border: 1px solid var(--border, #30363d);
}
.coll-stat-hbar {
height: 100%;
border-radius: 5px;
transition: width 0.4s ease;
}
.coll-stat-hbar-type {
background: var(--accent, #6366f1);
}
.coll-stat-hbar-cat {
background: #2ea043;
}
.coll-stat-hbar-val {
width: 36px;
font-size: 0.72rem;
color: var(--text-secondary, #8b949e);
text-align: right;
flex-shrink: 0;
}
/* Top value cards list */
.coll-stat-top-row {
display: flex;
justify-content: space-between;
font-size: 0.78rem;
padding: 3px 0;
border-bottom: 1px solid var(--border, #30363d);
gap: 8px;
}
.coll-stat-top-row span:first-child {
color: var(--text, #e6edf3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.coll-stat-top-row span:last-child {
color: #3fb950;
font-weight: 600;
flex-shrink: 0;
}
.coll-stats-empty {
color: var(--text-secondary, #8b949e);
font-size: 0.78rem;
font-style: italic;
}
@media (min-width: 900px) {
.coll-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.coll-stats-summary {
grid-column: 1 / -1;
}
}
@media (min-width: 1400px) {
.coll-stats-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* ── 18. Column Visibility Panel ───────────────────────────── */
.coll-col-toggle-btn {
background: rgba(139,148,158,0.1);
border: 1px solid var(--border, #30363d);
color: var(--text-secondary, #8b949e);
border-radius: 6px;
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.coll-col-toggle-btn:hover {
background: rgba(139,148,158,0.2);
color: var(--text, #e6edf3);
}
.coll-col-panel {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 500;
background: var(--surface, #161b22);
border: 1px solid var(--border, #30363d);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
min-width: 180px;
animation: coll-fade-in 0.15s ease;
}
.coll-col-panel-inner {
padding: 10px 12px 12px;
}
.coll-col-panel-hdr {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-secondary, #8b949e);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border, #30363d);
}
.coll-col-close {
background: none;
border: none;
color: var(--text-secondary, #8b949e);
cursor: pointer;
font-size: 1rem;
padding: 0 4px;
line-height: 1;
}
.coll-col-close:hover { color: var(--text, #e6edf3); }
.coll-col-toggle-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 0.8rem;
color: var(--text, #e6edf3);
cursor: pointer;
user-select: none;
}
.coll-col-toggle-item input[type="checkbox"] {
accent-color: var(--accent, #6366f1);
cursor: pointer;
}
.coll-col-toggle-item:hover {
color: var(--accent, #6366f1);
}
.coll-col-reset {
margin-top: 10px;
width: 100%;
background: rgba(99,102,241,0.1);
border: 1px solid rgba(99,102,241,0.3);
color: var(--accent, #6366f1);
border-radius: 5px;
padding: 5px 8px;
font-size: 0.75rem;
cursor: pointer;
transition: background 0.15s;
}
.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);
}
+9 -352
View File
@@ -164,7 +164,6 @@ const Collection = (() => {
importHeaders: [],
importMissingFinishNormal: true,
exportOpen: false,
selectedRows: new Set(),
preferences: {
pageSize: 50,
sortField: 'name',
@@ -411,148 +410,6 @@ const Collection = (() => {
renderCountBadge();
}
// ── Stats Dashboard ──────────────────────────────────────
let statsData = null;
let statsOpen = false;
async function fetchStats() {
try {
const res = await fetch(`${API_BASE}/api/collection/stats`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
statsData = await res.json();
} catch (err) {
console.error('[Collection] fetchStats error:', err);
statsData = null;
}
}
function toggleStats() {
statsOpen = !statsOpen;
const panel = document.getElementById('coll-stats-panel');
if (!panel) return;
if (statsOpen) {
if (!statsData) {
panel.innerHTML = '<div class="coll-stats-loading">Loading stats…</div>';
panel.style.display = 'block';
fetchStats().then(() => renderStatsPanel());
} else {
renderStatsPanel();
}
panel.style.display = 'block';
} else {
panel.style.display = 'none';
}
}
function renderStatsPanel() {
const panel = document.getElementById('coll-stats-panel');
if (!panel || !statsData) return;
const s = statsData;
// CMC curve bar chart (CSS-only)
const cmcMax = Math.max(...Object.values(s.cmcCurve || {}), 1);
const cmcBars = [0,1,2,3,4,5,6,7].map(i => {
const count = (s.cmcCurve || {})[String(i)] || 0;
const pct = Math.round((count / cmcMax) * 100);
const label = i === 7 ? '7+' : String(i);
return `<div class="coll-stat-bar-col">
<div class="coll-stat-bar" style="height:${pct}%" title="${count}"></div>
<span class="coll-stat-bar-label">${label}</span>
</div>`;
}).join('');
// Color distribution
const colorTotal = Math.max(Object.values(s.colorDistribution || {}).reduce((a,b) => a+b, 0), 1);
const colorBars = ['W','U','B','R','G','C'].map(c => {
const count = (s.colorDistribution || {})[c] || 0;
const pct = Math.round((count / colorTotal) * 100);
const ci = COLOR_MAP[c];
return `<div class="coll-stat-color-bar">
<span class="coll-stat-color-pip" style="background:${ci.bg};color:${ci.color}">${ci.label}</span>
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar" style="width:${pct}%;background:${ci.bg}"></div></div>
<span class="coll-stat-hbar-val">${count}</span>
</div>`;
}).join('');
// Type distribution
const typeTotal = Math.max(Object.values(s.typeDistribution || {}).reduce((a,b) => a+b, 0), 1);
const typeBars = Object.entries(s.typeDistribution || {}).sort((a,b) => b[1]-a[1]).map(([t, count]) => {
const pct = Math.round((count / typeTotal) * 100);
return `<div class="coll-stat-type-row">
<span class="coll-stat-type-label">${escapeHtml(t)}</span>
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar coll-stat-hbar-type" style="width:${pct}%"></div></div>
<span class="coll-stat-hbar-val">${count}</span>
</div>`;
}).join('');
// Rarity distribution
const rarityOrder = ['common','uncommon','rare','mythic'];
const rarityColors = {common:'#706f6f', uncommon:'#859ba6', rare:'#c9a849', mythic:'#d35400'};
const rarityTotal = Math.max(Object.values(s.rarityDistribution || {}).reduce((a,b) => a+b, 0), 1);
const rarityBars = rarityOrder.map(r => {
const count = (s.rarityDistribution || {})[r] || 0;
const pct = Math.round((count / rarityTotal) * 100);
return `<div class="coll-stat-type-row">
<span class="coll-stat-type-label" style="color:${rarityColors[r] || '#ccc'}">${r.charAt(0).toUpperCase() + r.slice(1)}</span>
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar" style="width:${pct}%;background:${rarityColors[r] || '#555'}"></div></div>
<span class="coll-stat-hbar-val">${count}</span>
</div>`;
}).join('');
// Top value cards
const topCards = (s.topValueCards || []).map(c =>
`<div class="coll-stat-top-row"><span>${escapeHtml(c.name)}</span><span>$${Number(c.price).toFixed(2)}</span></div>`
).join('');
// Category/role coverage
const catEntries = Object.entries(s.categoryDistribution || {}).sort((a,b) => b[1]-a[1]).slice(0, 12);
const catMax = Math.max(...catEntries.map(e => e[1]), 1);
const catBars = catEntries.map(([cat, count]) => {
const pct = Math.round((count / catMax) * 100);
return `<div class="coll-stat-type-row">
<span class="coll-stat-type-label">${escapeHtml(cat)}</span>
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar coll-stat-hbar-cat" style="width:${pct}%"></div></div>
<span class="coll-stat-hbar-val">${count}</span>
</div>`;
}).join('');
panel.innerHTML = `
<div class="coll-stats-grid">
<div class="coll-stats-summary">
<div class="coll-stat-card"><div class="coll-stat-num">${(s.totalCards || 0).toLocaleString()}</div><div class="coll-stat-label">Total Cards</div></div>
<div class="coll-stat-card"><div class="coll-stat-num">${(s.totalUniqueCards || 0).toLocaleString()}</div><div class="coll-stat-label">Unique</div></div>
<div class="coll-stat-card"><div class="coll-stat-num">$${(s.totalValue || 0).toLocaleString(undefined,{minimumFractionDigits:2})}</div><div class="coll-stat-label">Total Value</div></div>
<div class="coll-stat-card"><div class="coll-stat-num">${s.avgCmc || 0}</div><div class="coll-stat-label">Avg CMC</div></div>
</div>
<div class="coll-stats-section">
<h4>CMC Curve</h4>
<div class="coll-stat-bar-chart">${cmcBars}</div>
</div>
<div class="coll-stats-section">
<h4>Color Distribution</h4>
${colorBars}
</div>
<div class="coll-stats-section">
<h4>Type Distribution</h4>
${typeBars}
</div>
<div class="coll-stats-section">
<h4>Rarity</h4>
${rarityBars}
</div>
<div class="coll-stats-section">
<h4>Role Coverage</h4>
${catBars || '<div class="coll-stats-empty">No categories assigned yet</div>'}
</div>
<div class="coll-stats-section">
<h4>Top Value Cards</h4>
${topCards || '<div class="coll-stats-empty">No priced cards</div>'}
</div>
</div>
`;
}
async function fetchCardDetail(cardId) {
const res = await fetch(`${API_BASE}/api/collection/${cardId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -587,8 +444,7 @@ const Collection = (() => {
// Pre-load set/keyword caches for filter autocomplete
loadSetsCache();
loadKeywordsCache();
applyColumnVisibility();
await fetchCollection();
await fetchCollection();
}
/** Adjust table container height based on actual topbar height */
@@ -633,17 +489,10 @@ const Collection = (() => {
<button class="coll-export-btn" onclick="Collection.openExport()">Export</button>
<button class="coll-autoclassify-btn" onclick="Collection.autoClassify()" title="Auto-detect categories (Ramp, Draw, Removal, etc.) for uncategorized cards">Auto-Classify</button>
<button class="coll-reenrich-btn" onclick="Collection.reEnrich()" title="Re-fetch all card data from Scryfall">Refresh Data</button>
<button class="coll-stats-btn" onclick="Collection.toggleStats()" title="Show collection statistics dashboard">📊 Stats</button>
<div style="position:relative">
<button class="coll-col-toggle-btn" onclick="Collection.openColumnPanel()" title="Show/hide table columns">☰ Columns</button>
<div id="coll-col-panel" class="coll-col-panel" style="display:none"></div>
</div>
<a href="deckbuilder.html" class="coll-nav-link" style="color:var(--lab-purple);border-color:var(--lab-purple)">⚔ Deck Builder</a>
<a href="deckbuilder.html" class="coll-nav-link" style="color:var(--lab-purple);border-color:var(--lab-purple)">⚔ Deck Builder</a>
<a href="deckgenerator.html" class="coll-nav-link" style="color:var(--lab-warning);border-color:var(--lab-warning)">⚡ Auto Gen</a>
</div>
</div>
<!-- Stats Dashboard Panel -->
<div id="coll-stats-panel" class="coll-stats-panel" style="display:none"></div>
<!-- Main -->
<div class="coll-main">
@@ -663,18 +512,6 @@ 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>
@@ -984,79 +821,6 @@ const Collection = (() => {
</div>`;
}
// ── Column Visibility ──────────────────────────────────────
function colVis(key) {
return state.preferences.visibleColumns.includes(key) ? '' : 'display:none;';
}
function toggleColumn(key) {
const cols = state.preferences.visibleColumns;
const idx = cols.indexOf(key);
if (idx >= 0) {
if (cols.length <= 1) return; // keep at least 1 column
cols.splice(idx, 1);
} else {
cols.push(key);
}
savePreferences();
renderTable();
applyColumnVisibility();
}
function renderColumnPanel() {
const panel = document.getElementById('coll-col-panel');
if (!panel) return;
const cols = COLUMNS.map(col => {
const checked = state.preferences.visibleColumns.includes(col.key) ? 'checked' : '';
return `<label class="coll-col-toggle-item">
<input type="checkbox" ${checked} onchange="Collection.toggleColumn('${col.key}')">
${escapeHtml(col.label)}
</label>`;
}).join('');
panel.innerHTML = `
<div class="coll-col-panel-inner">
<div class="coll-col-panel-hdr">Columns <button class="coll-col-close" onclick="Collection.closeColumnPanel()">×</button></div>
${cols}
<button class="coll-col-reset" onclick="Collection.resetColumns()">Reset Defaults</button>
</div>`;
panel.style.display = 'block';
}
function openColumnPanel() {
const panel = document.getElementById('coll-col-panel');
if (!panel) return;
if (panel.style.display === 'block') {
panel.style.display = 'none';
} else {
renderColumnPanel();
}
}
function closeColumnPanel() {
const panel = document.getElementById('coll-col-panel');
if (panel) panel.style.display = 'none';
}
function resetColumns() {
state.preferences.visibleColumns = ['quantity','name','mana_cost','type_line','color_identity','cmc','power_toughness','rarity','set_name','tcg_price','edhrec_rank','salt_score','category','finish'];
savePreferences();
renderTable();
renderColumnPanel();
}
function applyColumnVisibility() {
let styleEl = document.getElementById('coll-col-vis-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'coll-col-vis-style';
document.head.appendChild(styleEl);
}
const hidden = COLUMNS.filter(col => !state.preferences.visibleColumns.includes(col.key));
styleEl.textContent = hidden.map(col =>
`.coll-th-${col.key}, .coll-td-${col.key} { display: none !important; }`
).join('\n');
}
// ── Table Header ────────────────────────────────────────
const COLUMNS = [
@@ -1079,8 +843,8 @@ const Collection = (() => {
];
function renderTableHeaderHtml() {
const visibleCols = COLUMNS.filter(col => state.preferences.visibleColumns.includes(col.key));
const headers = visibleCols.map(col => { if (!col.sortable) {
const headers = COLUMNS.map(col => {
if (!col.sortable) {
return `<th class="coll-th coll-th-${col.key}">${col.label}</th>`;
}
const isActive = state.sortField === col.key;
@@ -1090,7 +854,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>$<th class="coll-th-select"><input type="checkbox" id="coll-select-all" onclick="Collection.toggleSelectAll(this.checked)" title="Select all"></th>{headers}</tr></thead>`;
return `<thead><tr>${headers}</tr></thead>`;
}
// ── Table Body ──────────────────────────────────────────
@@ -1109,12 +873,12 @@ const Collection = (() => {
}
if (state.loading) {
tbody.innerHTML = `<tr><td colspan="${state.preferences.visibleColumns.length}" class="coll-empty">Loading…</td></tr>`;
tbody.innerHTML = `<tr><td colspan="${COLUMNS.length}" class="coll-empty">Loading…</td></tr>`;
return;
}
if (state.items.length === 0) {
tbody.innerHTML = `<tr><td colspan="${state.preferences.visibleColumns.length}" class="coll-empty">
tbody.innerHTML = `<tr><td colspan="${COLUMNS.length}" class="coll-empty">
<div class="coll-empty-state">
<div class="coll-empty-icon">🃏</div>
<div>No cards in collection</div>
@@ -1140,7 +904,6 @@ 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>
@@ -2336,8 +2099,7 @@ const Collection = (() => {
if (footer) footer.style.display = 'none';
// Refresh collection
applyColumnVisibility();
await fetchCollection();
await fetchCollection();
} catch (err) {
if (statusEl) {
@@ -2491,98 +2253,6 @@ 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() {
@@ -3066,7 +2736,7 @@ const Collection = (() => {
// Refresh collection after short delay
setTimeout(() => {
closeScan();
fetchCollection();
loadCollection();
}, 1500);
} catch (e) {
if (statusEl) statusEl.innerHTML = '<div class="coll-scan-error-msg">Failed: ' + escapeHtml(e.message) + '</div>';
@@ -3181,18 +2851,5 @@ const Collection = (() => {
popoverAddCatFromInput,
reEnrich,
autoClassify,
toggleStats,
// Column visibility
toggleColumn,
openColumnPanel,
closeColumnPanel,
resetColumns,
// Bulk selection & quantity
toggleRowSelect,
toggleSelectAll,
selectAllRows,
clearSelection,
bulkAdjustQuantity,
bulkSetQuantity,
};
})();
+2 -2
View File
@@ -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 Claude Opus...</span>
<span>Analyzing with Perplexity AI...</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 Claude Opus...</span>
<span>Generating deck with Perplexity AI...</span>
</div>
<div class="deck-ai-results" id="ai-gen-results" style="display:none"></div>
</div>
+1 -103
View File
@@ -1297,106 +1297,4 @@
.dg-log-error {
color: #f59e0b;
}
/* ── Progress Bar (Deck Generation) ──────────────────── */
.dg-progress-wrap {
width: 100%;
max-width: 400px;
margin-top: 16px;
}
.dg-progress-bar {
height: 6px;
background: var(--surface-2, #2a2a3a);
border-radius: 3px;
overflow: hidden;
}
.dg-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--accent, #7c6ff7), #a78bfa);
border-radius: 3px;
transition: width 1s ease;
}
.dg-progress-meta {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 0.78rem;
}
.dg-progress-phase {
color: var(--text-muted, #999);
}
.dg-progress-elapsed {
color: var(--text-muted, #999);
font-variant-numeric: tabular-nums;
}
/* — Omit Cards Chips ————————————————————————— */
.dg-omit-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 8px;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: var(--bg-input, #1a1a2e);
min-height: 38px;
cursor: text;
}
.dg-omit-wrap:focus-within {
border-color: var(--accent, #6c5ce7);
box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.25);
}
.dg-omit-chips {
display: contents;
}
.dg-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
background: var(--accent, #6c5ce7);
color: #fff;
font-size: 0.8rem;
white-space: nowrap;
line-height: 1.6;
}
.dg-chip-x {
cursor: pointer;
font-size: 0.9rem;
opacity: 0.7;
margin-left: 2px;
}
.dg-chip-x:hover {
opacity: 1;
}
.dg-omit-input {
flex: 1 1 120px;
border: none !important;
background: transparent !important;
padding: 2px 4px !important;
min-width: 120px;
outline: none;
box-shadow: none !important;
}
.dg-hint {
display: block;
margin-top: 6px;
color: var(--text-muted, #999);
font-size: 0.75rem;
}
}
+41 -53
View File
@@ -62,13 +62,9 @@
class="dg-input"
id="dg-strategy"
placeholder="e.g. zombie tokens, voltron, spellslinger..."
title="Describe your deck strategy — the AI uses this to pick thematically aligned cards"
/>
<div class="dg-bracket-row">
<label class="dg-label">
Target Bracket
<span class="tip-icon" data-tip="B1 = precon-level casual. B2 = upgraded with synergies. B3 = high power with strong combos. B4 = cEDH with maximum optimization, fast mana, and instant-speed interaction.">?</span>
</label>
<label class="dg-label">Target Bracket</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>
@@ -109,7 +105,7 @@
min="0"
step="25"
/>
<select class="dg-select" id="dg-budget-mode" title="Total Deck: caps the combined price of all 100 cards. Per Card Max: caps each individual card price.">
<select class="dg-select" id="dg-budget-mode">
<option value="total">Total Deck $</option>
<option value="per_card">Per Card Max $</option>
</select>
@@ -124,61 +120,33 @@
</div>
<div class="dg-panel-body">
<label class="dg-toggle-row">
<span class="dg-toggle-label">
Use my collection
<span class="tip-icon" data-tip="Prioritizes cards you already own before pulling from the full card pool. Saves money and ensures the deck is ready to play. Requires an imported collection.">?</span>
</span>
<span class="dg-toggle-label">Use my collection</span>
<input type="checkbox" class="dg-toggle-check" id="dg-use-collection" checked />
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
</label>
<label class="dg-toggle-row">
<span class="dg-toggle-label">
Smart Substitution
<span class="tip-icon" data-tip="When a suggested card isn't in your collection, automatically finds the closest functional replacement you do own. Keeps the deck playable without buying extra cards.">?</span>
</span>
<span class="dg-toggle-label">Smart Substitution</span>
<input type="checkbox" class="dg-toggle-check" id="dg-substitution" checked />
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
</label>
<div class="dg-model-row">
<label class="dg-label">
AI Model
<span class="tip-icon" data-tip="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>
<label class="dg-label">AI Model</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) -->
<div class="dg-panel">
<div class="dg-panel-header">
<span class="dg-panel-title">Omit Cards</span>
</div>
<div class="dg-panel-body">
<div class="dg-omit-wrap" id="dg-omit-wrap">
<div class="dg-omit-chips" id="dg-omit-chips"></div>
<input
type="text"
class="dg-input dg-omit-input"
id="dg-omit-input"
placeholder="Type a card name and press Enter…"
/>
</div>
<small class="dg-hint">Cards listed here will be excluded from the generated deck.</small>
</div>
</div>
<!-- Action Buttons -->
<div class="dg-actions">
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
<span class="dg-btn-icon">&#9889;</span> Generate Deck
<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>
@@ -188,9 +156,9 @@
<section class="dg-col-results">
<!-- Empty State -->
<div class="dg-empty-state" id="dg-empty-state">
<div class="dg-empty-icon">&#9889;</div>
<div class="dg-empty-icon">⚡</div>
<div class="dg-empty-title">V3 AI Deck Generator</div>
<div class="dg-empty-text">Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Claude Opus with structured output.</div>
<div 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>
<!-- Loading State -->
@@ -198,18 +166,38 @@
<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>
<!-- 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>
</div>
<!-- Results placeholder — rest generated dynamically by deckgenerator.js -->
<div class="dg-results" id="dg-results" style="display:none"></div>
</section>
</main>
+1 -115
View File
@@ -53,95 +53,6 @@ const DeckGenerator = (() => {
let searchTimeout = null;
// ── Progress bar state ──────────────────────────────
const PROGRESS_PHASES = [
{ at: 0, pct: 5, msg: 'Connecting to AI model...' },
{ at: 3, pct: 10, msg: 'Resolving commander on Scryfall...' },
{ at: 6, pct: 18, msg: 'Building collection summary...' },
{ at: 10, pct: 25, msg: 'Sending prompt to LLM...' },
{ at: 20, pct: 40, msg: 'AI is building the 99...' },
{ at: 35, pct: 55, msg: 'Selecting cards and filling slots...' },
{ at: 50, pct: 65, msg: 'Running smart substitution...' },
{ at: 70, pct: 78, msg: 'Cross-referencing with collection...' },
{ at: 90, pct: 88, msg: 'Almost there — finalizing deck...' },
{ at: 110, pct: 93, msg: 'Still working — large model can take a moment...' },
];
let _progressInterval = null;
let _progressStart = 0;
function startProgress() {
_progressStart = Date.now();
const wrap = $('dg-progress-wrap');
const fill = $('dg-progress-fill');
const phase = $('dg-progress-phase');
const elapsed = $('dg-progress-elapsed');
if (!wrap) return;
wrap.style.display = 'block';
fill.style.width = '0%';
phase.textContent = '';
elapsed.textContent = '0s elapsed';
let lastPhaseIdx = -1;
_progressInterval = setInterval(() => {
const secs = Math.floor((Date.now() - _progressStart) / 1000);
elapsed.textContent = secs + 's elapsed';
for (let i = PROGRESS_PHASES.length - 1; i >= 0; i--) {
if (secs >= PROGRESS_PHASES[i].at) {
fill.style.width = PROGRESS_PHASES[i].pct + '%';
if (i !== lastPhaseIdx) {
phase.textContent = PROGRESS_PHASES[i].msg;
logMessage(PROGRESS_PHASES[i].msg, 'info');
lastPhaseIdx = i;
}
break;
}
}
}, 1000);
}
function stopProgress(success) {
if (_progressInterval) {
clearInterval(_progressInterval);
_progressInterval = null;
}
const fill = $('dg-progress-fill');
const phase = $('dg-progress-phase');
const elapsed = $('dg-progress-elapsed');
if (fill) fill.style.width = success ? '100%' : '0%';
if (phase) phase.textContent = success ? 'Done!' : 'Failed';
const secs = Math.floor((Date.now() - _progressStart) / 1000);
if (elapsed) elapsed.textContent = secs + 's total';
}
// ── Omit Cards (chips) ─────────────────────────────
let omitCards = [];
function addOmitCard(name) {
const trimmed = name.trim();
if (!trimmed) return;
if (omitCards.some(c => c.toLowerCase() === trimmed.toLowerCase())) return;
omitCards.push(trimmed);
renderOmitChips();
}
function removeOmitCard(idx) {
omitCards.splice(idx, 1);
renderOmitChips();
}
function renderOmitChips() {
const container = $('dg-omit-chips');
if (!container) return;
container.innerHTML = omitCards.map((name, i) =>
'<span class="dg-chip">' +
escHtml(name) +
'<span class="dg-chip-x" data-idx="' + i + '">&times;</span>' +
'</span>'
).join('');
container.querySelectorAll('.dg-chip-x').forEach(el => {
el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10)));
});
}
const $ = (id) => document.getElementById(id);
// ── Init ────────────────────────────────────────────────
@@ -207,22 +118,6 @@ const DeckGenerator = (() => {
$('dg-export-dck').addEventListener('click', () => exportDeck('dck'));
$('dg-export-moxfield').addEventListener('click', () => exportMoxfield());
$('dg-export-shopping').addEventListener('click', () => exportShopping());
// Omit cards chips input
const omitInput = $('dg-omit-input');
if (omitInput) {
omitInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addOmitCard(omitInput.value);
omitInput.value = '';
}
});
}
const omitWrap = $('dg-omit-wrap');
if (omitWrap) {
omitWrap.addEventListener('click', () => omitInput && omitInput.focus());
}
}
function checkQueryParams() {
@@ -243,7 +138,7 @@ const DeckGenerator = (() => {
target_bracket: state.targetBracket,
budget_usd: budget,
budget_mode: $('dg-budget-mode').value,
omit_cards: omitCards.slice(),
omit_cards: [],
use_collection: $('dg-use-collection').checked,
run_substitution: $('dg-substitution').checked,
model: $('dg-model').value,
@@ -345,12 +240,6 @@ const DeckGenerator = (() => {
}
$('dg-preview-btn').disabled = false;
// Auto-fill deck name if empty
const deckNameInput = $('dg-deck-name');
if (deckNameInput && !deckNameInput.value.trim()) {
deckNameInput.value = 'Auto - ' + cmdr.name;
}
}
function clearCommander() {
@@ -395,7 +284,6 @@ const DeckGenerator = (() => {
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
setLoading(true, 'Generating deck via Local AI...');
startProgress();
const body = buildRequestBody();
logMessage('Sending request to AI model... this may take a moment.', 'info');
@@ -419,12 +307,10 @@ const DeckGenerator = (() => {
const stats = state.previewResult.stats || {};
const cardCount = (stats.total_cards || 0);
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
stopProgress(true);
renderResults(state.previewResult);
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
} catch (e) {
stopProgress(false);
toast(e.message || 'Generation failed', 'error');
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
setLoading(false);
+12 -44
View File
@@ -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 &rarr; PPO &rarr; quality gate &rarr; 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">&#8635;</span> Refresh
@@ -59,70 +59,43 @@
<div class="train-controls">
<div class="param-grid form-row">
<div class="form-group">
<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 &rarr; PPO fine-tune &rarr; quality gate. Stops early if convergence is detected.">?</span>
</label>
<label class="form-label" for="param-iterations">Max Generations</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
<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>
<label class="form-label" for="param-conv-window">Convergence Window</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
<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>
<label class="form-label" for="param-sup-epochs">Supervised Epochs</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
<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>
<label class="form-label" for="param-sup-lr">Supervised LR</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
<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>
<label class="form-label" for="param-ppo-iters">PPO Iterations</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
<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>
<label class="form-label" for="param-ppo-episodes">PPO Episodes/Iter</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
<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>
<label class="form-label" for="param-ppo-lr">PPO Learning Rate</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
<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>
<label class="form-label" for="param-min-wr">Min Win Rate Gate</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
<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>
<label class="form-label" for="param-opponent">Opponent</label>
<select id="param-opponent" class="form-select">
<option value="heuristic">Heuristic</option>
<option value="random">Random</option>
@@ -130,10 +103,7 @@
</select>
</div>
<div class="form-group">
<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>
<label class="form-label" for="param-playstyle">Playstyle</label>
<select id="param-playstyle" class="form-select">
<option value="midrange">Midrange</option>
<option value="aggro">Aggro</option>
@@ -194,9 +164,7 @@
<!-- ELO Rating Chart -->
<section class="panel" id="elo-panel">
<div class="panel-header elo-panel-header">
<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>
<h2>ELO Ratings</h2>
<button id="btn-run-elo" class="btn btn-sm btn-secondary" onclick="runEloTournament()">
<span class="btn-icon">&#127942;</span> Run Tournament
</button>
-89
View File
@@ -864,92 +864,3 @@ 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);
}
+7 -26
View File
@@ -28,27 +28,20 @@
</select>
</div>
<div class="form-group sim-field-auto">
<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>
<label class="form-label" for="sim-opponent-select">Opponent</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
<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>
<label class="form-label" for="sim-num-games">Games</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">
@@ -68,31 +61,19 @@
</div>
<div class="deepseek-config-row">
<div class="form-group sim-field-grow">
<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" />
<label class="form-label" for="ds-api-base">API Endpoint</label>
<input type="text" id="ds-api-base" class="form-input" value="http://192.168.0.240:11434" placeholder="http://192.168.0.240:11434" />
</div>
<div class="form-group sim-field-auto">
<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>
<label class="form-label" for="ds-model">Model</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
<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>
<label class="form-label" for="ds-temp">Temp</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)
<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>
<label class="form-label" for="ds-timeout">Timeout (s)</label>
<input type="number" id="ds-timeout" class="form-input sim-num-input" value="10" min="1" max="60" />
</div>
</div>
-179
View File
@@ -358,182 +358,3 @@
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);
}
+13 -71
View File
@@ -18,35 +18,9 @@
<h1>ML Training Dashboard</h1>
<p>Train, evaluate, and deploy the AI opponent policy network</p>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn btn-ghost btn-sm" onclick="showIntroBanner()" title="Show getting started guide">&#10067; 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">&#8635;</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">&#127891;</span>
<strong>Getting Started with ML Training</strong>
<button class="intro-banner-close" onclick="dismissIntroBanner()" title="Dismiss">&#10005;</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 &mdash; leave &ldquo;Rebuild dataset&rdquo; checked so it parses your logs into a dataset automatically.</li>
<li>Check <strong>Evaluation Results</strong> after training. Aim for &gt;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&rsquo;t show this again
</label>
<button class="btn btn-primary btn-sm" onclick="dismissIntroBanner()">Got it</button>
</div>
<button id="btn-refresh-data" class="btn btn-secondary" onclick="refreshAll()" title="Refresh training data status">
<span class="btn-icon" id="refresh-icon">&#8635;</span> Refresh
</button>
</div>
<!-- Status Overview Cards -->
@@ -85,31 +59,19 @@
<div class="train-controls">
<div class="param-grid form-row">
<div class="form-group">
<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>
<label class="form-label" for="param-epochs">Epochs</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
<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>
<label class="form-label" for="param-lr">Learning Rate</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
<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>
<label class="form-label" for="param-batch">Batch Size</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
<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>
<label class="form-label" for="param-patience">Early Stop Patience</label>
<input type="number" id="param-patience" class="form-input" value="10" min="3" max="100" />
</div>
</div>
@@ -117,7 +79,6 @@
<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">
@@ -171,31 +132,19 @@
<div class="train-controls">
<div class="param-grid form-row">
<div class="form-group">
<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>
<label class="form-label" for="ppo-iterations">Iterations</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
<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>
<label class="form-label" for="ppo-episodes">Episodes/Iter</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
<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>
<label class="form-label" for="ppo-lr">Learning Rate</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
<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>
<label class="form-label" for="ppo-opponent">Opponent</label>
<select id="ppo-opponent" class="form-select">
<option value="heuristic">Heuristic</option>
<option value="random">Random</option>
@@ -207,7 +156,6 @@
<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">
@@ -236,17 +184,11 @@
<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
<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>
<label class="form-label" for="tourney-episodes">Games per Matchup</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
<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>
<label class="form-label" for="tourney-style">Playstyle</label>
<select id="tourney-style" class="form-select">
<option value="midrange">Midrange</option>
<option value="aggro">Aggro</option>
-47
View File
@@ -19,8 +19,6 @@ let isTraining = false;
document.addEventListener('DOMContentLoaded', () => {
refreshDataStatus();
refreshTrainingStatus();
initIntroBanner();
initTooltipFlip();
});
async function refreshAll() {
@@ -38,51 +36,6 @@ 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
// ═══════════════════════════════════════════
-1
View File
@@ -1 +0,0 @@
"""WebSocket module for real-time game state streaming to Unity."""
-165
View File
@@ -1,165 +0,0 @@
"""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()
-211
View File
@@ -1,211 +0,0 @@
"""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)