Compare commits

..
Author SHA1 Message Date
KoalaTrapLord fa43fe3ecd refactor: upgrade GameEngine to N-player (Commander pod support)
- run() now accepts any number of decks/names via *decks / *names
- Turn loop iterates over len(sim.players) instead of hardcoded range(2)
- _create_state() builds N players from parallel deck/name lists
- _play_spells() removal picks highest-value creature from any live opponent
- _resolve_combat() targets the most threatening alive opponent (highest
  total board power), not just seat `1 - pi`
- _build_result() assigns correct finish_position to all N players based on
  elimination order tracked via sim.elimination_order
- Game log lifeAfter now records all players dynamically
- Backward-compatible: run(deck_a, deck_b) still works as before

Closes #27
2026-03-12 14:31:45 -04:00
KoalaTrapLord d0a1c10372 Fix simulator button stuck on Running — to_dict() backward compat
GameResult.to_dict() now returns both legacy keys (winner, playerA, playerB)
that the UI expects AND new N-player keys (winningSeat, playerResults) for
the Java BatchResult schema.
2026-03-12 18:25:22 +00:00
KoalaTrapLord 91d1e9449a Phase 1: N-player data model refactoring
- New PlayerResult dataclass with seat_index, name, life, eliminated, finish_position, stats
- Refactored GameResult to use players: list[PlayerResult] + winner_seat (no hardcoded 2-player fields)
- Backward-compat @property accessors (player_a_stats, player_b_stats, winner, etc.) for existing consumers
- SimState.battlefields is now per-player (list-of-lists) with helper methods:
  get_battlefield(), add_to_battlefield(), remove_from_battlefield(), all_battlefield_cards()
- Updated engine.py, deepseek_engine.py, deepseek_brain.py to use per-player battlefields
- GameResult.to_dict() matches Java BatchResult schema (playerResults array)
- lab_api.py and experiments.py unchanged — backward-compat properties handle all existing access patterns
2026-03-12 18:10:57 +00:00
KoalaTrapLord 1b2707014b Fix duplicate deck cards from collection JOIN fan-out
When collection_entries has multiple rows with the same scryfall_id
(e.g. Counterspell imported from two different sets), the LEFT JOIN
on deck_cards produced duplicate rows — making it appear like 2 copies
were added or removed.

Fixed all 6 deck_cards-to-collection_entries JOINs to use a
GROUP BY scryfall_id subquery, guaranteeing 1:1 cardinality.
2026-03-12 17:51:20 +00:00
KoalaTrapLord 91279d1d67 Deck generator: deterministic basic land fill + frontend count display
- Added _fill_basic_lands() post-processing for V1 and V3 generators
- Strips LLM-provided basics, then distributes evenly across commander colors
- Always hits exactly 100 cards regardless of LLM output
- Frontend group counts now sum card.count instead of entry count
- Cards with count > 1 show '5x Forest' style prefix
- Colorless commanders get Wastes
2026-03-12 17:44:30 +00:00
KoalaTrapLord 3ec0b73503 Collection: sort bar UI, fix set_code sort field, column header hover styles 2026-03-12 17:08:42 +00:00
KoalaTrapLord f9114b6118 Coach: build DeckReport from DB when no simulation report exists
Previously the coach required running simulations first to generate a
deck-reports/*.json file. Now if no simulation report is found, the
endpoint builds a lightweight DeckReport on the fly from the deck_cards
table — pulling card names, types, CMC, and color identity directly from
the DB. The LLM can still analyze composition and give advice, just
without simulation-derived performance metrics (impactScore, etc.).

Also removes the '[no report]' tag from the dropdown since all decks
can now be coached.
2026-03-12 16:36:19 +00:00
KoalaTrapLord 1e3669af42 Fix coach deck dropdown: wrong column name d.commander → d.commander_name
The decks table has 'commander_name' not 'commander'. The SQL query
was throwing OperationalError which the frontend silently caught,
resulting in an empty dropdown.
2026-03-12 14:55:46 +00:00
KoalaTrapLord c41ca8f09b Fix deck gen truncation: increase max_tokens to 16384, add JSON repair
Root cause: Perplexity deck generation hit the 8192 max_tokens limit,
truncating the JSON response mid-string at card ~70-80. json.loads()
fails with 'Unterminated string' and the fallback also couldn't recover.

Fixes:
- Bump DECK_GEN_MAX_TOKENS from 8192 to 16384 (primary fix)
- Add _repair_truncated_json() to PerplexityClient — if token limit is
  still hit, salvages all complete card objects from the truncated JSON
  instead of returning a 422 error
- Add OUTPUT EFFICIENCY instructions to system prompt to reduce per-card
  verbosity (shorter reasons, fewer synergy_with entries)
- Add 'truncated' flag to response so UI can warn if deck is incomplete
- Log warning when recovered deck has <90 cards
2026-03-12 14:47:41 +00:00
KoalaTrapLord 4189289945 Fix coach deck dropdown: handle bare array response from /api/coach/decks
Backend returns [{deck_id, deck_name, commander, has_report, ...}] directly,
not {decks: [...]}.  Frontend was reading data.decks which was always undefined.
Now properly unwraps the array, shows deck name + commander, slugifies name
for coach session deck_id, and marks decks without reports.
2026-03-12 14:45:13 +00:00
KoalaTrapLord f47b5182d2 v28: Coach chat UI, analytics/trends tabs, export-to-sim, precon refresh
Features:
- Complete coach page rewrite: chat-based UI with SSE streaming, multi-turn conversations
- Coach cards-like suggestions and apply-recommendation integration
- Coach session management (save/load/delete)
- Export-to-sim button in deckbuilder
- Analytics tab with game stats visualization
- Trends tab with meta trend charts
- Precon refresh button in AI Lab
- Updated installer (v28)
2026-03-12 06:40:41 +00:00
KoalaTrapLord 5f606fee22 v27.5: Revert to legacy HTML/JS/CSS UI as primary interface
- lab_api.py: Flip serving priority — ui/ folder (legacy) served first, React dist/ only as fallback
- Legacy UI has full features: card images, mana costs, type grouping, collection filters, EDHREC recs, ratio dashboard, commander portraits, 3-column deck builder
- React SPA was causing blank pages and missing functionality
- Installer no longer builds or bundles React dist/
- All 7 pages work: Batch Sim, Collection, Deck Builder, Auto Gen, Simulator, Coach, Training
2026-03-12 04:32:33 +00:00
KoalaTrapLord f5dae81193 v27.4: Fix blank Collection/Decks pages — API response unwrapping
- collection.ts: unwrap {items} -> cards, map camelCase params (pageSize, sortField, etc)
- decks.ts: unwrap {decks} from listDecks, {cards} from getDeckCards, normalize field names (commander_name->commander, total_cards->card_count, role_tag->category)
- decks.ts: transform getDeckAnalysis from backend shape (counts_by_type, color_pips) to frontend shape (card_count, land_count, type_distribution, etc)
- types: SetInfo.card_count now optional (backend doesn't provide it)
- Installer now bundles pre-built React dist/ as fallback
2026-03-12 03:47:20 +00:00
KoalaTrapLord 36b3c8c55b v27.3: Fix blank page — response shape mismatches
1. /api/lab/status: StatusResponse now includes run_id, games_completed,
   total_games, current_decks aliases so the React SPA gets the field
   names it expects.

2. /api/lab/decks returns {decks: [...]}, /api/lab/history returns
   {results: [...]}, /api/lab/precons returns {precons: [...]} — but
   the frontend expected bare arrays. Updated lab.ts API client to
   unwrap these wrapper objects automatically.
2026-03-12 03:36:36 +00:00
KoalaTrapLord a098524b98 v27.2: Fix /api/lab/status 422 crash — make batchId optional
GET /api/lab/status and /api/lab/result required batchId as a
mandatory query param, but the React SPA calls them with no param
on page load. FastAPI returned 422 Unprocessable Content, which
caused the BatchSimPage to crash and the UI to flash then vanish
in a retry loop.

Fix: batchId is now Optional[str] = None. When omitted, returns
the most recent active batch (or idle defaults for status).
2026-03-12 03:31:36 +00:00
KoalaTrapLord acc9d7cd47 v27.1: Fix unused import errors breaking tsc -b strict build
Removed unused imports in DecksPage, TrainingPage, and decks.ts
that caused noUnusedLocals/noUnusedParameters errors during
the React build step in the installer.
2026-03-12 03:26:38 +00:00
KoalaTrapLord 588314b282 v27: Coach mega-upgrade — all 9 improvements
Backend:
- POST /api/coach/chat: Multi-turn coaching chat with SSE streaming
- POST /api/coach/apply: Apply suggestions to deck (cut/add cards)
- GET /api/coach/cards-like: Embedding similarity search with ownership/price
- GET /api/coach/decks: Enhanced to show all decks with report status
- Goals config (power level, strategy, budget, focus areas) wired to prompts
- Session history with full diff tracking (cuts/adds/hints)

Frontend (complete CoachPage rewrite):
- Live multi-turn chat with streaming LLM responses
- Apply suggestions UI: checkbox cuts/adds, one-click apply to deck
- Goals config panel (power level slider, strategy chips, budget, focus areas)
- Session results view with summary, tips, mana advice, cuts, adds
- Session history browser with session drill-down
- Cards Like This search (embedding-powered card discovery)
- Price-aware card suggestions with collection ownership badges
- Responsive dark theme, tab navigation
2026-03-12 03:15:30 +00:00
KoalaTrapLord 7132b6f205 v26: Wire RL training pipeline UI (phases 1-5)
- Fixed all ML types to match actual backend response shapes:
  MLStatus (logging state + decision files), MLDataStatus (datasets,
  checkpoints, eval results), MLTrainStatus (phases, metrics, result),
  PPOTrainStatus (metrics with win_rate/reward/losses), TournamentStatus
- Updated training API client with full parameter support for all endpoints
- Rewrote TrainingPage with 5 clear pipeline phases:
  Phase 1: Data Collection (toggle logging, view decision files)
  Phase 2: Dataset & Model Artifacts (splits, checkpoints, eval, model info)
  Phase 3: Supervised Training (epochs/lr/batch/patience, live progress,
           phase badges, real-time metrics, final eval results)
  Phase 4: PPO Reinforcement Learning (full config: iterations, episodes,
           clip epsilon, entropy coeff, opponent selection, live win rate)
  Phase 5: Tournament Evaluation (episodes, playstyle, leaderboard table)
- All sections are collapsible with live phase badges and progress bars
- Advanced config toggles for supervised and PPO training
- Proper mutex: can't start PPO while supervised is running, etc.
- Auto-refresh model info and data status when training completes
2026-03-12 02:54:30 +00:00
KoalaTrapLord 1704d7ca9c v25: EDHREC recommendations tab in deck builder
- Redesigned Recommendations tab with EDHREC / From Collection sub-tabs
- EDHREC sub-tab: fetches average deck data, groups by role, shows owned/missing status
- Collection sub-tab: shows cards from collection grouped by type, shortfall indicators
- Both sub-tabs support: checkbox selection, bulk add to deck, single add buttons
- Role badges with color-coding (Ramp, Draw, Removal, BoardWipe, etc.)
- Card hover preview for EDHREC recommendations
- Show only owned filter for EDHREC results
- Fixed API client types: EdhRecsResponse, CollectionRecsResponse wrappers
- Added EdhRecCard and CollectionRecCard interfaces
2026-03-12 02:48:52 +00:00
KoalaTrapLord f639703fb4 v24: Resolve GitHub Issues #1-#5
Issue #1 — Winner's life total now captured:
  - Parse verbose Forge log for life-total-change events
  - Track last-known life per seat via 3 regex patterns
  - Winner's final life set from log data (not left at default 40)
  - Losers who died to mill/poison retain their actual life total

Issue #2 — Win condition classification formalized:
  - New WinCondition enum (schema/WinCondition.java) with 8 categories
  - Centralized Forge loss-reason-string → enum mapping (regex-based)
  - UNKNOWN fallback with logging for unrecognized loss reasons
  - All classification now goes through WinCondition.classify()

Issue #3 — JVM startup overhead reduced:
  - Added -XX:+UseSerialGC (lighter GC for batch processes)
  - Added -XX:TieredStopAtLevel=1 (faster JIT, less compilation)
  - ForgeWorker stub class for future persistent batch mode
  - Estimated reduction: ~2-3s → ~1-1.5s per game startup

Issue #4 — Single-threaded throughput optimization:
  - New CLI flags: --ai-simplified, --ai-think-time, --benchmark
  - JVM flags passed through to Forge subprocess
  - Benchmark mode: run 1 game before batch, report wall/Forge/overhead times
  - API support: aiSimplified, aiThinkTimeMs in StartRequest

Issue #5 — Threading, progress, and backpressure:
  - --threads 0 or -1 auto-detects optimal count from CPU topology
  - detectOptimalThreads() estimates physical cores (excludes HT)
  - ProgressCallback interface for real-time sims/sec reporting
  - Per-game progress emitted as [PROGRESS] lines, parsed by lab_api.py
  - --max-queue flag for graceful backpressure (limits concurrent JVMs)
  - Final throughput summary printed at batch completion
  - StatusResponse now includes simsPerSec field

Also: .gitignore restored, lab_api.py updated with all new API fields
2026-03-12 01:26:36 +00:00
KoalaTrapLord cbedfd1ed2 v23: React + TypeScript SPA migration
Complete frontend rewrite from vanilla HTML/CSS/JS to React 19 + TypeScript + Tailwind CSS v4.

Frontend (frontend/commander-ai-lab-ui/):
- Vite + React 19 + TypeScript + Tailwind CSS v4 + React Router
- 7 pages: BatchSim, Collection, Decks, AutoGen, Simulator, Coach, Training
- Reusable components: Spinner, StatusBadge, CardImage, ManaSymbols, EmptyState
- Full typed API client layer (8 modules matching all backend endpoints)
- Recharts for mana curves, win rate charts, progress bars
- Lucide icons throughout
- Custom dark theme with MTG mana color tokens
- Code splitting (recharts + react in separate chunks)

Backend (lab_api.py):
- SPA-aware static file serving with catch-all for client-side routing
- Fallback to legacy ui/ folder if React dist not built
- Updated startup log with SPA route list

Build system:
- .gitignore added (was missing - removes pyc, db, node_modules, etc.)
- build-full-installer-v2.py: v23 with Node.js check + React build step
- Vite build config with manual chunks for optimal loading

Also includes: V3 deck generator fixes, coach improvements, DeepSeek sim modules
2026-03-12 01:12:02 +00:00
KoalaTrapLord be55a02860 Initial commit 2026-03-10 12:13:56 -04:00
285 changed files with 22497 additions and 42391 deletions

No files matched your search

-21
View File
@@ -1,21 +0,0 @@
[run]
source = src/commander_ai_lab
omit =
*/tests/*
*/__pycache__/*
*/web/static/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
if TYPE_CHECKING:
if __name__ == .__main__.:
\.\.\. # ellipsis
show_missing = true
precision = 1
[html]
directory = htmlcov
-32
View File
@@ -1,32 +0,0 @@
# Commander AI Lab - Environment Variables
# Copy this file to .env and fill in your API keys.
# .env is gitignored and will NOT be committed.
# Ximilar API Key (card scanner / visual AI recognition)
# Get yours at: https://app.ximilar.com/
XIMILAR_API_KEY=your-ximilar-api-key-here
# Perplexity API Key (AI deck research & generation)
# Get yours at: https://www.perplexity.ai/settings/api
PPLX_API_KEY=your-perplexity-api-key-here
# CORS allowed origins (comma-separated)
# Default: http://localhost:5173,http://localhost:8080
# Add your LAN IP if accessing from another machine on your network
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
# ── Dual-Model AI Brain ─────────────────────────────────────
# Primary brain: GPT-OSS 20B (turn planner / decision maker)
BRAIN_API_BASE=http://localhost:11434
BRAIN_MODEL=gpt-oss:20b
BRAIN_TEMPERATURE=0.3
BRAIN_MAX_TOKENS=1024
BRAIN_TIMEOUT=120.0
# Validator brain: DeepSeek-R1-Distill-Qwen-14B (legality + explanation)
VALIDATOR_ENABLED=true
VALIDATOR_API_BASE=http://localhost:11434
VALIDATOR_MODEL=deepseek-r1:14b
VALIDATOR_TEMPERATURE=0.0
VALIDATOR_MAX_TOKENS=2048
VALIDATOR_TIMEOUT=120.0
-68
View File
@@ -1,68 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
python-tests:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]" || pip install -r requirements.txt || true
pip install ruff pytest pytest-asyncio httpx || true
- name: Lint with ruff
run: ruff check . --select E,F,W --ignore E501 || true
- name: Run tests
run: pytest -v --tb=short tests/ || true
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:
- uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build and run Maven tests
run: mvn test -B --no-transfer-progress || true
-60
View File
@@ -1,60 +0,0 @@
name: Unity Build (Windows + WebGL)
on:
push:
branches: [main]
paths:
- 'unity-client/**'
pull_request:
branches: [main]
paths:
- 'unity-client/**'
workflow_dispatch:
env:
UNITY_PROJECT_PATH: unity-client
jobs:
build:
name: Build ${{ matrix.targetPlatform }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
targetPlatform:
- StandaloneWindows64
- WebGL
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: true
- name: Cache Unity Library
uses: actions/cache@v4
with:
path: ${{ env.UNITY_PROJECT_PATH }}/Library
key: Library-${{ matrix.targetPlatform }}-${{ hashFiles(format('{0}/Assets/**', env.UNITY_PROJECT_PATH)) }}
restore-keys: |
Library-${{ matrix.targetPlatform }}-
Library-
- name: Build Unity project
uses: game-ci/unity-builder@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
projectPath: ${{ env.UNITY_PROJECT_PATH }}
targetPlatform: ${{ matrix.targetPlatform }}
buildName: CommanderAILab
versioning: Semantic
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: Build-${{ matrix.targetPlatform }}
path: build/${{ matrix.targetPlatform }}
retention-days: 14
-145
View File
@@ -1,145 +0,0 @@
# Commander-AI-Lab GUI + LLM Opponents Roadmap
## Project Summary
Build a playable Magic: The Gathering Commander interface where human players face AI opponents powered by a local LLM. The existing Python rules engine handles game legality and state management, while the LLM acts as the decision-making and narration brain for each AI opponent. A Pygame (or web-based) GUI renders the battlefield, zones, and card interactions. Each AI opponent gets a unique personality prompt to simulate different playstyles (aggro, control, combo, politics).
**Local LLM:** GPT-OSS 20B (Q4_K_M) via Ollama or LM Studio
---
## GUI Strategy: Two-Phase Frontend Plan
### Phase A (Prototype) — Pygame
Use **Pygame** for Phases 1–3 of development. Pure Python means the rules engine and AI opponent code plug in directly with zero bridging. Fast to iterate — a working battlefield renderer can be built in a day. Handles game loop, input events, and card rendering naturally. Ideal for testing AI decisions visually before investing in a polished UI.
### Phase B (Production) — Flask/FastAPI + Web Frontend
Once the core AI gameplay is working, migrate the UI to a **Flask or FastAPI backend + HTML/JS frontend** (vanilla JS or React). Benefits:
- Far more polished look for a Commander game
- Card art from Scryfall API renders natively in browser
- Easier to add multiplayer later via WebSockets
- Python backend stays pure — frontend calls it via REST API
- No Python/JS bridge complexity; clean separation of concerns
### GUI Framework Comparison
| Option | Language | Curve | Best For | Weakness |
|---|---|---|---|---|
| **Pygame** ✅ Phase A | Python | Low | Quick prototype, game loops | Dated UI, manual widgets |
| **Flask + HTML/JS** ✅ Phase B | Python + JS | Medium | Polished UI, browser-based | JS context switch |
| PyQt6 / PySide6 | Python | Medium | Desktop app widgets | Verbose, overkill for a game |
| Godot | GDScript | Medium | Full game engine, animations | Separate codebase from engine |
| Electron + Python | JS + Python | High | Desktop with web UI | Heavy, complex bridge |
| Tkinter | Python | Low | Simple UI | Very dated look |
---
## Architecture Overview
```
Game State (Python dict/dataclass)
↓
Legal Moves Generator (rules engine)
↓
LLM Prompt: "Given this state and these legal moves, what do you do?"
↓ (GPT-OSS 20B Q4_K_M)
LLM Response → parsed action
↓
Rules Engine executes + validates
↓
GUI updates board state (Pygame → Web)
```
---
## Task List
### Phase 1 — Game State Serialization
- [ ] Define a compressed game state schema (Python dataclass or dict) covering: hand, battlefield, graveyard, exile, stack, life totals, commander zone, mana pool
- [ ] Write a `state_to_prompt()` function that converts game state to a compact LLM-readable string
- [ ] Write a `legal_moves_to_prompt()` function that lists valid actions in numbered format for LLM selection
- [ ] Add a state snapshot logger for debugging AI decisions
### Phase 2 — LLM AI Opponent Integration
- [ ] Load GPT-OSS 20B (Q4_K_M) in Ollama or LM Studio; verify inference speed on RTX 5070 Ti
- [ ] Benchmark VRAM usage for GPT-OSS 20B Q4_K_M (expected ~12–14 GB VRAM at Q4_K_M quantization)
- [ ] Build `AIOpponent` class with fields: name, personality_prompt, model_endpoint, deck
- [ ] Implement `decide_action(game_state, legal_moves)` method — sends prompt to GPT-OSS 20B, parses LLM response, returns validated action index
- [ ] Add fallback logic: if LLM returns invalid/unparseable action, default to highest-priority heuristic move
- [ ] Write personality prompt templates (Aggro Timmy, Control Spike, Combo Johnny, Political Negotiator)
- [ ] Add narration method: `narrate_play(action)` — GPT-OSS 20B generates in-character flavor text for each play
- [ ] Tune system prompt length to stay within GPT-OSS 20B context window limits
### Phase 3 — Multi-Player Turn Management
- [ ] Implement turn queue: Player → AI1 → AI2 → AI3 → repeat
- [ ] Run AI decisions asynchronously (threading or asyncio) so GUI stays responsive during LLM inference
- [ ] Add priority-passing logic for instants/triggers during other players' turns
- [ ] Implement basic AI threat assessment: each AI scores opponents by board state danger level
### Phase 4 — Pygame Prototype GUI
- [ ] Design zone layout: hand, battlefield (creature/non-creature split), graveyard, exile, command zone, stack
- [ ] Build card rendering: card art placeholder, name, P/T or loyalty, tapped/untapped state
- [ ] Add click-to-play card interaction for human player
- [ ] Display AI narration/flavor text in a side panel
- [ ] Add life total trackers and commander damage matrix (4-player)
- [ ] Build phase/step indicator (Main 1, Combat, Main 2, etc.)
- [ ] Add end-of-game screen with win condition display
### Phase 5 — Web Frontend Migration *(Flask/FastAPI + HTML/JS)*
- [ ] Set up Flask or FastAPI backend exposing game state as REST API
- [ ] Build HTML/JS board layout mirroring Pygame zone design
- [ ] Integrate Scryfall API for card art rendering
- [ ] Add WebSocket support for real-time game state updates
- [ ] Add drag-and-drop card interaction
- [ ] Port AI narration panel to web chat-style UI
- [ ] Test full game loop end-to-end on web frontend
### Phase 6 — Politics & Table Talk *(Stretch Goal)*
- [ ] Implement inter-AI negotiation: before attacking, AI queries GPT-OSS 20B "should I propose a deal?"
- [ ] Build deal proposal UI: AI proposes, human can accept/decline, other AIs respond
- [ ] Add threat-based targeting memory: AIs remember who attacked them last turn
### Phase 7 — Testing & Tuning
- [ ] Run 10 full simulated games (4 AI vs AI) and log decision quality
- [ ] Benchmark GPT-OSS 20B Q4_K_M inference time per decision on RTX 5070 Ti
- [ ] Tune context window compression to stay within model token limits
- [ ] Profile Pygame and web render loops for stability during AI thinking phases
- [ ] Add unit tests for `state_to_prompt()` and `decide_action()` parsing
### Phase 8 — Multiplayer (Human vs Human + AI)
- [ ] **Lobby system** — create/join game rooms with configurable seat count (2–4 players); assign each seat as Human or AI
- [ ] **WebSocket game server** — upgrade Flask/FastAPI backend to handle persistent connections for real-time state sync across multiple browser clients
- [ ] **Player session management** — unique session tokens per human player; reconnect support if connection drops mid-game
- [ ] **Private hand views** — each human player sees only their own hand; other hands shown as face-down card backs
- [ ] **Turn notification system** — alert active player when it's their turn (browser notification or in-page indicator)
- [ ] **Action broadcast** — all players see each action played in real-time (card played, attack declared, ability activated) via WebSocket push
- [ ] **Mixed lobby support** — allow any combination of human and AI seats (e.g., 2 humans + 2 AIs, or 1 human + 3 AIs)
- [ ] **Spectator mode** — allow observers to watch a game without participating; full board visible, no hand info
- [ ] **Local LAN play** — host game server on local network so friends can connect via browser on the same Wi-Fi (no internet required)
- [ ] **Optional: Remote play** — expose server via ngrok or Tailscale for play over the internet without full deployment
- [ ] **Multiplayer AI pacing** — add configurable AI decision delay (e.g., 1–3 sec) so human players can follow AI turns without them resolving instantly
- [ ] **Chat panel** — in-game text chat between human players; AI opponents can optionally post narration lines into the same channel
- [ ] **End-of-game summary** — show full game recap (turns played, damage dealt, commanders cast count) visible to all players after win condition met
---
## Tech Stack
| Component | Choice | Notes |
|---|---|---|
| GUI (Prototype) | **Pygame** | Pure Python, fast iteration |
| GUI (Production) | **Flask/FastAPI + HTML/JS** | Polished, browser-based |
| Multiplayer | **WebSockets (Flask-SocketIO)** | Real-time state sync across clients |
| LLM Backend | Ollama or LM Studio | Serves GPT-OSS 20B locally |
| Model | **GPT-OSS 20B (Q4_K_M)** | ~12–14 GB VRAM, strong reasoning |
| Game State | Python dataclass | Structured, easy to serialize |
| Async | asyncio / threading | Keep GUI responsive during inference |
| Card Data | Scryfall API | Free MTG card data + art |
| LAN Hosting | Local network / ngrok | No deployment needed for local play |
### GPT-OSS 20B Q4_K_M Notes
- Q4_K_M quantization balances quality and speed well for a 20B model
- Expected VRAM: ~12–14 GB on RTX 5070 Ti (16 GB VRAM) — fits comfortably
- Stronger reasoning than 7B models — better for multi-step MTG decision making
- Slower inference than smaller models — async handling is essential so GUI doesn't block
- System prompt + game state should be kept under ~3000 tokens to maintain fast response times
-74
View File
@@ -1,74 +0,0 @@
# Commander AI Lab — Developer Makefile
# ======================================
# Targets:
# make test — run all tests
# make test-unit — run unit tests only (fast)
# make test-integration— run integration tests
# make test-benchmark — run benchmark suite with -s (print timings)
# make coverage — run tests with coverage report
# make serve — start the FastAPI dev server
# make lint — ruff + mypy
# make fmt — ruff format
# make clean — remove build artefacts
PYTHON ?= python
UVICORN ?= uvicorn
PORT ?= 8000
SRC = src/commander_ai_lab
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean frontend-dev frontend-build test-frontend
test:
$(PYTHON) -m pytest tests/ -q
test-unit:
$(PYTHON) -m pytest tests/ -q -m "not integration and not benchmark"
test-integration:
$(PYTHON) -m pytest tests/test_integration.py -v
test-benchmark:
$(PYTHON) -m pytest tests/test_benchmark.py -v -s
test-phase3:
$(PYTHON) -m pytest tests/test_phase3.py -v
test-phase4:
$(PYTHON) -m pytest tests/test_phase4.py -v
test-phase5:
$(PYTHON) -m pytest tests/test_phase5.py -v
test-phase6:
$(PYTHON) -m pytest tests/test_phase6.py -v
test-phase7:
$(PYTHON) -m pytest tests/test_integration.py tests/test_benchmark.py -v
coverage:
$(PYTHON) -m pytest tests/ --cov=$(SRC) --cov-report=term-missing --cov-report=html
echo "Coverage report: htmlcov/index.html"
serve:
$(UVICORN) commander_ai_lab.web.app:app --reload --port $(PORT)
lint:
$(PYTHON) -m ruff check $(SRC) tests/
$(PYTHON) -m mypy $(SRC) --ignore-missing-imports
fmt:
$(PYTHON) -m ruff format $(SRC) tests/
clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name '*.pyc' -delete
rm -rf .coverage htmlcov .mypy_cache .ruff_cache dist build
frontend-dev:
cd frontend && npm run dev
frontend-build:
cd frontend && npm run build
test-frontend:
cd frontend && npx vitest run
-19
View File
@@ -354,25 +354,6 @@ Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
}
```
## 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
+19 -16
View File
@@ -56,15 +56,15 @@ class PerplexityClient:
"""
def __init__(self, api_key: str, model: str = SONAR,
timeout: int = 120, base_url: str = None):
if not api_key and not base_url:
raise ValueError("Perplexity API key is required (or provide base_url for local)")
timeout: int = 120):
if not api_key:
raise ValueError("Perplexity API key is required")
self.api_key = api_key
self.model = model
self.timeout = timeout
self._client = OpenAI(
api_key=api_key,
base_url=base_url or "https://api.perplexity.ai",
base_url="https://api.perplexity.ai",
timeout=timeout,
)
@@ -72,30 +72,26 @@ class PerplexityClient:
self,
system_prompt: str,
user_prompt: str,
json_schema: dict = None,
json_schema: dict,
schema_name: str = "response",
model: str = None,
temperature: float = 0.2,
max_tokens: int = 8192,
) -> PerplexityResponse:
"""
Send a chat request with JSON output.
Uses Sonar models do not support the json_schema response_format.
Structured output enforcement relies on the system prompt
instructing the model to return JSON matching the expected shape.
Send a chat request with structured JSON output.
Args:
system_prompt: System message (should instruct JSON output)
system_prompt: System message
user_prompt: User message
json_schema: (ignored) Kept for call-site compatibility
schema_name: (ignored) Kept for call-site compatibility
json_schema: JSON Schema dict for response_format
schema_name: Name for the schema (used in response_format)
model: Override model (sonar, sonar-pro)
temperature: Sampling temperature (0.0-1.0)
temperature: Sampling temperature (0.0–1.0)
max_tokens: Max output tokens
Returns:
PerplexityResponse with parsed_json from the model's output
PerplexityResponse with parsed_json guaranteed by schema
"""
use_model = model or self.model
@@ -110,7 +106,14 @@ class PerplexityClient:
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
response_format={
"type": "json_schema",
"json_schema": {
"name": schema_name,
"schema": json_schema,
},
},
)
choice = response.choices[0]
content = choice.message.content or ""
+3 -3
View File
@@ -22,7 +22,7 @@ from .models import (
DeckReport, CoachGoals, CoachSession,
SuggestedCut, SuggestedAdd, CoachStatus,
)
from .llm_client import LLMClient
from .llm_client import LMStudioClient
from .embeddings import MTGEmbeddingIndex
from .prompt_template import build_system_prompt, build_user_prompt
@@ -36,12 +36,12 @@ class CoachService:
Usage:
embeddings = MTGEmbeddingIndex()
embeddings.load()
llm = LLMClient()
llm = LMStudioClient()
coach = CoachService(llm, embeddings)
session = await coach.run_coaching_session("edgar-markov")
"""
def __init__(self, llm: LLMClient, embeddings: MTGEmbeddingIndex):
def __init__(self, llm: LMStudioClient, embeddings: MTGEmbeddingIndex):
self.llm = llm
self.embeddings = embeddings
ensure_dirs()
+13 -14
View File
@@ -1,7 +1,7 @@
"""
Commander AI Lab — Coach Configuration
═══════════════════════════════════════
Central config for Ollama, embeddings, and coach service paths.
Central config for LM Studio, embeddings, and coach service paths.
All paths are relative to the commander-ai-lab root directory.
"""
@@ -12,11 +12,11 @@ from pathlib import Path
# 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://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
# ── LM Studio (OpenAI-compatible API) ──────────────────────
LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://192.168.0.122:1234/v1")
LM_STUDIO_MODEL = os.environ.get("LM_STUDIO_MODEL", "local-model")
LM_STUDIO_TIMEOUT = int(os.environ.get("LM_STUDIO_TIMEOUT", "120")) # seconds
LM_STUDIO_MAX_RETRIES = 3
# ── Embeddings ─────────────────────────────────────────────
EMBEDDINGS_DIR = LAB_ROOT / "data"
@@ -28,12 +28,12 @@ DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
# ── Prompt Limits ──────────────────────────────────────────
MAX_PROMPT_TOKENS = 2048
MAX_CANDIDATES_PER_UNDERPERFORMER = 5
MAX_UNDERPERFORMERS = 4
MAX_PROMPT_TOKENS = 4096
MAX_CANDIDATES_PER_UNDERPERFORMER = 10
MAX_UNDERPERFORMERS = 8
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
# ── LLM Generation Settings (Coach — Ollama) ──────────────
# ── LLM Generation Settings (Coach — LM Studio) ──────────────
DEFAULT_TEMPERATURE = 0.7
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
# so we need a generous limit to avoid truncation
@@ -41,10 +41,9 @@ DEFAULT_MAX_TOKENS = 8192
# ── 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")
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "perplexity")
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://192.168.0.240:11434/v1")
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "sonar")
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
@@ -54,7 +53,7 @@ SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY"
# Maximum number of alternatives to suggest per missing card
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
# Model for substitution fallback (sonar is fine — small focused queries)
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "gpt-oss:20b")
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "sonar")
# Enable Perplexity fallback for low-confidence embedding matches
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
+19 -19
View File
@@ -1,7 +1,7 @@
"""
Commander AI Lab — Ollama Client
Commander AI Lab — LM Studio Client
════════════════════════════════════
Async HTTP client targeting Ollama's OpenAI-compatible
Async HTTP client targeting LM Studio's OpenAI-compatible
/v1/chat/completions endpoint. Includes retry logic for
non-JSON responses and configurable timeout.
"""
@@ -14,8 +14,8 @@ from urllib.request import urlopen, Request
from urllib.error import URLError
from .config import (
LLM_URL, LLM_MODEL,
LLM_TIMEOUT, LLM_MAX_RETRIES,
LM_STUDIO_URL, LM_STUDIO_MODEL,
LM_STUDIO_TIMEOUT, LM_STUDIO_MAX_RETRIES,
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
)
@@ -34,12 +34,12 @@ class LLMResponse:
self.model = model
class LLMClient:
class LMStudioClient:
"""
Client for Ollama's OpenAI-compatible API.
Client for LM Studio's OpenAI-compatible API.
Usage:
client = LLMClient()
client = LMStudioClient()
response = await client.chat(system_prompt, user_prompt)
if response.parsed_json:
suggestions = response.parsed_json
@@ -47,20 +47,20 @@ class LLMClient:
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.base_url = (base_url or LM_STUDIO_URL).rstrip("/")
self.model = model or LM_STUDIO_MODEL
self.timeout = timeout or LM_STUDIO_TIMEOUT
self._resolved_model = None # actual model name from /models endpoint
def _resolve_model(self) -> str:
"""Get the actual loaded model name from Ollama /models endpoint."""
"""Get the actual loaded model name from LM Studio /models endpoint."""
if self._resolved_model:
return self._resolved_model
try:
status = self.check_connection()
if status.get("connected") and status.get("active_model"):
self._resolved_model = status["active_model"]
logger.info("Resolved Ollama model: %s", self._resolved_model)
logger.info("Resolved LM Studio model: %s", self._resolved_model)
return self._resolved_model
except Exception:
pass
@@ -196,8 +196,8 @@ class LLMClient:
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
"""
Synchronous chat call to Ollama.
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
Synchronous chat call to LM Studio.
Retries up to LM_STUDIO_MAX_RETRIES on failure or non-JSON response.
"""
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
@@ -205,9 +205,9 @@ class LLMClient:
temp, tokens, force_json)
last_error = None
for attempt in range(1, LLM_MAX_RETRIES + 1):
for attempt in range(1, LM_STUDIO_MAX_RETRIES + 1):
try:
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
logger.info("LLM call attempt %d/%d", attempt, LM_STUDIO_MAX_RETRIES)
req = Request(
f"{self.base_url}/chat/completions",
data=json.dumps(body).encode("utf-8"),
@@ -239,12 +239,12 @@ class LLMClient:
body = self._build_request_body(
system_prompt, user_prompt, temp, tokens, False)
if attempt < LLM_MAX_RETRIES:
if attempt < LM_STUDIO_MAX_RETRIES:
import time
time.sleep(2 * attempt) # Simple backoff
raise ConnectionError(
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
f"LLM call failed after {LM_STUDIO_MAX_RETRIES} attempts: {last_error}"
)
async def chat(self, system_prompt: str, user_prompt: str,
@@ -263,7 +263,7 @@ class LLMClient:
def check_connection(self) -> dict:
"""
Check if Ollama is reachable. Returns status dict.
Check if LM Studio is reachable. Returns status dict.
"""
try:
req = Request(
-3
View File
@@ -25,9 +25,6 @@ class CardAlternative(BaseModel):
class DeckCardWithStatus(BaseModel):
"""A deck card enriched with ownership and substitution data."""
class Config:
extra = "ignore"
name: str
count: int = 1
category: str = ""
+1 -1
View File
@@ -247,7 +247,7 @@ class DeckGeneratorV3:
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)),
raw_deck.get("bracket", {}).get("level", 0),
" (TRUNCATED — response may have hit token limit)" if truncated else "")
# Step 5: Check ownership and build enriched card list
-4
View File
@@ -1,4 +0,0 @@
name,quantity,set_code
Sol Ring,1,C21
Command Tower,1,C21
Arcane Signet,1,C21
+1 -5
View File
@@ -7,10 +7,6 @@ Currently returns a hardcoded sample for end-to-end wiring.
Real implementation will call the Archidekt API.
"""
from .models import TemplateDeck, TemplateDeckCard
import logging
log = logging.getLogger("commander_ai_lab.deckgen.archidekt")
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
@@ -30,5 +26,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
# 1. Search Archidekt for popular decks with this commander
# 2. Fetch the top 3-5 decklists
# 3. Normalize card names and return as TemplateDeck
log.info(f" Stub: No template decks for '{commander_name}' (not yet implemented)")
print(f" [ARCHIDEKT] Stub: No template decks for '{commander_name}' (not yet implemented)")
return []
+1 -5
View File
@@ -7,10 +7,6 @@ Currently returns a hardcoded sample for end-to-end wiring.
Real implementation will parse EDHREC commander pages.
"""
from .models import TemplateDeck, TemplateDeckCard
import logging
log = logging.getLogger("commander_ai_lab.deckgen.edhrec")
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
@@ -31,5 +27,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
# 2. Fetch the "Average Deck" or "Top Cards" list
# 3. Parse card names and inclusion percentages
# 4. Return as a single TemplateDeck with weighted cards
log.info(f" Stub: No recommendations for '{commander_name}' (not yet implemented)")
print(f" [EDHREC] Stub: No recommendations for '{commander_name}' (not yet implemented)")
return []
+9 -125
View File
@@ -1,133 +1,17 @@
"""
Moxfield Adapter
================
Moxfield Adapter (stub)
════════════════════════
Fetches template decklists from Moxfield for a given commander.
Uses the undocumented but usable api2.moxfield.com API.
Rate-limited to <=10 req/sec. Errors are caught and logged.
Currently returns empty (experimental — not yet implemented).
"""
import logging
import time
import httpx
from .models import TemplateDeck, TemplateDeckCard
log = logging.getLogger("commander_ai_lab.deckgen.moxfield")
MOXFIELD_API = "https://api2.moxfield.com"
HEADERS = {
"User-Agent": "CommanderAILab/1.0",
"Accept": "application/json",
}
# Minimum seconds between requests (<=10 req/sec)
_RATE_LIMIT_DELAY = 0.12
def _slugify_commander(name: str) -> str:
"""Return the commander name as-is for the Moxfield search query."""
return name.strip()
def _build_cards_from_deck(deck_data: dict) -> list:
"""Extract commander + mainboard cards from a full Moxfield deck response."""
cards = []
# Commanders first
for card_name, card_obj in deck_data.get("commanders", {}).items():
qty = card_obj.get("quantity", 1)
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
# Mainboard
for card_name, card_obj in deck_data.get("mainboard", {}).items():
qty = card_obj.get("quantity", 1)
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
return cards
def fetch_template_decks(
commander_name: str,
color_identity: list,
config: dict = None,
) -> list:
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
"""
Fetch top Commander decks from Moxfield for the given commander.
Returns a list of TemplateDeck objects. Returns empty list on any
API failure — errors are logged, never raised.
Args:
commander_name: The commander's card name.
color_identity: List of color symbols (unused, kept for interface compat).
config: Optional dict. Supported key: ``max_decks`` (default 3).
Returns:
List of TemplateDeck objects populated with commander + mainboard cards.
Fetch template decks from Moxfield for the given commander.
Experimental — not yet implemented.
"""
max_decks = (config or {}).get("max_decks", 3)
results = []
try:
# Step 1 — Search for top decks by commander name
search_url = f"{MOXFIELD_API}/v2/decks/search"
params = {
"q": "",
"fmt": "commander",
"commander": _slugify_commander(commander_name),
"sortType": "views",
"sortDirection": "Descending",
"pageSize": max_decks,
}
log.info(f" Moxfield: Searching for '{commander_name}' (max={max_decks})")
resp = httpx.get(search_url, params=params, headers=HEADERS, timeout=15)
resp.raise_for_status()
search_data = resp.json()
deck_summaries = search_data.get("data", [])[:max_decks]
if not deck_summaries:
log.info(f" Moxfield: No decks found for '{commander_name}'")
return []
log.info(f" Moxfield: Found {len(deck_summaries)} deck(s) for '{commander_name}', fetching details...")
# Step 2 — Fetch full detail for each deck
for summary in deck_summaries:
deck_id = summary.get("publicId")
deck_name = summary.get("name", f"Moxfield {commander_name}")
if not deck_id:
log.warning(" Moxfield: Skipping deck with no publicId")
continue
time.sleep(_RATE_LIMIT_DELAY) # Rate limit: <=10 req/sec
try:
deck_url = f"{MOXFIELD_API}/v3/decks/all/{deck_id}"
deck_resp = httpx.get(deck_url, headers=HEADERS, timeout=15)
deck_resp.raise_for_status()
deck_data = deck_resp.json()
except Exception as deck_err:
log.warning(f" Moxfield: Failed to fetch deck '{deck_id}': {deck_err}")
continue
cards = _build_cards_from_deck(deck_data)
if not cards:
log.warning(f" Moxfield: Deck '{deck_name}' ({deck_id}) had no cards, skipping")
continue
results.append(TemplateDeck(
name=f"{deck_name} (Moxfield)",
source=f"https://moxfield.com/decks/{deck_id}",
cards=cards,
))
log.info(f" Moxfield: Added deck '{deck_name}' ({len(cards)} cards)")
log.info(f" Moxfield: Returning {len(results)} deck(s) for '{commander_name}'")
except httpx.HTTPStatusError as e:
log.warning(
f" Moxfield fetch failed for '{commander_name}': "
f"HTTP {e.response.status_code} — {e}"
)
except httpx.RequestError as e:
log.warning(f" Moxfield request error for '{commander_name}': {e}")
except Exception as e:
log.warning(f" Moxfield unexpected error for '{commander_name}': {e}")
return results
print(f" [MOXFIELD] Stub: Not yet implemented for '{commander_name}'")
return []
+1 -5
View File
@@ -6,10 +6,6 @@ Fetches template decklists from MTGGoldfish for a given commander.
Currently returns empty (experimental — not yet implemented).
"""
from .models import TemplateDeck, TemplateDeckCard
import logging
log = logging.getLogger("commander_ai_lab.deckgen.mtggoldfish")
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
@@ -17,5 +13,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
Fetch template decks from MTGGoldfish for the given commander.
Experimental — not yet implemented.
"""
log.info(f" Stub: Not yet implemented for '{commander_name}'")
print(f" [MTGGOLDFISH] Stub: Not yet implemented for '{commander_name}'")
return []
-129
View File
@@ -1,129 +0,0 @@
# Closed-Loop Iterative Distillation — Implementation Roadmap
> **Goal:** Create a feedback loop where the PPO self-play agent exports its game decisions
> as JSONL files, which the supervised training pipeline incorporates alongside Forge
> heuristic data. The supervised baseline gradually improves as the RL agent discovers
> better play patterns — the RL agent teaches the supervised model.
---
## Phase 1 — PPO Decision Exporter
**Goal:** PPO self-play writes decisions in the same JSONL format as Forge batch sims.
- [ ] Add a `DecisionExporter` class in `ml/training/self_play.py` that captures each
(state, action, reward, game_context) during PPO rollouts
- [ ] Write to `data/results/ml-decisions-ppo-{batch_id}.jsonl` using the exact same
schema as the Forge `DecisionSnapshot` files
- [ ] Include metadata fields to distinguish PPO-generated data:
- `"source": "ppo"`
- `"model_version"`
- `"reward"`
- `"episode_return"`
- [ ] Only export decisions from **winning** games initially (higher signal-to-noise)
**Files touched:** `ml/training/self_play.py`, new `ml/training/decision_exporter.py`
---
## Phase 2 — Dataset Builder Source Awareness
**Goal:** `dataset_builder.py` can ingest both Forge and PPO decision files with
configurable mixing.
- [ ] Update `build_dataset()` to scan for both `ml-decisions-sim-*.jsonl` (Forge) and
`ml-decisions-ppo-*.jsonl` (PPO) files
- [ ] Add a `source_weights` config param — e.g., `{"forge": 1.0, "ppo": 0.5}` — to
control sampling ratios so PPO data doesn't overwhelm the Forge baseline early on
- [ ] Add a `min_reward_threshold` filter for PPO data — only include decisions from
games above a certain reward score
- [ ] Tag each sample with its source for downstream analysis
**Files touched:** `ml/data/dataset_builder.py`, `ml/config/scope.py`
---
## Phase 3 — Quality Gate & Validation
**Goal:** Prevent bad PPO data from degrading the supervised model.
- [ ] Add a validation split in `dataset_builder.py` (hold out 10-15% of Forge data as
a fixed eval set)
- [ ] After each supervised training run, evaluate on the held-out Forge set — if
accuracy drops below a threshold compared to the previous model, reject the PPO
data batch
- [ ] Track win rate of the PPO agent — only export decisions once the PPO model exceeds
a configurable win rate (e.g., >30% in 4-player pods, above the 25% random baseline)
- [ ] Log data quality metrics: action distribution entropy, reward distribution of
exported games
**Files touched:** `ml/training/trainer.py`, `ml/eval/`, new `ml/data/quality_gate.py`
---
## Phase 4 — Distillation Loop Orchestrator
**Goal:** Automate the full cycle: supervised train -> PPO self-play -> export -> retrain.
- [ ] New `ml/training/distillation_loop.py` that orchestrates:
1. Supervised train on current dataset (Forge + any existing PPO data)
2. Run N episodes of PPO self-play using the new supervised model as starting policy
3. Export winning PPO decisions to JSONL
4. Quality gate check
5. If passed, merge into dataset and go to step 1
6. If failed, discard PPO batch, adjust hyperparameters, retry
- [ ] Add `max_iterations` and `convergence_check` (stop if win rate plateaus across
iterations)
- [ ] Each iteration is a "generation" — tag data and models with generation number
**Files touched:** new `ml/training/distillation_loop.py`, `ml/config/scope.py`
---
## Phase 5 — UI Integration & Monitoring
**Goal:** Expose the distillation loop in the Commander AI Lab web UI.
- [ ] Add a "Distillation" tab or section in `ui/ai-lab.js` showing:
- Current generation number
- Per-generation win rate trend chart
- Data composition pie chart (% Forge vs % PPO per generation)
- Start / stop / pause controls for the loop
- [ ] API endpoints in `routes/ml.py`:
- `POST /api/ml/distill/start`
- `GET /api/ml/distill/status`
- `POST /api/ml/distill/stop`
- [ ] History of each generation's metrics stored as JSON for the trends view
**Files touched:** `routes/ml.py`, `ui/ai-lab.js`, `ui/ai-lab.css`
---
## Phase 6 — Advanced: ELO Tracking & Model Tournament
**Goal:** Quantify improvement across generations.
- [ ] Pit each generation's model against previous generations and the Forge heuristic
in batch sims
- [ ] Compute ELO ratings per generation
- [ ] Store in `data/elo_history.json` and visualize in the UI as a line chart
- [ ] Use ELO delta as a convergence signal — stop the loop when ELO gain per generation
drops below a threshold
**Files touched:** new `ml/eval/elo_tracker.py`, `routes/ml.py`, `ui/ai-lab.js`
---
## Priority
| Phase | Priority | Description |
|-------|----------|-------------|
| 1 | **Critical** | PPO decision export — foundation for everything |
| 2 | **Critical** | Dataset builder ingests both sources |
| 3 | **Critical** | Quality gate prevents model regression |
| 4 | High | Automation of the full loop |
| 5 | Medium | UI visibility and controls |
| 6 | Low | ELO tracking and tournament system |
> **Phases 1-3** are the core — get those working and you have a functional closed loop.
> **Phase 4** automates it. **Phases 5-6** are polish and observability.
@@ -1,93 +0,0 @@
# Phase 1 — Self-Play Integration Checklist
> **Issue:** #65 — PPO Decision Exporter
> **Ref:** `docs/CLOSED-LOOP-DISTILLATION.md`
> **Status:** In Progress
---
## Completed
- [x] Create `ml/training/decision_exporter.py` with `DecisionExporter` class
- [x] `ExporterConfig` dataclass (output_dir, only_wins, model_version, min_episode_steps)
- [x] `build_decision_record()` — converts PPO step data into Forge-compatible JSONL format
- [x] `DecisionExporter.begin_episode()` / `record_step()` / `end_episode()` / `flush()` lifecycle
- [x] Write to `data/results/ml-decisions-ppo-{batch_id}.jsonl`
- [x] Include PPO metadata fields: `source`, `model_version`, `reward`, `episode_return`
- [x] Win-only filtering (only export decisions from winning games)
- [x] Short-episode gating (`min_episode_steps` threshold)
- [x] `_macro_action_to_forge_type()` mapping (MacroAction enum → Forge action type strings)
- [x] Export stats tracking (`stats` property)
---
## Remaining — Integrate into `ml/training/self_play.py`
### Import
- [ ] Add import: `from ml.training.decision_exporter import DecisionExporter`
### Modify `run_self_play_episode()`
- [ ] Add `exporter: DecisionExporter = None` parameter to function signature
- [ ] After each `trajectory.append(...)` block (3 locations), add:
```python
if exporter is not None:
exporter.record_step(
game_state_snapshot=prev_snapshot,
action_idx=action_idx,
reward=reward,
)
```
### Modify `collect_rollouts()`
- [ ] Add `exporter: DecisionExporter = None` parameter to function signature
- [ ] Before each episode, call:
```python
if exporter is not None:
exporter.begin_episode(agent_seat=agent_seat, playstyle=playstyle)
```
- [ ] Pass `exporter=exporter` to `run_self_play_episode()`
- [ ] After outcome tracking, call:
```python
if exporter is not None:
won = trajectory and trajectory[-1]["done"] and trajectory[-1]["reward"] > 0.5
ep_return = sum(s["reward"] for s in trajectory)
exporter.end_episode(won=won, episode_return=ep_return)
```
### Commit
- [ ] `git add ml/training/self_play.py`
- [ ] `git commit -m "feat(ml): integrate DecisionExporter into self-play rollouts (#65)"`
- [ ] `git push origin main`
---
## Integration Pattern
```
collect_rollouts(exporter=exporter)
└─ for each episode:
├─ exporter.begin_episode(seat, playstyle)
├─ run_self_play_episode(..., exporter=exporter)
│ └─ for each step:
│ ├─ trajectory.append(step_data)
│ └─ exporter.record_step(snapshot, action, reward)
└─ exporter.end_episode(won, episode_return)
# Caller does: exporter.flush()
```
---
## Terminal Commands
```bash
cd commander-ai-lab
git pull origin main
# Make edits to ml/training/self_play.py per checklist above
git add ml/training/self_play.py
git commit -m "feat(ml): integrate DecisionExporter into self-play rollouts (#65)"
git push origin main
```
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>commander-ai-lab-ui</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "commander-ai-lab-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"lucide-react": "^0.577.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.8.0",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { PageLayout } from './components/layout/PageLayout'
import { BatchSimPage } from './pages/BatchSimPage'
import { CollectionPage } from './pages/CollectionPage'
import { DecksPage } from './pages/DecksPage'
import { AutoGenPage } from './pages/AutoGenPage'
import { SimulatorPage } from './pages/SimulatorPage'
import { CoachPage } from './pages/CoachPage'
import { TrainingPage } from './pages/TrainingPage'
export default function App() {
return (
<BrowserRouter>
<PageLayout>
<Routes>
<Route path="/" element={<BatchSimPage />} />
<Route path="/collection" element={<CollectionPage />} />
<Route path="/decks" element={<DecksPage />} />
<Route path="/autogen" element={<AutoGenPage />} />
<Route path="/simulator" element={<SimulatorPage />} />
<Route path="/coach" element={<CoachPage />} />
<Route path="/training" element={<TrainingPage />} />
</Routes>
</PageLayout>
</BrowserRouter>
)
}
@@ -0,0 +1,82 @@
// ═══════════════════════════════════════════════════════════
// Base API client — all fetch calls go through here
// ═══════════════════════════════════════════════════════════
const BASE = '' // same origin; Vite proxy handles /api in dev
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.text()
let message = `HTTP ${res.status}`
try {
const json = JSON.parse(body)
message = json.detail || json.error || message
} catch {
if (body) message = body
}
throw new ApiError(res.status, message)
}
// Handle 204 No Content
if (res.status === 204) return undefined as T
return res.json()
}
export async function get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
const url = new URL(BASE + path, window.location.origin)
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '') url.searchParams.set(k, String(v))
})
}
const res = await fetch(url.toString())
return handleResponse<T>(res)
}
export async function post<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
return handleResponse<T>(res)
}
export async function postForm<T>(path: string, formData: FormData): Promise<T> {
const res = await fetch(BASE + path, {
method: 'POST',
body: formData,
})
return handleResponse<T>(res)
}
export async function put<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return handleResponse<T>(res)
}
export async function patch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return handleResponse<T>(res)
}
export async function del<T>(path: string): Promise<T> {
const res = await fetch(BASE + path, { method: 'DELETE' })
return handleResponse<T>(res)
}
@@ -0,0 +1,131 @@
import { get, post } from './client'
import type {
CoachStatus, CoachDeck, CoachSession, CoachSessionSummary,
CoachChatResponse, CoachApplyResult, CoachCardLikeResult
} from '../types'
export async function getCoachStatus() {
return get<CoachStatus>('/api/coach/status')
}
export async function getCoachDecks() {
return get<CoachDeck[]>('/api/coach/decks')
}
export async function getCoachDeckReport(deckId: string) {
return get<Record<string, unknown>>(`/api/coach/decks/${deckId}/report`)
}
export async function runCoachSession(deckId: string, goals?: Record<string, unknown>) {
return post<CoachSession>(`/api/coach/decks/${deckId}`, goals ? { goals } : undefined)
}
export async function getCoachSessions(deckId?: string) {
const params: Record<string, string> = {}
if (deckId) params.deck_id = deckId
return get<{ sessions: CoachSessionSummary[] }>('/api/coach/sessions', params)
}
export async function getCoachSession(sessionId: string) {
return get<CoachSession>(`/api/coach/sessions/${sessionId}`)
}
export async function downloadEmbeddings() {
return post<{ success: boolean; cards: number }>('/api/coach/embeddings/download')
}
export async function coachChat(
deckId: string,
messages: { role: string; content: string }[],
goals?: Record<string, unknown>
) {
return post<CoachChatResponse>('/api/coach/chat', {
deck_id: deckId,
messages,
goals,
stream: false,
})
}
export async function coachChatStream(
deckId: string,
messages: { role: string; content: string }[],
goals?: Record<string, unknown>,
onChunk?: (text: string) => void,
signal?: AbortSignal
): Promise<string> {
const res = await fetch('/api/coach/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deck_id: deckId,
messages,
goals,
stream: true,
}),
signal,
})
if (!res.ok) {
const body = await res.text()
throw new Error(body || `HTTP ${res.status}`)
}
const reader = res.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
let fullText = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') break
try {
const parsed = JSON.parse(data)
if (parsed.content) {
fullText += parsed.content
onChunk?.(fullText)
}
} catch { /* skip bad chunks */ }
}
}
}
return fullText
}
export async function applyCoachSuggestions(
sessionId: string,
deckId: number,
acceptedCuts: string[],
acceptedAdds: string[]
) {
return post<CoachApplyResult>('/api/coach/apply', {
session_id: sessionId,
deck_id: deckId,
accepted_cuts: acceptedCuts,
accepted_adds: acceptedAdds,
})
}
export async function searchCardsLike(card: string, colors?: string, topN = 10) {
const params: Record<string, string | number> = { card, top_n: topN }
if (colors) params.colors = colors
return get<{ query: string; results: CoachCardLikeResult[] }>('/api/coach/cards-like', params)
}
export async function generateReport(deckId?: number) {
return post<{ status: string; decksUpdated: string[]; count: number }>('/api/coach/reports/generate',
deckId ? { deck_id: deckId } : undefined
)
}
@@ -0,0 +1,86 @@
import { get, post, patch, postForm } from './client'
import type { CollectionCard, CollectionFilters, SetInfo, ScanResult } from '../types'
export async function searchCollection(filters: CollectionFilters) {
// Map frontend snake_case filter names to backend camelCase param names
const params: Record<string, string | number | boolean> = {}
if (filters.q) params.q = filters.q
if (filters.page) params.page = filters.page
if (filters.page_size) params.pageSize = filters.page_size
if (filters.sort_by) params.sortField = filters.sort_by
if (filters.sort_dir) params.sortDir = filters.sort_dir
if (filters.color) params.colors = filters.color
if (filters.type_line) params.types = filters.type_line
if (filters.rarity) params.rarity = filters.rarity
if (filters.set_code) params.setCode = filters.set_code
if (filters.cmc_min !== undefined) params.cmcMin = filters.cmc_min
if (filters.cmc_max !== undefined) params.cmcMax = filters.cmc_max
if (filters.price_min !== undefined) params.priceMin = filters.price_min
if (filters.price_max !== undefined) params.priceMax = filters.price_max
if (filters.owned_only !== undefined) params.owned_only = filters.owned_only
const res = await get<{ items?: CollectionCard[]; cards?: CollectionCard[]; total: number; page: number; pageSize?: number; page_size?: number }>(
'/api/collection', params
)
return {
cards: res.items || res.cards || [],
total: res.total || 0,
page: res.page || 1,
page_size: res.pageSize || res.page_size || 48,
}
}
export async function getCard(cardId: number) {
return get<CollectionCard>(`/api/collection/${cardId}`)
}
export async function updateCard(cardId: number, updates: Partial<CollectionCard>) {
return patch<CollectionCard>(`/api/collection/${cardId}`, updates)
}
export async function getSets() {
const res = await get<SetInfo[] | { sets: SetInfo[] }>('/api/collection/sets')
return Array.isArray(res) ? res : (res as { sets: SetInfo[] }).sets || []
}
export async function getKeywords() {
const res = await get<string[] | { keywords: string[] }>('/api/collection/keywords')
return Array.isArray(res) ? res : (res as { keywords: string[] }).keywords || []
}
export async function exportCollection(format: string = 'csv') {
const res = await fetch(`/api/collection/export?format=${format}`)
return res.blob()
}
export async function importCollection(file: File) {
const fd = new FormData()
fd.append('file', file)
return postForm<{ imported: number; errors: string[] }>('/api/collection/import', fd)
}
export async function scanCard(file: File) {
const fd = new FormData()
fd.append('image', file)
return postForm<ScanResult>('/api/collection/scan', fd)
}
export async function addScanResult(match: { name: string; set_code: string; scryfall_id: string; quantity?: number }) {
return post<{ success: boolean; card_id: number }>('/api/collection/scan/add', match)
}
export async function reEnrichCards() {
return post<{ updated: number }>('/api/collection/re-enrich')
}
export async function autoClassify(cardId: number) {
return post<{ classifications: string[] }>(`/api/collection/auto-classify`, { card_id: cardId })
}
export async function autoClassifyAll() {
return post<{ processed: number }>('/api/collection/auto-classify-all')
}
export async function getEdhrecData(cardId: number) {
return get<Record<string, unknown>>(`/api/collection/${cardId}/edhrec`)
}
@@ -0,0 +1,22 @@
import { get, post } from './client'
import type { DeckGenV3Request, DeckGenV3Status, DeckGenV3Result, CommanderSearchResult } from '../types'
export async function getV3Status() {
return get<DeckGenV3Status>('/api/deck/v3/status')
}
export async function searchCommanders(q: string) {
return get<CommanderSearchResult[]>('/api/deck-generator/commander-search', { q })
}
export async function generateDeckV3(req: DeckGenV3Request) {
return post<DeckGenV3Result>('/api/deck/v3/generate', req)
}
export async function commitDeckV3(req: DeckGenV3Request) {
return post<DeckGenV3Result & { deck_id: number; dck_path: string }>('/api/deck/v3/commit', req)
}
export async function exportDeckV3(format: 'csv' | 'dck' | 'moxfield' | 'shopping', req: DeckGenV3Request) {
return post<Record<string, unknown>>(`/api/deck/v3/export/${format}`, req)
}
@@ -0,0 +1,130 @@
import { get, post, put, del, patch } from './client'
import type { Deck, DeckCard, DeckAnalysis, EdhRecsResponse, CollectionRecsResponse, PplxStatus } from '../types'
export async function listDecks() {
const res = await get<{ decks: Record<string, unknown>[] } | Record<string, unknown>[]>('/api/decks')
const raw = Array.isArray(res) ? res : (res as { decks: Record<string, unknown>[] }).decks || []
return raw.map(d => ({
...d,
commander: d.commander || d.commander_name || '',
card_count: d.card_count ?? d.total_cards ?? 0,
total_price: d.total_price ?? 0,
color_identity: d.color_identity || [],
created_date: d.created_date || d.created_at || '',
updated_date: d.updated_date || d.updated_at || '',
})) as Deck[]
}
export async function getDeck(deckId: number) {
return get<Deck>(`/api/decks/${deckId}`)
}
export async function createDeck(deck: { name: string; commander?: string; format?: string }) {
return post<Deck>('/api/decks', deck)
}
export async function updateDeck(deckId: number, updates: Partial<Deck>) {
return put<Deck>(`/api/decks/${deckId}`, updates)
}
export async function deleteDeck(deckId: number) {
return del<void>(`/api/decks/${deckId}`)
}
export async function deleteAllDecks() {
return del<void>('/api/decks')
}
export async function getDeckCards(deckId: number) {
const res = await get<{ cards: Record<string, unknown>[]; total: number } | Record<string, unknown>[]>(`/api/decks/${deckId}/cards`)
const raw = Array.isArray(res) ? res : (res as { cards: Record<string, unknown>[] }).cards || []
return raw.map(c => ({
...c,
name: c.name || c.card_name || '',
category: c.category || c.role_tag || '',
})) as DeckCard[]
}
export async function addCardToDeck(deckId: number, card: { name: string; scryfall_id?: string; quantity?: number; category?: string }) {
return post<DeckCard>(`/api/decks/${deckId}/cards`, card)
}
export async function removeCardFromDeck(deckId: number, cardId: number) {
return del<void>(`/api/decks/${deckId}/cards/${cardId}`)
}
export async function updateDeckCard(deckId: number, cardId: number, updates: Partial<DeckCard>) {
return patch<DeckCard>(`/api/decks/${deckId}/cards/${cardId}`, updates)
}
export async function getDeckAnalysis(deckId: number) {
const raw = await get<Record<string, unknown>>(`/api/decks/${deckId}/analysis`)
// Backend returns: counts_by_type, targets, deltas, mana_curve, color_pips, total_cards, roles
// Frontend DeckAnalysis expects: card_count, land_count, creature_count, noncreature_count, avg_cmc, mana_curve, color_distribution, type_distribution, total_price, owned_count, missing_count
const counts = (raw.counts_by_type || {}) as Record<string, number>
const totalCards = (raw.total_cards as number) || 0
const landCount = counts['Land'] || 0
const creatureCount = counts['Creature'] || 0
const manaCurve = (raw.mana_curve || {}) as Record<string | number, number>
const colorPips = (raw.color_pips || {}) as Record<string, number>
// Compute avg CMC from mana curve
let totalMana = 0, nonLandCards = 0
for (const [cmc, count] of Object.entries(manaCurve)) {
const cmcNum = cmc === '6+' ? 6 : Number(cmc)
totalMana += cmcNum * count
nonLandCards += count
}
return {
card_count: totalCards,
land_count: landCount,
creature_count: creatureCount,
noncreature_count: totalCards - landCount - creatureCount,
avg_cmc: nonLandCards > 0 ? totalMana / nonLandCards : 0,
mana_curve: manaCurve as Record<number, number>,
color_distribution: colorPips,
type_distribution: counts,
total_price: 0,
owned_count: totalCards,
missing_count: 0,
} as DeckAnalysis
}
export async function getCollectionRecs(deckId: number) {
return get<CollectionRecsResponse>(`/api/decks/${deckId}/recommended-from-collection`)
}
export async function getEdhRecs(deckId: number, onlyOwned = false, maxResults = 30) {
return get<EdhRecsResponse>(`/api/decks/${deckId}/edh-recs?only_owned=${onlyOwned}&max_results=${maxResults}`)
}
export async function bulkAddCards(deckId: number, cards: { name: string; quantity?: number }[]) {
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add`, { cards })
}
export async function bulkAddRecommended(deckId: number, cards: string[]) {
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add-recommended`, { card_names: cards })
}
export async function exportDeckToSim(deckId: number) {
return post<{ path: string }>(`/api/decks/${deckId}/export-to-sim`)
}
export async function importDeckToDeck(deckId: number, data: { text?: string; url?: string }) {
return post<{ imported: number }>(`/api/decks/${deckId}/import`, data)
}
export async function importNewDeck(data: { text?: string; url?: string; name?: string }) {
return post<Deck>('/api/decks/import-new', data)
}
export async function getPplxStatus() {
return get<PplxStatus>('/api/pplx/status')
}
export async function deckResearch(payload: { commander: string; strategy?: string }) {
return post<{ research: string }>('/api/deck-research', payload)
}
export async function deckGenerate(payload: { commander: string; strategy?: string; bracket?: number }) {
return post<{ cards: Array<{ name: string; quantity: number; category: string }> }>('/api/deck-generate', payload)
}
@@ -0,0 +1,7 @@
export * as collectionApi from './collection'
export * as decksApi from './decks'
export * as deckgenApi from './deckgen'
export * as labApi from './lab'
export * as simApi from './sim'
export * as coachApi from './coach'
export * as trainingApi from './training'
@@ -0,0 +1,65 @@
import { get, post } from './client'
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
export async function getLabDecks() {
const res = await get<{ decks: LabDeck[] } | LabDeck[]>('/api/lab/decks')
return Array.isArray(res) ? res : (res as { decks: LabDeck[] }).decks || []
}
export async function startBatchSim(config: { decks: string[]; games: number; threads?: number; use_deepseek?: boolean; deepseek_deck?: string }) {
return post<{ run_id: string }>('/api/lab/start', config)
}
export async function startDeepSeekSim(config: { decks: string[]; games: number; deepseek_deck: string }) {
return post<{ run_id: string }>('/api/lab/start-deepseek', config)
}
export async function getLabStatus() {
return get<LabStatus>('/api/lab/status')
}
export async function getLabResult() {
return get<LabResult>('/api/lab/result')
}
export async function getLabHistory() {
const res = await get<{ results: LabHistoryEntry[] } | LabHistoryEntry[]>('/api/lab/history')
return Array.isArray(res) ? res : (res as { results: LabHistoryEntry[] }).results || []
}
export async function getPrecons() {
const res = await get<{ precons: PreconDeck[] } | PreconDeck[]>('/api/lab/precons')
return Array.isArray(res) ? res : (res as { precons: PreconDeck[] }).precons || []
}
export async function installPrecon(name: string) {
return post<{ installed: boolean }>('/api/lab/precons/install', { name })
}
export async function installPreconBatch(names: string[]) {
return post<{ installed: number }>('/api/lab/precons/install-batch', { names })
}
export async function getMetaCommanders() {
return get<Array<{ name: string; color_identity: string[] }>>('/api/lab/meta/commanders')
}
export async function searchMetaCommanders(q: string) {
return get<Array<{ name: string; color_identity: string[]; source: string }>>('/api/lab/meta/search', { q })
}
export async function fetchMetaDeck(commander: string) {
return post<{ deck_name: string; cards: Array<{ name: string; quantity: number }> }>('/api/lab/meta/fetch', { commander })
}
export async function importDeckFromUrl(url: string) {
return post<{ deck_name: string }>('/api/lab/import/url', { url })
}
export async function importDeckFromText(text: string, name?: string) {
return post<{ deck_name: string }>('/api/lab/import/text', { text, name })
}
export async function getLabLog() {
return get<{ log: string }>('/api/lab/log')
}
@@ -0,0 +1,38 @@
import { get, post } from './client'
import type { SimStatus, SimResult, DeepSeekStatus } from '../types'
export async function runSim(config: { deck_a: string; deck_b: string; games: number }) {
return post<{ sim_id: string }>('/api/sim/run', config)
}
export async function runSimFromDeck(deckId: number, config: { opponent?: string; games?: number }) {
return post<{ sim_id: string }>('/api/sim/run-from-deck', { deck_id: deckId, ...config })
}
export async function runDeepSeekSim(config: { deck_a: string; deck_b: string; games: number; deepseek_deck: string }) {
return post<{ sim_id: string }>('/api/sim/run-deepseek', config)
}
export async function getSimStatus(simId: string) {
return get<SimStatus>('/api/sim/status', { simId })
}
export async function getSimResult(simId: string) {
return get<SimResult>('/api/sim/result', { simId })
}
export async function connectDeepSeek() {
return post<DeepSeekStatus>('/api/deepseek/connect')
}
export async function getDeepSeekStatus() {
return get<DeepSeekStatus>('/api/deepseek/status')
}
export async function configureDeepSeek(config: { model?: string; base_url?: string }) {
return post<DeepSeekStatus>('/api/deepseek/configure', config)
}
export async function getDeepSeekLogs() {
return get<Array<{ filename: string; size: number; modified: string }>>('/api/deepseek/logs')
}
@@ -0,0 +1,92 @@
import { get, post } from './client'
import type {
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
TournamentStatus, TournamentResults, MLModelInfo
} from '../types'
// ── ML Status & Toggle ──────────────────────────────────────
export async function getMLStatus() {
return get<MLStatus>('/api/ml/status')
}
export async function toggleML(enable = true) {
return post<{ ml_logging_enabled: boolean; message: string }>(`/api/ml/toggle?enable=${enable}`)
}
// ── Data & Model Info ───────────────────────────────────────
export async function getDataStatus() {
return get<MLDataStatus>('/api/ml/data/status')
}
export async function getModelInfo() {
return get<MLModelInfo>('/api/ml/model')
}
export async function reloadModel(checkpoint?: string) {
return post<{ success: boolean; status: MLModelInfo }>('/api/ml/reload', checkpoint ? { checkpoint } : undefined)
}
// ── Supervised Training ─────────────────────────────────────
export async function startTraining(config: {
epochs?: number
lr?: number
batchSize?: number
patience?: number
rebuildDataset?: boolean
}) {
return post<{ status: string; config: Record<string, unknown> }>('/api/ml/train', config)
}
export async function getTrainStatus() {
return get<MLTrainStatus>('/api/ml/train/status')
}
// ── PPO Training ────────────────────────────────────────────
export async function startPPOTraining(config: {
iterations?: number
episodesPerIter?: number
ppoEpochs?: number
batchSize?: number
lr?: number
clipEpsilon?: number
entropyCoeff?: number
opponent?: string
playstyle?: string
loadSupervised?: string
}) {
return post<{ status: string; iterations: number }>('/api/ml/train/ppo', config)
}
export async function getPPOStatus() {
return get<PPOTrainStatus>('/api/ml/train/ppo/status')
}
// ── Tournament ──────────────────────────────────────────────
export async function startTournament(config: {
episodes?: number
playstyle?: string
}) {
return post<{ status: string; episodes: number }>('/api/ml/tournament', config)
}
export async function getTournamentStatus() {
return get<TournamentStatus>('/api/ml/tournament/status')
}
export async function getTournamentResults() {
return get<TournamentResults>('/api/ml/tournament/results')
}
// ── Decision Files ──────────────────────────────────────────
export async function getDecisionFile(filename: string, limit = 100, offset = 0) {
return get<{ file: string; offset: number; limit: number; count: number; decisions: Record<string, unknown>[] }>(
`/api/ml/decisions/${filename}?limit=${limit}&offset=${offset}`
)
}
// ── Prediction ──────────────────────────────────────────────
export async function predict(state: Record<string, unknown>) {
return post<{ action: string; action_index: number; confidence: number; probabilities: Record<string, number>; inference_ms: number }>(
'/api/ml/predict', state
)
}
@@ -0,0 +1,49 @@
import { useState } from 'react'
interface CardImageProps {
src: string
alt: string
size?: 'sm' | 'md' | 'lg' | 'xl'
className?: string
onClick?: () => void
}
const SIZES = {
sm: 'w-16 h-[22px]', // tiny inline
md: 'w-[146px] h-[204px]', // grid thumbnail
lg: 'w-[244px] h-[340px]', // detail view
xl: 'w-[336px] h-[468px]', // full size
}
export function CardImage({ src, alt, size = 'md', className = '', onClick }: CardImageProps) {
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
// Build Scryfall URL if needed — ensure we get the right size
const imgUrl = src || ''
return (
<div
className={`relative overflow-hidden rounded-lg bg-bg-tertiary ${SIZES[size]} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-accent-blue/50 transition-all' : ''} ${className}`}
onClick={onClick}
>
{!error ? (
<img
src={imgUrl}
alt={alt}
className={`w-full h-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
loading="lazy"
/>
) : (
<div className="flex items-center justify-center w-full h-full text-text-tertiary text-xs text-center p-2">
{alt}
</div>
)}
{!loaded && !error && (
<div className="absolute inset-0 bg-bg-tertiary animate-pulse" />
)}
</div>
)
}
@@ -0,0 +1,19 @@
import type { LucideIcon } from 'lucide-react'
interface EmptyStateProps {
icon: LucideIcon
title: string
description?: string
action?: React.ReactNode
}
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Icon className="w-12 h-12 text-text-tertiary mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-semibold text-text-primary mb-1">{title}</h3>
{description && <p className="text-sm text-text-secondary max-w-md mb-4">{description}</p>}
{action}
</div>
)
}
@@ -0,0 +1,69 @@
// Parse MTG mana cost strings like "{2}{W}{U}" into colored symbols
const MANA_COLORS: Record<string, string> = {
W: 'bg-mana-white text-black',
U: 'bg-mana-blue text-white',
B: 'bg-mana-black text-white',
R: 'bg-mana-red text-white',
G: 'bg-mana-green text-white',
C: 'bg-mana-colorless text-white',
}
interface ManaSymbolsProps {
cost: string
size?: 'sm' | 'md'
}
export function ManaSymbols({ cost, size = 'sm' }: ManaSymbolsProps) {
if (!cost) return null
const symbols = cost.match(/\{([^}]+)\}/g)
if (!symbols) return <span className="text-text-tertiary">{cost}</span>
const sz = size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs'
return (
<span className="inline-flex items-center gap-0.5">
{symbols.map((s, i) => {
const val = s.replace(/[{}]/g, '')
const colorClass = MANA_COLORS[val] || 'bg-mana-colorless text-white'
return (
<span
key={i}
className={`${sz} inline-flex items-center justify-center rounded-full font-bold ${colorClass}`}
>
{val}
</span>
)
})}
</span>
)
}
// Color identity dots
interface ColorDotsProps {
colors: string[]
size?: 'sm' | 'md'
}
export function ColorDots({ colors, size = 'sm' }: ColorDotsProps) {
if (!colors || colors.length === 0) return <span className="text-text-tertiary text-xs">Colorless</span>
const sz = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'
const DOT_COLORS: Record<string, string> = {
W: 'bg-mana-white',
U: 'bg-mana-blue',
B: 'bg-mana-black border border-border-secondary',
R: 'bg-mana-red',
G: 'bg-mana-green',
}
return (
<span className="inline-flex items-center gap-0.5">
{colors.map(c => (
<span key={c} className={`${sz} rounded-full ${DOT_COLORS[c] || 'bg-mana-colorless'}`} />
))}
</span>
)
}
@@ -0,0 +1,38 @@
interface SpinnerProps {
size?: 'sm' | 'md' | 'lg'
className?: string
}
const SIZES = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-8 h-8' }
export function Spinner({ size = 'md', className = '' }: SpinnerProps) {
return (
<svg
className={`animate-spin ${SIZES[size]} ${className}`}
viewBox="0 0 24 24"
fill="none"
>
<circle
cx="12" cy="12" r="10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
className="opacity-20"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
)
}
export function PageSpinner() {
return (
<div className="flex items-center justify-center h-64">
<Spinner size="lg" className="text-accent-blue" />
</div>
)
}
@@ -0,0 +1,35 @@
import { CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
type Variant = 'success' | 'error' | 'warning' | 'info' | 'neutral'
interface StatusBadgeProps {
variant: Variant
label: string
className?: string
}
const STYLES: Record<Variant, string> = {
success: 'bg-status-success/15 text-status-success border-status-success/30',
error: 'bg-status-error/15 text-status-error border-status-error/30',
warning: 'bg-status-warning/15 text-status-warning border-status-warning/30',
info: 'bg-status-info/15 text-status-info border-status-info/30',
neutral: 'bg-bg-tertiary text-text-secondary border-border-primary',
}
const ICONS: Record<Variant, typeof CheckCircle> = {
success: CheckCircle,
error: XCircle,
warning: AlertTriangle,
info: Info,
neutral: Info,
}
export function StatusBadge({ variant, label, className = '' }: StatusBadgeProps) {
const Icon = ICONS[variant]
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md border ${STYLES[variant]} ${className}`}>
<Icon className="w-3.5 h-3.5" />
{label}
</span>
)
}
@@ -0,0 +1,5 @@
export { Spinner, PageSpinner } from './Spinner'
export { StatusBadge } from './StatusBadge'
export { CardImage } from './CardImage'
export { ManaSymbols, ColorDots } from './ManaSymbols'
export { EmptyState } from './EmptyState'
@@ -0,0 +1,16 @@
import { Sidebar } from './Sidebar'
interface PageLayoutProps {
children: React.ReactNode
}
export function PageLayout({ children }: PageLayoutProps) {
return (
<div className="min-h-screen bg-bg-primary">
<Sidebar />
<main className="ml-56 min-h-screen">
{children}
</main>
</div>
)
}
@@ -0,0 +1,51 @@
import { NavLink } from 'react-router-dom'
const NAV_ITEMS = [
{ to: '/', label: 'Batch Sim', icon: '⚗️' },
{ to: '/collection', label: 'Collection', icon: '📦' },
{ to: '/decks', label: 'Decks', icon: '🃏' },
{ to: '/autogen', label: 'Auto Gen', icon: '⚡' },
{ to: '/simulator', label: 'Simulator', icon: '🎮' },
{ to: '/coach', label: 'Coach', icon: '🧠' },
{ to: '/training', label: 'Training', icon: '📈' },
]
export function Sidebar() {
return (
<aside className="fixed left-0 top-0 bottom-0 w-56 bg-bg-secondary border-r border-border-primary flex flex-col z-30">
{/* Logo */}
<div className="px-5 py-5 border-b border-border-primary">
<h1 className="text-base font-bold text-text-primary tracking-tight">
Commander AI Lab
</h1>
<p className="text-xs text-text-tertiary mt-0.5">MTG Intelligence Platform</p>
</div>
{/* Nav */}
<nav className="flex-1 py-3 overflow-y-auto">
{NAV_ITEMS.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-5 py-2.5 text-sm transition-colors duration-150 ${
isActive
? 'bg-accent-blue/10 text-accent-blue border-r-2 border-accent-blue'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-hover'
}`
}
>
<span className="text-base">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</nav>
{/* Footer */}
<div className="px-5 py-4 border-t border-border-primary">
<p className="text-xs text-text-tertiary">v4.0 — React + TypeScript</p>
</div>
</aside>
)
}
@@ -0,0 +1,13 @@
import { useEffect, useRef } from 'react'
/** Polls a function at the given interval while `enabled` is true */
export function usePolling(fn: () => void, intervalMs: number, enabled: boolean) {
const savedFn = useRef(fn)
savedFn.current = fn
useEffect(() => {
if (!enabled) return
const id = setInterval(() => savedFn.current(), intervalMs)
return () => clearInterval(id)
}, [intervalMs, enabled])
}
+114
View File
@@ -0,0 +1,114 @@
@import "tailwindcss";
/* ═══════════════════════════════════════════════════════════
Commander AI Lab — Design Tokens (Tailwind v4 CSS Theme)
Fresh dark theme with MTG-inspired accents
═══════════════════════════════════════════════════════════ */
@theme {
/* ── Backgrounds ── */
--color-bg-primary: #0f1117;
--color-bg-secondary: #161921;
--color-bg-tertiary: #1c2030;
--color-bg-elevated: #222738;
--color-bg-hover: #2a3048;
/* ── Borders ── */
--color-border-primary: #2a2f42;
--color-border-secondary: #363c54;
--color-border-accent: #4f6ef7;
/* ── Text ── */
--color-text-primary: #e8eaf0;
--color-text-secondary: #9ba1b8;
--color-text-tertiary: #6b7190;
--color-text-inverse: #0f1117;
/* ── Accents ── */
--color-accent-blue: #4f6ef7;
--color-accent-blue-hover: #6b84ff;
--color-accent-teal: #2dd4bf;
--color-accent-teal-hover: #5eead4;
--color-accent-purple: #a78bfa;
--color-accent-amber: #fbbf24;
--color-accent-red: #f87171;
--color-accent-green: #4ade80;
/* ── MTG Mana Colors ── */
--color-mana-white: #f9faf4;
--color-mana-blue: #0e67ab;
--color-mana-black: #2b2424;
--color-mana-red: #d3202a;
--color-mana-green: #00733e;
--color-mana-colorless: #706f6f;
/* ── Status ── */
--color-status-success: #4ade80;
--color-status-warning: #fbbf24;
--color-status-error: #f87171;
--color-status-info: #60a5fa;
/* ── Radius ── */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* ── Shadows ── */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-glow-blue: 0 0 20px rgba(79, 110, 247, 0.15);
--shadow-glow-teal: 0 0 20px rgba(45, 212, 191, 0.15);
}
/* ── Base Styles ── */
html {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
/* ── Utility animations ── */
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(79, 110, 247, 0.2); }
50% { box-shadow: 0 0 16px 4px rgba(79, 110, 247, 0.15); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-pulse-glow {
animation: pulse-glow 2s infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,436 @@
import { useState, useEffect, useRef } from 'react'
import {
Search, Wand2, Save, AlertCircle,
ChevronDown, Sparkles, RefreshCw, Check
} from 'lucide-react'
import { Spinner, StatusBadge, ManaSymbols, CardImage, ColorDots } from '../components/common'
import { deckgenApi } from '../api'
import type { DeckGenV3Status, DeckGenV3Result, DeckGenV3Card, CommanderSearchResult } from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
// ── Commander Search ──────────────────────────────────────────
function CommanderSearch({ onSelect }: { onSelect: (c: CommanderSearchResult) => void }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<CommanderSearchResult[]>([])
const [searching, setSearching] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
function handleSearch(q: string) {
setQuery(q)
clearTimeout(debounceRef.current)
if (q.length < 2) { setResults([]); return }
debounceRef.current = setTimeout(async () => {
setSearching(true)
try {
const r = await deckgenApi.searchCommanders(q)
setResults(r)
} catch { setResults([]) }
setSearching(false)
}, 300)
}
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search for a commander..."
value={query}
onChange={e => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
/>
{searching && <Spinner size="sm" className="absolute right-3 top-1/2 -translate-y-1/2 text-accent-blue" />}
</div>
{results.length > 0 && (
<div className="absolute z-20 top-full mt-1 w-full bg-bg-elevated border border-border-primary rounded-xl shadow-lg max-h-80 overflow-y-auto">
{results.map(c => (
<button
key={c.scryfall_id}
onClick={() => { onSelect(c); setQuery(c.name); setResults([]) }}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-bg-hover text-left transition-colors"
>
<CardImage src={c.image_url} alt={c.name} size="sm" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{c.name}</p>
<p className="text-xs text-text-secondary truncate">{c.type_line}</p>
</div>
<ColorDots colors={c.color_identity} />
{c.in_collection && (
<span className="text-[10px] px-1.5 py-0.5 bg-accent-green/15 text-accent-green rounded">Owned</span>
)}
</button>
))}
</div>
)}
</div>
)
}
// ── Generated Deck View ───────────────────────────────────────
function GeneratedDeckView({ result, onCommit }: { result: DeckGenV3Result; onCommit: () => void }) {
const [view, setView] = useState<'visual' | 'list'>('visual')
const [committing, setCommitting] = useState(false)
const [selectedCard, setSelectedCard] = useState<DeckGenV3Card | null>(null)
// Group by category
const grouped = result.cards.reduce<Record<string, DeckGenV3Card[]>>((acc, c) => {
const cat = c.category || 'Other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(c)
return acc
}, {})
const curveData = Object.entries(result.stats.mana_curve)
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
async function handleCommit() {
setCommitting(true)
try { onCommit() } catch { /* ignore */ }
setCommitting(false)
}
return (
<div className="space-y-5 animate-fade-in">
{/* Commander header */}
<div className="flex gap-5 bg-bg-secondary rounded-xl border border-border-primary p-5">
<CardImage src={result.commander.image_url} alt={result.commander.name} size="lg" />
<div className="flex-1 space-y-3">
<div>
<h2 className="text-xl font-bold text-text-primary">{result.commander.name}</h2>
<p className="text-sm text-text-secondary">{result.commander.type_line}</p>
<div className="flex items-center gap-3 mt-2">
<ManaSymbols cost={result.commander.mana_cost} size="md" />
<ColorDots colors={result.commander.color_identity} size="md" />
</div>
</div>
<p className="text-sm text-text-primary whitespace-pre-wrap">{result.commander.oracle_text}</p>
<div className="flex items-center gap-4 text-xs text-text-secondary">
<span>Strategy: <span className="text-accent-blue font-medium">{result.strategy}</span></span>
<span>Bracket: <span className="text-accent-amber font-medium">{result.bracket}</span></span>
<span>Model: <span className="text-accent-purple font-medium">{result.model}</span></span>
</div>
{result.substitution && (
<div className="flex items-center gap-3 text-xs">
<span className="text-accent-green">{result.substitution.owned} owned</span>
<span className="text-accent-amber">{result.substitution.substituted} substituted</span>
<span className="text-accent-red">{result.substitution.missing} missing</span>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button onClick={handleCommit} disabled={committing}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-green/15 text-accent-green text-sm font-medium rounded-lg hover:bg-accent-green/25 border border-accent-green/30 transition-colors disabled:opacity-50">
{committing ? <Spinner size="sm" /> : <Save className="w-4 h-4" />}
Save to Decks
</button>
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
<button onClick={() => setView('visual')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'visual' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
Visual
</button>
<button onClick={() => setView('list')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
List
</button>
</div>
{/* Stats summary */}
<div className="flex-1" />
<span className="text-xs text-text-tertiary">{result.stats.card_count} cards</span>
<span className="text-xs text-accent-teal font-medium">${result.stats.total_price.toFixed(2)}</span>
</div>
{/* Mana curve mini */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">Mana Curve</h4>
<div className="h-28">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={curveData} barSize={20}>
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 10 }} />
<YAxis hide />
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 11 }} />
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
{curveData.map((_, i) => <Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Card grid/list */}
{view === 'visual' ? (
<div className="space-y-5">
{Object.entries(grouped).map(([cat, cards]) => (
<div key={cat}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-3">{cat} ({cards.length})</h4>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-2">
{cards.map(card => (
<div key={card.name} className="group cursor-pointer relative" onClick={() => setSelectedCard(card)}>
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
{card.is_substitute && (
<div className="absolute top-1 right-1 w-4 h-4 bg-accent-amber rounded-full flex items-center justify-center">
<RefreshCw className="w-2.5 h-2.5 text-bg-primary" />
</div>
)}
{card.owned && (
<div className="absolute top-1 left-1 w-4 h-4 bg-accent-green rounded-full flex items-center justify-center">
<Check className="w-2.5 h-2.5 text-bg-primary" />
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary text-left">
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Category</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Role</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{result.cards.map(card => (
<tr key={card.name} className="hover:bg-bg-hover transition-colors">
<td className="px-4 py-2.5">
<span className="font-medium text-text-primary">{card.quantity}x {card.name}</span>
{card.is_substitute && (
<span className="ml-2 text-[10px] text-accent-amber">sub for {card.original_name}</span>
)}
</td>
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.category}</td>
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.role}</td>
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
<td className="px-4 py-2.5 text-right text-accent-teal text-xs">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
<td className="px-4 py-2.5">
<span className={`text-[10px] px-2 py-0.5 rounded ${card.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary'}`}>
{card.owned ? `Owned (${card.owned_qty})` : 'Missing'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Card detail popup */}
{selectedCard && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={() => setSelectedCard(null)}>
<div className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-2xl w-full mx-4 p-6 animate-fade-in" onClick={e => e.stopPropagation()}>
<div className="flex gap-5">
<CardImage src={selectedCard.image_url} alt={selectedCard.name} size="lg" />
<div className="flex-1 space-y-3">
<h3 className="text-lg font-bold text-text-primary">{selectedCard.name}</h3>
<p className="text-sm text-text-secondary">{selectedCard.type_line}</p>
<ManaSymbols cost={selectedCard.mana_cost} size="md" />
<p className="text-sm text-text-primary whitespace-pre-wrap">{selectedCard.reason}</p>
<div className="flex items-center gap-3 text-xs">
<span className="text-text-tertiary">Role: <span className="text-accent-blue">{selectedCard.role}</span></span>
{selectedCard.is_substitute && (
<span className="text-accent-amber">Substituted for {selectedCard.original_name} ({(selectedCard.similarity_score ?? 0 * 100).toFixed(0)}%)</span>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
)
}
// ── Main Auto Gen Page ────────────────────────────────────────
export function AutoGenPage() {
const [status, setStatus] = useState<DeckGenV3Status | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [result, setResult] = useState<DeckGenV3Result | null>(null)
const [commander, setCommander] = useState<CommanderSearchResult | null>(null)
const [strategy, setStrategy] = useState('')
const [bracket, setBracket] = useState(3)
const [budget, setBudget] = useState<string>('')
const [useCollection, setUseCollection] = useState(true)
const [runSub, setRunSub] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
deckgenApi.getV3Status().then(s => { setStatus(s); setLoading(false) }).catch(() => setLoading(false))
}, [])
async function handleGenerate() {
if (!commander) return
setGenerating(true)
setError(null)
setResult(null)
try {
const res = await deckgenApi.generateDeckV3({
commander_name: commander.name,
strategy: strategy || undefined,
target_bracket: bracket,
budget_usd: budget ? Number(budget) : undefined,
use_collection: useCollection,
run_substitution: runSub,
})
setResult(res)
} catch (e) {
setError(e instanceof Error ? e.message : 'Generation failed')
}
setGenerating(false)
}
async function handleCommit() {
if (!commander || !result) return
try {
await deckgenApi.commitDeckV3({
commander_name: commander.name,
strategy: strategy || undefined,
target_bracket: bracket,
budget_usd: budget ? Number(budget) : undefined,
use_collection: useCollection,
run_substitution: runSub,
})
} catch { /* ignore */ }
}
if (loading) {
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
}
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Sparkles className="w-6 h-6 text-accent-purple" />
Auto Gen V3
</h1>
<p className="text-sm text-text-secondary mt-0.5">AI-powered deck generation with Perplexity</p>
</div>
{/* Status bar */}
<div className="flex items-center gap-3">
<StatusBadge
variant={status?.initialized ? 'success' : 'error'}
label={status?.initialized ? 'Ready' : 'Not Initialized'}
/>
<StatusBadge
variant={status?.pplx_configured ? 'success' : 'warning'}
label={status?.pplx_configured ? 'Perplexity Connected' : 'Perplexity Not Set'}
/>
{status?.embeddings_loaded && (
<StatusBadge variant="info" label={`${status.embedding_cards.toLocaleString()} embeddings`} />
)}
</div>
{!result ? (
/* Config form */
<div className="max-w-2xl space-y-5">
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Commander</label>
<CommanderSearch onSelect={setCommander} />
{commander && (
<div className="mt-2 flex items-center gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
<CardImage src={commander.image_url} alt={commander.name} size="sm" />
<div>
<p className="text-sm font-medium text-text-primary">{commander.name}</p>
<p className="text-xs text-text-secondary">{commander.type_line}</p>
</div>
<ColorDots colors={commander.color_identity} />
</div>
)}
</div>
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Strategy (optional)</label>
<input
type="text"
placeholder="e.g. Voltron, Aristocrats, Storm..."
value={strategy}
onChange={e => setStrategy(e.target.value)}
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Target Bracket</label>
<select
value={bracket}
onChange={e => setBracket(Number(e.target.value))}
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value={1}>1 — Casual</option>
<option value={2}>2 — Low Power</option>
<option value={3}>3 — Mid Power</option>
<option value={4}>4 — High Power</option>
</select>
</div>
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Budget (USD, optional)</label>
<input
type="number"
placeholder="No limit"
value={budget}
onChange={e => setBudget(e.target.value)}
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={useCollection} onChange={e => setUseCollection(e.target.checked)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
<span className="text-sm text-text-secondary">Use collection cards</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={runSub} onChange={e => setRunSub(e.target.checked)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
<span className="text-sm text-text-secondary">Run substitution pass</span>
</label>
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-3 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
<button
onClick={handleGenerate}
disabled={!commander || generating || !status?.initialized}
className="flex items-center gap-2 px-6 py-3 bg-accent-blue text-white text-sm font-semibold rounded-lg hover:bg-accent-blue-hover shadow-glow-blue transition-all disabled:opacity-50 disabled:shadow-none"
>
{generating ? (
<><Spinner size="sm" /> Generating...</>
) : (
<><Wand2 className="w-4 h-4" /> Generate Deck</>
)}
</button>
</div>
) : (
/* Results */
<div>
<button onClick={() => setResult(null)}
className="flex items-center gap-2 mb-4 text-sm text-text-secondary hover:text-text-primary transition-colors">
<ChevronDown className="w-4 h-4 rotate-90" />
Back to Config
</button>
<GeneratedDeckView result={result} onCommit={handleCommit} />
</div>
)}
</div>
)
}
@@ -0,0 +1,415 @@
import { useState, useEffect } from 'react'
import {
FlaskConical, Play, Clock, Trophy,
Upload, Link, FileText, History, ChevronRight, AlertCircle,
Check
} from 'lucide-react'
import { Spinner } from '../components/common'
import { labApi } from '../api'
import { usePolling } from '../hooks/usePolling'
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
// ── Results Chart ─────────────────────────────────────────────
function ResultsChart({ result }: { result: LabResult }) {
const data = result.decks
.sort((a, b) => b.win_rate - a.win_rate)
.map(d => ({
name: d.name.length > 20 ? d.name.slice(0, 18) + '...' : d.name,
win_rate: Math.round(d.win_rate * 100),
fullName: d.name,
}))
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" barSize={20} margin={{ left: 10, right: 20 }}>
<XAxis type="number" domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis type="category" dataKey="name" width={150} tick={{ fill: '#9ba1b8', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
formatter={(value: unknown) => [`${value}%`, 'Win Rate']}
labelFormatter={(label: unknown) => data.find(d => d.name === String(label))?.fullName || String(label)}
/>
<Bar dataKey="win_rate" radius={[0, 4, 4, 0]}>
{data.map((_, i) => (
<Cell key={i} fill={i === 0 ? '#4ade80' : i === 1 ? '#4f6ef7' : '#363c54'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
// ── Deck Selector ─────────────────────────────────────────────
function DeckSelector({
decks,
selected,
onToggle,
precons,
onInstallPrecon,
}: {
decks: LabDeck[]
selected: Set<string>
onToggle: (name: string) => void
precons: PreconDeck[]
onInstallPrecon: (name: string) => void
}) {
const [showPrecons, setShowPrecons] = useState(false)
const [installing, setInstalling] = useState<string | null>(null)
async function handleInstall(name: string) {
setInstalling(name)
await onInstallPrecon(name)
setInstalling(null)
}
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-text-primary">Available Decks</h3>
{decks.length === 0 ? (
<p className="text-xs text-text-tertiary">No decks available. Import or install precons.</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{decks.map(d => (
<label key={d.name} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover cursor-pointer transition-colors">
<input
type="checkbox"
checked={selected.has(d.name)}
onChange={() => onToggle(d.name)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-text-primary truncate block">{d.name}</span>
<span className="text-[10px] text-text-tertiary">{d.source} · {d.card_count} cards</span>
</div>
</label>
))}
</div>
)}
<button
onClick={() => setShowPrecons(!showPrecons)}
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium bg-bg-tertiary rounded-lg text-text-secondary hover:bg-bg-hover border border-border-primary transition-colors"
>
Precon Decks
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showPrecons ? 'rotate-90' : ''}`} />
</button>
{showPrecons && (
<div className="space-y-1 max-h-48 overflow-y-auto animate-fade-in">
{precons.map(p => (
<div key={p.name} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-bg-tertiary">
<span className="flex-1 text-xs text-text-secondary truncate">{p.name}</span>
{p.installed ? (
<span className="text-[10px] text-accent-green flex items-center gap-1"><Check className="w-3 h-3" /> Installed</span>
) : (
<button
onClick={() => handleInstall(p.name)}
disabled={installing === p.name}
className="text-[10px] text-accent-blue hover:text-accent-blue-hover transition-colors"
>
{installing === p.name ? <Spinner size="sm" /> : 'Install'}
</button>
)}
</div>
))}
</div>
)}
</div>
)
}
// ── Import Panel ──────────────────────────────────────────────
function ImportPanel({ onImported }: { onImported: () => void }) {
const [importUrl, setImportUrl] = useState('')
const [importText, setImportText] = useState('')
const [importName, setImportName] = useState('')
const [importing, setImporting] = useState(false)
const [importMode, setImportMode] = useState<'url' | 'text'>('url')
async function handleImport() {
setImporting(true)
try {
if (importMode === 'url' && importUrl.trim()) {
await labApi.importDeckFromUrl(importUrl.trim())
} else if (importMode === 'text' && importText.trim()) {
await labApi.importDeckFromText(importText.trim(), importName || undefined)
}
setImportUrl('')
setImportText('')
setImportName('')
onImported()
} catch { /* ignore */ }
setImporting(false)
}
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Upload className="w-4 h-4 text-accent-blue" />
Import Deck
</h3>
<div className="flex gap-1">
<button onClick={() => setImportMode('url')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'url' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
<Link className="w-3 h-3 inline mr-1" />URL
</button>
<button onClick={() => setImportMode('text')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'text' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
<FileText className="w-3 h-3 inline mr-1" />Text
</button>
</div>
{importMode === 'url' ? (
<input
type="text"
placeholder="Moxfield, Archidekt, or deck URL..."
value={importUrl}
onChange={e => setImportUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
) : (
<>
<input
type="text"
placeholder="Deck name"
value={importName}
onChange={e => setImportName(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<textarea
placeholder={"1x Sol Ring\n1x Command Tower\n..."}
value={importText}
onChange={e => setImportText(e.target.value)}
rows={5}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
/>
</>
)}
<button onClick={handleImport} disabled={importing}
className="w-full px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
{importing ? <Spinner size="sm" /> : 'Import'}
</button>
</div>
)
}
// ── Main Batch Sim Page ───────────────────────────────────────
export function BatchSimPage() {
const [decks, setDecks] = useState<LabDeck[]>([])
const [precons, setPrecons] = useState<PreconDeck[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [games, setGames] = useState(100)
const [status, setStatus] = useState<LabStatus | null>(null)
const [result, setResult] = useState<LabResult | null>(null)
const [history, setHistory] = useState<LabHistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [starting, setStarting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showHistory, setShowHistory] = useState(false)
async function fetchDecks() {
try {
const [d, p] = await Promise.all([labApi.getLabDecks(), labApi.getPrecons()])
setDecks(d)
setPrecons(p)
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => {
fetchDecks()
labApi.getLabStatus().then(setStatus).catch(() => {})
labApi.getLabHistory().then(setHistory).catch(() => {})
}, [])
// Poll while running
usePolling(async () => {
try {
const s = await labApi.getLabStatus()
setStatus(s)
if (!s.running && s.run_id) {
const r = await labApi.getLabResult()
setResult(r)
labApi.getLabHistory().then(setHistory).catch(() => {})
}
} catch { /* ignore */ }
}, 2000, status?.running || false)
function toggleDeck(name: string) {
setSelected(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
async function handleStart() {
if (selected.size < 2) { setError('Select at least 2 decks'); return }
setStarting(true)
setError(null)
setResult(null)
try {
await labApi.startBatchSim({ decks: [...selected], games })
const s = await labApi.getLabStatus()
setStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start')
}
setStarting(false)
}
async function handleInstallPrecon(name: string) {
try {
await labApi.installPrecon(name)
fetchDecks()
} catch { /* ignore */ }
}
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<FlaskConical className="w-6 h-6 text-accent-teal" />
Batch Simulator
</h1>
<p className="text-sm text-text-secondary mt-0.5">Run mass simulations between decks</p>
</div>
<div className="flex gap-5">
{/* Left sidebar: deck selection + import */}
<div className="w-72 flex-shrink-0 space-y-4">
{loading ? (
<div className="flex items-center justify-center h-32"><Spinner size="md" className="text-accent-blue" /></div>
) : (
<DeckSelector
decks={decks}
selected={selected}
onToggle={toggleDeck}
precons={precons}
onInstallPrecon={handleInstallPrecon}
/>
)}
<ImportPanel onImported={fetchDecks} />
</div>
{/* Main content */}
<div className="flex-1 space-y-5">
{/* Config + Start */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
<div className="flex items-center gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
<input
type="number"
min={10}
max={10000}
step={10}
value={games}
onChange={e => setGames(Number(e.target.value))}
className="mt-1 w-28 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<div className="flex-1">
<p className="text-xs text-text-tertiary">Selected: {selected.size} decks</p>
</div>
{status?.running ? (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Spinner size="sm" className="text-accent-blue" />
<span className="text-sm text-text-secondary">
{status.games_completed} / {status.total_games} games
</span>
</div>
<div className="w-48 h-2 bg-bg-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-accent-blue rounded-full transition-all"
style={{ width: `${status.total_games ? (status.games_completed / status.total_games) * 100 : 0}%` }}
/>
</div>
</div>
) : (
<button onClick={handleStart} disabled={starting || selected.size < 2}
className="flex items-center gap-2 px-5 py-2.5 bg-accent-teal/15 text-accent-teal text-sm font-semibold rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50">
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Start Simulation
</button>
)}
</div>
{error && (
<div className="mt-3 flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4" />{error}
</div>
)}
</div>
{/* Results */}
{result && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Trophy className="w-4 h-4 text-accent-amber" />
Results
</h3>
<span className="text-xs text-text-tertiary">{result.total_games} games · {new Date(result.timestamp).toLocaleString()}</span>
</div>
<ResultsChart result={result} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary">
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium uppercase tracking-wider">Deck</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Wins</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Games</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Win Rate</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Avg Turns</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{result.decks.sort((a, b) => b.win_rate - a.win_rate).map((d, i) => (
<tr key={d.name} className="hover:bg-bg-hover transition-colors">
<td className="px-3 py-2.5 flex items-center gap-2">
{i === 0 && <Trophy className="w-3.5 h-3.5 text-accent-amber" />}
<span className="font-medium text-text-primary">{d.name}</span>
</td>
<td className="px-3 py-2.5 text-right text-text-secondary">{d.wins}</td>
<td className="px-3 py-2.5 text-right text-text-secondary">{d.games}</td>
<td className="px-3 py-2.5 text-right font-semibold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</td>
<td className="px-3 py-2.5 text-right text-text-secondary">{d.avg_turns.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* History */}
<div>
<button onClick={() => setShowHistory(!showHistory)}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors">
<History className="w-4 h-4" />
Run History ({history.length})
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showHistory ? 'rotate-90' : ''}`} />
</button>
{showHistory && history.length > 0 && (
<div className="mt-3 space-y-2 animate-fade-in">
{history.map(h => (
<div key={h.run_id} className="flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary">
<Clock className="w-4 h-4 text-text-tertiary" />
<div className="flex-1">
<p className="text-sm text-text-primary">{h.decks.join(' vs ')}</p>
<p className="text-xs text-text-tertiary">{new Date(h.timestamp).toLocaleString()} · {h.total_games} games</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,949 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import {
GraduationCap, MessageSquare, Send, Sparkles, AlertCircle, X, Check,
ChevronRight, ChevronDown, Download, Settings2, Search, Zap,
Minus, Plus, History, ArrowRightLeft, Lightbulb, DollarSign, Loader2,
ClipboardList, Brain, BarChart3
} from 'lucide-react'
import { Spinner, StatusBadge, EmptyState } from '../components/common'
import { coachApi } from '../api'
import type {
CoachStatus, CoachDeck, CoachSession, CoachSessionSummary,
CoachMessage, CoachGoals, CoachApplyResult, CoachCardLikeResult
} from '../types'
// ════════════════════════════════════════════════════════════
// Goals Config Panel
// ════════════════════════════════════════════════════════════
function GoalsPanel({
goals, onChange, collapsed, onToggle
}: {
goals: CoachGoals
onChange: (g: CoachGoals) => void
collapsed: boolean
onToggle: () => void
}) {
const strategies = ['aggro', 'control', 'combo', 'midrange', 'stax', 'voltron', 'aristocrats', 'tokens']
const budgets = [
{ value: 'budget', label: 'Budget', desc: 'Under $5/card' },
{ value: 'medium', label: 'Medium', desc: 'Under $20/card' },
{ value: 'no-limit', label: 'No Limit', desc: 'Any price' },
]
const focusOptions = ['ramp', 'card draw', 'removal', 'protection', 'threats', 'mana base', 'combos', 'synergy']
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary">
<button
onClick={onToggle}
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-bg-hover transition-colors rounded-xl"
>
<Settings2 className="w-4 h-4 text-accent-teal" />
<span className="text-sm font-medium text-text-primary flex-1">Coaching Goals</span>
{collapsed ? <ChevronRight className="w-4 h-4 text-text-tertiary" /> : <ChevronDown className="w-4 h-4 text-text-tertiary" />}
</button>
{!collapsed && (
<div className="px-4 pb-4 space-y-4 border-t border-border-primary pt-3">
{/* Power Level Slider */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1 block">
Power Level: {goals.targetPowerLevel ?? 'Any'}
</label>
<input
type="range" min={1} max={10}
value={goals.targetPowerLevel ?? 5}
onChange={e => onChange({ ...goals, targetPowerLevel: parseInt(e.target.value) })}
className="w-full accent-accent-teal"
/>
<div className="flex justify-between text-[10px] text-text-tertiary">
<span>Casual</span><span>Focused</span><span>cEDH</span>
</div>
</div>
{/* Strategy */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Strategy Focus</label>
<div className="flex flex-wrap gap-1.5">
{strategies.map(s => (
<button
key={s}
onClick={() => onChange({ ...goals, metaFocus: goals.metaFocus === s ? null : s })}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
goals.metaFocus === s
? 'bg-accent-teal/20 border-accent-teal/40 text-accent-teal'
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
}`}
>
{s}
</button>
))}
</div>
</div>
{/* Budget */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Budget</label>
<div className="grid grid-cols-3 gap-2">
{budgets.map(b => (
<button
key={b.value}
onClick={() => onChange({ ...goals, budget: goals.budget === b.value ? null : b.value })}
className={`px-3 py-2 text-xs rounded-lg border transition-colors ${
goals.budget === b.value
? 'bg-accent-teal/20 border-accent-teal/40 text-accent-teal'
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
}`}
>
<div className="font-medium">{b.label}</div>
<div className="text-[10px] opacity-60">{b.desc}</div>
</button>
))}
</div>
</div>
{/* Focus Areas */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Focus Areas</label>
<div className="flex flex-wrap gap-1.5">
{focusOptions.map(f => {
const active = (goals.focusAreas || []).includes(f)
return (
<button
key={f}
onClick={() => {
const areas = goals.focusAreas || []
onChange({
...goals,
focusAreas: active ? areas.filter(a => a !== f) : [...areas, f]
})
}}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
active
? 'bg-accent-blue/20 border-accent-blue/40 text-accent-blue'
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
}`}
>
{f}
</button>
)
})}
</div>
</div>
</div>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════
// Session View — Shows coaching results with apply functionality
// ════════════════════════════════════════════════════════════
function SessionView({
session, deckId, onClose, onApplied
}: {
session: CoachSession
deckId: number
onClose: () => void
onApplied?: () => void
}) {
const [selectedCuts, setSelectedCuts] = useState<Set<string>>(new Set())
const [selectedAdds, setSelectedAdds] = useState<Set<string>>(new Set())
const [applying, setApplying] = useState(false)
const [applyResult, setApplyResult] = useState<CoachApplyResult | null>(null)
const toggleCut = (name: string) => {
const s = new Set(selectedCuts)
s.has(name) ? s.delete(name) : s.add(name)
setSelectedCuts(s)
}
const toggleAdd = (name: string) => {
const s = new Set(selectedAdds)
s.has(name) ? s.delete(name) : s.add(name)
setSelectedAdds(s)
}
const handleApply = async () => {
if (selectedCuts.size === 0 && selectedAdds.size === 0) return
setApplying(true)
try {
const result = await coachApi.applyCoachSuggestions(
session.sessionId, deckId,
[...selectedCuts], [...selectedAdds]
)
setApplyResult(result)
onApplied?.()
} catch (e) {
console.error('Apply failed:', e)
}
setApplying(false)
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-text-primary">Coaching Session</h3>
<p className="text-xs text-text-tertiary">
{new Date(session.timestamp).toLocaleString()} · {session.modelUsed}
{session.promptTokens > 0 && ` · ${session.promptTokens + session.completionTokens} tokens`}
</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-bg-hover text-text-tertiary">
<X className="w-4 h-4" />
</button>
</div>
{/* Summary */}
{session.summary && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
<h4 className="text-xs font-medium text-text-secondary mb-2 flex items-center gap-1.5">
<Brain className="w-3.5 h-3.5" /> Summary
</h4>
<p className="text-sm text-text-primary leading-relaxed">{session.summary}</p>
</div>
)}
{/* Heuristic Hints */}
{session.heuristicHints?.length > 0 && (
<div className="bg-accent-amber/5 rounded-xl border border-accent-amber/20 p-4">
<h4 className="text-xs font-medium text-accent-amber mb-2 flex items-center gap-1.5">
<Lightbulb className="w-3.5 h-3.5" /> Strategic Tips
</h4>
<ul className="space-y-1.5">
{session.heuristicHints.map((h, i) => (
<li key={i} className="text-sm text-text-primary flex items-start gap-2">
<span className="text-accent-amber mt-0.5">•</span> {h}
</li>
))}
</ul>
</div>
)}
{/* Mana Base Advice */}
{session.manaBaseAdvice && (
<div className="bg-accent-blue/5 rounded-xl border border-accent-blue/20 p-4">
<h4 className="text-xs font-medium text-accent-blue mb-2 flex items-center gap-1.5">
<BarChart3 className="w-3.5 h-3.5" /> Mana Base
</h4>
<p className="text-sm text-text-primary">{session.manaBaseAdvice}</p>
</div>
)}
{/* Suggested Cuts */}
{session.suggestedCuts?.length > 0 && (
<div>
<h4 className="text-sm font-medium text-status-error mb-2 flex items-center gap-1.5">
<Minus className="w-4 h-4" /> Suggested Cuts ({session.suggestedCuts.length})
</h4>
<div className="space-y-2">
{session.suggestedCuts.map((cut, i) => (
<div
key={i}
onClick={() => toggleCut(cut.cardName)}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedCuts.has(cut.cardName)
? 'bg-status-error/10 border-status-error/30'
: 'bg-bg-secondary border-border-primary hover:border-border-secondary'
}`}
>
<div className={`mt-0.5 w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
selectedCuts.has(cut.cardName)
? 'bg-status-error border-status-error text-white'
: 'border-border-secondary'
}`}>
{selectedCuts.has(cut.cardName) && <Check className="w-3 h-3" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary">{cut.cardName}</p>
<p className="text-xs text-text-secondary mt-0.5">{cut.reason}</p>
{cut.replacementOptions?.length > 0 && (
<p className="text-[10px] text-text-tertiary mt-1">
Replace with: {cut.replacementOptions.join(', ')}
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Suggested Adds */}
{session.suggestedAdds?.length > 0 && (
<div>
<h4 className="text-sm font-medium text-status-success mb-2 flex items-center gap-1.5">
<Plus className="w-4 h-4" /> Suggested Adds ({session.suggestedAdds.length})
</h4>
<div className="space-y-2">
{session.suggestedAdds.map((add, i) => (
<div
key={i}
onClick={() => toggleAdd(add.cardName)}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedAdds.has(add.cardName)
? 'bg-status-success/10 border-status-success/30'
: 'bg-bg-secondary border-border-primary hover:border-border-secondary'
}`}
>
<div className={`mt-0.5 w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
selectedAdds.has(add.cardName)
? 'bg-status-success border-status-success text-white'
: 'border-border-secondary'
}`}>
{selectedAdds.has(add.cardName) && <Check className="w-3 h-3" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary">{add.cardName}</p>
{add.role && (
<span className="px-1.5 py-0.5 text-[10px] bg-accent-blue/15 text-accent-blue rounded">
{add.role}
</span>
)}
</div>
<p className="text-xs text-text-secondary mt-0.5">{add.reason}</p>
{add.synergyWith?.length > 0 && (
<p className="text-[10px] text-text-tertiary mt-1">
Synergy: {add.synergyWith.join(', ')}
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Apply Button */}
{!applyResult && (selectedCuts.size > 0 || selectedAdds.size > 0) && (
<div className="sticky bottom-0 bg-bg-primary/95 backdrop-blur-sm border-t border-border-primary py-3 -mx-6 px-6">
<button
onClick={handleApply}
disabled={applying}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50"
>
{applying ? <Spinner size="sm" /> : <ArrowRightLeft className="w-4 h-4" />}
Apply {selectedCuts.size > 0 && `${selectedCuts.size} cuts`}
{selectedCuts.size > 0 && selectedAdds.size > 0 && ' + '}
{selectedAdds.size > 0 && `${selectedAdds.size} adds`} to Deck
</button>
</div>
)}
{/* Apply Results */}
{applyResult && (
<div className="bg-status-success/5 rounded-xl border border-status-success/20 p-4">
<h4 className="text-sm font-medium text-status-success mb-2 flex items-center gap-1.5">
<Check className="w-4 h-4" /> Changes Applied
</h4>
<p className="text-sm text-text-primary">
{applyResult.total_cuts > 0 && `Removed ${applyResult.total_cuts} card(s). `}
{applyResult.total_adds > 0 && `Added ${applyResult.total_adds} card(s). `}
{applyResult.errors.length > 0 && `${applyResult.errors.length} error(s).`}
</p>
{applyResult.errors.length > 0 && (
<ul className="mt-2 text-xs text-status-error">
{applyResult.errors.map((e, i) => (
<li key={i}>{e.name}: {e.error}</li>
))}
</ul>
)}
</div>
)}
{/* Raw Explanation */}
{session.rawTextExplanation && (
<details className="group">
<summary className="text-xs text-text-tertiary cursor-pointer hover:text-text-secondary">
Full analysis...
</summary>
<p className="mt-2 text-sm text-text-secondary whitespace-pre-wrap leading-relaxed bg-bg-secondary rounded-lg p-4 border border-border-primary">
{session.rawTextExplanation}
</p>
</details>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════
// Cards Like Search
// ════════════════════════════════════════════════════════════
function CardsLikePanel() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<CoachCardLikeResult[]>([])
const [searching, setSearching] = useState(false)
const handleSearch = async () => {
if (!query.trim()) return
setSearching(true)
try {
const data = await coachApi.searchCardsLike(query.trim(), undefined, 12)
setResults(data.results)
} catch { /* ignore */ }
setSearching(false)
}
return (
<div className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
placeholder="Enter a card name..."
className="w-full pl-9 pr-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue"
/>
</div>
<button
onClick={handleSearch}
disabled={searching || !query.trim()}
className="px-4 py-2 bg-accent-teal/15 text-accent-teal text-sm font-medium rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50"
>
{searching ? <Spinner size="sm" /> : 'Search'}
</button>
</div>
{results.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{results.map((card, i) => (
<div key={i} className="flex items-start gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary truncate">{card.name}</p>
<span className="text-[10px] text-text-tertiary whitespace-nowrap">
{(card.similarity * 100).toFixed(0)}%
</span>
</div>
<p className="text-[11px] text-text-secondary mt-0.5">{card.types}</p>
{card.text && (
<p className="text-[10px] text-text-tertiary mt-1 line-clamp-2">{card.text}</p>
)}
<div className="flex items-center gap-3 mt-1.5 text-[10px]">
{card.tcg_price != null && (
<span className="text-accent-amber flex items-center gap-0.5">
<DollarSign className="w-2.5 h-2.5" />{card.tcg_price.toFixed(2)}
</span>
)}
{card.owned_qty > 0 && (
<span className="text-status-success">Owned: {card.owned_qty}</span>
)}
<span className="text-text-tertiary">MV: {card.mana_value}</span>
</div>
</div>
{card.image_url && (
<img src={card.image_url} alt={card.name} className="w-14 h-auto rounded" />
)}
</div>
))}
</div>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════
// Live Chat View
// ════════════════════════════════════════════════════════════
function LiveChat({
deckId, deckName, goals, onClose
}: {
deckId: string
deckName: string
goals: CoachGoals
onClose: () => void
}) {
const [messages, setMessages] = useState<CoachMessage[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const [streamText, setStreamText] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streamText])
const sendMessage = async () => {
if (!input.trim() || streaming) return
const userMsg: CoachMessage = { role: 'user', content: input.trim() }
const allMessages = [...messages, userMsg]
setMessages(allMessages)
setInput('')
setStreaming(true)
setStreamText('')
const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0
? {
targetPowerLevel: goals.targetPowerLevel,
metaFocus: goals.metaFocus,
budget: goals.budget,
focusAreas: goals.focusAreas,
}
: undefined
try {
abortRef.current = new AbortController()
const fullText = await coachApi.coachChatStream(
deckId,
allMessages.map(m => ({ role: m.role, content: m.content })),
goalsDict,
(text) => setStreamText(text),
abortRef.current.signal
)
setMessages(prev => [...prev, { role: 'assistant', content: fullText }])
setStreamText('')
} catch (e) {
if ((e as Error).name !== 'AbortError') {
// Fallback to non-streaming
try {
const resp = await coachApi.coachChat(
deckId,
allMessages.map(m => ({ role: m.role, content: m.content })),
goalsDict
)
setMessages(prev => [...prev, { role: 'assistant', content: resp.content }])
} catch (e2) {
setMessages(prev => [...prev, {
role: 'assistant',
content: `Error: ${(e2 as Error).message || 'Failed to get response'}`
}])
}
}
setStreamText('')
}
setStreaming(false)
}
return (
<div className="flex flex-col h-[calc(100vh-180px)]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border-primary">
<div>
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-accent-blue" />
Chat: {deckName}
</h3>
<p className="text-[10px] text-text-tertiary">
{goals.metaFocus && `${goals.metaFocus} · `}
{goals.targetPowerLevel && `Power ${goals.targetPowerLevel} · `}
{goals.budget && `${goals.budget}`}
</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-bg-hover text-text-tertiary">
<X className="w-4 h-4" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 && !streaming && (
<div className="text-center py-12">
<GraduationCap className="w-10 h-10 text-text-tertiary mx-auto mb-3" />
<p className="text-sm text-text-secondary">Ask your coach anything about this deck.</p>
<div className="mt-4 flex flex-wrap gap-2 justify-center">
{[
'What should I cut?',
'How is my mana curve?',
'Suggest win conditions',
'Rate my removal suite',
].map(q => (
<button
key={q}
onClick={() => { setInput(q); }}
className="px-3 py-1.5 text-xs bg-bg-secondary border border-border-primary rounded-full text-text-secondary hover:text-text-primary hover:border-border-secondary transition-colors"
>
{q}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed ${
msg.role === 'user'
? 'bg-accent-blue/15 text-text-primary border border-accent-blue/20'
: 'bg-bg-secondary text-text-primary border border-border-primary'
}`}>
{msg.content}
</div>
</div>
))}
{/* Streaming indicator */}
{streaming && (
<div className="flex justify-start">
<div className="max-w-[85%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed bg-bg-secondary text-text-primary border border-border-primary">
{streamText || (
<span className="flex items-center gap-2 text-text-tertiary">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Thinking...
</span>
)}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t border-border-primary p-3">
<div className="flex gap-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage()}
placeholder="Ask the coach..."
disabled={streaming}
className="flex-1 px-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue disabled:opacity-50"
/>
<button
onClick={sendMessage}
disabled={streaming || !input.trim()}
className="px-3 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
}
// ════════════════════════════════════════════════════════════
// Main Coach Page
// ════════════════════════════════════════════════════════════
type Tab = 'decks' | 'chat' | 'session' | 'history' | 'search'
export function CoachPage() {
const [status, setStatus] = useState<CoachStatus | null>(null)
const [decks, setDecks] = useState<CoachDeck[]>([])
const [sessions, setSessions] = useState<CoachSessionSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// UI state
const [tab, setTab] = useState<Tab>('decks')
const [selectedDeck, setSelectedDeck] = useState<CoachDeck | null>(null)
const [selectedSession, setSelectedSession] = useState<CoachSession | null>(null)
const [goals, setGoals] = useState<CoachGoals>({})
const [goalsCollapsed, setGoalsCollapsed] = useState(true)
const [generatingReport, setGeneratingReport] = useState(false)
const [runningSession, setRunningSession] = useState<number | null>(null)
const [downloadingEmbeddings, setDownloadingEmbeddings] = useState(false)
const loadData = useCallback(async () => {
try {
const [s, d, sess] = await Promise.all([
coachApi.getCoachStatus(),
coachApi.getCoachDecks().catch(() => []),
coachApi.getCoachSessions().catch(() => ({ sessions: [] })),
])
setStatus(s)
setDecks(Array.isArray(d) ? d : [])
setSessions(Array.isArray(sess) ? sess : (sess as { sessions: CoachSessionSummary[] }).sessions || [])
} catch { /* ignore */ }
setLoading(false)
}, [])
useEffect(() => { loadData() }, [loadData])
const handleDownloadEmbeddings = async () => {
setDownloadingEmbeddings(true)
try {
await coachApi.downloadEmbeddings()
const s = await coachApi.getCoachStatus()
setStatus(s)
} catch { /* ignore */ }
setDownloadingEmbeddings(false)
}
const handleGenerateReports = async () => {
setGeneratingReport(true)
setError(null)
try {
await coachApi.generateReport()
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Report generation failed')
}
setGeneratingReport(false)
}
const handleRunSession = async (deck: CoachDeck) => {
setRunningSession(deck.deck_id)
setError(null)
try {
const deckSlug = deck.deck_name.toLowerCase().replace(/\s+/g, '-')
const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0
? goals : undefined
const session = await coachApi.runCoachSession(deckSlug, goalsDict as Record<string, unknown>)
setSelectedSession(session)
setSelectedDeck(deck)
setTab('session')
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Session failed')
}
setRunningSession(null)
}
const handleOpenChat = (deck: CoachDeck) => {
setSelectedDeck(deck)
setTab('chat')
}
const handleViewSession = async (sessionId: string) => {
try {
const s = await coachApi.getCoachSession(sessionId)
setSelectedSession(s)
// Find the matching deck
const matchDeck = decks.find(d =>
d.deck_name.toLowerCase().replace(/\s+/g, '-') === s.deckId.toLowerCase() ||
d.deck_name.toLowerCase() === s.deckId.toLowerCase()
)
if (matchDeck) setSelectedDeck(matchDeck)
setTab('session')
} catch { /* ignore */ }
}
if (loading) {
return (
<div className="p-6 flex items-center justify-center h-64">
<Spinner size="lg" className="text-accent-blue" />
</div>
)
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<GraduationCap className="w-6 h-6 text-accent-amber" />
Deck Coach
</h1>
<p className="text-sm text-text-secondary mt-0.5">
AI-powered deck analysis, coaching, and live chat
</p>
</div>
</div>
{/* Status Bar */}
<div className="flex items-center gap-3 flex-wrap">
<StatusBadge
variant={status?.llmConnected ? 'success' : 'warning'}
label={status?.llmConnected ? `LLM: ${status.llmModel || 'Connected'}` : 'LLM: Disconnected'}
/>
<StatusBadge
variant={status?.embeddingsLoaded ? 'success' : 'neutral'}
label={status?.embeddingsLoaded ? `${(status.embeddingCards || 0).toLocaleString()} embeddings` : 'No embeddings'}
/>
{status?.deckReportsAvailable != null && status.deckReportsAvailable > 0 && (
<StatusBadge variant="success" label={`${status.deckReportsAvailable} reports`} />
)}
{!status?.embeddingsLoaded && (
<button onClick={handleDownloadEmbeddings} disabled={downloadingEmbeddings}
className="flex items-center gap-1.5 text-xs text-accent-blue hover:text-accent-blue-hover transition-colors disabled:opacity-50">
{downloadingEmbeddings ? <Spinner size="sm" /> : <Download className="w-3 h-3" />}
Download Embeddings
</button>
)}
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4 flex-shrink-0" />{error}
<button onClick={() => setError(null)} className="ml-auto"><X className="w-3 h-3" /></button>
</div>
)}
{/* Tab Nav */}
<div className="flex gap-1 bg-bg-secondary rounded-lg p-1 border border-border-primary">
{([
{ id: 'decks', label: 'Decks', icon: ClipboardList },
{ id: 'history', label: 'Sessions', icon: History },
{ id: 'search', label: 'Cards Like', icon: Search },
] as { id: Tab; label: string; icon: typeof ClipboardList }[]).map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
tab === t.id
? 'bg-bg-primary text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
<t.icon className="w-3.5 h-3.5" />
{t.label}
{t.id === 'history' && sessions.length > 0 && (
<span className="text-[10px] bg-bg-tertiary px-1.5 rounded-full">{sessions.length}</span>
)}
</button>
))}
{tab === 'chat' && selectedDeck && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-bg-primary text-accent-blue shadow-sm">
<MessageSquare className="w-3.5 h-3.5" />
Chat
</div>
)}
{tab === 'session' && selectedSession && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-bg-primary text-accent-amber shadow-sm">
<Sparkles className="w-3.5 h-3.5" />
Results
</div>
)}
</div>
{/* Goals Panel (shown when on decks tab) */}
{tab === 'decks' && (
<GoalsPanel
goals={goals}
onChange={setGoals}
collapsed={goalsCollapsed}
onToggle={() => setGoalsCollapsed(!goalsCollapsed)}
/>
)}
{/* ── Decks Tab ─────────────────────────────────────── */}
{tab === 'decks' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-text-primary">Your Decks</h2>
<button
onClick={handleGenerateReports}
disabled={generatingReport}
className="flex items-center gap-1.5 text-xs text-accent-amber hover:text-accent-amber transition-colors disabled:opacity-50"
>
{generatingReport ? <Spinner size="sm" /> : <Zap className="w-3 h-3" />}
Generate Reports
</button>
</div>
{decks.length === 0 ? (
<EmptyState
icon={GraduationCap}
title="No decks available"
description="Create decks in the Deck Builder to start coaching."
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{decks.map(deck => (
<div key={deck.deck_id} className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
<div>
<h3 className="text-sm font-semibold text-text-primary">{deck.deck_name}</h3>
<p className="text-xs text-text-secondary">{deck.commander}</p>
</div>
<div className="flex items-center gap-3 text-[10px] text-text-tertiary">
<span>{deck.card_count} cards</span>
{deck.has_report && (
<span className="flex items-center gap-1 text-status-success">
<Check className="w-2.5 h-2.5" /> Report
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleRunSession(deck)}
disabled={runningSession === deck.deck_id || !status?.llmConnected}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-amber/15 text-accent-amber text-xs font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-50"
>
{runningSession === deck.deck_id ? <Spinner size="sm" /> : <Sparkles className="w-3 h-3" />}
Analyze
</button>
<button
onClick={() => handleOpenChat(deck)}
disabled={!status?.llmConnected}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-blue/15 text-accent-blue text-xs font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-50"
>
<MessageSquare className="w-3 h-3" />
Chat
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ── Chat Tab ──────────────────────────────────────── */}
{tab === 'chat' && selectedDeck && (
<LiveChat
deckId={selectedDeck.deck_name.toLowerCase().replace(/\s+/g, '-')}
deckName={selectedDeck.deck_name}
goals={goals}
onClose={() => setTab('decks')}
/>
)}
{/* ── Session Results Tab ───────────────────────────── */}
{tab === 'session' && selectedSession && selectedDeck && (
<SessionView
session={selectedSession}
deckId={selectedDeck.deck_id}
onClose={() => setTab('decks')}
onApplied={() => loadData()}
/>
)}
{/* ── History Tab ───────────────────────────────────── */}
{tab === 'history' && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-text-primary">Coaching Sessions</h2>
{sessions.length === 0 ? (
<EmptyState
icon={History}
title="No sessions yet"
description="Run an analysis on a deck to create your first coaching session."
/>
) : (
<div className="space-y-2">
{sessions.map(s => (
<button
key={s.sessionId}
onClick={() => handleViewSession(s.sessionId)}
className="w-full flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary hover:border-border-secondary text-left transition-colors"
>
<Sparkles className="w-4 h-4 text-accent-amber flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary">{s.deckId}</p>
<span className="text-[10px] text-text-tertiary">
{s.cutsCount > 0 && `${s.cutsCount} cuts`}
{s.cutsCount > 0 && s.addsCount > 0 && ' · '}
{s.addsCount > 0 && `${s.addsCount} adds`}
</span>
</div>
<p className="text-xs text-text-tertiary mt-0.5 truncate">{s.summary || 'No summary'}</p>
<p className="text-[10px] text-text-tertiary mt-0.5">{new Date(s.timestamp).toLocaleString()}</p>
</div>
<ChevronRight className="w-4 h-4 text-text-tertiary flex-shrink-0" />
</button>
))}
</div>
)}
</div>
)}
{/* ── Cards Like Tab ────────────────────────────────── */}
{tab === 'search' && (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold text-text-primary mb-1">Cards Like This</h2>
<p className="text-xs text-text-secondary">
Find similar cards using AI embeddings. Search by card name to discover alternatives.
</p>
</div>
<CardsLikePanel />
</div>
)}
</div>
)
}
@@ -0,0 +1,484 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Search, Filter, Upload, Download, Camera, RefreshCw,
ChevronLeft, ChevronRight, X, Package, SlidersHorizontal,
Plus, Grid3x3, List
} from 'lucide-react'
import { Spinner, EmptyState, ManaSymbols, CardImage } from '../components/common'
import { collectionApi } from '../api'
import type { CollectionCard, CollectionFilters, SetInfo } from '../types'
// ── Card Detail Modal ─────────────────────────────────────────
function CardDetailModal({ card, onClose }: { card: CollectionCard; onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div
className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto animate-fade-in"
onClick={e => e.stopPropagation()}
>
<div className="flex items-start justify-between p-5 border-b border-border-primary">
<div>
<h2 className="text-xl font-bold text-text-primary">{card.name}</h2>
<p className="text-sm text-text-secondary mt-0.5">{card.type_line}</p>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-bg-hover text-text-tertiary">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-col md:flex-row gap-6 p-5">
{/* Large card image */}
<div className="flex-shrink-0">
<CardImage src={card.image_url} alt={card.name} size="xl" />
</div>
{/* Card info */}
<div className="flex-1 space-y-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Mana Cost</label>
<div className="mt-1"><ManaSymbols cost={card.mana_cost} size="md" /></div>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Oracle Text</label>
<p className="mt-1 text-sm text-text-primary whitespace-pre-wrap leading-relaxed">{card.oracle_text}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
<p className="mt-1 text-sm text-text-primary">{card.set_name} ({card.set_code.toUpperCase()})</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
<p className={`mt-1 text-sm font-medium capitalize ${
card.rarity === 'mythic' ? 'text-accent-amber' :
card.rarity === 'rare' ? 'text-accent-amber/80' :
card.rarity === 'uncommon' ? 'text-accent-teal' : 'text-text-secondary'
}`}>{card.rarity}</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC</label>
<p className="mt-1 text-sm text-text-primary">{card.cmc}</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">EDHREC Rank</label>
<p className="mt-1 text-sm text-text-primary">{card.edhrec_rank ?? 'N/A'}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Quantity</label>
<p className="mt-1 text-sm text-text-primary">{card.quantity} (+ {card.foil_quantity} foil)</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Price</label>
<p className="mt-1 text-sm text-accent-teal font-semibold">
${card.tcg_price?.toFixed(2) ?? '—'}
{card.foil_price ? <span className="text-text-tertiary ml-2">Foil: ${card.foil_price.toFixed(2)}</span> : null}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
// ── Scanner Panel ─────────────────────────────────────────────
function ScannerPanel({ onCardAdded }: { onCardAdded: () => void }) {
const [scanning, setScanning] = useState(false)
const [results, setResults] = useState<import('../types').ScanMatch[]>([])
const [adding, setAdding] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
async function handleScan(file: File) {
setScanning(true)
setResults([])
try {
const res = await collectionApi.scanCard(file)
setResults(res.matches || [])
} catch {
setResults([])
}
setScanning(false)
}
async function handleAdd(match: import('../types').ScanMatch) {
setAdding(match.scryfall_id)
try {
await collectionApi.addScanResult({
name: match.name,
set_code: match.set_code,
scryfall_id: match.scryfall_id,
})
onCardAdded()
} catch { /* ignore */ }
setAdding(null)
}
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2 mb-3">
<Camera className="w-4 h-4 text-accent-blue" />
Card Scanner
</h3>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => e.target.files?.[0] && handleScan(e.target.files[0])}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={scanning}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue/10 text-accent-blue border border-accent-blue/30 rounded-lg text-sm font-medium hover:bg-accent-blue/20 transition-colors disabled:opacity-50"
>
{scanning ? <Spinner size="sm" /> : <Camera className="w-4 h-4" />}
{scanning ? 'Scanning...' : 'Scan a Card'}
</button>
{results.length > 0 && (
<div className="mt-3 space-y-2 max-h-60 overflow-y-auto">
{results.map(m => (
<div key={m.scryfall_id} className="flex items-center gap-3 p-2 rounded-lg bg-bg-tertiary">
<CardImage src={m.image_url} alt={m.name} size="sm" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{m.name}</p>
<p className="text-xs text-text-tertiary">{m.set_name} — {(m.confidence * 100).toFixed(0)}%</p>
</div>
<button
onClick={() => handleAdd(m)}
disabled={adding === m.scryfall_id}
className="p-1.5 rounded-lg bg-accent-green/15 text-accent-green hover:bg-accent-green/25 transition-colors"
>
{adding === m.scryfall_id ? <Spinner size="sm" /> : <Plus className="w-4 h-4" />}
</button>
</div>
))}
</div>
)}
</div>
)
}
// ── Main Collection Page ──────────────────────────────────────
export function CollectionPage() {
const [cards, setCards] = useState<CollectionCard[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [sets, setSets] = useState<SetInfo[]>([])
const [selectedCard, setSelectedCard] = useState<CollectionCard | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [showFilters, setShowFilters] = useState(false)
const [importing, setImporting] = useState(false)
const [enriching, setEnriching] = useState(false)
const importRef = useRef<HTMLInputElement>(null)
const [filters, setFilters] = useState<CollectionFilters>({
q: '',
page: 1,
page_size: 48,
sort_by: 'name',
sort_dir: 'asc',
})
const fetchCards = useCallback(async () => {
setLoading(true)
try {
const res = await collectionApi.searchCollection(filters)
setCards(res.cards)
setTotal(res.total)
} catch { /* ignore */ }
setLoading(false)
}, [filters])
useEffect(() => { fetchCards() }, [fetchCards])
useEffect(() => {
collectionApi.getSets().then(setSets).catch(() => {})
}, [])
const totalPages = Math.ceil(total / (filters.page_size || 48))
function updateFilter(key: keyof CollectionFilters, value: string | number | boolean | undefined) {
setFilters(prev => ({ ...prev, [key]: value, page: 1 }))
}
async function handleExport() {
const blob = await collectionApi.exportCollection('csv')
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'collection.csv'
a.click()
URL.revokeObjectURL(url)
}
async function handleImport(file: File) {
setImporting(true)
try {
await collectionApi.importCollection(file)
fetchCards()
} catch { /* ignore */ }
setImporting(false)
}
async function handleReEnrich() {
setEnriching(true)
try {
await collectionApi.reEnrichCards()
fetchCards()
} catch { /* ignore */ }
setEnriching(false)
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Collection</h1>
<p className="text-sm text-text-secondary mt-0.5">{total.toLocaleString()} cards in your collection</p>
</div>
<div className="flex items-center gap-2">
<button onClick={handleReEnrich} disabled={enriching}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
{enriching ? <Spinner size="sm" /> : <RefreshCw className="w-3.5 h-3.5" />}
Re-Enrich
</button>
<input ref={importRef} type="file" accept=".csv,.txt" className="hidden"
onChange={e => e.target.files?.[0] && handleImport(e.target.files[0])} />
<button onClick={() => importRef.current?.click()} disabled={importing}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
{importing ? <Spinner size="sm" /> : <Upload className="w-3.5 h-3.5" />}
Import
</button>
<button onClick={handleExport}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
<Download className="w-3.5 h-3.5" />
Export
</button>
</div>
</div>
<div className="flex gap-5">
{/* Left sidebar: search + filters + scanner */}
<div className="w-72 flex-shrink-0 space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search cards..."
value={filters.q || ''}
onChange={e => updateFilter('q', e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
/>
</div>
{/* Filter toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-secondary hover:bg-bg-hover transition-colors"
>
<span className="flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4" />
Filters
</span>
<Filter className={`w-3.5 h-3.5 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</button>
{showFilters && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3 animate-fade-in">
{/* Rarity */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
<select
value={filters.rarity || ''}
onChange={e => updateFilter('rarity', e.target.value || undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="">All</option>
<option value="common">Common</option>
<option value="uncommon">Uncommon</option>
<option value="rare">Rare</option>
<option value="mythic">Mythic</option>
</select>
</div>
{/* Color */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Color</label>
<select
value={filters.color || ''}
onChange={e => updateFilter('color', e.target.value || undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="">All</option>
<option value="W">White</option>
<option value="U">Blue</option>
<option value="B">Black</option>
<option value="R">Red</option>
<option value="G">Green</option>
<option value="C">Colorless</option>
</select>
</div>
{/* Set */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
<select
value={filters.set_code || ''}
onChange={e => updateFilter('set_code', e.target.value || undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="">All Sets</option>
{sets.map(s => (
<option key={s.code} value={s.code}>{s.name} ({s.card_count})</option>
))}
</select>
</div>
{/* CMC Range */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Min</label>
<input type="number" min="0" max="20"
value={filters.cmc_min ?? ''}
onChange={e => updateFilter('cmc_min', e.target.value ? Number(e.target.value) : undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
/>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Max</label>
<input type="number" min="0" max="20"
value={filters.cmc_max ?? ''}
onChange={e => updateFilter('cmc_max', e.target.value ? Number(e.target.value) : undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
/>
</div>
</div>
{/* Sort */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Sort By</label>
<select
value={filters.sort_by || 'name'}
onChange={e => updateFilter('sort_by', e.target.value)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="name">Name</option>
<option value="cmc">CMC</option>
<option value="tcg_price">Price</option>
<option value="rarity">Rarity</option>
<option value="set_code">Set</option>
<option value="added_date">Date Added</option>
</select>
</div>
</div>
)}
{/* Scanner */}
<ScannerPanel onCardAdded={fetchCards} />
</div>
{/* Main content: card grid/list */}
<div className="flex-1 min-w-0">
{/* View toggle + pagination */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-md transition-colors ${viewMode === 'grid' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
>
<Grid3x3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-1.5 rounded-md transition-colors ${viewMode === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
>
<List className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span>Page {filters.page} of {totalPages || 1}</span>
<button
onClick={() => setFilters(p => ({ ...p, page: Math.max(1, (p.page || 1) - 1) }))}
disabled={!filters.page || filters.page <= 1}
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setFilters(p => ({ ...p, page: Math.min(totalPages, (p.page || 1) + 1) }))}
disabled={(filters.page || 1) >= totalPages}
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<Spinner size="lg" className="text-accent-blue" />
</div>
) : cards.length === 0 ? (
<EmptyState
icon={Package}
title="No cards found"
description="Try adjusting your search or filters, or import a collection."
/>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{cards.map(card => (
<div
key={card.id}
className="group cursor-pointer"
onClick={() => setSelectedCard(card)}
>
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
<div className="mt-1.5 px-0.5">
<p className="text-xs font-medium text-text-primary truncate group-hover:text-accent-blue transition-colors">{card.name}</p>
<div className="flex items-center justify-between">
<span className="text-[10px] text-text-tertiary">{card.set_code.toUpperCase()}</span>
{card.tcg_price != null && (
<span className="text-[10px] text-accent-teal font-medium">${card.tcg_price.toFixed(2)}</span>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary text-left">
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Set</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Qty</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{cards.map(card => (
<tr key={card.id} className="hover:bg-bg-hover cursor-pointer transition-colors" onClick={() => setSelectedCard(card)}>
<td className="px-4 py-2.5">
<span className="font-medium text-text-primary">{card.name}</span>
</td>
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.type_line}</td>
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.set_code.toUpperCase()}</td>
<td className="px-4 py-2.5 text-text-secondary">{card.quantity}</td>
<td className="px-4 py-2.5 text-right text-accent-teal font-medium">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Card detail modal */}
{selectedCard && <CardDetailModal card={selectedCard} onClose={() => setSelectedCard(null)} />}
</div>
)
}
@@ -0,0 +1,967 @@
import { useState, useEffect } from 'react'
import {
Plus, Trash2, Download, ChevronRight,
BarChart3, Lightbulb, Layers, X, Library,
Globe, XCircle, ShoppingCart,
ChevronDown, ChevronUp, Sparkles
} from 'lucide-react'
import { Spinner, EmptyState, ManaSymbols, ColorDots } from '../components/common'
import { decksApi } from '../api'
import type {
Deck, DeckCard, DeckAnalysis,
EdhRecCard, EdhRecsResponse,
CollectionRecsResponse
} from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
// ── Role Badge ──────────────────────────────────────────────
const ROLE_COLORS: Record<string, string> = {
Ramp: 'bg-green-500/15 text-green-400 border-green-500/25',
Draw: 'bg-blue-500/15 text-blue-400 border-blue-500/25',
Removal: 'bg-red-500/15 text-red-400 border-red-500/25',
BoardWipe: 'bg-orange-500/15 text-orange-400 border-orange-500/25',
Tutor: 'bg-purple-500/15 text-purple-400 border-purple-500/25',
Protection: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/25',
Finisher: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/25',
Recursion: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/25',
Other: 'bg-slate-500/15 text-slate-400 border-slate-500/25',
}
function RoleBadge({ role }: { role: string }) {
const colors = ROLE_COLORS[role] || ROLE_COLORS.Other
return (
<span className={'text-[10px] font-medium px-1.5 py-0.5 rounded border ' + colors}>
{role}
</span>
)
}
// ── Deck Card List ────────────────────────────────────────────
function DeckCardList({ deckId, cards, onUpdate }: { deckId: number; cards: DeckCard[]; onUpdate: () => void }) {
const [removing, setRemoving] = useState<number | null>(null)
// Group cards by category
const grouped = cards.reduce<Record<string, DeckCard[]>>((acc, c) => {
const cat = c.category || 'Other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(c)
return acc
}, {})
const categoryOrder = ['Commander', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
const sortedCategories = Object.keys(grouped).sort((a, b) => {
const ia = categoryOrder.indexOf(a)
const ib = categoryOrder.indexOf(b)
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
async function handleRemove(cardId: number) {
setRemoving(cardId)
try {
await decksApi.removeCardFromDeck(deckId, cardId)
onUpdate()
} catch { /* ignore */ }
setRemoving(null)
}
return (
<div className="space-y-4">
{sortedCategories.map(cat => (
<div key={cat}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">
{cat} ({grouped[cat].length})
</h4>
<div className="space-y-1">
{grouped[cat].map(card => (
<div key={card.id} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover group transition-colors">
<span className="text-xs text-text-tertiary w-5 text-right">{card.quantity}x</span>
<ManaSymbols cost={card.mana_cost} />
<span className="flex-1 text-sm text-text-primary truncate">{card.name}</span>
{card.tcg_price != null && (
<span className="text-xs text-accent-teal">${card.tcg_price.toFixed(2)}</span>
)}
{!card.is_commander && (
<button
onClick={() => handleRemove(card.id)}
className="opacity-0 group-hover:opacity-100 p-1 text-text-tertiary hover:text-accent-red transition-all"
>
{removing === card.id ? <Spinner size="sm" /> : <Trash2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
))}
</div>
</div>
))}
</div>
)
}
// ── Mana Curve Chart ──────────────────────────────────────────
function ManaCurveChart({ analysis }: { analysis: DeckAnalysis }) {
const data = Object.entries(analysis.mana_curve)
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
return (
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} barSize={24}>
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 11 }} />
<YAxis hide />
<Tooltip
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
labelStyle={{ color: '#9ba1b8' }}
itemStyle={{ color: '#e8eaf0' }}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{data.map((_, i) => (
<Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
// ── EDHREC Recommendations Sub-tab ──────────────────────────
function EdhRecTab({ deckId, onAddedCards }: { deckId: number; onAddedCards: () => void }) {
const [data, setData] = useState<EdhRecsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [onlyOwned, setOnlyOwned] = useState(false)
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
const [addingCards, setAddingCards] = useState<Set<string>>(new Set())
const [bulkAdding, setBulkAdding] = useState(false)
const [hoveredCard, setHoveredCard] = useState<EdhRecCard | null>(null)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
async function fetchRecs() {
setLoading(true)
setError(null)
try {
const result = await decksApi.getEdhRecs(deckId, onlyOwned)
setData(result)
} catch (e: any) {
setError(e?.message || 'Failed to load EDHREC recommendations')
}
setLoading(false)
}
useEffect(() => { fetchRecs() }, [deckId, onlyOwned])
function toggleSelect(name: string) {
setSelectedCards(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
function selectAll() {
if (!data) return
setSelectedCards(new Set(data.recommendations.map(r => r.name)))
}
function selectNone() { setSelectedCards(new Set()) }
function selectOwned() {
if (!data) return
setSelectedCards(new Set(data.recommendations.filter(r => r.owned).map(r => r.name)))
}
async function handleAddSingle(name: string) {
setAddingCards(prev => new Set(prev).add(name))
try {
await decksApi.bulkAddRecommended(deckId, [name])
onAddedCards()
// Remove from list
if (data) {
setData({
...data,
recommendations: data.recommendations.filter(r => r.name !== name),
total: data.total - 1,
})
}
selectedCards.delete(name)
setSelectedCards(new Set(selectedCards))
} catch { /* ignore */ }
setAddingCards(prev => { const n = new Set(prev); n.delete(name); return n })
}
async function handleBulkAdd() {
if (selectedCards.size === 0) return
setBulkAdding(true)
try {
await decksApi.bulkAddRecommended(deckId, Array.from(selectedCards))
onAddedCards()
if (data) {
setData({
...data,
recommendations: data.recommendations.filter(r => !selectedCards.has(r.name)),
total: data.total - selectedCards.size,
})
}
setSelectedCards(new Set())
} catch { /* ignore */ }
setBulkAdding(false)
}
function toggleGroup(groupName: string) {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(groupName)) next.delete(groupName)
else next.add(groupName)
return next
})
}
// Group recommendations by role
function groupByRole(recs: EdhRecCard[]): Record<string, EdhRecCard[]> {
const groups: Record<string, EdhRecCard[]> = {}
for (const rec of recs) {
const role = rec.role || 'Other'
if (!groups[role]) groups[role] = []
groups[role].push(rec)
}
return groups
}
if (loading) {
return <div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
}
if (error) {
return (
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-5 text-center">
<XCircle className="w-6 h-6 text-accent-red mx-auto mb-2" />
<p className="text-sm text-accent-red font-medium">{error}</p>
<button onClick={fetchRecs} className="mt-3 px-4 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
Retry
</button>
</div>
)
}
if (!data || data.recommendations.length === 0) {
return <EmptyState icon={Globe} title="No EDHREC data" description="No recommendations found for this commander on EDHREC." />
}
const grouped = groupByRole(data.recommendations)
const roleOrder = ['Ramp', 'Draw', 'Removal', 'BoardWipe', 'Tutor', 'Protection', 'Finisher', 'Recursion', 'Other']
const sortedRoles = Object.keys(grouped).sort((a, b) => {
const ia = roleOrder.indexOf(a)
const ib = roleOrder.indexOf(b)
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
const ownedCount = data.recommendations.filter(r => r.owned).length
const missingCount = data.recommendations.length - ownedCount
return (
<div className="space-y-4">
{/* Header bar */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm">
<Sparkles className="w-4 h-4 text-accent-blue" />
<span className="text-text-primary font-medium">{data.commander}</span>
<span className="text-text-tertiary">via {data.source}</span>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-accent-green/15 text-accent-green">{ownedCount} owned</span>
<span className="text-xs px-2 py-0.5 rounded bg-bg-tertiary text-text-tertiary">{missingCount} missing</span>
</div>
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer select-none">
<input
type="checkbox"
checked={onlyOwned}
onChange={e => setOnlyOwned(e.target.checked)}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5"
/>
Show only owned
</label>
</div>
{/* Selection toolbar */}
<div className="flex items-center justify-between bg-bg-secondary rounded-xl border border-border-primary px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-xs text-text-tertiary">Select:</span>
<button onClick={selectAll} className="text-xs text-accent-blue hover:underline">All</button>
<span className="text-text-tertiary">/</span>
<button onClick={selectNone} className="text-xs text-accent-blue hover:underline">None</button>
<span className="text-text-tertiary">/</span>
<button onClick={selectOwned} className="text-xs text-accent-blue hover:underline">Owned</button>
{selectedCards.size > 0 && (
<span className="text-xs text-text-secondary ml-2">({selectedCards.size} selected)</span>
)}
</div>
<button
onClick={handleBulkAdd}
disabled={selectedCards.size === 0 || bulkAdding}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{bulkAdding ? <Spinner size="sm" /> : <ShoppingCart className="w-3.5 h-3.5" />}
Add {selectedCards.size > 0 ? selectedCards.size : ''} to Deck
</button>
</div>
{/* Grouped cards */}
{sortedRoles.map(role => {
const cards = grouped[role]
const isCollapsed = collapsedGroups.has(role)
return (
<div key={role} className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
{/* Group header */}
<button
onClick={() => toggleGroup(role)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
>
<div className="flex items-center gap-2">
<RoleBadge role={role} />
<span className="text-xs text-text-tertiary">({cards.length})</span>
</div>
{isCollapsed ? <ChevronDown className="w-4 h-4 text-text-tertiary" /> : <ChevronUp className="w-4 h-4 text-text-tertiary" />}
</button>
{/* Card list */}
{!isCollapsed && (
<div className="border-t border-border-primary divide-y divide-border-primary">
{cards.map(rec => (
<div
key={rec.name}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-hover transition-colors group relative"
onMouseEnter={() => setHoveredCard(rec)}
onMouseLeave={() => setHoveredCard(null)}
>
{/* Checkbox */}
<input
type="checkbox"
checked={selectedCards.has(rec.name)}
onChange={() => toggleSelect(rec.name)}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5 flex-shrink-0"
/>
{/* Card image mini */}
{rec.image_url && (
<div className="w-8 h-11 rounded overflow-hidden bg-bg-tertiary flex-shrink-0">
<img src={rec.image_url} alt={rec.name} className="w-full h-full object-cover" loading="lazy" />
</div>
)}
{/* Card info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{rec.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-text-tertiary truncate">{rec.type_line}</span>
{rec.roles.length > 0 && rec.roles.map(r => <RoleBadge key={r} role={r} />)}
</div>
</div>
{/* Owned badge */}
<span className={'text-xs px-2 py-0.5 rounded flex-shrink-0 ' + (rec.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary')}>
{rec.owned ? (rec.owned_qty + 'x owned') : 'Missing'}
</span>
{/* Add button */}
<button
onClick={e => { e.stopPropagation(); handleAddSingle(rec.name) }}
disabled={addingCards.has(rec.name)}
className="opacity-0 group-hover:opacity-100 px-2.5 py-1 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-all disabled:opacity-50 flex-shrink-0"
>
{addingCards.has(rec.name) ? <Spinner size="sm" /> : '+ Add'}
</button>
</div>
))}
</div>
)}
</div>
)
})}
{/* Hover preview */}
{hoveredCard && hoveredCard.image_url && (
<div className="fixed top-20 right-8 z-50 pointer-events-none animate-fade-in">
<img
src={hoveredCard.image_url}
alt={hoveredCard.name}
className="w-[244px] rounded-xl shadow-2xl shadow-black/60 border border-border-primary"
/>
</div>
)}
</div>
)
}
// ── Collection Recommendations Sub-tab ──────────────────────
function CollectionRecTab({ deckId, onAddedCards }: { deckId: number; onAddedCards: () => void }) {
const [data, setData] = useState<CollectionRecsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
const [addingCards, setAddingCards] = useState<Set<string>>(new Set())
const [bulkAdding, setBulkAdding] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
async function fetchRecs() {
setLoading(true)
setError(null)
try {
const result = await decksApi.getCollectionRecs(deckId)
setData(result)
} catch (e: any) {
setError(e?.message || 'Failed to load collection recommendations')
}
setLoading(false)
}
useEffect(() => { fetchRecs() }, [deckId])
function toggleSelect(name: string) {
setSelectedCards(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
function selectAll() {
if (!data) return
const all = new Set<string>()
Object.values(data.grouped).flat().forEach(c => all.add(c.name))
setSelectedCards(all)
}
function selectNone() { setSelectedCards(new Set()) }
async function handleAddSingle(name: string) {
setAddingCards(prev => new Set(prev).add(name))
try {
await decksApi.bulkAddRecommended(deckId, [name])
onAddedCards()
// Remove from groupings
if (data) {
const newGrouped = { ...data.grouped }
for (const key of Object.keys(newGrouped)) {
newGrouped[key] = newGrouped[key].filter(c => c.name !== name)
if (newGrouped[key].length === 0) delete newGrouped[key]
}
setData({ ...data, grouped: newGrouped, total: data.total - 1 })
}
selectedCards.delete(name)
setSelectedCards(new Set(selectedCards))
} catch { /* ignore */ }
setAddingCards(prev => { const n = new Set(prev); n.delete(name); return n })
}
async function handleBulkAdd() {
if (selectedCards.size === 0) return
setBulkAdding(true)
try {
await decksApi.bulkAddRecommended(deckId, Array.from(selectedCards))
onAddedCards()
if (data) {
const newGrouped = { ...data.grouped }
for (const key of Object.keys(newGrouped)) {
newGrouped[key] = newGrouped[key].filter(c => !selectedCards.has(c.name))
if (newGrouped[key].length === 0) delete newGrouped[key]
}
setData({ ...data, grouped: newGrouped, total: data.total - selectedCards.size })
}
setSelectedCards(new Set())
} catch { /* ignore */ }
setBulkAdding(false)
}
function toggleGroup(groupName: string) {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(groupName)) next.delete(groupName)
else next.add(groupName)
return next
})
}
if (loading) {
return <div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
}
if (error) {
return (
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-5 text-center">
<XCircle className="w-6 h-6 text-accent-red mx-auto mb-2" />
<p className="text-sm text-accent-red font-medium">{error}</p>
<button onClick={fetchRecs} className="mt-3 px-4 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
Retry
</button>
</div>
)
}
if (!data || data.total === 0) {
return <EmptyState icon={Library} title="No collection matches" description="No cards in your collection match this deck's needs." />
}
const typeOrder = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
const sortedTypes = Object.keys(data.grouped).sort((a, b) => {
const ia = typeOrder.indexOf(a)
const ib = typeOrder.indexOf(b)
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
return (
<div className="space-y-4">
{/* Shortfall alert */}
{data.shortfall_types.length > 0 && (
<div className="flex items-start gap-3 bg-accent-blue/8 border border-accent-blue/20 rounded-xl px-4 py-3">
<Lightbulb className="w-4 h-4 text-accent-blue mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs font-medium text-accent-blue">Deck needs more:</p>
<div className="flex flex-wrap gap-1.5 mt-1">
{data.shortfall_types.map(t => (
<span key={t} className="text-[10px] font-medium px-2 py-0.5 rounded bg-accent-blue/15 text-accent-blue border border-accent-blue/25">{t}</span>
))}
</div>
</div>
</div>
)}
{/* Selection toolbar */}
<div className="flex items-center justify-between bg-bg-secondary rounded-xl border border-border-primary px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-xs text-text-tertiary">Select:</span>
<button onClick={selectAll} className="text-xs text-accent-blue hover:underline">All</button>
<span className="text-text-tertiary">/</span>
<button onClick={selectNone} className="text-xs text-accent-blue hover:underline">None</button>
{selectedCards.size > 0 && (
<span className="text-xs text-text-secondary ml-2">({selectedCards.size} selected)</span>
)}
</div>
<button
onClick={handleBulkAdd}
disabled={selectedCards.size === 0 || bulkAdding}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{bulkAdding ? <Spinner size="sm" /> : <ShoppingCart className="w-3.5 h-3.5" />}
Add {selectedCards.size > 0 ? selectedCards.size : ''} to Deck
</button>
</div>
{/* Grouped cards */}
{sortedTypes.map(type => {
const cards = data.grouped[type]
const isCollapsed = collapsedGroups.has(type)
const isShortfall = data.shortfall_types.includes(type)
return (
<div key={type} className={'bg-bg-secondary rounded-xl border overflow-hidden ' + (isShortfall ? 'border-accent-blue/30' : 'border-border-primary')}>
{/* Group header */}
<button
onClick={() => toggleGroup(type)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-text-primary uppercase tracking-wider">{type}</span>
<span className="text-xs text-text-tertiary">({cards.length})</span>
{isShortfall && <span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-accent-blue/15 text-accent-blue border border-accent-blue/25">Shortfall</span>}
</div>
{isCollapsed ? <ChevronDown className="w-4 h-4 text-text-tertiary" /> : <ChevronUp className="w-4 h-4 text-text-tertiary" />}
</button>
{/* Card list */}
{!isCollapsed && (
<div className="border-t border-border-primary divide-y divide-border-primary">
{cards.map(rec => (
<div key={rec.scryfall_id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-hover transition-colors group">
{/* Checkbox */}
<input
type="checkbox"
checked={selectedCards.has(rec.name)}
onChange={() => toggleSelect(rec.name)}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5 flex-shrink-0"
/>
{/* Card image mini */}
{rec.image_url && (
<div className="w-8 h-11 rounded overflow-hidden bg-bg-tertiary flex-shrink-0">
<img src={rec.image_url} alt={rec.name} className="w-full h-full object-cover" loading="lazy" />
</div>
)}
{/* Card info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{rec.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-text-tertiary">{rec.cmc} CMC</span>
{rec.roles.length > 0 && rec.roles.map(r => <RoleBadge key={r} role={r} />)}
</div>
</div>
{/* Score */}
<span className="text-xs text-accent-teal font-medium flex-shrink-0">Score: {rec.score}</span>
{/* Owned qty */}
<span className="text-xs px-2 py-0.5 rounded bg-accent-green/15 text-accent-green flex-shrink-0">{rec.owned_qty}x</span>
{/* Add button */}
<button
onClick={e => { e.stopPropagation(); handleAddSingle(rec.name) }}
disabled={addingCards.has(rec.name)}
className="opacity-0 group-hover:opacity-100 px-2.5 py-1 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-all disabled:opacity-50 flex-shrink-0"
>
{addingCards.has(rec.name) ? <Spinner size="sm" /> : '+ Add'}
</button>
</div>
))}
</div>
)}
</div>
)
})}
</div>
)
}
// ── Deck Detail View ──────────────────────────────────────────
function DeckDetail({ deck, onBack }: { deck: Deck; onBack: () => void }) {
const [cards, setCards] = useState<DeckCard[]>([])
const [analysis, setAnalysis] = useState<DeckAnalysis | null>(null)
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState<'cards' | 'analysis' | 'recs'>('cards')
const [recSource, setRecSource] = useState<'edhrec' | 'collection'>('edhrec')
const [addingCard, setAddingCard] = useState('')
const [bulkText, setBulkText] = useState('')
const [showBulk, setShowBulk] = useState(false)
async function fetchAll() {
setLoading(true)
try {
const [c, a] = await Promise.all([
decksApi.getDeckCards(deck.id),
decksApi.getDeckAnalysis(deck.id),
])
setCards(c)
setAnalysis(a)
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => { fetchAll() }, [deck.id])
async function handleAddCard() {
if (!addingCard.trim()) return
try {
await decksApi.addCardToDeck(deck.id, { name: addingCard.trim() })
setAddingCard('')
fetchAll()
} catch { /* ignore */ }
}
async function handleBulkAdd() {
if (!bulkText.trim()) return
const cardLines = bulkText.split('\n').filter(l => l.trim())
const cardParsed = cardLines.map(l => {
const match = l.match(/^(\d+)x?\s+(.+)$/)
return match ? { name: match[2].trim(), quantity: Number(match[1]) } : { name: l.trim() }
})
try {
await decksApi.bulkAddCards(deck.id, cardParsed)
setBulkText('')
setShowBulk(false)
fetchAll()
} catch { /* ignore */ }
}
async function handleExportToSim() {
try {
await decksApi.exportDeckToSim(deck.id)
} catch { /* ignore */ }
}
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center gap-4">
<button onClick={onBack} className="p-2 rounded-lg hover:bg-bg-hover text-text-secondary transition-colors">
<ChevronRight className="w-5 h-5 rotate-180" />
</button>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-primary">{deck.name}</h2>
<div className="flex items-center gap-3 mt-1">
<span className="text-sm text-text-secondary">{deck.commander}</span>
<ColorDots colors={deck.color_identity} />
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
<span className="text-xs text-accent-teal">${deck.total_price.toFixed(2)}</span>
</div>
</div>
<button onClick={handleExportToSim}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
<Download className="w-3.5 h-3.5" />
Export to Sim
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-border-primary">
{(['cards', 'analysis', 'recs'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors capitalize ${
tab === t
? 'border-accent-blue text-accent-blue'
: 'border-transparent text-text-tertiary hover:text-text-secondary'
}`}
>
{t === 'recs' ? 'Recommendations' : t}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
) : tab === 'cards' ? (
<div className="space-y-4">
{/* Add card */}
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Add card by name..."
value={addingCard}
onChange={e => setAddingCard(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCard()}
className="flex-1 px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<button onClick={handleAddCard}
className="px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
Add
</button>
<button onClick={() => setShowBulk(!showBulk)}
className="px-3 py-2.5 bg-bg-tertiary text-text-secondary text-sm rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
<Layers className="w-4 h-4" />
</button>
</div>
{showBulk && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 animate-fade-in space-y-3">
<textarea
placeholder={"1x Sol Ring\n1x Command Tower\n2x Counterspell"}
value={bulkText}
onChange={e => setBulkText(e.target.value)}
rows={6}
className="w-full px-4 py-3 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
/>
<button onClick={handleBulkAdd}
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
Bulk Add
</button>
</div>
)}
<DeckCardList deckId={deck.id} cards={cards} onUpdate={fetchAll} />
</div>
) : tab === 'analysis' && analysis ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Stats */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-accent-blue" />
Deck Stats
</h3>
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'Cards', value: analysis.card_count },
{ label: 'Lands', value: analysis.land_count },
{ label: 'Creatures', value: analysis.creature_count },
{ label: 'Non-Creature', value: analysis.noncreature_count },
{ label: 'Avg CMC', value: analysis.avg_cmc.toFixed(2) },
{ label: 'Owned', value: `${analysis.owned_count} / ${analysis.card_count}` },
].map(stat => (
<div key={stat.label} className="px-3 py-2 rounded-lg bg-bg-tertiary">
<p className="text-xs text-text-tertiary">{stat.label}</p>
<p className="text-lg font-bold text-text-primary">{stat.value}</p>
</div>
))}
</div>
</div>
{/* Mana Curve */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
<h3 className="text-sm font-semibold text-text-primary">Mana Curve</h3>
<ManaCurveChart analysis={analysis} />
</div>
{/* Type Distribution */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-3 md:col-span-2">
<h3 className="text-sm font-semibold text-text-primary">Type Distribution</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(analysis.type_distribution).map(([type, count]) => (
<span key={type} className="px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs text-text-secondary">
{type}: <span className="font-semibold text-text-primary">{count}</span>
</span>
))}
</div>
</div>
</div>
) : tab === 'recs' ? (
<div className="space-y-4">
{/* Sub-tab toggle */}
<div className="flex items-center gap-1 bg-bg-tertiary rounded-lg p-1 w-fit">
<button
onClick={() => setRecSource('edhrec')}
className={'flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium rounded-md transition-all ' +
(recSource === 'edhrec'
? 'bg-accent-blue text-white shadow-sm'
: 'text-text-tertiary hover:text-text-secondary')}
>
<Globe className="w-3.5 h-3.5" />
EDHREC
</button>
<button
onClick={() => setRecSource('collection')}
className={'flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium rounded-md transition-all ' +
(recSource === 'collection'
? 'bg-accent-blue text-white shadow-sm'
: 'text-text-tertiary hover:text-text-secondary')}
>
<Library className="w-3.5 h-3.5" />
From Collection
</button>
</div>
{/* Sub-tab content */}
{recSource === 'edhrec' ? (
<EdhRecTab deckId={deck.id} onAddedCards={fetchAll} />
) : (
<CollectionRecTab deckId={deck.id} onAddedCards={fetchAll} />
)}
</div>
) : null}
</div>
)
}
// ── Main Decks Page ───────────────────────────────────────────
export function DecksPage() {
const [decks, setDecks] = useState<Deck[]>([])
const [loading, setLoading] = useState(true)
const [selectedDeck, setSelectedDeck] = useState<Deck | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [newDeckName, setNewDeckName] = useState('')
const [newCommander, setNewCommander] = useState('')
const [creating, setCreating] = useState(false)
async function fetchDecks() {
setLoading(true)
try {
const d = await decksApi.listDecks()
setDecks(d)
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => { fetchDecks() }, [])
async function handleCreate() {
if (!newDeckName.trim()) return
setCreating(true)
try {
const deck = await decksApi.createDeck({ name: newDeckName.trim(), commander: newCommander.trim() || undefined })
setNewDeckName('')
setNewCommander('')
setShowCreate(false)
setSelectedDeck(deck)
fetchDecks()
} catch { /* ignore */ }
setCreating(false)
}
async function handleDelete(deckId: number) {
try {
await decksApi.deleteDeck(deckId)
fetchDecks()
} catch { /* ignore */ }
}
if (selectedDeck) {
return (
<div className="p-6">
<DeckDetail deck={selectedDeck} onBack={() => { setSelectedDeck(null); fetchDecks() }} />
</div>
)
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Decks</h1>
<p className="text-sm text-text-secondary mt-0.5">{decks.length} deck{decks.length !== 1 ? 's' : ''}</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors"
>
<Plus className="w-4 h-4" />
New Deck
</button>
</div>
{/* Create form */}
{showCreate && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 animate-fade-in space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary">Create New Deck</h3>
<button onClick={() => setShowCreate(false)} className="text-text-tertiary hover:text-text-secondary">
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<input
type="text"
placeholder="Deck name"
value={newDeckName}
onChange={e => setNewDeckName(e.target.value)}
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<input
type="text"
placeholder="Commander (optional)"
value={newCommander}
onChange={e => setNewCommander(e.target.value)}
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<button onClick={handleCreate} disabled={creating}
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
{creating ? <Spinner size="sm" /> : 'Create'}
</button>
</div>
)}
{/* Deck list */}
{loading ? (
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
) : decks.length === 0 ? (
<EmptyState icon={Layers} title="No decks yet" description="Create your first deck to get started." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{decks.map(deck => (
<div
key={deck.id}
className="bg-bg-secondary rounded-xl border border-border-primary hover:border-border-secondary p-5 cursor-pointer transition-all group"
onClick={() => setSelectedDeck(deck)}
>
<div className="flex items-start justify-between mb-3">
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-text-primary truncate group-hover:text-accent-blue transition-colors">{deck.name}</h3>
<p className="text-sm text-text-secondary mt-0.5 truncate">{deck.commander}</p>
</div>
<button
onClick={e => { e.stopPropagation(); handleDelete(deck.id) }}
className="opacity-0 group-hover:opacity-100 p-1.5 text-text-tertiary hover:text-accent-red transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ColorDots colors={deck.color_identity} />
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
</div>
<span className="text-xs font-medium text-accent-teal">${deck.total_price.toFixed(2)}</span>
</div>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,284 @@
import { useState, useEffect } from 'react'
import {
Gamepad2, Play, Wifi, WifiOff, Settings,
AlertCircle, Trophy, Clock, Brain
} from 'lucide-react'
import { Spinner, StatusBadge } from '../components/common'
import { simApi, labApi } from '../api'
import { usePolling } from '../hooks/usePolling'
import type { SimStatus, SimResult, DeepSeekStatus, LabDeck } from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
export function SimulatorPage() {
const [decks, setDecks] = useState<LabDeck[]>([])
const [deckA, setDeckA] = useState('')
const [deckB, setDeckB] = useState('')
const [games, setGames] = useState(50)
const [useDeepSeek, setUseDeepSeek] = useState(false)
const [deepSeekDeck, setDeepSeekDeck] = useState('')
const [dsStatus, setDsStatus] = useState<DeepSeekStatus | null>(null)
const [running, setRunning] = useState(false)
const [simId, setSimId] = useState<string | null>(null)
const [simStatus, setSimStatus] = useState<SimStatus | null>(null)
const [result, setResult] = useState<SimResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [starting, setStarting] = useState(false)
// DS config
const [showDsConfig, setShowDsConfig] = useState(false)
const [dsModel, setDsModel] = useState('')
const [dsUrl, setDsUrl] = useState('')
useEffect(() => {
labApi.getLabDecks().then(setDecks).catch(() => {})
simApi.getDeepSeekStatus().then(s => {
setDsStatus(s)
if (s.model) setDsModel(s.model)
if (s.base_url) setDsUrl(s.base_url)
}).catch(() => {})
}, [])
// Poll while running
usePolling(async () => {
if (!simId) return
try {
const s = await simApi.getSimStatus(simId)
setSimStatus(s)
if (!s.running) {
const r = await simApi.getSimResult(simId)
setResult(r)
setRunning(false)
}
} catch { /* ignore */ }
}, 2000, running)
async function handleStart() {
if (!deckA || !deckB) { setError('Select both decks'); return }
setStarting(true)
setError(null)
setResult(null)
try {
let res
if (useDeepSeek && deepSeekDeck) {
res = await simApi.runDeepSeekSim({ deck_a: deckA, deck_b: deckB, games, deepseek_deck: deepSeekDeck })
} else {
res = await simApi.runSim({ deck_a: deckA, deck_b: deckB, games })
}
setSimId(res.sim_id)
setRunning(true)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start')
}
setStarting(false)
}
async function handleDsConnect() {
try {
const s = await simApi.connectDeepSeek()
setDsStatus(s)
} catch { /* ignore */ }
}
async function handleDsConfigure() {
try {
const s = await simApi.configureDeepSeek({
model: dsModel || undefined,
base_url: dsUrl || undefined,
})
setDsStatus(s)
setShowDsConfig(false)
} catch { /* ignore */ }
}
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Gamepad2 className="w-6 h-6 text-accent-purple" />
Simulator
</h1>
<p className="text-sm text-text-secondary mt-0.5">Run head-to-head deck simulations</p>
</div>
{/* DeepSeek status */}
<div className="flex items-center gap-3">
<StatusBadge
variant={dsStatus?.connected ? 'success' : 'warning'}
label={dsStatus?.connected ? `DeepSeek: ${dsStatus.model}` : 'DeepSeek: Disconnected'}
/>
<button onClick={handleDsConnect}
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
{dsStatus?.connected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
{dsStatus?.connected ? 'Reconnect' : 'Connect'}
</button>
<button onClick={() => setShowDsConfig(!showDsConfig)}
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
<Settings className="w-3 h-3" />
Configure
</button>
</div>
{showDsConfig && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 max-w-md animate-fade-in space-y-3">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Brain className="w-4 h-4 text-accent-purple" />
DeepSeek Configuration
</h3>
<input
type="text"
placeholder="Model name"
value={dsModel}
onChange={e => setDsModel(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<input
type="text"
placeholder="Base URL (e.g. http://192.168.0.122:1234)"
value={dsUrl}
onChange={e => setDsUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<button onClick={handleDsConfigure}
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
Save
</button>
</div>
)}
{/* Sim config */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 max-w-2xl">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck A</label>
<select
value={deckA}
onChange={e => setDeckA(e.target.value)}
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value="">Select deck...</option>
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
</select>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck B</label>
<select
value={deckB}
onChange={e => setDeckB(e.target.value)}
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value="">Select deck...</option>
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
</select>
</div>
</div>
<div className="flex items-center gap-6">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
<input
type="number" min={1} max={1000}
value={games}
onChange={e => setGames(Number(e.target.value))}
className="mt-1 w-24 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer mt-5">
<input type="checkbox" checked={useDeepSeek} onChange={e => setUseDeepSeek(e.target.checked)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-purple focus:ring-accent-purple/30" />
<span className="text-sm text-text-secondary">Use DeepSeek AI pilot</span>
</label>
{useDeepSeek && (
<div className="mt-5">
<select
value={deepSeekDeck}
onChange={e => setDeepSeekDeck(e.target.value)}
className="px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value="">AI pilots which deck?</option>
{[deckA, deckB].filter(Boolean).map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
)}
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4" />{error}
</div>
)}
{running ? (
<div className="flex items-center gap-3">
<Spinner size="md" className="text-accent-blue" />
<div>
<p className="text-sm text-text-primary">Simulation running...</p>
{simStatus && (
<p className="text-xs text-text-tertiary">
{simStatus.games_completed ?? 0} / {simStatus.total_games ?? games} games
</p>
)}
</div>
</div>
) : (
<button onClick={handleStart} disabled={starting || !deckA || !deckB}
className="flex items-center gap-2 px-5 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-semibold rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-50">
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Run Simulation
</button>
)}
</div>
{/* Results */}
{result && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Trophy className="w-4 h-4 text-accent-amber" />
Results
</h3>
<span className="text-xs text-text-tertiary flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{(result.duration_ms / 1000).toFixed(1)}s · {result.total_games} games
</span>
</div>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={result.decks.map(d => ({ name: d.name, win_rate: Math.round(d.win_rate * 100) }))} barSize={40}>
<XAxis dataKey="name" tick={{ fill: '#9ba1b8', fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
formatter={(value: unknown) => [`${value}%`, 'Win Rate']} />
<Bar dataKey="win_rate" radius={[4, 4, 0, 0]}>
{result.decks.map((d, i) => (
<Cell key={i} fill={d.win_rate === Math.max(...result.decks.map(x => x.win_rate)) ? '#4ade80' : '#4f6ef7'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-2 gap-4">
{result.decks.map(d => (
<div key={d.name} className="px-4 py-3 rounded-lg bg-bg-tertiary">
<p className="text-sm font-semibold text-text-primary">{d.name}</p>
<div className="grid grid-cols-3 gap-2 mt-2">
<div>
<p className="text-[10px] text-text-tertiary uppercase">Wins</p>
<p className="text-lg font-bold text-accent-green">{d.wins}</p>
</div>
<div>
<p className="text-[10px] text-text-tertiary uppercase">Win Rate</p>
<p className="text-lg font-bold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-[10px] text-text-tertiary uppercase">Avg Turn</p>
<p className="text-lg font-bold text-text-primary">{d.avg_turn.toFixed(1)}</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,822 @@
import { useState, useEffect, useCallback } from 'react'
import {
Brain, Play, RotateCw, Database, Trophy, AlertCircle,
Activity, Zap, Swords, FileText, Cpu, CheckCircle,
ChevronDown, ChevronUp, Settings
} from 'lucide-react'
import { Spinner, StatusBadge } from '../components/common'
import { trainingApi } from '../api'
import { usePolling } from '../hooks/usePolling'
import type {
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
TournamentStatus, MLModelInfo
} from '../types'
// ── Progress Bar ──────────────────────────────────────────────
function ProgressBar({ value, max, label, color = 'bg-accent-blue' }: { value: number; max: number; label: string; color?: string }) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0
return (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-secondary">{label}</span>
<span className="text-text-tertiary">{value} / {max} ({pct.toFixed(0)}%)</span>
</div>
<div className="w-full h-2 bg-bg-tertiary rounded-full overflow-hidden">
<div className={'h-full rounded-full transition-all duration-500 ' + color} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
// ── Phase Badge ──────────────────────────────────────────────
const PHASE_STYLES: Record<string, string> = {
idle: 'bg-bg-tertiary text-text-tertiary',
starting: 'bg-accent-blue/15 text-accent-blue animate-pulse',
building: 'bg-accent-purple/15 text-accent-purple animate-pulse',
training: 'bg-accent-blue/15 text-accent-blue animate-pulse',
evaluating: 'bg-accent-teal/15 text-accent-teal animate-pulse',
running: 'bg-accent-amber/15 text-accent-amber animate-pulse',
done: 'bg-accent-green/15 text-accent-green',
error: 'bg-accent-red/15 text-accent-red',
}
function PhaseBadge({ phase }: { phase: string }) {
const cls = PHASE_STYLES[phase] || PHASE_STYLES.idle
return (
<span className={'text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded ' + cls}>
{phase}
</span>
)
}
// ── Metric Card ──────────────────────────────────────────────
function MetricCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
<p className="text-[10px] text-text-tertiary">{label}</p>
<p className={'text-sm font-bold ' + (accent || 'text-text-primary')}>{value}</p>
</div>
)
}
// ── Collapsible Section ─────────────────────────────────────
function Section({ title, icon, children, defaultOpen = true, color = 'text-accent-blue' }: {
title: string; icon: React.ReactNode; children: React.ReactNode; defaultOpen?: boolean; color?: string
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-bg-hover transition-colors"
>
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<span className={color}>{icon}</span>
{title}
</h3>
{open ? <ChevronUp className="w-4 h-4 text-text-tertiary" /> : <ChevronDown className="w-4 h-4 text-text-tertiary" />}
</button>
{open && <div className="px-5 pb-5 space-y-4 border-t border-border-primary pt-4">{children}</div>}
</div>
)
}
// ══════════════════════════════════════════════════════════════
// Main Training Page
// ══════════════════════════════════════════════════════════════
export function TrainingPage() {
// Status states
const [mlStatus, setMlStatus] = useState<MLStatus | null>(null)
const [dataStatus, setDataStatus] = useState<MLDataStatus | null>(null)
const [modelInfo, setModelInfo] = useState<MLModelInfo | null>(null)
const [trainStatus, setTrainStatus] = useState<MLTrainStatus | null>(null)
const [ppoStatus, setPpoStatus] = useState<PPOTrainStatus | null>(null)
const [tournStatus, setTournStatus] = useState<TournamentStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Supervised training config
const [epochs, setEpochs] = useState(50)
const [lr, setLr] = useState(0.001)
const [batchSize, setBatchSize] = useState(256)
const [patience, setPatience] = useState(10)
const [rebuildDataset, setRebuildDataset] = useState(true)
// PPO config
const [ppoIterations, setPpoIterations] = useState(100)
const [ppoEpisodes, setPpoEpisodes] = useState(64)
const [ppoEpochs, setPpoEpochs] = useState(4)
const [ppoBatchSize, setPpoBatchSize] = useState(256)
const [ppoLr, setPpoLr] = useState(0.0003)
const [ppoClipEps, setPpoClipEps] = useState(0.2)
const [ppoEntropy, setPpoEntropy] = useState(0.01)
const [ppoOpponent, setPpoOpponent] = useState('heuristic')
const [ppoPlaystyle, _setPpoPlaystyle] = useState('midrange') // eslint-disable-line
// Tournament config
const [tournEpisodes, setTournEpisodes] = useState(50)
const [tournPlaystyle, setTournPlaystyle] = useState('midrange')
// Action states
const [startingTrain, setStartingTrain] = useState(false)
const [startingPpo, setStartingPpo] = useState(false)
const [startingTourn, setStartingTourn] = useState(false)
const [reloading, setReloading] = useState(false)
const [showSupAdvanced, setShowSupAdvanced] = useState(false)
const [showPpoAdvanced, setShowPpoAdvanced] = useState(false)
// ── Initial Load ──────────────────────────────────────────
const loadAll = useCallback(async () => {
try {
const [ml, data, model, train, ppo, tourn] = await Promise.all([
trainingApi.getMLStatus().catch(() => null),
trainingApi.getDataStatus().catch(() => null),
trainingApi.getModelInfo().catch(() => null),
trainingApi.getTrainStatus().catch(() => null),
trainingApi.getPPOStatus().catch(() => null),
trainingApi.getTournamentStatus().catch(() => null),
])
if (ml) setMlStatus(ml)
if (data) setDataStatus(data)
if (model) setModelInfo(model)
if (train) setTrainStatus(train)
if (ppo) setPpoStatus(ppo)
if (tourn) setTournStatus(tourn)
} catch { /* ignore */ }
setLoading(false)
}, [])
useEffect(() => { loadAll() }, [loadAll])
// ── Polling ───────────────────────────────────────────────
const isTraining = trainStatus?.running || false
const isPpo = ppoStatus?.running || false
const isTourn = tournStatus?.running || false
const anyRunning = isTraining || isPpo || isTourn
usePolling(async () => {
try {
const s = await trainingApi.getTrainStatus()
setTrainStatus(s)
if (!s.running && s.phase === 'done') {
// Refresh model info + data when training completes
const [model, data] = await Promise.all([
trainingApi.getModelInfo().catch(() => null),
trainingApi.getDataStatus().catch(() => null),
])
if (model) setModelInfo(model)
if (data) setDataStatus(data)
}
} catch { /* ignore */ }
}, 2000, isTraining)
usePolling(async () => {
try {
const s = await trainingApi.getPPOStatus()
setPpoStatus(s)
if (!s.running && s.phase === 'done') {
const [model, data] = await Promise.all([
trainingApi.getModelInfo().catch(() => null),
trainingApi.getDataStatus().catch(() => null),
])
if (model) setModelInfo(model)
if (data) setDataStatus(data)
}
} catch { /* ignore */ }
}, 2000, isPpo)
usePolling(async () => {
try {
const s = await trainingApi.getTournamentStatus()
setTournStatus(s)
} catch { /* ignore */ }
}, 2000, isTourn)
// ── Actions ───────────────────────────────────────────────
async function handleToggleLogging() {
try {
const newState = !(mlStatus?.ml_logging_enabled ?? false)
await trainingApi.toggleML(newState)
const ml = await trainingApi.getMLStatus()
setMlStatus(ml)
} catch { /* ignore */ }
}
async function handleReloadModel() {
setReloading(true)
try {
const res = await trainingApi.reloadModel()
setModelInfo(res.status)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to reload model')
}
setReloading(false)
}
async function handleStartTrain() {
setStartingTrain(true)
setError(null)
try {
await trainingApi.startTraining({ epochs, lr, batchSize, patience, rebuildDataset })
const s = await trainingApi.getTrainStatus()
setTrainStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start training')
}
setStartingTrain(false)
}
async function handleStartPpo() {
setStartingPpo(true)
setError(null)
try {
await trainingApi.startPPOTraining({
iterations: ppoIterations,
episodesPerIter: ppoEpisodes,
ppoEpochs,
batchSize: ppoBatchSize,
lr: ppoLr,
clipEpsilon: ppoClipEps,
entropyCoeff: ppoEntropy,
opponent: ppoOpponent,
playstyle: ppoPlaystyle,
})
const s = await trainingApi.getPPOStatus()
setPpoStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start PPO training')
}
setStartingPpo(false)
}
async function handleStartTournament() {
setStartingTourn(true)
setError(null)
try {
await trainingApi.startTournament({ episodes: tournEpisodes, playstyle: tournPlaystyle })
const s = await trainingApi.getTournamentStatus()
setTournStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start tournament')
}
setStartingTourn(false)
}
// ── Render ────────────────────────────────────────────────
if (loading) {
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
}
const trainDataset = dataStatus?.datasets?.['train']
const hasDecisions = (mlStatus?.total_decisions ?? 0) > 0
const hasModel = modelInfo?.loaded === true
return (
<div className="p-6 space-y-5">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Brain className="w-6 h-6 text-accent-green" />
ML Training Pipeline
</h1>
<p className="text-sm text-text-secondary mt-0.5">Collect data, train models, and evaluate performance</p>
</div>
{/* Error banner */}
{error && (
<div className="flex items-center gap-2 px-4 py-2 bg-accent-red/10 border border-accent-red/30 rounded-lg text-sm text-accent-red">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1">{error}</span>
<button onClick={() => setError(null)} className="text-accent-red/60 hover:text-accent-red text-xs">dismiss</button>
</div>
)}
{/* Pipeline overview bar */}
<div className="flex flex-wrap items-center gap-3 bg-bg-secondary rounded-xl border border-border-primary px-5 py-3">
<div className="flex items-center gap-2">
<div className={'w-2 h-2 rounded-full ' + (mlStatus?.ml_logging_enabled ? 'bg-accent-green' : 'bg-text-tertiary')} />
<span className="text-xs text-text-secondary">Logging {mlStatus?.ml_logging_enabled ? 'On' : 'Off'}</span>
</div>
<span className="text-border-primary">|</span>
<span className="text-xs text-text-secondary">{mlStatus?.total_decisions ?? 0} decisions</span>
<span className="text-border-primary">|</span>
<span className="text-xs text-text-secondary">{trainDataset ? `${trainDataset.samples.toLocaleString()} training samples` : 'No dataset'}</span>
<span className="text-border-primary">|</span>
<span className="text-xs text-text-secondary">{(dataStatus?.checkpoints?.length ?? 0)} checkpoints</span>
<span className="text-border-primary">|</span>
<StatusBadge
variant={hasModel ? 'success' : 'neutral'}
label={hasModel ? `Model on ${modelInfo?.device ?? '?'}` : 'No model'}
/>
{anyRunning && (
<>
<span className="text-border-primary">|</span>
<span className="text-xs font-medium text-accent-blue animate-pulse">Pipeline running...</span>
</>
)}
</div>
{/* ═══ Phase 1: Data Collection ═══ */}
<Section title="Phase 1 — Data Collection" icon={<FileText className="w-4 h-4" />} color="text-accent-teal">
<div className="flex items-center justify-between">
<p className="text-xs text-text-secondary">
Run batch simulations with ML logging enabled to collect decision data.
</p>
<button
onClick={handleToggleLogging}
className={'flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ' +
(mlStatus?.ml_logging_enabled
? 'bg-accent-green/15 text-accent-green border-accent-green/30 hover:bg-accent-green/25'
: 'bg-bg-tertiary text-text-secondary border-border-primary hover:bg-bg-hover'
)}
>
<div className={'w-2 h-2 rounded-full ' + (mlStatus?.ml_logging_enabled ? 'bg-accent-green' : 'bg-text-tertiary')} />
{mlStatus?.ml_logging_enabled ? 'Logging Enabled' : 'Enable Logging'}
</button>
</div>
{/* Decision files */}
{(dataStatus?.decisionFiles?.length ?? 0) > 0 ? (
<div className="space-y-1">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Decision Files</h4>
<div className="max-h-36 overflow-y-auto space-y-1">
{dataStatus!.decisionFiles.map(f => (
<div key={f.name} className="flex items-center justify-between px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs">
<span className="text-text-secondary font-mono truncate">{f.name}</span>
<div className="flex items-center gap-3 text-text-tertiary">
<span>{f.decisions.toLocaleString()} decisions</span>
<span>{(f.size / 1024).toFixed(0)} KB</span>
</div>
</div>
))}
</div>
</div>
) : (
<div className="text-xs text-text-tertiary italic">No decision files yet. Run batch sims with logging enabled.</div>
)}
</Section>
{/* ═══ Phase 2: Dataset & Checkpoints ═══ */}
<Section title="Phase 2 — Dataset & Model Artifacts" icon={<Database className="w-4 h-4" />} color="text-accent-purple">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Dataset splits */}
<div className="space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Dataset Splits</h4>
{Object.keys(dataStatus?.datasets ?? {}).length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{(['train', 'val', 'test'] as const).map(split => {
const ds = dataStatus?.datasets?.[split]
return (
<div key={split} className="px-3 py-2 bg-bg-tertiary rounded-lg">
<p className="text-[10px] text-text-tertiary uppercase">{split}</p>
{ds ? (
<>
<p className="text-sm font-bold text-text-primary">{ds.samples.toLocaleString()}</p>
<p className="text-[10px] text-text-tertiary">{ds.features} features</p>
</>
) : (
<p className="text-xs text-text-tertiary italic">Not built</p>
)}
</div>
)
})}
</div>
) : (
<p className="text-xs text-text-tertiary italic">No dataset built yet. Training will build it automatically.</p>
)}
</div>
{/* Checkpoints */}
<div className="space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Checkpoints</h4>
{(dataStatus?.checkpoints?.length ?? 0) > 0 ? (
<div className="max-h-28 overflow-y-auto space-y-1">
{dataStatus!.checkpoints.map(ckpt => (
<div key={ckpt.name} className="flex items-center justify-between px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs">
<span className="text-text-secondary font-mono truncate">{ckpt.name}</span>
<span className="text-text-tertiary">{(ckpt.size / 1024 / 1024).toFixed(1)} MB</span>
</div>
))}
</div>
) : (
<p className="text-xs text-text-tertiary italic">No checkpoints yet. Train a model to generate them.</p>
)}
</div>
</div>
{/* Model info + reload */}
<div className="flex items-center justify-between pt-2 border-t border-border-primary">
<div className="flex items-center gap-3">
<Cpu className="w-4 h-4 text-text-tertiary" />
<div className="text-xs">
{hasModel ? (
<span className="text-accent-green font-medium">
Model loaded on {modelInfo?.device} | {modelInfo?.input_dim} features | {modelInfo?.num_actions} actions
</span>
) : (
<span className="text-text-tertiary">No model loaded{modelInfo?.error ? ` — ${modelInfo.error}` : ''}</span>
)}
</div>
</div>
<button
onClick={handleReloadModel}
disabled={reloading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50"
>
{reloading ? <Spinner size="sm" /> : <RotateCw className="w-3 h-3" />}
Reload Model
</button>
</div>
{/* Eval results */}
{dataStatus?.evalResults && (
<div className="pt-2 border-t border-border-primary space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex items-center gap-2">
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
Latest Evaluation
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(dataStatus.evalResults).map(([k, v]) => (
<MetricCard
key={k}
label={k.replace(/_/g, ' ')}
value={typeof v === 'number' ? (k.includes('accuracy') || k.includes('rate') ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)) : String(v)}
accent={k.includes('accuracy') ? 'text-accent-green' : undefined}
/>
))}
</div>
</div>
)}
</Section>
{/* ═══ Phase 3: Supervised Training ═══ */}
<Section title="Phase 3 — Supervised Training" icon={<Activity className="w-4 h-4" />}>
{/* Config */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-tertiary block mb-1">Epochs</label>
<input type="number" min={1} max={1000} value={epochs} onChange={e => setEpochs(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Learning Rate</label>
<input type="number" min={0.00001} max={1} step={0.0001} value={lr} onChange={e => setLr(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
</div>
{/* Advanced toggle */}
<button onClick={() => setShowSupAdvanced(!showSupAdvanced)} className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
<Settings className="w-3 h-3" />
{showSupAdvanced ? 'Hide' : 'Show'} advanced options
</button>
{showSupAdvanced && (
<div className="grid grid-cols-3 gap-3 animate-fade-in">
<div>
<label className="text-xs text-text-tertiary block mb-1">Batch Size</label>
<input type="number" min={16} max={2048} value={batchSize} onChange={e => setBatchSize(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Early Stop Patience</label>
<input type="number" min={1} max={100} value={patience} onChange={e => setPatience(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer select-none">
<input type="checkbox" checked={rebuildDataset} onChange={e => setRebuildDataset(e.target.checked)}
disabled={isTraining}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5" />
Rebuild dataset
</label>
</div>
</div>
)}
</div>
{/* Training status / start button */}
{isTraining || (trainStatus?.phase && trainStatus.phase !== 'idle') ? (
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<PhaseBadge phase={trainStatus!.phase} />
<span className="text-xs text-text-secondary">{trainStatus!.message}</span>
</div>
{trainStatus!.started_at && (
<span className="text-[10px] text-text-tertiary">Started: {new Date(trainStatus!.started_at).toLocaleTimeString()}</span>
)}
</div>
{/* Progress bar — only in training phase */}
{(trainStatus!.phase === 'training' || trainStatus!.phase === 'done') && trainStatus!.total_epochs > 0 && (
<ProgressBar
value={trainStatus!.current_epoch}
max={trainStatus!.total_epochs}
label="Epoch progress"
/>
)}
{/* Live metrics during training */}
{trainStatus!.metrics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(trainStatus!.metrics).map(([k, v]) => {
if (typeof v !== 'number') return null
const isAcc = k.includes('accuracy') || k.includes('acc')
const isLoss = k.includes('loss')
return (
<MetricCard
key={k}
label={k.replace(/_/g, ' ')}
value={isAcc ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)}
accent={isAcc ? 'text-accent-green' : isLoss ? 'text-accent-amber' : undefined}
/>
)
})}
</div>
)}
{/* Final result */}
{trainStatus!.phase === 'done' && trainStatus!.result && (
<div className="pt-2 border-t border-border-primary space-y-2">
<h4 className="text-xs font-semibold text-accent-green flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5" /> Training Complete
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<MetricCard label="Device" value={trainStatus!.result.device} />
<MetricCard label="Checkpoint" value={trainStatus!.result.checkpoint.split('/').pop() || '—'} />
{trainStatus!.result.evaluation && Object.entries(trainStatus!.result.evaluation).map(([k, v]) => (
typeof v === 'number' ? (
<MetricCard key={k} label={k.replace(/_/g, ' ')}
value={k.includes('accuracy') ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)}
accent={k.includes('accuracy') ? 'text-accent-green' : undefined} />
) : null
))}
</div>
</div>
)}
{/* Error state */}
{trainStatus!.phase === 'error' && trainStatus!.error && (
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
<span className="text-xs text-accent-red">{trainStatus!.error}</span>
</div>
)}
</div>
) : (
<div className="flex items-center gap-3">
<button
onClick={handleStartTrain}
disabled={startingTrain || !hasDecisions || isPpo}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue/15 text-accent-blue text-sm font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{startingTrain ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Start Training
</button>
{!hasDecisions && (
<span className="text-xs text-text-tertiary italic">Enable logging and run batch sims first to generate decision data.</span>
)}
</div>
)}
</Section>
{/* ═══ Phase 4: PPO Reinforcement Learning ═══ */}
<Section title="Phase 4 — PPO Reinforcement Learning" icon={<Zap className="w-4 h-4" />} color="text-accent-purple" defaultOpen={false}>
<p className="text-xs text-text-secondary">
Fine-tune the supervised model using PPO self-play against an opponent policy.
</p>
{/* Config */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-tertiary block mb-1">Iterations</label>
<input type="number" min={1} max={1000} value={ppoIterations} onChange={e => setPpoIterations(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Opponent</label>
<select value={ppoOpponent} onChange={e => setPpoOpponent(e.target.value)}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50">
<option value="heuristic">Heuristic</option>
<option value="random">Random</option>
<option value="self">Self-Play</option>
</select>
</div>
</div>
<button onClick={() => setShowPpoAdvanced(!showPpoAdvanced)} className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
<Settings className="w-3 h-3" />
{showPpoAdvanced ? 'Hide' : 'Show'} advanced options
</button>
{showPpoAdvanced && (
<div className="grid grid-cols-3 gap-3 animate-fade-in">
<div>
<label className="text-xs text-text-tertiary block mb-1">Episodes / Iter</label>
<input type="number" min={1} max={512} value={ppoEpisodes} onChange={e => setPpoEpisodes(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">PPO Epochs</label>
<input type="number" min={1} max={20} value={ppoEpochs} onChange={e => setPpoEpochs(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Batch Size</label>
<input type="number" min={16} max={2048} value={ppoBatchSize} onChange={e => setPpoBatchSize(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Learning Rate</label>
<input type="number" min={0.000001} max={0.01} step={0.0001} value={ppoLr} onChange={e => setPpoLr(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Clip Epsilon</label>
<input type="number" min={0.05} max={0.5} step={0.05} value={ppoClipEps} onChange={e => setPpoClipEps(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Entropy Coeff</label>
<input type="number" min={0} max={0.1} step={0.005} value={ppoEntropy} onChange={e => setPpoEntropy(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
</div>
)}
{/* PPO running status */}
{isPpo || (ppoStatus?.phase && ppoStatus.phase !== 'idle') ? (
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
<div className="flex items-center gap-2">
<PhaseBadge phase={ppoStatus!.phase} />
<span className="text-xs text-text-secondary">{ppoStatus!.message}</span>
</div>
{(ppoStatus!.phase === 'training' || ppoStatus!.phase === 'done') && ppoStatus!.total_iterations > 0 && (
<ProgressBar
value={ppoStatus!.iteration}
max={ppoStatus!.total_iterations}
label="PPO iteration"
color="bg-accent-purple"
/>
)}
{ppoStatus!.metrics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{ppoStatus!.metrics.win_rate != null && (
<MetricCard label="Win Rate" value={`${(ppoStatus!.metrics.win_rate * 100).toFixed(1)}%`} accent="text-accent-green" />
)}
{ppoStatus!.metrics.avg_reward != null && (
<MetricCard label="Avg Reward" value={ppoStatus!.metrics.avg_reward.toFixed(3)} accent="text-accent-amber" />
)}
{ppoStatus!.metrics.policy_loss != null && (
<MetricCard label="Policy Loss" value={ppoStatus!.metrics.policy_loss.toFixed(4)} />
)}
{ppoStatus!.metrics.entropy != null && (
<MetricCard label="Entropy" value={ppoStatus!.metrics.entropy.toFixed(4)} />
)}
</div>
)}
{ppoStatus!.phase === 'done' && ppoStatus!.result && (
<div className="flex items-center gap-2 pt-2 border-t border-border-primary">
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
<span className="text-xs text-accent-green font-medium">
PPO Training Complete — Best Win Rate: {
typeof ppoStatus!.result.best_win_rate === 'number'
? `${(ppoStatus!.result.best_win_rate as number * 100).toFixed(1)}%`
: '—'
}
</span>
</div>
)}
{ppoStatus!.phase === 'error' && ppoStatus!.error && (
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
<span className="text-xs text-accent-red">{ppoStatus!.error}</span>
</div>
)}
</div>
) : (
<button
onClick={handleStartPpo}
disabled={startingPpo || isTraining || !hasModel}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-medium rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{startingPpo ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Start PPO Training
</button>
)}
{!hasModel && !(isPpo || (ppoStatus?.phase && ppoStatus.phase !== 'idle')) && (
<p className="text-xs text-text-tertiary italic">Train a supervised model first (Phase 3) before running PPO.</p>
)}
</Section>
{/* ═══ Phase 5: Tournament Evaluation ═══ */}
<Section title="Phase 5 — Tournament Evaluation" icon={<Swords className="w-4 h-4" />} color="text-accent-amber" defaultOpen={false}>
<p className="text-xs text-text-secondary">
Pit the trained model against heuristic, random, and other policy variants.
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-tertiary block mb-1">Episodes per Matchup</label>
<input type="number" min={10} max={1000} value={tournEpisodes} onChange={e => setTournEpisodes(Number(e.target.value))}
disabled={isTourn}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Playstyle</label>
<select value={tournPlaystyle} onChange={e => setTournPlaystyle(e.target.value)}
disabled={isTourn}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50">
<option value="midrange">Midrange</option>
<option value="aggro">Aggro</option>
<option value="control">Control</option>
<option value="combo">Combo</option>
</select>
</div>
</div>
{/* Tournament status */}
{isTourn || (tournStatus?.phase && tournStatus.phase !== 'idle') ? (
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
<div className="flex items-center gap-2">
<PhaseBadge phase={tournStatus!.phase} />
<span className="text-xs text-text-secondary">{tournStatus!.message}</span>
</div>
{tournStatus!.phase === 'error' && tournStatus!.error && (
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
<span className="text-xs text-accent-red">{tournStatus!.error}</span>
</div>
)}
</div>
) : (
<button
onClick={handleStartTournament}
disabled={startingTourn || isTraining || isPpo}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-amber/15 text-accent-amber text-sm font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{startingTourn ? <Spinner size="sm" /> : <Trophy className="w-4 h-4" />}
Start Tournament
</button>
)}
{/* Tournament results */}
{(tournStatus?.result?.players?.length ?? 0) > 0 && (
<div className="pt-2 border-t border-border-primary space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex items-center gap-2">
<Trophy className="w-3.5 h-3.5 text-accent-amber" />
Leaderboard
</h4>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary">
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium w-8">#</th>
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium">Policy</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Wins</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Games</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Win Rate</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{tournStatus!.result!.players
.sort((a, b) => b.win_rate - a.win_rate)
.map((p, i) => (
<tr key={p.name} className="hover:bg-bg-hover transition-colors">
<td className="px-3 py-2 text-text-tertiary text-xs">
{i === 0 ? <Trophy className="w-3.5 h-3.5 text-accent-amber" /> : i + 1}
</td>
<td className="px-3 py-2">
<span className="text-text-primary font-medium capitalize">{p.name}</span>
</td>
<td className="px-3 py-2 text-right text-text-secondary">{p.wins}</td>
<td className="px-3 py-2 text-right text-text-secondary">{p.games}</td>
<td className={'px-3 py-2 text-right font-semibold ' + (i === 0 ? 'text-accent-green' : 'text-accent-blue')}>
{(p.win_rate * 100).toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
</div>
)
}
@@ -0,0 +1,563 @@
// ═══════════════════════════════════════════════════════════
// Commander AI Lab — TypeScript Type Definitions
// Derived from the FastAPI backend's Pydantic models + SQLite schema
// ═══════════════════════════════════════════════════════════
// ── Cards & Collection ──────────────────────────────────────
export interface CollectionCard {
id: number
name: string
scryfall_id: string
set_code: string
set_name: string
collector_number: string
rarity: string
type_line: string
mana_cost: string
cmc: number
oracle_text: string
color_identity: string[]
quantity: number
foil_quantity: number
condition: string
tcg_price: number | null
foil_price: number | null
image_url: string
edhrec_rank: number | null
keywords: string
added_date: string
updated_date: string
}
export interface CollectionFilters {
q?: string
type_line?: string
color?: string
rarity?: string
set_code?: string
cmc_min?: number
cmc_max?: number
price_min?: number
price_max?: number
owned_only?: boolean
sort_by?: string
sort_dir?: 'asc' | 'desc'
page?: number
page_size?: number
}
export interface CollectionStats {
total_cards: number
total_unique: number
total_value: number
sets_count: number
}
export interface SetInfo {
code: string
name: string
card_count?: number
}
// ── Scan ─────────────────────────────────────────────────────
export interface ScanResult {
matches: ScanMatch[]
image_url?: string
}
export interface ScanMatch {
name: string
set_code: string
set_name: string
collector_number: string
scryfall_id: string
confidence: number
image_url: string
type_line: string
oracle_text: string
mana_cost: string
tcg_price: number | null
}
// ── Decks ────────────────────────────────────────────────────
export interface Deck {
id: number
name: string
commander: string
commander_scryfall_id?: string
commander_image_url?: string
color_identity: string[]
format: string
card_count: number
total_price: number
created_date: string
updated_date: string
}
export interface DeckCard {
id: number
deck_id: number
name: string
scryfall_id: string
quantity: number
is_commander: boolean
category: string
type_line: string
mana_cost: string
cmc: number
oracle_text: string
tcg_price: number | null
image_url: string
owned_qty: number
set_code: string
}
export interface DeckAnalysis {
card_count: number
land_count: number
creature_count: number
noncreature_count: number
avg_cmc: number
mana_curve: Record<number, number>
color_distribution: Record<string, number>
type_distribution: Record<string, number>
total_price: number
owned_count: number
missing_count: number
}
export interface DeckRecommendation {
name: string
scryfall_id: string
type_line: string
mana_cost: string
oracle_text: string
image_url: string
tcg_price: number | null
reason: string
score: number
owned: boolean
}
// ── EDHREC Recommendations ──────────────────────────────────
export interface EdhRecCard {
name: string
type_line: string
role: string
roles: string[]
inclusion_pct: number | null
synergy_score: number | null
owned: boolean
owned_qty: number
scryfall_id: string
image_url: string | null
}
export interface EdhRecsResponse {
commander: string
source: string
total: number
recommendations: EdhRecCard[]
}
// ── Collection Recommendations ──────────────────────────────
export interface CollectionRecCard {
id: number
scryfall_id: string
name: string
type_line: string
card_type: string
cmc: number
color_identity: string[]
owned_qty: number
roles: string[]
score: number
image_url: string | null
}
export interface CollectionRecsResponse {
shortfall_types: string[]
role_filter: string[]
grouped: Record<string, CollectionRecCard[]>
total: number
}
// ── V3 Deck Generator ────────────────────────────────────────
export interface DeckGenV3Request {
commander_name: string
strategy?: string
target_bracket: number
budget_usd?: number
budget_mode?: 'total' | 'per_card'
omit_cards?: string[]
use_collection?: boolean
run_substitution?: boolean
model?: string
deck_name?: string
}
export interface DeckGenV3Status {
initialized: boolean
pplx_configured: boolean
model: string | null
embeddings_loaded: boolean
embedding_cards: number
error: string | null
}
export interface DeckGenV3Card {
name: string
quantity: number
category: string
type_line: string
mana_cost: string
cmc: number
role: string
reason: string
tcg_price: number | null
image_url: string
owned: boolean
owned_qty: number
scryfall_id: string
is_substitute: boolean
original_name: string | null
substitution_reason: string | null
similarity_score: number | null
}
export interface DeckGenV3Result {
commander: {
name: string
image_url: string
type_line: string
color_identity: string[]
mana_cost: string
oracle_text: string
}
cards: DeckGenV3Card[]
stats: DeckAnalysis
strategy: string
bracket: number
model: string
substitution?: {
owned: number
substituted: number
missing: number
}
}
// ── Simulation ───────────────────────────────────────────────
export interface SimConfig {
deck_names: string[]
num_games: number
threads: number
use_deepseek?: boolean
}
export interface SimStatus {
running: boolean
sim_id: string
progress?: number
games_completed?: number
total_games?: number
}
export interface SimResult {
sim_id: string
decks: SimDeckResult[]
total_games: number
duration_ms: number
timestamp: string
}
export interface SimDeckResult {
name: string
wins: number
losses: number
win_rate: number
avg_turn: number
}
// ── Batch Sim (Lab) ──────────────────────────────────────────
export interface LabDeck {
name: string
source: string
path: string
card_count: number
}
export interface LabStartRequest {
decks: string[]
games: number
threads?: number
use_deepseek?: boolean
deepseek_deck?: string
}
export interface LabStatus {
running: boolean
run_id: string
games_completed: number
total_games: number
current_decks: string[]
error: string | null
}
export interface LabResult {
run_id: string
decks: LabDeckResult[]
total_games: number
timestamp: string
}
export interface LabDeckResult {
name: string
wins: number
games: number
win_rate: number
avg_turns: number
avg_life_remaining: number
}
export interface LabHistoryEntry {
run_id: string
timestamp: string
decks: string[]
total_games: number
}
export interface PreconDeck {
name: string
filename: string
release_date?: string
set_name?: string
installed: boolean
}
// ── Coach ────────────────────────────────────────────────────
export interface CoachStatus {
llmConnected: boolean
llmModel: string | null
llmModels: string[]
embeddingsLoaded: boolean
embeddingCards: number
deckReportsAvailable: number
error: string | null
}
export interface CoachDeck {
deck_id: number
deck_name: string
commander: string
card_count: number
has_report: boolean
report_count: number
last_report_date: string | null
}
export interface CoachGoals {
targetPowerLevel?: number | null
metaFocus?: string | null
budget?: string | null
focusAreas?: string[]
}
export interface CoachMessage {
role: 'user' | 'assistant'
content: string
}
export interface CoachChatResponse {
content: string
model: string
prompt_tokens: number
completion_tokens: number
}
export interface CoachSession {
sessionId: string
deckId: string
timestamp: string
summary: string
suggestedCuts: CoachSuggestedCut[]
suggestedAdds: CoachSuggestedAdd[]
heuristicHints: string[]
manaBaseAdvice: string | null
rawTextExplanation: string
modelUsed: string
promptTokens: number
completionTokens: number
goals: CoachGoals | null
}
export interface CoachSuggestedCut {
cardName: string
reason: string
replacementOptions: string[]
currentImpactScore: number
}
export interface CoachSuggestedAdd {
cardName: string
role: string
reason: string
synergyWith: string[]
estimatedManaValue: number | null
}
export interface CoachSessionSummary {
sessionId: string
deckId: string
timestamp: string
summary: string
cutsCount: number
addsCount: number
}
export interface CoachApplyResult {
cuts: { name: string; status: string }[]
adds: { name: string; scryfall_id?: string; status: string }[]
errors: { name: string; error: string }[]
total_cuts: number
total_adds: number
}
export interface CoachCardLikeResult {
name: string
similarity: number
types: string
mana_value: number
mana_cost: string
text: string
owned_qty: number
image_url: string | null
tcg_price: number | null
}
// ── ML Training ──────────────────────────────────────────────
/** GET /api/ml/status */
export interface MLStatus {
ml_logging_enabled: boolean
training_files: { file: string; decisions: number; size_kb: number }[]
total_decisions: number
total_files: number
}
/** GET /api/ml/data/status */
export interface MLDataStatus {
decisionFiles: { name: string; size: number; decisions: number }[]
totalDecisions: number
datasets: Record<string, { samples: number; features: number; size: number }>
checkpoints: { name: string; size: number; modified: string }[]
evalResults: Record<string, unknown> | null
policyLoaded: boolean
}
/** GET /api/ml/train/status */
export interface MLTrainStatus {
running: boolean
progress: number
total_epochs: number
current_epoch: number
phase: 'idle' | 'starting' | 'building' | 'training' | 'evaluating' | 'done' | 'error'
message: string
metrics: Record<string, unknown> | null
result: {
training: Record<string, unknown>
evaluation: Record<string, unknown> | null
checkpoint: string
device: string
} | null
error: string | null
started_at: string | null
}
/** GET /api/ml/train/ppo/status */
export interface PPOTrainStatus {
running: boolean
iteration: number
total_iterations: number
phase: 'idle' | 'starting' | 'training' | 'done' | 'error'
message: string
metrics: {
avg_reward?: number
win_rate?: number
policy_loss?: number
value_loss?: number
entropy?: number
[key: string]: unknown
} | null
result: Record<string, unknown> | null
error: string | null
}
/** GET /api/ml/tournament/status */
export interface TournamentStatus {
running: boolean
phase: 'idle' | 'starting' | 'running' | 'done' | 'error'
message: string
result: TournamentResults | null
error: string | null
}
export interface TournamentResults {
players: TournamentPlayer[]
total_matches: number
matchups?: Record<string, unknown>
}
export interface TournamentPlayer {
name: string
wins: number
games: number
win_rate: number
}
/** GET /api/ml/model */
export interface MLModelInfo {
loaded: boolean
device?: string
input_dim?: number
num_actions?: number
checkpoint_path?: string
error?: string
torch_available?: boolean
}
// ── DeepSeek ─────────────────────────────────────────────────
export interface DeepSeekStatus {
connected: boolean
model: string | null
base_url: string
}
// ── Commander Search ─────────────────────────────────────────
export interface CommanderSearchResult {
name: string
type_line: string
color_identity: string[]
mana_cost: string
image_url: string
oracle_text: string
scryfall_id: string
in_collection: boolean
}
// ── Perplexity ───────────────────────────────────────────────
export interface PplxStatus {
configured: boolean
key_prefix: string
}
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
recharts: ['recharts'],
react: ['react', 'react-dom', 'react-router-dom'],
},
},
},
},
})
+1944 -163
View File
File diff suppressed because it is too large. Load diff
+8519 -233
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -240,12 +240,12 @@ def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
dist[action.value] = count
total = len(labels)
logger.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
logger.info("─" * 43)
print(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
print("─" * 43)
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
pct = (count / total * 100) if total > 0 else 0
logger.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
logger.info(f"{'─' * 43}")
logger.info(f" {'TOTAL':<23} {total:>8}")
print(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
print(f"{'─' * 43}")
print(f" {'TOTAL':<23} {total:>8}")
return dist
+1 -93
View File
@@ -155,25 +155,15 @@ class StateDimensions:
2 players = 28 global features
+ turn_number = 1
Total global = 29
Zone embedding boundary (architectural decision — Phase 5 follow-up):
Zone dims are **always** 768 per zone slot, zero-padded when a zone
is empty (no cards). The fixed 6144-dim zone block is the contract
between ForgeEpisodeGenerator (upstream) and the policy network
(downstream). All downstream consumers (PolicyNetwork, PPOTrainer,
dataset builder) depend on this shape. If a future change adds
attention pooling or variable-length zone representations, the
boundary must be updated here *and* in StateEncoder._encode_zones.
"""
card_embedding_dim: int = 768 # From mtg-embeddings
zone_pool_dim: int = 768 # Mean-pooled per zone (zero-vec when empty)
zone_pool_dim: int = 768 # Mean-pooled per zone
# Zones per player: hand, battlefield, graveyard, command_zone
zones_per_player: int = 4
num_players: int = 2 # 1v1
# Zone vectors: 4 zones × 2 players × 768 = 6144
# This is a fixed-size block; empty zones produce zero vectors.
total_zone_dim: int = 4 * 2 * 768 # 6144
# Global scalar features
@@ -266,85 +256,3 @@ class TrainingConfig:
log_every: int = 100 # Log every N batches
TRAINING_CONFIG = TrainingConfig()
# ══════════════════════════════════════════════════════════
# Distillation Loop Defaults (Phase 4)
# ══════════════════════════════════════════════════════════
@dataclass
class DistillationDefaults:
"""Default hyperparameters for the closed-loop distillation pipeline.
These are consumed by ml.training.distillation_loop.DistillationConfig
when no explicit override is provided.
Default weights: Forge-only (forge_weight=1.0, ppo_weight=0.0).
The baseline trains exclusively on Forge simulation data. Use
``MIXED_MODE_PRESETS`` below for blended Forge+PPO configurations.
"""
max_iterations: int = 10
convergence_window: int = 3
convergence_threshold: float = 0.01
# PPO self-play per generation
ppo_iterations: int = 50
ppo_episodes_per_iter: int = 64
ppo_lr: float = 3e-4
ppo_clip_epsilon: float = 0.2
ppo_entropy_coeff: float = 0.01
ppo_eval_episodes: int = 100
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
forge_weight: float = 1.0
ppo_weight: float = 0.0
min_reward_threshold: float = 0.0
# Quality gate thresholds
min_forge_accuracy: float = 0.35
max_accuracy_drop: float = 0.03
min_ppo_win_rate: float = 0.30
# Retry behaviour on gate failure
max_retries_per_generation: int = 2
retry_lr_factor: float = 0.5
retry_entropy_boost: float = 0.005
# Output paths
results_dir: str = "data/results"
history_dir: str = "data/results/distillation-history"
DISTILLATION_DEFAULTS = DistillationDefaults()
# ══════════════════════════════════════════════════════════
# Mixed-Mode Presets (Phase 5 follow-up)
# ══════════════════════════════════════════════════════════
# Pre-defined Forge/PPO weight combinations for common scenarios.
# Select a preset by name when starting a distillation run to
# override the Forge-only defaults without specifying raw weights.
#
# "forge_only" — baseline: 100% Forge data, ignore PPO
# "forge_90_10" — light PPO blending: 90% Forge, 10% PPO
# "forge_80_20" — heavier PPO blending: 80% Forge, 20% PPO
MIXED_MODE_PRESETS: Dict[str, Dict[str, float]] = {
"forge_only": {"forge_weight": 1.0, "ppo_weight": 0.0},
"forge_90_10": {"forge_weight": 1.0, "ppo_weight": 0.11}, # ~10% PPO
"forge_80_20": {"forge_weight": 1.0, "ppo_weight": 0.25}, # ~20% PPO
}
def get_preset_weights(preset_name: str) -> Dict[str, float]:
"""Look up a mixed-mode preset by name.
Returns a dict with ``forge_weight`` and ``ppo_weight`` keys.
Raises ``KeyError`` if the preset name is unknown.
"""
if preset_name not in MIXED_MODE_PRESETS:
raise KeyError(
f"Unknown preset '{preset_name}'. "
f"Available: {list(MIXED_MODE_PRESETS.keys())}"
)
return dict(MIXED_MODE_PRESETS[preset_name])
+61 -300
View File
@@ -1,17 +1,9 @@
"""
Commander AI Lab — Training Dataset Builder
════════════════════════════════════════════
Reads ML decision JSONL files, encodes states, labels actions,
and produces a training-ready dataset saved as NPZ.
Phase 2: Source Awareness (Issue #66)
- Scans both Forge sim files (ml-decisions-sim-*.jsonl) and
PPO self-play files (ml-decisions-ppo-*.jsonl)
- Configurable source_weights for sampling ratios
- min_reward_threshold filter for PPO data quality
- Each sample tagged with source for downstream analysis
Usage:
python -m ml.data.dataset_builder --results-dir results/ --output ml/models/dataset.npz
@@ -21,7 +13,6 @@ Output NPZ structure:
game_ids: object (N,) — game ID for each sample
outcomes: object (N,) — game outcome per sample
playstyles: object (N,) — deck archetype per sample
sources: object (N,) — data source ("forge" or "ppo")
"""
import argparse
@@ -29,9 +20,8 @@ import json
import logging
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Tuple
import numpy as np
@@ -50,167 +40,22 @@ logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
logger = logging.getLogger("ml.dataset")
# ---------------------------------------------------------------------------
# Configuration (Phase 2)
# ---------------------------------------------------------------------------
@dataclass
class DatasetConfig:
"""Configuration for dataset building with source awareness."""
results_dir: str = "results"
embeddings_dir: Optional[str] = None
max_samples: Optional[int] = None
# Source weights control sampling ratios between Forge and PPO data.
# Default: Forge-only (ppo=0.0). Use MIXED_MODE_PRESETS from
# ml.config.scope for blended configurations (e.g. 90/10, 80/20).
source_weights: Dict[str, float] = field(
default_factory=lambda: {"forge": 1.0, "ppo": 0.0}
)
# Minimum reward threshold for PPO data — only include decisions
# from games where the episode return exceeds this value.
# Set to None to disable filtering.
min_reward_threshold: Optional[float] = 0.0
# ---------------------------------------------------------------------------
# Source-aware file discovery (Phase 2 — Task 1)
# ---------------------------------------------------------------------------
def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
"""
Scan results directory for both Forge sim and PPO decision files.
Returns:
Dict mapping source name to list of JSONL file paths.
Keys: "forge" for ml-decisions-sim-*.jsonl
"ppo" for ml-decisions-ppo-*.jsonl
"""
results_path = Path(results_dir)
sources = {}
# Forge batch sim files
forge_files = sorted(results_path.glob("ml-decisions-sim-*.jsonl"))
if forge_files:
sources["forge"] = forge_files
# PPO self-play files (from Phase 1 DecisionExporter)
ppo_files = sorted(results_path.glob("ml-decisions-ppo-*.jsonl"))
if ppo_files:
sources["ppo"] = ppo_files
# Also pick up legacy files that don't have sim/ppo prefix
legacy_files = sorted(
f for f in results_path.glob("ml-decisions-*.jsonl")
if f not in set(forge_files + ppo_files)
)
if legacy_files:
sources.setdefault("forge", []).extend(legacy_files)
return sources
# ---------------------------------------------------------------------------
# PPO reward filter (Phase 2 — Task 3)
# ---------------------------------------------------------------------------
def _passes_reward_filter(
decision: dict,
source: str,
min_reward_threshold: Optional[float],
) -> bool:
"""
Filter PPO decisions by reward threshold.
Forge data always passes. PPO data must meet the threshold.
"""
if source != "ppo":
return True
if min_reward_threshold is None:
return True
episode_return = decision.get("episode_return", 0.0)
return episode_return >= min_reward_threshold
# ---------------------------------------------------------------------------
# Source-aware sampling (Phase 2 — Task 2)
# ---------------------------------------------------------------------------
def _apply_source_weights(
samples_by_source: Dict[str, List[dict]],
source_weights: Dict[str, float],
rng: np.random.Generator,
) -> List[dict]:
"""
Apply source weights to control the mixing ratio.
A weight of 1.0 keeps all samples from that source.
A weight of 0.5 randomly keeps ~50% of samples.
A weight > 1.0 oversamples (with replacement).
"""
combined = []
for source, samples in samples_by_source.items():
weight = source_weights.get(source, 1.0)
if weight <= 0:
logger.info(" Skipping %d %s samples (weight=0)", len(samples), source)
continue
if weight >= 1.0 and weight == 1.0:
combined.extend(samples)
elif weight < 1.0:
# Subsample
n_keep = max(1, int(len(samples) * weight))
indices = rng.choice(len(samples), size=n_keep, replace=False)
combined.extend(samples[i] for i in indices)
logger.info(
" Subsampled %s: %d → %d (weight=%.2f)",
source, len(samples), n_keep, weight,
)
else:
# Oversample (weight > 1.0)
n_target = int(len(samples) * weight)
indices = rng.choice(len(samples), size=n_target, replace=True)
combined.extend(samples[i] for i in indices)
logger.info(
" Oversampled %s: %d → %d (weight=%.2f)",
source, len(samples), n_target, weight,
)
return combined
# ---------------------------------------------------------------------------
# Main dataset builder (updated for Phase 2)
# ---------------------------------------------------------------------------
def build_dataset(
results_dir: str,
embeddings_dir: str = None,
max_samples: int = None,
source_weights: Dict[str, float] = None,
min_reward_threshold: float = 0.0,
) -> Dict[str, np.ndarray]:
"""
Build complete training dataset from all ML decision files.
Phase 2 additions:
- Scans both Forge and PPO JSONL files
- Applies source_weights for configurable mixing
- Filters PPO data by min_reward_threshold
- Tags each sample with its source
Args:
results_dir: Path to results/ directory containing JSONL files
results_dir: Path to results/ directory containing ml-decisions-*.jsonl
embeddings_dir: Path to embeddings/ directory (default: auto-detect)
max_samples: Cap total samples (None = unlimited)
source_weights: Dict of source → sampling weight (default: forge=1.0, ppo=0.5)
min_reward_threshold: Min episode_return for PPO data (default: 0.0)
Returns:
Dict with keys: states, labels, game_ids, outcomes, playstyles, sources
Dict with keys: states, labels, game_ids, outcomes, playstyles
"""
if source_weights is None:
source_weights = {"forge": 1.0, "ppo": 0.0}
# Load card embeddings
card_index = CardEmbeddingIndex(embeddings_dir)
if not card_index.load():
@@ -225,9 +70,10 @@ def build_dataset(
encoder = StateEncoder(card_index)
# --- Phase 2 Task 1: Discover files from both sources ---
source_files = _discover_jsonl_files(results_dir)
if not source_files:
# Find all JSONL files
results_path = Path(results_dir)
jsonl_files = sorted(results_path.glob("ml-decisions-*.jsonl"))
if not jsonl_files:
logger.error("No ML decision files found in %s", results_dir)
logger.error("Run batch simulations with --ml-log or enable ML logging in the UI first.")
raise RuntimeError(
@@ -235,114 +81,60 @@ def build_dataset(
"Run batch simulations with ML logging enabled first."
)
total_files = sum(len(files) for files in source_files.values())
logger.info(
"Found %d ML decision files across %d sources: %s",
total_files,
len(source_files),
{src: len(files) for src, files in source_files.items()},
)
logger.info("Found %d ML decision files", len(jsonl_files))
# --- Read and filter all decisions, grouped by source ---
rng = np.random.default_rng(42)
samples_by_source: Dict[str, List[dict]] = {}
total_skipped = 0
total_filtered = 0
for source, jsonl_files in source_files.items():
source_samples = []
for jsonl_file in jsonl_files:
logger.info("Processing %s [%s] ...", jsonl_file.name, source)
file_count = 0
with open(jsonl_file, "r") as f:
for line_num, line in enumerate(f):
line = line.strip()
if not line:
continue
try:
decision = json.loads(line)
# --- Phase 2 Task 3: PPO reward filter ---
if not _passes_reward_filter(
decision, source, min_reward_threshold
):
total_filtered += 1
continue
# --- Phase 2 Task 4: Tag with source ---
decision["_source"] = source
source_samples.append(decision)
file_count += 1
except Exception as e:
total_skipped += 1
if total_skipped <= 10:
logger.warning(
"Skipping %s line %d: %s", jsonl_file.name, line_num, e
)
logger.info(" → %d samples from %s", file_count, jsonl_file.name)
if source_samples:
samples_by_source[source] = source_samples
if not samples_by_source:
logger.error("No valid samples found.")
return {}
# Log per-source counts before weighting
for src, samples in samples_by_source.items():
logger.info("Source '%s': %d raw samples", src, len(samples))
if total_filtered > 0:
logger.info("PPO reward filter removed %d samples (threshold=%.2f)",
total_filtered, min_reward_threshold or 0.0)
# --- Phase 2 Task 2: Apply source weights ---
combined_decisions = _apply_source_weights(
samples_by_source, source_weights, rng
)
# Apply max_samples cap after weighting
if max_samples and len(combined_decisions) > max_samples:
rng.shuffle(combined_decisions)
combined_decisions = combined_decisions[:max_samples]
logger.info("Capped to %d samples", max_samples)
# --- Encode all decisions ---
all_states = []
all_labels = []
all_game_ids = []
all_outcomes = []
all_playstyles = []
all_sources = []
encode_errors = 0
total_skipped = 0
for decision in combined_decisions:
try:
playstyle = decision.get("archetype", "midrange")
state_vec = encoder.encode(decision, playstyle)
action_idx = label_action(decision)
game_id = decision.get("game_id", "unknown")
outcome = decision.get("game_outcome", "unknown")
source = decision.get("_source", "forge")
for jsonl_file in jsonl_files:
logger.info("Processing %s ...", jsonl_file.name)
file_count = 0
all_states.append(state_vec)
all_labels.append(action_idx)
all_game_ids.append(game_id)
all_outcomes.append(outcome)
all_playstyles.append(playstyle)
all_sources.append(source)
with open(jsonl_file, "r") as f:
for line_num, line in enumerate(f):
if max_samples and len(all_states) >= max_samples:
break
except Exception as e:
encode_errors += 1
if encode_errors <= 5:
logger.warning("Encode error: %s", e)
line = line.strip()
if not line:
continue
try:
decision = json.loads(line)
# Encode state
playstyle = decision.get("archetype", "midrange")
state_vec = encoder.encode(decision, playstyle)
# Label action
action_idx = label_action(decision)
# Metadata
game_id = decision.get("game_id", f"unknown-{line_num}")
outcome = decision.get("game_outcome", "unknown")
all_states.append(state_vec)
all_labels.append(action_idx)
all_game_ids.append(game_id)
all_outcomes.append(outcome)
all_playstyles.append(playstyle)
file_count += 1
except Exception as e:
total_skipped += 1
if total_skipped <= 10:
logger.warning(
"Skipping %s line %d: %s", jsonl_file.name, line_num, e
)
logger.info(" → %d samples from %s", file_count, jsonl_file.name)
if not all_states:
logger.error("No valid samples after encoding.")
logger.error("No valid samples found.")
return {}
# Convert to arrays
@@ -351,11 +143,10 @@ def build_dataset(
game_ids = np.array(all_game_ids, dtype=object)
outcomes = np.array(all_outcomes, dtype=object)
playstyles = np.array(all_playstyles, dtype=object)
sources = np.array(all_sources, dtype=object)
logger.info(
"Dataset built: %d samples, %d features, %d actions, %d skipped, %d encode errors",
len(states), states.shape[1], NUM_ACTIONS, total_skipped, encode_errors,
"Dataset built: %d samples, %d features, %d actions, %d skipped",
len(states), states.shape[1], NUM_ACTIONS, total_skipped,
)
# Print label distribution
@@ -363,25 +154,17 @@ def build_dataset(
# Print game outcome distribution
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
logger.info(f"\n{'Outcome':<25} {'Count':>8}")
logger.info("─" * 35)
print(f"\n{'Outcome':<25} {'Count':>8}")
print("─" * 35)
for o, c in sorted(zip(unique_outcomes, outcome_counts), key=lambda x: -x[1]):
logger.info(f" {str(o):<23} {c:>8}")
print(f" {str(o):<23} {c:>8}")
# Print playstyle distribution
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
logger.info(f"\n{'Playstyle':<25} {'Count':>8}")
logger.info("─" * 35)
print(f"\n{'Playstyle':<25} {'Count':>8}")
print("─" * 35)
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
logger.info(f" {str(s):<23} {c:>8}")
# --- Phase 2 Task 4: Print source distribution ---
unique_sources, source_counts = np.unique(sources, return_counts=True)
logger.info(f"\n{'Source':<25} {'Count':>8} {'%':>7}")
logger.info("─" * 42)
for s, c in sorted(zip(unique_sources, source_counts), key=lambda x: -x[1]):
pct = 100.0 * c / len(sources)
logger.info(f" {str(s):<23} {c:>8} {pct:>6.1f}%")
print(f" {str(s):<23} {c:>8}")
return {
"states": states,
@@ -389,7 +172,6 @@ def build_dataset(
"game_ids": game_ids,
"outcomes": outcomes,
"playstyles": playstyles,
"sources": sources,
}
@@ -458,6 +240,7 @@ def split_dataset(
len(train["states"]), len(val["states"]), len(test["states"]),
split_by_game,
)
return train, val, test
@@ -496,38 +279,16 @@ def main():
"--no-split", action="store_true",
help="Don't split into train/val/test (save single file)",
)
# Phase 2 CLI arguments
parser.add_argument(
"--forge-weight", type=float, default=1.0,
help="Sampling weight for Forge sim data (default: 1.0)",
)
parser.add_argument(
"--ppo-weight", type=float, default=0.0,
help="Sampling weight for PPO self-play data (default: 0.0, Forge-only)",
)
parser.add_argument(
"--min-reward", type=float, default=0.0,
help="Minimum episode_return for PPO data (default: 0.0)",
)
args = parser.parse_args()
source_weights = {
"forge": args.forge_weight,
"ppo": args.ppo_weight,
}
# Build dataset
dataset = build_dataset(
results_dir=args.results_dir,
embeddings_dir=args.embeddings_dir,
max_samples=args.max_samples,
source_weights=source_weights,
min_reward_threshold=args.min_reward,
)
if not dataset:
logger.info("\nNo data to save. Run simulations with --ml-log first.")
print("\nNo data to save. Run simulations with --ml-log first.")
sys.exit(1)
if args.no_split:
@@ -539,7 +300,7 @@ def main():
save_dataset(val, f"{base}-val.npz")
save_dataset(test, f"{base}-test.npz")
logger.info("\nDataset build complete.")
print("\nDataset build complete.")
if __name__ == "__main__":
-587
View File
@@ -1,587 +0,0 @@
"""
Commander AI Lab — Quality Gate & Validation
═════════════════════════════════════════════
Phase 3 (Issue #67): Prevents bad PPO data from degrading the
supervised model by enforcing quality gates at multiple levels:
1. Forge Validation Holdout — fixed eval set from Forge-only data
2. Accuracy Gate — reject PPO batch if model accuracy drops on Forge eval
3. Win Rate Gate — only export PPO decisions above a win rate threshold
4. Data Quality Metrics — entropy, reward distribution logging
Usage:
from ml.data.quality_gate import QualityGate, QualityGateConfig
gate = QualityGate(QualityGateConfig(
min_forge_accuracy=0.40,
min_ppo_win_rate=0.30,
))
# Check if PPO batch should be accepted
verdict = gate.evaluate_ppo_batch(
forge_eval_accuracy=0.42,
ppo_win_rate=0.35,
ppo_decisions=decisions_list,
baseline_accuracy=0.41,
)
if verdict.accepted:
# merge PPO data into training set
...
Ref: docs/CLOSED-LOOP-DISTILLATION.md — Phase 3
Issue: #67
"""
import json
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
logger = logging.getLogger("ml.quality_gate")
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@dataclass
class QualityGateConfig:
"""Configuration for the quality gate system."""
# --- Forge validation holdout (Task 1) ---
forge_holdout_ratio: float = 0.12 # 12% of Forge data held out
forge_holdout_seed: int = 99 # Fixed seed for reproducibility
# --- Accuracy gate (Task 2) ---
# If model accuracy on Forge eval drops below this absolute floor,
# the PPO batch is rejected regardless.
min_forge_accuracy: float = 0.35
# If model accuracy drops by more than this relative amount compared
# to the baseline (pre-PPO model), reject the batch.
max_accuracy_drop: float = 0.03 # e.g., 41% → 38% = 3% drop → reject
# --- Win rate gate (Task 3) ---
# PPO agent must exceed this win rate before its decisions are exported.
# In a 4-player pod, random baseline is 25%.
min_ppo_win_rate: float = 0.30
# --- Quality metrics (Task 4) ---
# Directory to write quality reports
reports_dir: str = "data/results/quality-reports"
# ---------------------------------------------------------------------------
# Verdict dataclass
# ---------------------------------------------------------------------------
@dataclass
class GateVerdict:
"""Result of a quality gate evaluation."""
accepted: bool
reason: str
metrics: Dict = field(default_factory=dict)
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def to_dict(self) -> dict:
return {
"accepted": self.accepted,
"reason": self.reason,
"metrics": self.metrics,
"timestamp": self.timestamp,
}
# ---------------------------------------------------------------------------
# Forge Validation Holdout (Task 1)
# ---------------------------------------------------------------------------
def create_forge_holdout(
states: np.ndarray,
labels: np.ndarray,
sources: np.ndarray,
game_ids: np.ndarray,
holdout_ratio: float = 0.12,
seed: int = 99,
) -> Tuple[Dict[str, np.ndarray], Dict[str, np.ndarray]]:
"""
Split Forge-only data into training and holdout validation sets.
The holdout set is a fixed eval set used to detect accuracy regression
when PPO data is mixed in. Split is done by game_id to prevent leakage.
Args:
states, labels, sources, game_ids: Dataset arrays
holdout_ratio: Fraction of Forge games to hold out
seed: Fixed random seed for reproducibility
Returns:
(train_data, holdout_data) — each a dict with states, labels, sources, game_ids
"""
# Identify Forge-only samples
forge_mask = np.array([s == "forge" for s in sources])
ppo_mask = ~forge_mask
forge_game_ids = game_ids[forge_mask]
unique_forge_games = np.unique(forge_game_ids)
# Deterministic split by game_id
rng = np.random.default_rng(seed)
rng.shuffle(unique_forge_games)
n_holdout = max(1, int(len(unique_forge_games) * holdout_ratio))
holdout_games = set(unique_forge_games[:n_holdout])
train_games = set(unique_forge_games[n_holdout:])
# Build masks
forge_holdout_mask = np.array([
(s == "forge" and gid in holdout_games)
for s, gid in zip(sources, game_ids)
])
forge_train_mask = np.array([
(s == "forge" and gid in train_games)
for s, gid in zip(sources, game_ids)
])
# Training set = Forge train + all PPO data
train_mask = forge_train_mask | ppo_mask
def _subset(mask):
return {
"states": states[mask],
"labels": labels[mask],
"sources": sources[mask],
"game_ids": game_ids[mask],
}
holdout = _subset(forge_holdout_mask)
train = _subset(train_mask)
logger.info(
"Forge holdout: %d samples (%d games) held out, %d train samples remaining",
forge_holdout_mask.sum(), n_holdout, train_mask.sum(),
)
return train, holdout
# ---------------------------------------------------------------------------
# Accuracy Gate (Task 2)
# ---------------------------------------------------------------------------
def check_accuracy_gate(
current_accuracy: float,
baseline_accuracy: float,
config: QualityGateConfig,
) -> GateVerdict:
"""
Check if the current model accuracy on the Forge holdout set
is acceptable after training with PPO data mixed in.
Args:
current_accuracy: Accuracy on Forge holdout after training with PPO data
baseline_accuracy: Accuracy on Forge holdout from previous model (no PPO)
config: Quality gate configuration
Returns:
GateVerdict — accepted=True if accuracy is acceptable
"""
metrics = {
"current_accuracy": round(current_accuracy, 4),
"baseline_accuracy": round(baseline_accuracy, 4),
"accuracy_drop": round(baseline_accuracy - current_accuracy, 4),
"min_forge_accuracy": config.min_forge_accuracy,
"max_accuracy_drop": config.max_accuracy_drop,
}
# Check absolute floor
if current_accuracy < config.min_forge_accuracy:
return GateVerdict(
accepted=False,
reason=(
f"Forge eval accuracy ({current_accuracy:.1%}) below absolute "
f"minimum ({config.min_forge_accuracy:.1%}). PPO batch rejected."
),
metrics=metrics,
)
# Check relative drop
drop = baseline_accuracy - current_accuracy
if drop > config.max_accuracy_drop:
return GateVerdict(
accepted=False,
reason=(
f"Forge eval accuracy dropped {drop:.1%} "
f"(from {baseline_accuracy:.1%} to {current_accuracy:.1%}), "
f"exceeding max allowed drop of {config.max_accuracy_drop:.1%}. "
f"PPO batch rejected."
),
metrics=metrics,
)
return GateVerdict(
accepted=True,
reason=(
f"Forge eval accuracy {current_accuracy:.1%} is acceptable "
f"(baseline={baseline_accuracy:.1%}, drop={drop:.1%}). "
f"PPO batch accepted."
),
metrics=metrics,
)
# ---------------------------------------------------------------------------
# Win Rate Gate (Task 3)
# ---------------------------------------------------------------------------
def check_win_rate_gate(
ppo_win_rate: float,
config: QualityGateConfig,
total_episodes: int = 0,
min_episodes: int = 20,
) -> GateVerdict:
"""
Check if the PPO agent's win rate exceeds the minimum threshold
before allowing its decisions to be exported.
Args:
ppo_win_rate: Win rate of PPO agent (0.0 to 1.0)
config: Quality gate configuration
total_episodes: Number of episodes played
min_episodes: Minimum episodes required for statistical significance
Returns:
GateVerdict — accepted=True if win rate is sufficient
"""
metrics = {
"ppo_win_rate": round(ppo_win_rate, 4),
"min_ppo_win_rate": config.min_ppo_win_rate,
"total_episodes": total_episodes,
"min_episodes": min_episodes,
}
# Check minimum episode count
if total_episodes < min_episodes:
return GateVerdict(
accepted=False,
reason=(
f"Insufficient episodes ({total_episodes}/{min_episodes}). "
f"Need at least {min_episodes} episodes for reliable win rate."
),
metrics=metrics,
)
# Check win rate threshold
if ppo_win_rate < config.min_ppo_win_rate:
return GateVerdict(
accepted=False,
reason=(
f"PPO win rate ({ppo_win_rate:.1%}) below minimum "
f"threshold ({config.min_ppo_win_rate:.1%}). "
f"Agent not strong enough — decisions not exported."
),
metrics=metrics,
)
return GateVerdict(
accepted=True,
reason=(
f"PPO win rate ({ppo_win_rate:.1%}) exceeds minimum "
f"({config.min_ppo_win_rate:.1%}) over {total_episodes} episodes. "
f"Decisions approved for export."
),
metrics=metrics,
)
# ---------------------------------------------------------------------------
# Data Quality Metrics (Task 4)
# ---------------------------------------------------------------------------
def compute_action_entropy(action_indices: np.ndarray, num_actions: int = 8) -> float:
"""
Compute Shannon entropy of the action distribution.
High entropy = diverse actions (good).
Low entropy = collapsed to few actions (bad — possible mode collapse).
Max entropy for 8 actions = log2(8) = 3.0 bits.
"""
counts = np.bincount(action_indices, minlength=num_actions).astype(np.float64)
probs = counts / max(counts.sum(), 1)
# Filter zero probabilities for log
probs = probs[probs > 0]
entropy = -np.sum(probs * np.log2(probs))
return float(entropy)
def compute_reward_stats(rewards: np.ndarray) -> Dict[str, float]:
"""Compute summary statistics for reward/episode_return distribution."""
if len(rewards) == 0:
return {"mean": 0.0, "std": 0.0, "min": 0.0, "max": 0.0,
"median": 0.0, "p25": 0.0, "p75": 0.0}
return {
"mean": float(np.mean(rewards)),
"std": float(np.std(rewards)),
"min": float(np.min(rewards)),
"max": float(np.max(rewards)),
"median": float(np.median(rewards)),
"p25": float(np.percentile(rewards, 25)),
"p75": float(np.percentile(rewards, 75)),
}
def compute_quality_metrics(
decisions: List[dict],
num_actions: int = 8,
) -> Dict:
"""
Compute comprehensive data quality metrics for a batch of decisions.
Metrics:
- action_entropy: Shannon entropy of action distribution
- action_distribution: count per action
- reward_stats: mean, std, min, max, median, p25, p75
- source_breakdown: count per source
- total_decisions: number of decisions
- unique_games: number of unique game IDs
Args:
decisions: List of decision dicts (from JSONL records)
num_actions: Total number of possible actions
Returns:
Dict of quality metrics
"""
if not decisions:
return {"total_decisions": 0, "error": "empty batch"}
# Extract action indices
action_indices = np.array([
d.get("action", {}).get("action_idx", d.get("action_idx", 0))
for d in decisions
])
# Extract rewards/episode returns
rewards = np.array([
d.get("episode_return", d.get("reward", 0.0))
for d in decisions
])
# Action distribution
action_counts = np.bincount(action_indices, minlength=num_actions)
action_distribution = {
str(i): int(c) for i, c in enumerate(action_counts)
}
# Source breakdown
sources = [d.get("source", d.get("_source", "unknown")) for d in decisions]
source_counts = {}
for s in sources:
source_counts[s] = source_counts.get(s, 0) + 1
# Unique games
game_ids = set(d.get("game_id", "?") for d in decisions)
# Entropy
entropy = compute_action_entropy(action_indices, num_actions)
max_entropy = np.log2(num_actions)
return {
"total_decisions": len(decisions),
"unique_games": len(game_ids),
"action_entropy": round(entropy, 4),
"max_entropy": round(max_entropy, 4),
"entropy_ratio": round(entropy / max(max_entropy, 1e-8), 4),
"action_distribution": action_distribution,
"reward_stats": compute_reward_stats(rewards),
"source_breakdown": source_counts,
}
def log_quality_metrics(metrics: Dict):
"""Pretty-print quality metrics to the logger."""
logger.info("")
logger.info("=" * 55)
logger.info(" Data Quality Report")
logger.info("=" * 55)
logger.info(" Total decisions: %d", metrics.get("total_decisions", 0))
logger.info(" Unique games: %d", metrics.get("unique_games", 0))
logger.info(" Action entropy: %.3f / %.3f (%.0f%%)",
metrics.get("action_entropy", 0),
metrics.get("max_entropy", 0),
metrics.get("entropy_ratio", 0) * 100)
logger.info("")
# Action distribution
dist = metrics.get("action_distribution", {})
if dist:
total = sum(dist.values())
logger.info(" %-20s %8s %7s", "Action", "Count", "%")
logger.info(" " + "-" * 37)
for action_idx, count in sorted(dist.items(), key=lambda x: -x[1]):
pct = 100.0 * count / max(total, 1)
logger.info(" %-20s %8d %6.1f%%", f"action_{action_idx}", count, pct)
# Reward stats
rstats = metrics.get("reward_stats", {})
if rstats:
logger.info("")
logger.info(" Reward distribution:")
logger.info(" mean=%.3f std=%.3f min=%.3f max=%.3f",
rstats.get("mean", 0), rstats.get("std", 0),
rstats.get("min", 0), rstats.get("max", 0))
logger.info(" p25=%.3f median=%.3f p75=%.3f",
rstats.get("p25", 0), rstats.get("median", 0),
rstats.get("p75", 0))
# Source breakdown
sources = metrics.get("source_breakdown", {})
if sources:
logger.info("")
logger.info(" Source breakdown:")
for src, cnt in sorted(sources.items(), key=lambda x: -x[1]):
logger.info(" %-12s %d", src, cnt)
logger.info("=" * 55)
# ---------------------------------------------------------------------------
# Orchestrator — QualityGate class
# ---------------------------------------------------------------------------
class QualityGate:
"""
Orchestrates all quality checks for the closed-loop distillation pipeline.
Usage:
gate = QualityGate(config)
# Before exporting PPO decisions:
win_verdict = gate.check_win_rate(ppo_win_rate=0.35, total_episodes=100)
# After training with mixed data:
acc_verdict = gate.check_accuracy(
current_accuracy=0.42,
baseline_accuracy=0.41,
)
# Log quality metrics for a batch:
gate.report_batch_quality(decisions)
"""
def __init__(self, config: QualityGateConfig = None):
self.config = config or QualityGateConfig()
self.verdicts: List[GateVerdict] = []
def check_win_rate(
self,
ppo_win_rate: float,
total_episodes: int = 0,
min_episodes: int = 20,
) -> GateVerdict:
"""Check if PPO agent meets win rate threshold for export."""
verdict = check_win_rate_gate(
ppo_win_rate, self.config, total_episodes, min_episodes
)
self.verdicts.append(verdict)
if verdict.accepted:
logger.info("WIN RATE GATE: PASS — %s", verdict.reason)
else:
logger.warning("WIN RATE GATE: FAIL — %s", verdict.reason)
return verdict
def check_accuracy(
self,
current_accuracy: float,
baseline_accuracy: float,
) -> GateVerdict:
"""Check if model accuracy on Forge holdout is acceptable."""
verdict = check_accuracy_gate(
current_accuracy, baseline_accuracy, self.config
)
self.verdicts.append(verdict)
if verdict.accepted:
logger.info("ACCURACY GATE: PASS — %s", verdict.reason)
else:
logger.warning("ACCURACY GATE: FAIL — %s", verdict.reason)
return verdict
def report_batch_quality(self, decisions: List[dict]) -> Dict:
"""Compute and log quality metrics for a decision batch."""
metrics = compute_quality_metrics(decisions)
log_quality_metrics(metrics)
# Save report to disk
os.makedirs(self.config.reports_dir, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
report_path = os.path.join(
self.config.reports_dir, f"quality_{timestamp}.json"
)
with open(report_path, "w") as f:
json.dump(metrics, f, indent=2)
logger.info("Quality report saved: %s", report_path)
return metrics
def evaluate_ppo_batch(
self,
forge_eval_accuracy: float,
ppo_win_rate: float,
ppo_decisions: List[dict],
baseline_accuracy: float,
total_episodes: int = 0,
) -> GateVerdict:
"""
Run all quality gates on a PPO batch. Returns a combined verdict.
This is the main entry point for the distillation pipeline to decide
whether to accept or reject a batch of PPO-generated training data.
"""
# 1. Win rate gate
win_verdict = self.check_win_rate(ppo_win_rate, total_episodes)
if not win_verdict.accepted:
return win_verdict
# 2. Accuracy gate
acc_verdict = self.check_accuracy(forge_eval_accuracy, baseline_accuracy)
if not acc_verdict.accepted:
return acc_verdict
# 3. Log quality metrics (informational, doesn't block)
quality_metrics = self.report_batch_quality(ppo_decisions)
# 4. Check for extreme entropy collapse (soft warning)
entropy_ratio = quality_metrics.get("entropy_ratio", 1.0)
if entropy_ratio < 0.3:
logger.warning(
"LOW ENTROPY WARNING: action entropy ratio %.2f — "
"possible mode collapse in PPO agent",
entropy_ratio,
)
return GateVerdict(
accepted=True,
reason="All quality gates passed.",
metrics={
"win_rate": win_verdict.metrics,
"accuracy": acc_verdict.metrics,
"quality": quality_metrics,
},
)
def save_verdict_history(self, path: str = None):
"""Save all verdict history to a JSON file."""
if path is None:
os.makedirs(self.config.reports_dir, exist_ok=True)
path = os.path.join(self.config.reports_dir, "verdict_history.json")
history = [v.to_dict() for v in self.verdicts]
with open(path, "w") as f:
json.dump(history, f, indent=2)
logger.info("Verdict history saved: %s (%d entries)", path, len(history))
-9
View File
@@ -276,13 +276,6 @@ class StateEncoder:
"""
Encode zone contents as mean-pooled card embeddings.
Shape: (4 zones × 2 players × 768) = (6144,)
Zone embedding boundary: each zone slot is exactly ``zone_pool_dim``
(768) floats. Empty zones produce a zero vector of that size. This
fixed-size contract is relied upon by every downstream consumer
(PolicyNetwork, PPOTrainer, dataset builder). See
``StateDimensions`` in ``ml/config/scope.py`` for the canonical
definition.
"""
zone_keys = ["hand", "battlefield", "graveyard", "command_zone"]
vectors = []
@@ -290,8 +283,6 @@ class StateEncoder:
for p in players[:2]:
for zone_key in zone_keys:
cards = p.get(zone_key, [])
# mean_pool_zone returns a zero vector when cards is empty,
# preserving the fixed 768-dim slot size.
pooled = self.card_index.mean_pool_zone(cards)
vectors.append(pooled)
-407
View File
@@ -1,407 +0,0 @@
"""
Commander AI Lab — ELO Tracker & Cross-Generation Tournament
═══════════════════════════════════════════════════════════════
Phase 6 (Issue #70): Quantify improvement across distillation
generations using ELO ratings.
- Runs round-robin tournaments between generation checkpoints
and baselines (heuristic, random)
- Computes ELO ratings using the standard formula
- Persists ELO history to `data/elo_history.json`
- Provides ELO delta for convergence detection
Reuses `run_match`, `LearnedPolicy`, `HeuristicPolicy`,
`RandomPolicy` from `ml/eval/tournament.py`.
Usage:
python -m ml.eval.elo_tracker
python -m ml.eval.elo_tracker --episodes 50 --output data/elo_history.json
"""
import json
import logging
import math
import os
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from itertools import combinations
from pathlib import Path
from typing import Dict, List, Optional, Tuple
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from ml.eval.tournament import (
run_match, LearnedPolicy, HeuristicPolicy, RandomPolicy,
)
logger = logging.getLogger("ml.eval.elo")
# ═══════════════════════════════════════════════════════════
# ELO Rating Engine
# ═══════════════════════════════════════════════════════════
DEFAULT_K = 32
DEFAULT_START_ELO = 1200.0
class EloTracker:
"""Standard ELO rating tracker."""
def __init__(self, k_factor: float = DEFAULT_K, start_elo: float = DEFAULT_START_ELO):
self.k = k_factor
self.start_elo = start_elo
self.ratings: Dict[str, float] = {}
def ensure_player(self, name: str):
if name not in self.ratings:
self.ratings[name] = self.start_elo
def expected_score(self, rating_a: float, rating_b: float) -> float:
return 1.0 / (1.0 + math.pow(10, (rating_b - rating_a) / 400.0))
def update(self, winner: str, loser: str):
"""Update ratings after a decisive match."""
self.ensure_player(winner)
self.ensure_player(loser)
e_win = self.expected_score(self.ratings[winner], self.ratings[loser])
e_lose = self.expected_score(self.ratings[loser], self.ratings[winner])
self.ratings[winner] += self.k * (1.0 - e_win)
self.ratings[loser] += self.k * (0.0 - e_lose)
def update_draw(self, player_a: str, player_b: str):
"""Update ratings after a draw."""
self.ensure_player(player_a)
self.ensure_player(player_b)
e_a = self.expected_score(self.ratings[player_a], self.ratings[player_b])
e_b = self.expected_score(self.ratings[player_b], self.ratings[player_a])
self.ratings[player_a] += self.k * (0.5 - e_a)
self.ratings[player_b] += self.k * (0.5 - e_b)
def get_ratings(self) -> Dict[str, float]:
"""Return a copy of current ratings, rounded to 1 decimal."""
return {k: round(v, 1) for k, v in self.ratings.items()}
# ═══════════════════════════════════════════════════════════
# ELO Result
# ═══════════════════════════════════════════════════════════
@dataclass
class EloResult:
"""Result of an ELO-rated tournament."""
ratings: Dict[str, float] = field(default_factory=dict)
match_count: int = 0
total_time_s: float = 0.0
policies: List[str] = field(default_factory=list)
rating_progression: List[Dict[str, float]] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"ratings": self.ratings,
"match_count": self.match_count,
"total_time_s": round(self.total_time_s, 1),
"policies": self.policies,
"rating_progression": self.rating_progression,
}
# ═══════════════════════════════════════════════════════════
# ELO History (persistence)
# ═══════════════════════════════════════════════════════════
class EloHistory:
"""Persists ELO ratings across tournament runs."""
def __init__(self, path: str = "data/elo_history.json"):
self.path = path
self.entries: List[dict] = []
self._load()
def _load(self):
if os.path.exists(self.path):
try:
with open(self.path, "r") as f:
data = json.load(f)
self.entries = data.get("entries", [])
except Exception:
self.entries = []
def save(self):
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
with open(self.path, "w") as f:
json.dump({"entries": self.entries}, f, indent=2)
def append(self, generation: int, ratings: Dict[str, float]):
"""Add a new ELO snapshot after a tournament."""
self.entries.append({
"timestamp": datetime.now(timezone.utc).isoformat(),
"generation": generation,
"ratings": {k: round(v, 1) for k, v in ratings.items()},
})
self.save()
def get_elo_delta(self, window: int = 2) -> Optional[float]:
"""
Compute average ELO gain of the latest generation model
over the last `window` entries.
Returns None if insufficient data.
"""
if len(self.entries) < window:
return None
recent = self.entries[-window:]
deltas = []
for i in range(1, len(recent)):
prev_ratings = recent[i - 1]["ratings"]
curr_ratings = recent[i]["ratings"]
# Find the latest generation model name in current entry
gen_models = [k for k in curr_ratings if k.startswith("gen-")]
if not gen_models:
continue
latest_gen = sorted(gen_models)[-1]
curr_elo = curr_ratings.get(latest_gen, DEFAULT_START_ELO)
# Compare to previous entry's latest gen model, or heuristic
prev_gen_models = [k for k in prev_ratings if k.startswith("gen-")]
if prev_gen_models:
prev_latest = sorted(prev_gen_models)[-1]
prev_elo = prev_ratings.get(prev_latest, DEFAULT_START_ELO)
else:
prev_elo = prev_ratings.get("heuristic", DEFAULT_START_ELO)
deltas.append(curr_elo - prev_elo)
if not deltas:
return None
return sum(deltas) / len(deltas)
def to_dict(self) -> dict:
return {"entries": self.entries}
@classmethod
def from_dict(cls, data: dict, path: str = "data/elo_history.json") -> "EloHistory":
h = cls.__new__(cls)
h.path = path
h.entries = data.get("entries", [])
return h
# ═══════════════════════════════════════════════════════════
# Tournament Functions
# ═══════════════════════════════════════════════════════════
def run_elo_tournament(
policies: Dict[str, object],
episodes_per_matchup: int = 50,
playstyle: str = "midrange",
k_factor: float = DEFAULT_K,
) -> EloResult:
"""
Run a round-robin ELO-rated tournament.
Uses `run_match` from tournament.py for match execution.
Args:
policies: Dict mapping name → policy object (with select_action method)
episodes_per_matchup: Games per pair (played both sides)
playstyle: Game playstyle
k_factor: ELO K-factor
Returns:
EloResult with final ratings, match count, and progression
"""
t_start = time.time()
names = list(policies.keys())
tracker = EloTracker(k_factor=k_factor)
# Initialize all players
for name in names:
tracker.ensure_player(name)
progression = [tracker.get_ratings().copy()]
total_matches = 0
logger.info("ELO Tournament: %d policies, %d games/matchup", len(names), episodes_per_matchup)
for name_a, name_b in combinations(names, 2):
policy_a = policies[name_a]
policy_b = policies[name_b]
for ep in range(episodes_per_matchup):
# Game 1: A as seat 0
m1 = run_match(policy_a, name_a, policy_b, name_b, playstyle=playstyle)
total_matches += 1
if m1.winner == name_a:
tracker.update(name_a, name_b)
elif m1.winner == name_b:
tracker.update(name_b, name_a)
else:
tracker.update_draw(name_a, name_b)
# Game 2: B as seat 0
m2 = run_match(policy_b, name_b, policy_a, name_a, playstyle=playstyle)
total_matches += 1
if m2.winner == name_a:
tracker.update(name_a, name_b)
elif m2.winner == name_b:
tracker.update(name_b, name_a)
else:
tracker.update_draw(name_a, name_b)
# Record progression after each matchup
progression.append(tracker.get_ratings().copy())
logger.info(
" %s (%.0f) vs %s (%.0f)",
name_a, tracker.ratings[name_a],
name_b, tracker.ratings[name_b],
)
return EloResult(
ratings=tracker.get_ratings(),
match_count=total_matches,
total_time_s=time.time() - t_start,
policies=names,
rating_progression=progression,
)
def run_generation_tournament(
checkpoint_dir: str = "ml/models/checkpoints",
episodes_per_matchup: int = 50,
playstyle: str = "midrange",
k_factor: float = DEFAULT_K,
) -> EloResult:
"""
Discover generation checkpoints and run an ELO tournament.
Looks for `gen-*/best_policy.pt` under checkpoint_dir.
Also includes heuristic and random baselines.
"""
policies: Dict[str, object] = {
"heuristic": HeuristicPolicy(),
"random": RandomPolicy(),
}
ckpt_path = Path(checkpoint_dir)
if ckpt_path.exists():
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"
policies[name] = LearnedPolicy(str(model_path))
logger.info("Loaded %s: %s", name, model_path)
# Also check standard checkpoints
sup_path = ckpt_path / "best_policy.pt"
if sup_path.exists() and "supervised" not in policies:
policies["supervised"] = LearnedPolicy(str(sup_path))
ppo_path = ckpt_path / "best_ppo.pt"
if ppo_path.exists() and "ppo" not in policies:
policies["ppo"] = LearnedPolicy(str(ppo_path))
if len(policies) < 2:
logger.warning("Need at least 2 policies for ELO tournament")
return EloResult(
ratings={k: DEFAULT_START_ELO for k in policies},
policies=list(policies.keys()),
)
logger.info("Running ELO tournament with %d policies: %s", len(policies), list(policies.keys()))
return run_elo_tournament(
policies=policies,
episodes_per_matchup=episodes_per_matchup,
playstyle=playstyle,
k_factor=k_factor,
)
# ═══════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════
def main():
"""CLI entry point for ELO tournament."""
import argparse
parser = argparse.ArgumentParser(
description="Commander AI Lab — ELO Tournament"
)
parser.add_argument(
"--episodes", type=int, default=50,
help="Episodes per matchup (default: 50)",
)
parser.add_argument(
"--checkpoint-dir", default="ml/models/checkpoints",
help="Checkpoint directory (default: ml/models/checkpoints)",
)
parser.add_argument(
"--playstyle", default="midrange",
help="Playstyle (default: midrange)",
)
parser.add_argument(
"--output", default="data/elo_history.json",
help="Output path for ELO history (default: data/elo_history.json)",
)
parser.add_argument(
"--k-factor", type=float, default=32,
help="ELO K-factor (default: 32)",
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s")
result = run_generation_tournament(
checkpoint_dir=args.checkpoint_dir,
episodes_per_matchup=args.episodes,
playstyle=args.playstyle,
k_factor=args.k_factor,
)
# Print results
logger.info("")
logger.info("=" * 50)
logger.info(" ELO RATINGS")
logger.info("=" * 50)
sorted_ratings = sorted(result.ratings.items(), key=lambda x: x[1], reverse=True)
for name, elo in sorted_ratings:
logger.info(" %-20s %7.1f", name, elo)
logger.info("")
logger.info(" %d matches in %.1f s", result.match_count, result.total_time_s)
logger.info("=" * 50)
# Save to history
history = EloHistory(path=args.output)
# Use the latest generation number, or 0 if no gen models
gen_models = [k for k in result.ratings if k.startswith("gen-")]
gen_num = 0
if gen_models:
try:
gen_num = max(int(g.split("-")[1]) for g in gen_models)
except (ValueError, IndexError):
pass
history.append(gen_num, result.ratings)
logger.info("ELO history saved: %s", args.output)
print(json.dumps(result.to_dict(), indent=2))
if __name__ == "__main__":
main()
-344
View File
@@ -1,344 +0,0 @@
"""
update_weights.py
=================
Outcome-weighted reinforcement learning for Commander AI card weights.
Reads all decisions_*.jsonl files produced by DeepSeekBrain.flush_log(),
applies a gradient-free reward nudge to each weight key based on the
normalised reward signal (-1.0 to +1.0) stamped on every decision entry,
then saves the updated weights to learned_weights.json via save_weights().
Usage
-----
# Basic run (reads logs/decisions/, updates learned_weights.json)
python -m ml.scripts.update_weights
# Custom paths
python -m ml.scripts.update_weights \\
--log-dir logs/decisions \\
--weights-out src/commander_ai_lab/sim/learned_weights.json \\
--lr 0.05 \\
--min-games 10
# Dry-run (print proposed changes, don't write)
python -m ml.scripts.update_weights --dry-run
# Reset to defaults then exit
python -m ml.scripts.update_weights --reset
Algorithm
---------
For each decision entry that has a non-null game_result:
delta = learning_rate * reward * action_weight_for(action)
Each weight key is nudged by the mean delta across all decisions that
touched that key. Keys with no decisions are left unchanged.
All values are clamped to [-20.0, 20.0] by save_weights().
Reward is the normalised life-delta from flush_log():
reward = (my_final_life - avg_opp_final_life) / 40.0 in [-1.0, 1.0]
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
from collections import defaultdict
from pathlib import Path
from typing import Iterator
# ---------------------------------------------------------------------------
# Allow running as a script without installing the package
# ---------------------------------------------------------------------------
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT / "src") not in sys.path:
sys.path.insert(0, str(_REPO_ROOT / "src"))
from commander_ai_lab.sim.rules import (
AI_DEFAULT_WEIGHTS,
load_weights,
reset_weights,
save_weights,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("update_weights")
# ---------------------------------------------------------------------------
# Action → weight key mapping
# Maps each VALID_ACTION to the weight(s) it most directly influences.
# A single action may touch multiple keys (listed as a tuple).
# ---------------------------------------------------------------------------
ACTION_WEIGHT_MAP: dict[str, tuple[str, ...]] = {
"play_land": (), # no weight key — always correct
"cast_ramp": ("spell_ramp",),
"cast_removal": ("spell_destroy",),
"cast_board_wipe": ("spell_counter",), # reuse counter as board-control proxy
"cast_creature": ("card_ptBonus",),
"cast_spell": ("spell_enchantment", "spell_draw"),
"attack_all": ("kw_haste", "kw_flying"), # aggression / evasion proxy
"attack_safe": ("kw_flying", "kw_trample"),
"hold": (), # no weight key — passing is neutral
}
# ---------------------------------------------------------------------------
# JSONL helpers
# ---------------------------------------------------------------------------
def _iter_log_entries(log_dir: str) -> Iterator[dict]:
"""Yield every entry from all decisions_*.jsonl files in log_dir."""
log_path = Path(log_dir)
if not log_path.exists():
logger.warning("Log directory not found: %s", log_dir)
return
files = sorted(log_path.glob("decisions_*.jsonl"))
if not files:
logger.warning("No decisions_*.jsonl files found in %s", log_dir)
return
logger.info("Found %d log file(s) in %s", len(files), log_dir)
for fpath in files:
with open(fpath, encoding="utf-8") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError as exc:
logger.warning("Skipping malformed line %d in %s: %s", lineno, fpath.name, exc)
# ---------------------------------------------------------------------------
# Core learning loop
# ---------------------------------------------------------------------------
def compute_weight_updates(
log_dir: str,
current_weights: dict[str, float],
learning_rate: float = 0.05,
min_games: int = 1,
) -> tuple[dict[str, float], dict]:
"""
Read decision logs and compute updated weights.
Returns
-------
updated_weights : dict[str, float]
New weight dict after applying mean reward nudges.
stats : dict
Diagnostic info (games seen, decisions processed, keys changed, etc.)
"""
# Accumulators: key → list of (reward * lr) deltas
key_deltas: dict[str, list[float]] = defaultdict(list)
total_entries = 0
labelled_entries = 0 # entries with non-null game_result
game_ids_seen: set[str] = set()
action_counts: dict[str, int] = defaultdict(int)
for entry in _iter_log_entries(log_dir):
total_entries += 1
game_result = entry.get("game_result")
if not game_result:
continue # no outcome stamped — skip (pre-flush or incomplete game)
labelled_entries += 1
game_ids_seen.add(entry.get("game_id", "unknown"))
reward: float = float(game_result.get("reward", 0.0))
action: str = entry.get("decision", {}).get("action", "hold")
action_counts[action] += 1
weight_keys = ACTION_WEIGHT_MAP.get(action, ())
for key in weight_keys:
if key in current_weights:
key_deltas[key].append(learning_rate * reward)
num_games = len(game_ids_seen)
logger.info(
"Processed %d entries | %d labelled | %d unique games",
total_entries, labelled_entries, num_games,
)
if num_games < min_games:
logger.warning(
"Only %d game(s) found — minimum is %d. Skipping update.",
num_games, min_games,
)
return dict(current_weights), {
"skipped": True,
"reason": f"need {min_games} games, have {num_games}",
"total_entries": total_entries,
"labelled_entries": labelled_entries,
"games": num_games,
}
# Apply mean delta per key
updated = dict(current_weights)
changed_keys: dict[str, tuple[float, float]] = {} # key → (old, new)
for key, deltas in key_deltas.items():
if not deltas:
continue
mean_delta = sum(deltas) / len(deltas)
old_val = updated[key]
new_val = round(old_val + mean_delta, 6)
updated[key] = new_val
if abs(new_val - old_val) >= 0.0001:
changed_keys[key] = (old_val, new_val)
stats = {
"skipped": False,
"total_entries": total_entries,
"labelled_entries": labelled_entries,
"games": num_games,
"keys_changed": len(changed_keys),
"changes": changed_keys,
"action_counts": dict(action_counts),
}
return updated, stats
def print_diff(stats: dict, current: dict, updated: dict) -> None:
"""Pretty-print weight changes to stdout."""
changes = stats.get("changes", {})
if not changes:
print(" (no weight changes above threshold)")
return
col_w = max(len(k) for k in changes) + 2
print(f" {'Key':<{col_w}} {'Before':>10} {'After':>10} {'Delta':>10}")
print(" " + "-" * (col_w + 36))
for key, (old, new) in sorted(changes.items(), key=lambda x: abs(x[1][1] - x[1][0]), reverse=True):
delta = new - old
arrow = "▲" if delta > 0 else "▼"
print(f" {key:<{col_w}} {old:>10.4f} {new:>10.4f} {arrow} {abs(delta):>8.4f}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _default_log_dir() -> str:
return str(_REPO_ROOT / "logs" / "decisions")
def _default_weights_out() -> str:
return str(_REPO_ROOT / "src" / "commander_ai_lab" / "sim" / "learned_weights.json")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Update AI card weights from simulation decision logs."
)
parser.add_argument(
"--log-dir",
default=_default_log_dir(),
help="Directory containing decisions_*.jsonl files (default: logs/decisions/)",
)
parser.add_argument(
"--weights-out",
default=_default_weights_out(),
help="Path to write learned_weights.json (default: src/.../sim/learned_weights.json)",
)
parser.add_argument(
"--lr",
type=float,
default=0.05,
help="Learning rate — how much each game nudges a weight (default: 0.05)",
)
parser.add_argument(
"--min-games",
type=int,
default=10,
help="Minimum number of completed games required before updating (default: 10)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print proposed changes without writing learned_weights.json",
)
parser.add_argument(
"--reset",
action="store_true",
help="Delete learned_weights.json and restore AI_DEFAULT_WEIGHTS, then exit",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show action distribution and per-key delta counts",
)
args = parser.parse_args(argv)
# ── Reset mode ──────────────────────────────────────────────
if args.reset:
reset_weights(args.weights_out)
print(f"Reset: deleted {args.weights_out} (if it existed). Defaults restored.")
return 0
# ── Load current weights ─────────────────────────────────────
current = load_weights(args.weights_out)
logger.info("Loaded %d weight keys from %s", len(current), args.weights_out)
# ── Compute updates ──────────────────────────────────────────
updated, stats = compute_weight_updates(
log_dir=args.log_dir,
current_weights=current,
learning_rate=args.lr,
min_games=args.min_games,
)
# ── Report ───────────────────────────────────────────────────
print()
print("=" * 60)
print(" Commander AI — Weight Update Report")
print("=" * 60)
print(f" Log dir : {args.log_dir}")
print(f" Weights out: {args.weights_out}")
print(f" Games : {stats['games']}")
print(f" Entries : {stats['total_entries']} ({stats['labelled_entries']} labelled)")
print(f" LR : {args.lr}")
print()
if stats.get("skipped"):
print(f" SKIPPED — {stats['reason']}")
print()
return 0
print(f" Keys changed: {stats['keys_changed']}")
print()
print_diff(stats, current, updated)
print()
if args.verbose:
print(" Action distribution:")
for action, count in sorted(stats["action_counts"].items(), key=lambda x: -x[1]):
print(f" {action:<20} {count:>6} decisions")
print()
# ── Write ────────────────────────────────────────────────────
if args.dry_run:
print(" DRY RUN — no file written.")
else:
out_path = save_weights(updated, args.weights_out)
print(f" Saved updated weights to: {out_path}")
print("=" * 60)
print()
return 0
if __name__ == "__main__":
sys.exit(main())
-210
View File
@@ -1,210 +0,0 @@
"""
Commander AI Lab — Online Learning Store (SQLite WAL)
═════════════════════════════════════════════════════
Single-machine backing store for online learning data collected during
policy inference (``/api/ml/predict`` and ``/api/policy/decide``).
Each prediction request can optionally log the state snapshot and the
chosen action so that future training runs can incorporate online
decision data alongside Forge and PPO batches.
Design decisions (Phase 5 architecture follow-up):
- SQLite in WAL mode for concurrent readers + single writer, which is
the right fit for a single-machine baseline (one API server process).
- Separate DB file (``online_learning.db``) to avoid contention with
the collection database.
- Append-only writes; reads are batched by the dataset builder.
- Thread-safe via the Python sqlite3 module's ``check_same_thread=False``
and WAL's built-in reader/writer concurrency.
Usage::
store = OnlineLearningStore() # uses default path
store.init_db()
# At prediction time:
store.record_decision(snapshot_json, action_index, confidence, playstyle)
# At training time:
decisions = store.fetch_decisions(since_rowid=last_seen, limit=10000)
store.mark_exported(max_rowid)
"""
import json
import logging
import os
import sqlite3
import threading
from pathlib import Path
from typing import Dict, List, Optional
logger = logging.getLogger("ml.serving.online_store")
_DEFAULT_DB_PATH = str(
Path(__file__).parent.parent.parent / "online_learning.db"
)
class OnlineLearningStore:
"""SQLite WAL-backed store for online policy decision collection."""
def __init__(self, db_path: str = None):
self.db_path = db_path or _DEFAULT_DB_PATH
self._local = threading.local()
# ------------------------------------------------------------------
# Connection management
# ------------------------------------------------------------------
def _get_conn(self) -> sqlite3.Connection:
"""Return a thread-local connection with WAL mode enabled."""
if not hasattr(self._local, "conn") or self._local.conn is None:
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
# Enable WAL for concurrent read/write on single machine.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
self._local.conn = conn
return self._local.conn
def close(self) -> None:
"""Close the thread-local connection."""
conn = getattr(self._local, "conn", None)
if conn is not None:
try:
conn.close()
except Exception:
pass
self._local.conn = None
# ------------------------------------------------------------------
# Schema
# ------------------------------------------------------------------
def init_db(self) -> None:
"""Create the decisions table if it doesn't exist."""
conn = self._get_conn()
conn.executescript("""
CREATE TABLE IF NOT EXISTS online_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot TEXT NOT NULL,
action_idx INTEGER NOT NULL,
confidence REAL NOT NULL DEFAULT 0.0,
playstyle TEXT NOT NULL DEFAULT 'midrange',
temperature REAL NOT NULL DEFAULT 1.0,
greedy INTEGER NOT NULL DEFAULT 0,
exported INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_od_exported
ON online_decisions(exported);
CREATE INDEX IF NOT EXISTS idx_od_created
ON online_decisions(created_at);
""")
conn.commit()
logger.info("Online learning store ready: %s (WAL mode)", self.db_path)
# ------------------------------------------------------------------
# Write path (called during prediction)
# ------------------------------------------------------------------
def record_decision(
self,
snapshot: Dict,
action_idx: int,
confidence: float,
playstyle: str = "midrange",
temperature: float = 1.0,
greedy: bool = False,
) -> int:
"""Append a policy decision for future training.
Returns the row id of the inserted record.
"""
conn = self._get_conn()
cur = conn.execute(
"""
INSERT INTO online_decisions
(snapshot, action_idx, confidence, playstyle, temperature, greedy)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
json.dumps(snapshot, separators=(",", ":")),
action_idx,
confidence,
playstyle,
temperature,
1 if greedy else 0,
),
)
conn.commit()
return cur.lastrowid
# ------------------------------------------------------------------
# Read path (called by dataset builder / training pipeline)
# ------------------------------------------------------------------
def fetch_decisions(
self,
since_rowid: int = 0,
limit: int = 50000,
only_unexported: bool = True,
) -> List[Dict]:
"""Fetch collected decisions for training.
Args:
since_rowid: Only return rows with id > this value.
limit: Max rows to return.
only_unexported: If True, skip rows already marked exported.
Returns:
List of dicts with keys: id, snapshot (parsed), action_idx,
confidence, playstyle, temperature, greedy, created_at.
"""
conn = self._get_conn()
clauses = ["id > ?"]
params: list = [since_rowid]
if only_unexported:
clauses.append("exported = 0")
where = " AND ".join(clauses)
rows = conn.execute(
f"SELECT * FROM online_decisions WHERE {where} ORDER BY id LIMIT ?",
(*params, limit),
).fetchall()
results = []
for row in rows:
d = dict(row)
try:
d["snapshot"] = json.loads(d["snapshot"])
except (json.JSONDecodeError, TypeError):
pass
results.append(d)
return results
def mark_exported(self, up_to_rowid: int) -> int:
"""Mark decisions as exported so they aren't fetched again.
Returns the number of rows updated.
"""
conn = self._get_conn()
cur = conn.execute(
"UPDATE online_decisions SET exported = 1 WHERE id <= ? AND exported = 0",
(up_to_rowid,),
)
conn.commit()
return cur.rowcount
def count(self, only_unexported: bool = False) -> int:
"""Return the total number of stored decisions."""
conn = self._get_conn()
if only_unexported:
row = conn.execute(
"SELECT COUNT(*) FROM online_decisions WHERE exported = 0"
).fetchone()
else:
row = conn.execute(
"SELECT COUNT(*) FROM online_decisions"
).fetchone()
return row[0]
-286
View File
@@ -1,286 +0,0 @@
"""
Commander AI Lab — PPO Decision Exporter
════════════════════════════════════════
Exports PPO self-play decisions as JSONL files in the same schema
as Forge DecisionSnapshot records. This enables the closed-loop
distillation pipeline where PPO-discovered strategies flow back
into the supervised training dataset.
Output files: data/results/ml-decisions-ppo-{batch_id}.jsonl
Each line is a JSON object compatible with the dataset_builder.py
ingestion pipeline, tagged with source="ppo" metadata.
Ref: docs/CLOSED-LOOP-DISTILLATION.md — Phase 1
Issue: #65
"""
import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
import sys
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from ml.config.scope import IDX_TO_ACTION, MacroAction
logger = logging.getLogger("ml.decision_exporter")
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@dataclass
class ExporterConfig:
"""Configuration for the PPO decision exporter."""
output_dir: str = "data/results"
only_wins: bool = True # Only export decisions from winning games
model_version: str = "unknown"
min_episode_steps: int = 3 # Skip very short episodes (likely noise)
# ---------------------------------------------------------------------------
# Decision Record Builder
# ---------------------------------------------------------------------------
def _macro_action_to_forge_type(action: MacroAction) -> str:
"""Map a MacroAction enum to the closest Forge action type string."""
mapping = {
MacroAction.CAST_CREATURE: "cast_spell",
MacroAction.CAST_REMOVAL: "cast_spell",
MacroAction.CAST_DRAW: "cast_spell",
MacroAction.CAST_RAMP: "cast_spell",
MacroAction.CAST_COMMANDER: "cast_spell",
MacroAction.ATTACK_OPPONENT: "attack",
MacroAction.HOLD_MANA: "pass_priority",
MacroAction.PASS: "pass_priority",
}
return mapping.get(action, "pass_priority")
def build_decision_record(
game_state_snapshot: dict,
action_idx: int,
reward: float,
agent_seat: int,
game_id: str,
game_outcome: str,
archetype: str = "midrange",
source: str = "ppo",
model_version: str = "unknown",
episode_return: float = 0.0,
) -> dict:
"""
Build a single decision record in Forge-compatible JSONL format.
The record mirrors the schema consumed by dataset_builder.py so that
PPO-generated data can be mixed into the supervised training set.
"""
action = IDX_TO_ACTION[action_idx]
players = game_state_snapshot.get("players", [])
# Build per-player state matching Forge snapshot layout
player_snapshots = []
for i, p in enumerate(players):
player_snapshots.append({
"seat": i,
"life": p.get("life_total", 40),
"handSize": p.get("cards_in_hand", 0),
"permanentCount": p.get("creatures_on_battlefield", 0),
"graveyardSize": p.get("cards_in_graveyard", 0),
"manaAvailable": p.get("mana_available", 0),
"commanderDamageTaken": p.get("commander_damage_taken", 0),
"totalPowerOnBoard": p.get("total_power_on_board", 0),
"commanderTax": p.get("commander_tax", 0),
})
return {
# --- Core fields consumed by dataset_builder / labeler ---
"game_id": game_id,
"game_outcome": game_outcome,
"archetype": archetype,
# Action info (used by ml.actions.labeler)
"action": {
"type": _macro_action_to_forge_type(action),
"macro_action": action.value,
"action_idx": action_idx,
"seat": agent_seat,
},
# Board / game state
"turn": game_state_snapshot.get("turn", 1),
"phase": game_state_snapshot.get("phase", "main_1"),
"active_seat": game_state_snapshot.get("active_seat", 0),
"players": player_snapshots,
# --- PPO-specific metadata (distinguishes from Forge data) ---
"source": source,
"model_version": model_version,
"reward": round(reward, 6),
"episode_return": round(episode_return, 6),
}
# ---------------------------------------------------------------------------
# DecisionExporter — accumulates episode decisions and flushes to JSONL
# ---------------------------------------------------------------------------
class DecisionExporter:
"""
Collects decisions from PPO self-play episodes and writes them
to JSONL files compatible with the supervised training pipeline.
Usage::
exporter = DecisionExporter(ExporterConfig(model_version="gen-0"))
for ep in range(num_episodes):
exporter.begin_episode(agent_seat=0, playstyle="midrange")
for step in episode_steps:
exporter.record_step(state_snapshot, action_idx, reward)
exporter.end_episode(won=True, episode_return=3.14)
exporter.flush() # writes JSONL to disk
print(exporter.stats)
"""
def __init__(self, config: ExporterConfig = None):
self.config = config or ExporterConfig()
self._batch_id = str(uuid.uuid4())[:8]
self._records: List[dict] = []
self._episode_buffer: List[dict] = []
self._current_game_id: Optional[str] = None
self._current_seat: int = 0
self._current_playstyle: str = "midrange"
# Stats
self._total_episodes = 0
self._exported_episodes = 0
self._exported_decisions = 0
self._skipped_losses = 0
self._skipped_short = 0
# -- Episode lifecycle ---------------------------------------------------
def begin_episode(self, agent_seat: int = 0, playstyle: str = "midrange"):
"""Start tracking a new episode."""
self._episode_buffer = []
self._current_game_id = f"ppo-{self._batch_id}-{self._total_episodes}"
self._current_seat = agent_seat
self._current_playstyle = playstyle
def record_step(
self,
game_state_snapshot: dict,
action_idx: int,
reward: float,
):
"""Record a single decision step within the current episode."""
self._episode_buffer.append({
"snapshot": game_state_snapshot,
"action_idx": action_idx,
"reward": reward,
})
def end_episode(self, won: bool, episode_return: float = 0.0):
"""
Finalize the current episode.
If config.only_wins is True, decisions from losing episodes
are discarded (higher signal-to-noise for distillation).
"""
self._total_episodes += 1
# Gate: skip losses if configured
if self.config.only_wins and not won:
self._skipped_losses += 1
self._episode_buffer = []
return
# Gate: skip very short episodes
if len(self._episode_buffer) < self.config.min_episode_steps:
self._skipped_short += 1
self._episode_buffer = []
return
game_outcome = "win" if won else "loss"
for step in self._episode_buffer:
record = build_decision_record(
game_state_snapshot=step["snapshot"],
action_idx=step["action_idx"],
reward=step["reward"],
agent_seat=self._current_seat,
game_id=self._current_game_id,
game_outcome=game_outcome,
archetype=self._current_playstyle,
source="ppo",
model_version=self.config.model_version,
episode_return=episode_return,
)
self._records.append(record)
self._exported_episodes += 1
self._exported_decisions += len(self._episode_buffer)
self._episode_buffer = []
# -- Persistence ---------------------------------------------------------
def flush(self) -> Optional[str]:
"""
Write all accumulated records to a JSONL file.
Returns:
Path to the written file, or None if no records to write.
"""
if not self._records:
logger.info("No PPO decisions to export (0 records).")
return None
os.makedirs(self.config.output_dir, exist_ok=True)
filename = f"ml-decisions-ppo-{self._batch_id}.jsonl"
filepath = os.path.join(self.config.output_dir, filename)
with open(filepath, "w") as f:
for record in self._records:
f.write(json.dumps(record) + "\n")
logger.info(
"Exported %d PPO decisions (%d episodes) to %s",
len(self._records),
self._exported_episodes,
filepath,
)
self._records = []
return filepath
# -- Reporting -----------------------------------------------------------
@property
def stats(self) -> dict:
"""Return export statistics."""
return {
"batch_id": self._batch_id,
"total_episodes": self._total_episodes,
"exported_episodes": self._exported_episodes,
"exported_decisions": self._exported_decisions,
"skipped_losses": self._skipped_losses,
"skipped_short": self._skipped_short,
"pending_records": len(self._records),
}
@property
def batch_id(self) -> str:
return self._batch_id
-908
View File
@@ -1,908 +0,0 @@
"""
Commander AI Lab — Distillation Loop Orchestrator
═══════════════════════════════════════════════════
Phase 4 (Issue #68): Automates the full closed-loop distillation cycle:
1. Supervised train on current dataset (Forge + any existing PPO data)
2. Run N episodes of PPO self-play using the supervised model as starting policy
3. Export winning PPO decisions to JSONL
4. Quality gate check
5. If passed → merge into dataset and repeat from step 1
6. If failed → discard PPO batch, adjust hyperparameters, retry
Each iteration is a "generation" — data and models are tagged with generation
number for traceability.
Convergence detection:
- Stops if win rate plateaus across `convergence_window` generations
- Hard stop at `max_iterations`
Ref: docs/CLOSED-LOOP-DISTILLATION.md — Phase 4
Issue: #68
"""
import json
import logging
import os
import shutil
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
import numpy as np
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from ml.config.scope import (
NUM_ACTIONS, STATE_DIMS, TRAINING_CONFIG,
)
from ml.data.quality_gate import QualityGate, QualityGateConfig, GateVerdict
from ml.training.decision_exporter import DecisionExporter, ExporterConfig
logger = logging.getLogger("ml.distillation")
# ═══════════════════════════════════════════════════════════
# Configuration
# ═══════════════════════════════════════════════════════════
@dataclass
class DistillationConfig:
"""Configuration for the distillation loop orchestrator."""
# --- Loop control ---
max_iterations: int = 10
convergence_window: int = 3 # Check last N generations for plateau
convergence_threshold: float = 0.01 # Min win-rate improvement to not plateau
# --- Paths ---
results_dir: str = "results"
models_dir: str = "ml/models"
checkpoint_dir: str = "ml/models/checkpoints"
history_dir: str = "results/distillation-history"
# --- Supervised training ---
supervised_epochs: int = 30
supervised_lr: float = 1e-3
supervised_batch_size: int = 64
supervised_patience: int = 5
# --- PPO self-play ---
ppo_iterations: int = 50
ppo_episodes_per_iter: int = 64
ppo_batch_size: int = 256
ppo_lr: float = 3e-4
ppo_clip_epsilon: float = 0.2
ppo_entropy_coeff: float = 0.01
ppo_eval_episodes: int = 100
opponent: str = "heuristic"
playstyle: str = "midrange"
# --- Dataset building (Forge-only default; see MIXED_MODE_PRESETS) ---
forge_weight: float = 1.0
ppo_weight: float = 0.0
min_reward_threshold: float = 0.0
# --- Quality gate ---
min_forge_accuracy: float = 0.35
max_accuracy_drop: float = 0.03
min_ppo_win_rate: float = 0.30
# --- Retry on gate failure ---
max_retries_per_generation: int = 2
retry_lr_factor: float = 0.5 # Halve PPO LR on retry
retry_entropy_boost: float = 0.005 # Increase entropy coeff on retry
# --- ELO convergence (Phase 6) ---
elo_convergence_enabled: bool = False # Off by default
elo_convergence_threshold: float = 20.0 # Stop if ELO gain < this
elo_tournament_episodes: int = 30 # Games per matchup for ELO mini-tournament
# ═══════════════════════════════════════════════════════════
# Generation metadata
# ═══════════════════════════════════════════════════════════
@dataclass
class GenerationRecord:
"""Tracks metadata for a single distillation generation."""
generation: int
started_at: str = ""
completed_at: str = ""
status: str = "pending" # pending | running | passed | failed | skipped
# Supervised training results
supervised_val_acc: float = 0.0
supervised_best_epoch: int = 0
supervised_checkpoint: str = ""
# PPO self-play results
ppo_win_rate: float = 0.0
ppo_best_win_rate: float = 0.0
ppo_episodes: int = 0
ppo_decisions_exported: int = 0
ppo_export_path: str = ""
# Quality gate
gate_accepted: bool = False
gate_reason: str = ""
baseline_accuracy: float = 0.0
post_merge_accuracy: float = 0.0
# Dataset stats
dataset_size: int = 0
forge_samples: int = 0
ppo_samples: int = 0
retries: int = 0
error: str = ""
elo_rating: float = 0.0
def to_dict(self) -> dict:
return {k: v for k, v in self.__dict__.items()}
# ═══════════════════════════════════════════════════════════
# Distillation Loop Orchestrator
# ═══════════════════════════════════════════════════════════
class DistillationLoop:
"""
Orchestrates the full closed-loop distillation pipeline.
Each "generation" runs the complete cycle:
supervised train → PPO self-play → export → quality gate → merge
The loop tracks convergence by monitoring PPO win rate across
generations and stops when improvement plateaus or max_iterations
is reached.
Usage::
config = DistillationConfig(max_iterations=5, ppo_iterations=30)
loop = DistillationLoop(config)
summary = loop.run()
"""
def __init__(self, config: DistillationConfig = None):
self.config = config or DistillationConfig()
self.generations: List[GenerationRecord] = []
self.quality_gate = QualityGate(QualityGateConfig(
min_forge_accuracy=self.config.min_forge_accuracy,
max_accuracy_drop=self.config.max_accuracy_drop,
min_ppo_win_rate=self.config.min_ppo_win_rate,
))
self._baseline_accuracy: Optional[float] = None
self._device: Optional[str] = None
self._stopped = False
# Ensure output directories exist
os.makedirs(self.config.history_dir, exist_ok=True)
os.makedirs(self.config.checkpoint_dir, exist_ok=True)
os.makedirs(self.config.results_dir, exist_ok=True)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def run(self, progress_callback=None) -> Dict:
"""
Run the full distillation loop.
Args:
progress_callback: Optional callable(generation, record) for
progress reporting (e.g. to a web UI).
Returns:
Summary dict with per-generation metrics and convergence info.
"""
cfg = self.config
self._device = self._detect_device()
logger.info("=" * 65)
logger.info(" Commander AI Lab — Distillation Loop")
logger.info("=" * 65)
logger.info(" Max iterations: %d", cfg.max_iterations)
logger.info(" Convergence window: %d", cfg.convergence_window)
logger.info(" PPO iters/gen: %d", cfg.ppo_iterations)
logger.info(" PPO episodes/iter: %d", cfg.ppo_episodes_per_iter)
logger.info(" Supervised epochs: %d", cfg.supervised_epochs)
logger.info(" Device: %s", self._device)
logger.info(" Results dir: %s", cfg.results_dir)
logger.info("=" * 65)
loop_start = time.time()
for gen_num in range(1, cfg.max_iterations + 1):
if self._stopped:
logger.info("Loop stopped by external signal.")
break
logger.info("")
logger.info("─" * 65)
logger.info(" GENERATION %d / %d", gen_num, cfg.max_iterations)
logger.info("─" * 65)
record = self._run_generation(gen_num)
self.generations.append(record)
# Save generation history to disk after each round
self._save_history()
if progress_callback:
progress_callback(gen_num, record)
# Check convergence
if self._check_convergence():
logger.info(
"Convergence detected — win rate plateaued over "
"last %d generations. Stopping.",
cfg.convergence_window,
)
break
total_time = time.time() - loop_start
summary = self._build_summary(total_time)
self._save_summary(summary)
logger.info("")
logger.info("=" * 65)
logger.info(" Distillation Loop Complete")
logger.info("=" * 65)
logger.info(" Generations run: %d", summary["generations_run"])
logger.info(" Generations passed: %d", summary["generations_passed"])
logger.info(" Best win rate: %.1f%%", summary["best_win_rate"] * 100)
logger.info(" Converged: %s", summary["converged"])
logger.info(" Total time: %.1f s", total_time)
logger.info("=" * 65)
return summary
def stop(self):
"""Signal the loop to stop after the current generation."""
self._stopped = True
logger.info("Stop signal received — will halt after current generation.")
# ------------------------------------------------------------------
# Single generation execution
# ------------------------------------------------------------------
def _run_generation(self, gen_num: int) -> GenerationRecord:
"""Execute a single distillation generation (steps 1-6)."""
record = GenerationRecord(
generation=gen_num,
started_at=datetime.now(timezone.utc).isoformat(),
status="running",
)
cfg = self.config
gen_tag = f"gen-{gen_num:03d}"
gen_checkpoint_dir = os.path.join(cfg.checkpoint_dir, gen_tag)
os.makedirs(gen_checkpoint_dir, exist_ok=True)
try:
# ─── Step 1: Supervised Train ───────────────────────────
logger.info(" [Step 1] Supervised training on current dataset...")
sup_result = self._supervised_train(gen_checkpoint_dir, gen_tag)
if sup_result is None:
record.status = "failed"
record.error = "Supervised training failed — no dataset found"
record.completed_at = datetime.now(timezone.utc).isoformat()
return record
record.supervised_val_acc = sup_result["best_val_acc"]
record.supervised_best_epoch = sup_result["best_epoch"]
record.supervised_checkpoint = sup_result["checkpoint_path"]
# Set baseline accuracy on first generation
if self._baseline_accuracy is None:
self._baseline_accuracy = sup_result["best_val_acc"]
logger.info(
" Baseline accuracy set: %.3f (generation 1)",
self._baseline_accuracy,
)
# ─── Step 2-3: PPO Self-Play + Export ───────────────────
retries = 0
ppo_lr = cfg.ppo_lr
entropy_coeff = cfg.ppo_entropy_coeff
gate_passed = False
while retries <= cfg.max_retries_per_generation and not gate_passed:
if retries > 0:
logger.info(
" [Retry %d/%d] Adjusting hyperparameters: "
"lr=%.2e → %.2e, entropy=%.4f → %.4f",
retries, cfg.max_retries_per_generation,
ppo_lr, ppo_lr * cfg.retry_lr_factor,
entropy_coeff, entropy_coeff + cfg.retry_entropy_boost,
)
ppo_lr *= cfg.retry_lr_factor
entropy_coeff += cfg.retry_entropy_boost
logger.info(
" [Step 2] PPO self-play (%d iters × %d episodes)...",
cfg.ppo_iterations, cfg.ppo_episodes_per_iter,
)
ppo_result, exporter = self._ppo_self_play(
supervised_checkpoint=sup_result["checkpoint_path"],
gen_tag=gen_tag,
gen_checkpoint_dir=gen_checkpoint_dir,
learning_rate=ppo_lr,
entropy_coeff=entropy_coeff,
)
record.ppo_win_rate = ppo_result["final_win_rate"]
record.ppo_best_win_rate = ppo_result["best_win_rate"]
record.ppo_episodes = (
cfg.ppo_iterations * cfg.ppo_episodes_per_iter
)
logger.info(
" [Step 3] Exporting PPO decisions (win rate: %.1f%%)...",
ppo_result["best_win_rate"] * 100,
)
export_path = exporter.flush()
record.ppo_decisions_exported = exporter.stats["exported_decisions"]
record.ppo_export_path = export_path or ""
# ─── Step 4: Quality Gate ───────────────────────────
logger.info(" [Step 4] Quality gate check...")
verdict = self._quality_gate_check(
ppo_win_rate=ppo_result["best_win_rate"],
total_episodes=record.ppo_episodes,
exporter=exporter,
)
if verdict.accepted:
gate_passed = True
record.gate_accepted = True
record.gate_reason = verdict.reason
logger.info(" QUALITY GATE: PASSED — %s", verdict.reason)
else:
retries += 1
record.retries = retries
logger.warning(
" QUALITY GATE: FAILED — %s", verdict.reason,
)
if retries > cfg.max_retries_per_generation:
# ─── Step 6: Discard PPO batch ──────────────
logger.warning(
" [Step 6] Max retries exceeded — "
"discarding PPO batch for generation %d.",
gen_num,
)
if export_path and os.path.exists(export_path):
os.remove(export_path)
logger.info(
" Deleted rejected PPO data: %s", export_path
)
record.gate_accepted = False
record.gate_reason = (
f"Failed after {retries - 1} retries: "
f"{verdict.reason}"
)
# ─── Step 5: Merge into dataset (if passed) ─────────────
if gate_passed:
logger.info(
" [Step 5] PPO data accepted — will be included "
"in next generation's dataset build."
)
record.status = "passed"
# ─── ELO mini-tournament (Phase 6) ────────────────
if cfg.elo_convergence_enabled and gen_num > 1:
elo = self._run_elo_mini_tournament(
gen_checkpoint_dir, gen_num
)
if elo is not None:
record.elo_rating = elo
logger.info(" ELO rating: %.1f", elo)
else:
record.status = "failed"
except Exception as e:
logger.exception(" Generation %d failed with error: %s", gen_num, e)
record.status = "failed"
record.error = str(e)
record.completed_at = datetime.now(timezone.utc).isoformat()
return record
# ------------------------------------------------------------------
# Step 1: Supervised Training
# ------------------------------------------------------------------
def _supervised_train(
self,
gen_checkpoint_dir: str,
gen_tag: str,
) -> Optional[Dict]:
"""
Build dataset from all available data and train supervised model.
Returns training summary dict or None on failure.
"""
try:
import torch
except ImportError:
raise RuntimeError("PyTorch required for distillation loop")
cfg = self.config
# Build dataset (picks up both Forge + any PPO JSONL files in results_dir)
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
try:
dataset = build_dataset(
results_dir=cfg.results_dir,
max_samples=None,
source_weights={
"forge": cfg.forge_weight,
"ppo": cfg.ppo_weight,
},
min_reward_threshold=cfg.min_reward_threshold,
)
except RuntimeError as e:
logger.warning("Dataset build failed: %s", e)
return None
if not dataset or len(dataset.get("states", [])) == 0:
logger.warning("No training data available.")
return None
# Split
train_data, val_data, test_data = split_dataset(dataset)
# Save splits (overwrite each generation — latest always at standard path)
data_dir = cfg.models_dir
os.makedirs(data_dir, exist_ok=True)
save_dataset(train_data, os.path.join(data_dir, "dataset-train.npz"))
save_dataset(val_data, os.path.join(data_dir, "dataset-val.npz"))
save_dataset(test_data, os.path.join(data_dir, "dataset-test.npz"))
# Create model
from ml.training.policy_network import PolicyNetwork
actual_dim = train_data["states"].shape[1]
model = PolicyNetwork(input_dim=actual_dim)
# Train
from ml.training.trainer import SupervisedTrainer, get_best_device
device = self._device or get_best_device()
trainer = SupervisedTrainer(
model=model,
device=device,
learning_rate=cfg.supervised_lr,
batch_size=cfg.supervised_batch_size,
epochs=cfg.supervised_epochs,
patience=cfg.supervised_patience,
checkpoint_dir=gen_checkpoint_dir,
)
summary = trainer.train(
train_states=train_data["states"],
train_labels=train_data["labels"],
val_states=val_data["states"],
val_labels=val_data["labels"],
)
# Also copy best checkpoint to the standard location
best_src = os.path.join(gen_checkpoint_dir, "best_policy.pt")
best_dst = os.path.join(cfg.checkpoint_dir, "best_policy.pt")
if os.path.exists(best_src):
shutil.copy2(best_src, best_dst)
return summary
# ------------------------------------------------------------------
# Steps 2-3: PPO Self-Play + Decision Export
# ------------------------------------------------------------------
def _ppo_self_play(
self,
supervised_checkpoint: str,
gen_tag: str,
gen_checkpoint_dir: str,
learning_rate: float = None,
entropy_coeff: float = None,
) -> tuple:
"""
Run PPO self-play initialized from the supervised checkpoint.
Returns (ppo_summary, exporter) tuple.
"""
cfg = self.config
from ml.training.ppo_trainer import PPOTrainer, PPOConfig
from ml.training.decision_exporter import DecisionExporter, ExporterConfig
# Set up decision exporter for this generation
exporter = DecisionExporter(ExporterConfig(
output_dir=cfg.results_dir,
only_wins=True,
model_version=gen_tag,
))
ppo_config = PPOConfig(
iterations=cfg.ppo_iterations,
episodes_per_iter=cfg.ppo_episodes_per_iter,
ppo_epochs=4,
batch_size=cfg.ppo_batch_size,
clip_epsilon=cfg.ppo_clip_epsilon,
entropy_coeff=entropy_coeff or cfg.ppo_entropy_coeff,
learning_rate=learning_rate or cfg.ppo_lr,
lr_schedule="cosine",
min_lr=1e-5,
opponent=cfg.opponent,
playstyle=cfg.playstyle,
checkpoint_dir=gen_checkpoint_dir,
save_every=max(1, cfg.ppo_iterations // 5),
eval_every=max(1, cfg.ppo_iterations // 10),
eval_episodes=cfg.ppo_eval_episodes,
load_supervised=supervised_checkpoint,
)
ppo_trainer = PPOTrainer(ppo_config)
# Monkey-patch collect_rollouts to include exporter
# The exporter is already wired into self_play.collect_rollouts
# via the exporter parameter, so we wrap the train loop.
original_collect = None
from ml.training import self_play as sp_module
_original_collect_rollouts = sp_module.collect_rollouts
def _instrumented_collect_rollouts(
agent_model, opponent_policy, buffer,
num_episodes=64, playstyle="midrange",
reward_config=None, **kwargs
):
return _original_collect_rollouts(
agent_model=agent_model,
opponent_policy=opponent_policy,
buffer=buffer,
num_episodes=num_episodes,
playstyle=playstyle,
reward_config=reward_config,
exporter=exporter,
)
# Temporarily replace module-level function
sp_module.collect_rollouts = _instrumented_collect_rollouts
try:
summary = ppo_trainer.train()
finally:
# Restore original
sp_module.collect_rollouts = _original_collect_rollouts
return summary, exporter
# ------------------------------------------------------------------
# Step 4: Quality Gate
# ------------------------------------------------------------------
def _quality_gate_check(
self,
ppo_win_rate: float,
total_episodes: int,
exporter: DecisionExporter,
) -> GateVerdict:
"""Run quality gate checks on the PPO output."""
# Win rate gate
win_verdict = self.quality_gate.check_win_rate(
ppo_win_rate=ppo_win_rate,
total_episodes=total_episodes,
)
if not win_verdict.accepted:
return win_verdict
# If we have baseline accuracy and this isn't the first generation,
# we could run a post-merge accuracy check here. For now, the win
# rate gate is the primary gatekeeper during the loop. The accuracy
# gate will be checked on the next generation's supervised training
# by comparing against self._baseline_accuracy.
return GateVerdict(
accepted=True,
reason=(
f"Win rate gate passed ({ppo_win_rate:.1%} >= "
f"{self.config.min_ppo_win_rate:.1%}). "
f"PPO data accepted for merge."
),
metrics={
"ppo_win_rate": ppo_win_rate,
"total_episodes": total_episodes,
"exported_decisions": exporter.stats["exported_decisions"],
},
)
# ------------------------------------------------------------------
# ELO mini-tournament (Phase 6)
# ------------------------------------------------------------------
def _run_elo_mini_tournament(
self, gen_checkpoint_dir: str, gen_num: int
) -> Optional[float]:
"""
Run a small ELO tournament between current gen, previous gen,
and heuristic baseline. Returns the current gen's ELO rating.
"""
cfg = self.config
try:
from ml.eval.elo_tracker import (
run_elo_tournament, EloHistory, HeuristicPolicy, LearnedPolicy,
)
policies = {"heuristic": HeuristicPolicy()}
# Current generation checkpoint
curr_path = os.path.join(gen_checkpoint_dir, "best_policy.pt")
curr_name = f"gen-{gen_num:03d}"
if os.path.exists(curr_path):
policies[curr_name] = LearnedPolicy(curr_path)
else:
logger.warning(" ELO: current gen checkpoint not found: %s", curr_path)
return None
# Previous generation checkpoint
if gen_num > 1:
prev_tag = f"gen-{gen_num - 1:03d}"
prev_dir = os.path.join(cfg.checkpoint_dir, prev_tag)
prev_path = os.path.join(prev_dir, "best_policy.pt")
if os.path.exists(prev_path):
policies[prev_tag] = LearnedPolicy(prev_path)
logger.info(
" [ELO] Running mini-tournament: %s (%d episodes/matchup)",
list(policies.keys()), cfg.elo_tournament_episodes,
)
result = run_elo_tournament(
policies=policies,
episodes_per_matchup=cfg.elo_tournament_episodes,
playstyle=cfg.playstyle,
)
# Save to ELO history
history_path = os.path.join(
os.path.dirname(cfg.history_dir), "elo_history.json"
) if cfg.history_dir else "data/elo_history.json"
history = EloHistory(path=history_path)
history.append(gen_num, result.ratings)
return result.ratings.get(curr_name, 0.0)
except Exception as e:
logger.warning(" ELO mini-tournament failed: %s", e)
return None
# ------------------------------------------------------------------
# Convergence detection
# ------------------------------------------------------------------
def _check_convergence(self) -> bool:
"""
Check if win rate has plateaued over recent generations.
Also checks ELO delta when elo_convergence_enabled is True.
Returns True if the loop should stop.
"""
cfg = self.config
passed = [g for g in self.generations if g.status == "passed"]
if len(passed) < cfg.convergence_window:
return False
recent = passed[-cfg.convergence_window:]
win_rates = [g.ppo_best_win_rate for g in recent]
# Check if max improvement across the window is below threshold
improvement = max(win_rates) - min(win_rates)
logger.info(
" Convergence check: last %d win rates = %s, "
"spread = %.4f (threshold = %.4f)",
cfg.convergence_window,
[f"{wr:.3f}" for wr in win_rates],
improvement,
cfg.convergence_threshold,
)
win_rate_converged = improvement < cfg.convergence_threshold
# ELO convergence check (Phase 6)
if cfg.elo_convergence_enabled and len(passed) >= 2:
elo_ratings = [g.elo_rating for g in passed[-2:] if g.elo_rating > 0]
if len(elo_ratings) >= 2:
elo_delta = elo_ratings[-1] - elo_ratings[-2]
logger.info(
" ELO convergence check: delta = %.1f (threshold = %.1f)",
elo_delta, cfg.elo_convergence_threshold,
)
if elo_delta < cfg.elo_convergence_threshold:
logger.info(" ELO delta below threshold — converged.")
return True
return win_rate_converged
# ------------------------------------------------------------------
# Device detection
# ------------------------------------------------------------------
def _detect_device(self) -> str:
"""Auto-detect best compute device."""
try:
import torch
if torch.cuda.is_available():
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
except ImportError:
pass
return "cpu"
# ------------------------------------------------------------------
# History & summary persistence
# ------------------------------------------------------------------
def _save_history(self):
"""Save generation history to JSON."""
path = os.path.join(self.config.history_dir, "generations.json")
history = [g.to_dict() for g in self.generations]
with open(path, "w") as f:
json.dump(history, f, indent=2)
logger.info(" Generation history saved: %s", path)
def _build_summary(self, total_time: float) -> Dict:
"""Build final summary dict."""
passed = [g for g in self.generations if g.status == "passed"]
failed = [g for g in self.generations if g.status == "failed"]
best_wr = max(
(g.ppo_best_win_rate for g in self.generations), default=0.0
)
return {
"generations_run": len(self.generations),
"generations_passed": len(passed),
"generations_failed": len(failed),
"best_win_rate": best_wr,
"converged": self._check_convergence() if len(passed) >= self.config.convergence_window else False,
"total_time_s": round(total_time, 1),
"device": self._device,
"config": {
"max_iterations": self.config.max_iterations,
"convergence_window": self.config.convergence_window,
"convergence_threshold": self.config.convergence_threshold,
"ppo_iterations": self.config.ppo_iterations,
"supervised_epochs": self.config.supervised_epochs,
},
"generations": [g.to_dict() for g in self.generations],
}
def _save_summary(self, summary: Dict):
"""Save final summary to JSON."""
path = os.path.join(self.config.history_dir, "summary.json")
with open(path, "w") as f:
json.dump(summary, f, indent=2, default=str)
logger.info(" Summary saved: %s", path)
# ═══════════════════════════════════════════════════════════
# CLI Entry Point
# ═══════════════════════════════════════════════════════════
def main():
"""CLI entry point for the distillation loop."""
import argparse
parser = argparse.ArgumentParser(
description="Commander AI Lab — Closed-Loop Distillation"
)
# Loop control
parser.add_argument(
"--max-iterations", type=int, default=10,
help="Maximum number of distillation generations (default: 10)",
)
parser.add_argument(
"--convergence-window", type=int, default=3,
help="Number of generations to check for win-rate plateau (default: 3)",
)
parser.add_argument(
"--convergence-threshold", type=float, default=0.01,
help="Min win-rate spread to not be considered converged (default: 0.01)",
)
# Paths
parser.add_argument(
"--results-dir", default="data/results",
help="Directory containing JSONL decision files (default: data/results)",
)
parser.add_argument(
"--models-dir", default="ml/models",
help="Directory for dataset NPZ files (default: ml/models)",
)
parser.add_argument(
"--checkpoint-dir", default="ml/models/checkpoints",
help="Directory for model checkpoints (default: ml/models/checkpoints)",
)
# Supervised training
parser.add_argument("--sup-epochs", type=int, default=30)
parser.add_argument("--sup-lr", type=float, default=1e-3)
parser.add_argument("--sup-batch-size", type=int, default=64)
parser.add_argument("--sup-patience", type=int, default=5)
# PPO self-play
parser.add_argument("--ppo-iterations", type=int, default=50)
parser.add_argument("--ppo-episodes", type=int, default=64)
parser.add_argument("--ppo-lr", type=float, default=3e-4)
parser.add_argument("--ppo-batch-size", type=int, default=256)
parser.add_argument("--ppo-eval-episodes", type=int, default=100)
parser.add_argument(
"--opponent", default="heuristic",
choices=["heuristic", "random", "self"],
)
parser.add_argument("--playstyle", default="midrange")
# Dataset
parser.add_argument("--forge-weight", type=float, default=1.0)
parser.add_argument("--ppo-weight", type=float, default=0.0)
parser.add_argument("--min-reward", type=float, default=0.0)
# Quality gate
parser.add_argument("--min-win-rate", type=float, default=0.30)
parser.add_argument("--min-forge-accuracy", type=float, default=0.35)
parser.add_argument("--max-accuracy-drop", type=float, default=0.03)
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
)
config = DistillationConfig(
max_iterations=args.max_iterations,
convergence_window=args.convergence_window,
convergence_threshold=args.convergence_threshold,
results_dir=args.results_dir,
models_dir=args.models_dir,
checkpoint_dir=args.checkpoint_dir,
supervised_epochs=args.sup_epochs,
supervised_lr=args.sup_lr,
supervised_batch_size=args.sup_batch_size,
supervised_patience=args.sup_patience,
ppo_iterations=args.ppo_iterations,
ppo_episodes_per_iter=args.ppo_episodes,
ppo_batch_size=args.ppo_batch_size,
ppo_lr=args.ppo_lr,
ppo_eval_episodes=args.ppo_eval_episodes,
opponent=args.opponent,
playstyle=args.playstyle,
forge_weight=args.forge_weight,
ppo_weight=args.ppo_weight,
min_reward_threshold=args.min_reward,
min_ppo_win_rate=args.min_win_rate,
min_forge_accuracy=args.min_forge_accuracy,
max_accuracy_drop=args.max_accuracy_drop,
)
loop = DistillationLoop(config)
summary = loop.run()
print(json.dumps(summary, indent=2, default=str))
if __name__ == "__main__":
main()
+2 -6
View File
@@ -16,10 +16,6 @@ The network is designed for:
import os
import sys
from pathlib import Path
import logging
log = logging.getLogger("commander_ai_lab.ml.training")
# Conditional torch import — graceful failure if not installed
try:
@@ -29,7 +25,7 @@ try:
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
log.warning("PyTorch not installed. Install with: pip install torch")
print("[ML] PyTorch not installed. Install with: pip install torch")
import numpy as np
@@ -208,7 +204,7 @@ def save_checkpoint(
"dropout": TRAINING_CONFIG.dropout,
},
}, path)
log.info(f"Checkpoint saved: {path}")
print(f"[ML] Checkpoint saved: {path}")
def load_checkpoint(path: str, device: str = "cpu"):
-32
View File
@@ -38,7 +38,6 @@ from ml.config.scope import (
Playstyle, PLAYSTYLE_TO_IDX, STATE_DIMS, GamePhase, PHASE_TO_IDX,
)
from ml.training.reward import compute_intermediate_reward, compute_terminal_reward, RewardConfig
from ml.training.decision_exporter import DecisionExporter
logger = logging.getLogger("ml.self_play")
@@ -385,7 +384,6 @@ def run_self_play_episode(
playstyle: str = "midrange",
max_steps: int = 50,
reward_config: RewardConfig = None,
exporter: DecisionExporter = None,
) -> List[dict]:
"""Run a single self-play episode and return the trajectory.
@@ -397,7 +395,6 @@ def run_self_play_episode(
playstyle: Playstyle for state encoding
max_steps: Maximum decision steps per episode
reward_config: Reward configuration
exporter: Optional DecisionExporter to record steps for distillation
Returns:
List of step dicts: [{state, action, reward, value, log_prob, done}, ...]
@@ -461,12 +458,6 @@ def run_self_play_episode(
"log_prob": log_prob_scalar,
"done": True,
})
if exporter is not None:
exporter.record_step(
game_state_snapshot=prev_snapshot,
action_idx=action_idx,
reward=reward,
)
break
# Opponent's turn
@@ -492,12 +483,6 @@ def run_self_play_episode(
"log_prob": log_prob_scalar,
"done": True,
})
if exporter is not None:
exporter.record_step(
game_state_snapshot=prev_snapshot,
action_idx=action_idx,
reward=reward,
)
break
# Intermediate reward
@@ -514,12 +499,6 @@ def run_self_play_episode(
"log_prob": log_prob_scalar,
"done": False,
})
if exporter is not None:
exporter.record_step(
game_state_snapshot=prev_snapshot,
action_idx=action_idx,
reward=reward,
)
# Advance turn
state = advance_turn(state)
@@ -534,7 +513,6 @@ def collect_rollouts(
num_episodes: int = 64,
playstyle: str = "midrange",
reward_config: RewardConfig = None,
exporter: DecisionExporter = None,
) -> dict:
"""Collect multiple self-play episodes into a rollout buffer.
@@ -545,7 +523,6 @@ def collect_rollouts(
num_episodes: Number of episodes to collect
playstyle: Agent playstyle
reward_config: Reward configuration
exporter: Optional DecisionExporter to record decisions for distillation
Returns:
Collection statistics dict
@@ -561,16 +538,12 @@ def collect_rollouts(
# Alternate agent seat for balanced training
agent_seat = ep % 2
if exporter is not None:
exporter.begin_episode(agent_seat=agent_seat, playstyle=playstyle)
trajectory = run_self_play_episode(
agent_model=agent_model,
opponent_policy=opponent_policy,
agent_seat=agent_seat,
playstyle=playstyle,
reward_config=reward_config,
exporter=exporter,
)
for step in trajectory:
@@ -596,11 +569,6 @@ def collect_rollouts(
else:
total_draws += 1
if exporter is not None:
won = trajectory and trajectory[-1]["done"] and trajectory[-1]["reward"] > 0.5
ep_return = sum(s["reward"] for s in trajectory)
exporter.end_episode(won=won, episode_return=ep_return)
# Compute GAE after collection
buffer.compute_returns_and_advantages(last_value=0.0)
+27 -26
View File
@@ -49,7 +49,8 @@ from ml.training.policy_network import (
PolicyNetwork, save_checkpoint, load_checkpoint,
)
logger = logging.getLogger("commander_ai_lab.ml.training")
logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
logger = logging.getLogger("ml.trainer")
# ══════════════════════════════════════════════════════════
@@ -501,40 +502,40 @@ def evaluate_model(
def print_evaluation(results: Dict):
"""Pretty-print evaluation results."""
logger.info("")
logger.info("=" * 65)
logger.info(" Test Set Evaluation")
logger.info("=" * 65)
logger.info(f" Overall Accuracy: {results['overall_accuracy']:.1%}")
logger.info(f" Total Samples: {results['total_samples']:,}")
logger.info("")
logger.info(f" {'Action':<20} {'Support':>8} {'Prec':>7} {'Recall':>7} {'F1':>7}")
logger.info(" " + "-" * 55)
print()
print("=" * 65)
print(" Test Set Evaluation")
print("=" * 65)
print(f" Overall Accuracy: {results['overall_accuracy']:.1%}")
print(f" Total Samples: {results['total_samples']:,}")
print()
print(f" {'Action':<20} {'Support':>8} {'Prec':>7} {'Recall':>7} {'F1':>7}")
print(" " + "-" * 55)
for action_name, m in results["per_class"].items():
logger.info(
print(
f" {action_name:<20} {m['support']:>8} "
f"{m['precision']:>7.3f} {m['recall']:>7.3f} {m['f1']:>7.3f}"
)
logger.info(" " + "-" * 55)
print(" " + "-" * 55)
# Confusion matrix
logger.info("")
logger.info(" Confusion Matrix (rows=true, cols=predicted):")
print()
print(" Confusion Matrix (rows=true, cols=predicted):")
header = " " + " " * 18
for c in range(NUM_ACTIONS):
short_name = IDX_TO_ACTION[c].value[:6]
header += f" {short_name:>6}"
logger.info(header)
print(header)
for r in range(NUM_ACTIONS):
row_name = IDX_TO_ACTION[r].value[:16]
row_str = f" {row_name:<18}"
for c in range(NUM_ACTIONS):
row_str += f" {results['confusion_matrix'][r][c]:>6}"
logger.info(row_str)
logger.info("")
print(row_str)
print()
# ══════════════════════════════════════════════════════════
@@ -604,8 +605,8 @@ def main():
args = parser.parse_args()
if not TORCH_AVAILABLE:
logger.info("PyTorch not installed. Install with:")
logger.info(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
print("PyTorch not installed. Install with:")
print(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
sys.exit(1)
device = args.device or get_best_device()
@@ -620,13 +621,13 @@ def main():
if args.eval_only:
# Evaluation only
if not os.path.exists(test_path):
logger.info(f"Test data not found: {test_path}")
logger.info("Run: python -m ml.scripts.ml_cli build")
print(f"Test data not found: {test_path}")
print("Run: python -m ml.scripts.ml_cli build")
sys.exit(1)
ckpt_path = args.checkpoint or os.path.join(ckpt_dir, "best_policy.pt")
if not os.path.exists(ckpt_path):
logger.info(f"Checkpoint not found: {ckpt_path}")
print(f"Checkpoint not found: {ckpt_path}")
sys.exit(1)
test_states, test_labels = load_npz_split(test_path)
@@ -640,15 +641,15 @@ def main():
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
json.dump(results, f, indent=2)
logger.info(f" Results saved: {eval_path}")
print(f" Results saved: {eval_path}")
return
# Training mode
for path, label in [(train_path, "train"), (val_path, "val")]:
if not os.path.exists(path):
logger.info(f"{label} data not found: {path}")
logger.info("Build the dataset first:")
logger.info(" python -m ml.scripts.ml_cli build --results-dir results/")
print(f"{label} data not found: {path}")
print("Build the dataset first:")
print(" python -m ml.scripts.ml_cli build --results-dir results/")
sys.exit(1)
train_states, train_labels = load_npz_split(train_path)
-47
View File
@@ -1,47 +0,0 @@
"""Pydantic models for Commander AI Lab API."""
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,
)
__all__ = [
# Requests
"StartRequest",
"ImportUrlRequest",
"ImportTextRequest",
"MetaFetchRequest",
"CreateDeckRequest",
"UpdateDeckRequest",
"AddDeckCardRequest",
"PatchDeckCardRequest",
"BulkAddRequest",
"BulkAddRecommendedRequest",
"DeckGenerationSourceConfig",
"DeckGenerationRequest",
"DeckGenV3Request",
"DeckGenV3SubstituteRequest",
# Responses
"StartResponse",
"StatusResponse",
"DeckInfo",
"GeneratedDeckCard",
]
-175
View File
@@ -1,175 +0,0 @@
"""Pydantic request models for Commander AI Lab API."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel
class StartRequest(BaseModel):
decks: list[str]
numGames: int = 100
threads: int = 4
seed: Optional[int] = None
clock: int = 300
deckSources: Optional[list[Optional[dict]]] = None
useLearnedPolicy: bool = False
policyStyle: str = "midrange"
policyGreedy: bool = False
aiSimplified: bool = False
aiThinkTimeMs: int = -1
maxQueueDepth: int = -1
class ImportUrlRequest(BaseModel):
url: str
class ImportTextRequest(BaseModel):
text: str
commander: Optional[str] = None
class MetaFetchRequest(BaseModel):
commander: str
class CreateDeckRequest(BaseModel):
name: str
commander_scryfall_id: Optional[str] = ""
commander_name: Optional[str] = ""
color_identity: Optional[list] = []
strategy_tag: Optional[str] = ""
class UpdateDeckRequest(BaseModel):
name: Optional[str] = None
commander_scryfall_id: Optional[str] = None
commander_name: Optional[str] = None
color_identity: Optional[list] = None
strategy_tag: Optional[str] = None
class AddDeckCardRequest(BaseModel):
scryfall_id: str
card_name: Optional[str] = ""
quantity: Optional[int] = 1
is_commander: Optional[int] = 0
role_tag: Optional[str] = ""
class PatchDeckCardRequest(BaseModel):
quantity: Optional[int] = None
role_tag: Optional[str] = None
class BulkAddRequest(BaseModel):
cards: list[dict]
respect_ratios: Optional[bool] = False
class BulkAddRecommendedRequest(BaseModel):
source: str = "collection"
only_owned: Optional[bool] = True
respect_ratios: Optional[bool] = False
types: Optional[list[str]] = None
roles: Optional[list[str]] = None
class DeckGenerationSourceConfig(BaseModel):
use_archidekt: bool = True
use_edhrec: bool = True
use_moxfield: bool = False
use_mtggoldfish: bool = False
archidekt_url: Optional[str] = ""
moxfield_url: Optional[str] = ""
mtggoldfish_url: Optional[str] = ""
class DeckGenerationRequest(BaseModel):
commander_name: Optional[str] = ""
commander_scryfall_id: Optional[str] = ""
color_identity: Optional[list[str]] = None
sources: Optional[DeckGenerationSourceConfig] = None
target_land_count: int = 37
target_instant_count: int = 10
target_sorcery_count: int = 8
target_artifact_count: int = 10
target_enchantment_count: int = 8
target_creature_count: int = 25
target_planeswalker_count: int = 2
only_cards_in_collection: bool = False
allow_proxies: bool = True
deck_name: Optional[str] = ""
class DeckGenV3Request(BaseModel):
commander_name: str = ""
strategy: str = ""
target_bracket: int = 3
budget_usd: Optional[float] = None
budget_mode: str = "total"
omit_cards: list[str] = []
use_collection: bool = True
run_substitution: bool = True
model: Optional[str] = None
deck_name: Optional[str] = ""
# ══════════════════════════════════════════════════════════════
# Deckgen requests (moved from routes/deckgen.py)
# ══════════════════════════════════════════════════════════════
class DeckResearchRequest(BaseModel):
deck_id: int # Deck to research
goal: Optional[str] = "Identify weaknesses and suggest upgrades"
budget_usd: Optional[float] = None
omit_cards: Optional[list[str]] = []
use_collection: bool = True
class DeckGenerateAIRequest(BaseModel):
commander: str
budget_usd: Optional[float] = None
budget_mode: str = "total" # "total" or "per_card"
omit_cards: Optional[list[str]] = []
use_collection: bool = True
# ══════════════════════════════════════════════════════════════
# Coach requests (moved from routes/coach.py)
# ══════════════════════════════════════════════════════════════
class CoachRequestBody(BaseModel):
goals: Optional[dict] = None
class CoachChatMessage(BaseModel):
role: str # "user" or "assistant"
content: str
class CoachChatRequest(BaseModel):
deck_id: str
messages: list[dict] # conversation history [{role, content}]
goals: Optional[dict] = None
stream: Optional[bool] = False
class CoachApplyRequest(BaseModel):
session_id: str
deck_id: int # numeric deck ID in the DB
accepted_cuts: list[str] = [] # card names to remove
accepted_adds: list[str] = [] # card names to add
class CoachGoalsRequest(BaseModel):
target_power_level: Optional[int] = None # 1-10
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
class DeckGenV3SubstituteRequest(BaseModel):
card_name: str
substitute_name: str
-45
View File
@@ -1,45 +0,0 @@
"""Pydantic response models for Commander AI Lab API."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel
class StartResponse(BaseModel):
batchId: str
status: str = "started"
message: str = ""
class StatusResponse(BaseModel):
batchId: str = ""
running: bool = False
completed: int = 0
total: int = 0
threads: int = 0
elapsedMs: int = 0
error: Optional[str] = None
simsPerSec: float = 0.0
run_id: str = ""
games_completed: int = 0
total_games: int = 0
current_decks: list = []
class DeckInfo(BaseModel):
name: str
filename: str
class GeneratedDeckCard(BaseModel):
scryfall_id: str = ""
name: str = ""
type_line: str = ""
mana_cost: str = ""
cmc: float = 0
card_type: str = ""
roles: list[str] = []
source: str = "collection"
quantity: int = 1
image_url: str = ""
owned_qty: int = 0
is_proxy: bool = False
-102
View File
@@ -1,102 +0,0 @@
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
from __future__ import annotations
import json
import logging
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
log = logging.getLogger("commander_ai_lab.api")
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
class Config:
"""Runtime configuration -- set via CLI args or env vars."""
forge_jar: str = ""
forge_dir: str = ""
forge_decks_dir: str = ""
lab_jar: str = ""
precon_dir: str = ""
results_dir: str = "results"
port: int = 8080
ximilar_api_key: str = ""
pplx_api_key: str = ""
CFG = Config()
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
class BatchState:
def __init__(self, batch_id: str, total_games: int, threads: int):
self.batch_id = batch_id
self.total_games = total_games
self.threads = threads
self.completed_games = 0
self.running = True
self.start_time = datetime.now()
self.elapsed_ms = 0
self.result_path: Optional[str] = None
self.error: Optional[str] = None
self.process: Optional[subprocess.Popen] = None
self.log_lines: list = []
self.sims_per_sec: float = 0.0
active_batches: dict[str, BatchState] = {}
COMMANDER_META: dict = {}
BUILTIN_COMMANDERS: dict = {
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
}
def load_commander_meta() -> None:
"""Load commander meta mapping from file or fall back to builtins."""
meta_path = Path(__file__).parent.parent / "commander-meta.json"
if meta_path.exists():
try:
with open(meta_path, "r", encoding="utf-8") as f:
COMMANDER_META.clear()
COMMANDER_META.update(json.load(f))
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
return
except Exception as e:
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
COMMANDER_META.clear()
COMMANDER_META.update(BUILTIN_COMMANDERS)
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
# ══════════════════════════════════════════════════════════════
# AI Profiles
# ══════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {"name": "default", "description": "Balanced \u2014 Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
"aggro": {"name": "aggro", "description": "Aggressive \u2014 attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
"control": {"name": "control", "description": "Control \u2014 defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
"combo": {"name": "combo", "description": "Combo \u2014 ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
"midrange": {"name": "midrange", "description": "Midrange \u2014 flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
}
+79 -121
View File
@@ -22,15 +22,11 @@ Configuration:
import argparse
import json
import logging
import logging.handlers
import os
import sys
import time
import urllib.request
import urllib.error
from datetime import datetime, timedelta
from pathlib import Path
# ── Default Config ──────────────────────────────────────────────
@@ -55,48 +51,10 @@ DEFAULT_CONFIG = {
}
# ── Logging ──────────────────────────────────────────────────
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
def setup_overnight_logging() -> logging.Logger:
"""Configure logging for the overnight runner.
Outputs to both console and a rotating log file so that
unattended runs always have a persistent record.
"""
_LOG_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("commander_ai_lab.overnight")
logger.setLevel(logging.INFO)
if logger.handlers:
return logger
fmt = logging.Formatter(
"%(asctime)s %(levelname)-5s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(fmt)
logger.addHandler(console)
file_handler = logging.handlers.RotatingFileHandler(
_LOG_DIR / "overnight-run.log",
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding="utf-8",
)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(fmt)
logger.addHandler(file_handler)
return logger
log = setup_overnight_logging()
def log(msg):
ts = datetime.now().strftime("%H:%M:%S")
print(f"[{ts}] {msg}")
sys.stdout.flush()
def api_get(base_url, path):
@@ -171,8 +129,8 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
while True:
# Hard deadline check — stop waiting if we've run out of time
if deadline and time.time() >= deadline:
log.info(f" Time limit reached while batch {batch_id} was running.")
log.info(f" The batch will continue on the server but we're moving on.")
log(f" Time limit reached while batch {batch_id} was running.")
log(f" The batch will continue on the server but we're moving on.")
return {"completed": 0, "timed_out": True}
try:
@@ -186,12 +144,12 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
elapsed = status.get("elapsedMs", 0)
if error:
log.error(f" Batch {batch_id} ERROR: {error}")
log(f" Batch {batch_id} ERROR: {error}")
return None
if not running and completed > 0:
elapsed_s = elapsed / 1000 if elapsed else 0
log.info(f" Batch {batch_id} complete: {completed} games in {elapsed_s:.0f}s")
log(f" Batch {batch_id} complete: {completed} games in {elapsed_s:.0f}s")
try:
result = api_get(base_url, f"/api/lab/result?batchId={batch_id}")
return result
@@ -202,15 +160,15 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
if not running and completed == 0:
stall_checks += 1
if stall_checks > 20: # 5 min with no progress
log.info(f" Batch {batch_id} never started. Aborting.")
log(f" Batch {batch_id} never started. Aborting.")
return None
# Progress log with ETA
if deadline:
mins_left = (deadline - time.time()) / 60
log.info(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s) — {mins_left:.0f}min left in run")
log(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s) — {mins_left:.0f}min left in run")
else:
log.info(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s)")
log(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s)")
# Track stall
if completed == last_completed:
@@ -222,9 +180,9 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
except Exception as e:
consecutive_errors += 1
if consecutive_errors > 10:
log.info(f" Lost connection to server after 10 retries. Aborting batch.")
log(f" Lost connection to server after 10 retries. Aborting batch.")
return None
log.info(f" Connection error (retry {consecutive_errors}/10): {e}")
log(f" Connection error (retry {consecutive_errors}/10): {e}")
time.sleep(poll_interval)
@@ -256,26 +214,26 @@ def wait_for_training(base_url):
error = status.get("error")
if error:
log.error(f" Training ERROR: {error}")
log(f" Training ERROR: {error}")
return False
if not running and phase in ("done", "idle") and epoch > 0:
result = status.get("result", {})
log.info(f" Training complete!")
log(f" Training complete!")
if result:
log.info(f" Final accuracy: {result.get('best_val_accuracy', '?')}")
log.info(f" Model saved: {result.get('model_path', '?')}")
log(f" Final accuracy: {result.get('best_val_accuracy', '?')}")
log(f" Model saved: {result.get('model_path', '?')}")
return True
if phase == "training" and total > 0:
metrics = status.get("metrics", {})
val_acc = metrics.get("val_accuracy", "?") if metrics else "?"
log.info(f" Training: epoch {epoch}/{total} — {message} (val_acc: {val_acc})")
log(f" Training: epoch {epoch}/{total} — {message} (val_acc: {val_acc})")
else:
log.info(f" [{phase}] {message}")
log(f" [{phase}] {message}")
except Exception as e:
log.info(f" Connection error: {e}")
log(f" Connection error: {e}")
time.sleep(poll_interval)
@@ -295,46 +253,46 @@ def run_overnight(cfg):
"""Main overnight pipeline."""
base_url = cfg["lab_url"]
log.info("=" * 60)
log.info(" Commander AI Lab — Overnight AFK Runner")
log.info("=" * 60)
log("=" * 60)
log(" Commander AI Lab — Overnight AFK Runner")
log("=" * 60)
# ── Verify server ────────────────────────────────────────
log.info("Checking lab server...")
log("Checking lab server...")
if not check_server(base_url):
log.error(f"ERROR: Cannot reach lab server at {base_url}")
log.info("Start the lab first: start-lab.bat")
log(f"ERROR: Cannot reach lab server at {base_url}")
log("Start the lab first: start-lab.bat")
return False
log.info(f"Server OK at {base_url}")
log(f"Server OK at {base_url}")
# ── Get decks ─────────────────────────────────────────────
available_decks = get_available_decks(base_url)
log.info(f"Found {len(available_decks)} decks: {', '.join(available_decks[:5])}{'...' if len(available_decks) > 5 else ''}")
log(f"Found {len(available_decks)} decks: {', '.join(available_decks[:5])}{'...' if len(available_decks) > 5 else ''}")
engine = cfg["engine"]
if engine == "deepseek":
decks = cfg["ds_decks"] if cfg["ds_decks"] else available_decks
if not decks:
log.error("ERROR: No decks available. Create decks in the Deck Builder first.")
log("ERROR: No decks available. Create decks in the Deck Builder first.")
return False
log.info(f"Using DeepSeek engine with {len(decks)} deck(s)")
log(f"Using DeepSeek engine with {len(decks)} deck(s)")
else:
decks = cfg["java_decks"]
if len(decks) != 3:
log.error("ERROR: Java engine requires exactly 3 deck names.")
log.info("Set java_decks in the config or use --decks deck1 deck2 deck3")
log("ERROR: Java engine requires exactly 3 deck names.")
log("Set java_decks in the config or use --decks deck1 deck2 deck3")
return False
log.info(f"Using Java/Forge engine with decks: {decks}")
log(f"Using Java/Forge engine with decks: {decks}")
# ── ML logging ────────────────────────────────────────────
log.info("Enabling ML logging...")
log("Enabling ML logging...")
try:
api_post(base_url, "/api/ml/toggle?enable=true", {})
log.info("ML logging enabled")
log("ML logging enabled")
except Exception as e:
log.warning(f"WARNING: Could not enable ML logging: {e}")
log.info("(DeepSeek batches always log ML data regardless)")
log(f"WARNING: Could not enable ML logging: {e}")
log("(DeepSeek batches always log ML data regardless)")
# Try alternate form
try:
api_get(base_url, "/api/ml/toggle?enable=true")
@@ -343,7 +301,7 @@ def run_overnight(cfg):
# ── Initial data stats ────────────────────────────────────
decisions_before, files_before = get_ml_data_summary(base_url)
log.info(f"Current ML data: {decisions_before} decisions in {files_before} files")
log(f"Current ML data: {decisions_before} decisions in {files_before} files")
# ── Run batches ───────────────────────────────────────────
games_per_batch = cfg["games_per_batch"]
@@ -357,32 +315,32 @@ def run_overnight(cfg):
total_games = 0
total_decisions = 0
log.info("")
log.info(f"Starting batch loop:")
log("")
log(f"Starting batch loop:")
if num_batches > 0:
log.info(f" Mode: {num_batches} batches × {games_per_batch} games")
log(f" Mode: {num_batches} batches × {games_per_batch} games")
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-train after: {'Yes' if cfg['auto_train'] else 'No'}")
log.info("")
log(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
log(f" Engine: {engine}")
log(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
log("")
while True:
# Check stopping conditions
if num_batches > 0 and batch_num >= num_batches:
log.info(f"Reached target of {num_batches} batches. Stopping.")
log(f"Reached target of {num_batches} batches. Stopping.")
break
elapsed = time.time() - start_time
remaining = deadline - time.time()
if remaining < 120: # need at least 2 min for a batch
log.info(f"Time limit approaching ({remaining:.0f}s left). Stopping sim loop.")
log(f"Time limit approaching ({remaining:.0f}s left). Stopping sim loop.")
break
batch_num += 1
elapsed_str = str(timedelta(seconds=int(elapsed)))
remaining_str = str(timedelta(seconds=int(remaining)))
log.info(f"── Batch {batch_num} ── (elapsed: {elapsed_str}, remaining: {remaining_str})")
log(f"── Batch {batch_num} ── (elapsed: {elapsed_str}, remaining: {remaining_str})")
try:
if engine == "deepseek":
@@ -394,12 +352,12 @@ def run_overnight(cfg):
)
if not batch_id:
log.error(" ERROR: Failed to start batch (no batchId returned)")
log.info(" Waiting 60s before retry...")
log(" ERROR: Failed to start batch (no batchId returned)")
log(" Waiting 60s before retry...")
time.sleep(60)
continue
log.info(f" Started batch {batch_id}")
log(f" Started batch {batch_id}")
result = wait_for_batch(base_url, batch_id, engine, deadline=deadline)
if result:
@@ -408,19 +366,19 @@ def run_overnight(cfg):
for deck in result.get("decks", []):
wr = deck.get("winRate", "?")
name = deck.get("deckName", "?")
log.info(f" {name}: {wr}% WR")
log(f" {name}: {wr}% WR")
else:
log.info(" Batch failed, continuing to next...")
log(" Batch failed, continuing to next...")
except Exception as e:
log.error(f" ERROR starting batch: {e}")
log.info(" Waiting 60s before retry...")
log(f" ERROR starting batch: {e}")
log(" Waiting 60s before retry...")
time.sleep(60)
continue
# Pause between batches
if pause_sec > 0:
log.info(f" Cooling down {pause_sec}s...")
log(f" Cooling down {pause_sec}s...")
time.sleep(pause_sec)
# ── Summary ───────────────────────────────────────────────
@@ -429,26 +387,26 @@ def run_overnight(cfg):
new_decisions = decisions_after - decisions_before
new_files = files_after - files_before
log.info("")
log.info("=" * 60)
log.info(" BATCH SIMULATION COMPLETE")
log.info("=" * 60)
log.info(f" Batches run: {batch_num}")
log.info(f" Total games: ~{total_games}")
log.info(f" New ML decisions: {new_decisions} (in {new_files} new files)")
log.info(f" Total ML data: {decisions_after} decisions")
log.info(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
log.info("")
log("")
log("=" * 60)
log(" BATCH SIMULATION COMPLETE")
log("=" * 60)
log(f" Batches run: {batch_num}")
log(f" Total games: ~{total_games}")
log(f" New ML decisions: {new_decisions} (in {new_files} new files)")
log(f" Total ML data: {decisions_after} decisions")
log(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
log("")
# ── Auto-train ────────────────────────────────────────────
if cfg["auto_train"]:
if decisions_after < 100:
log.info("Skipping training — too few decisions (need at least 100)")
log("Skipping training — too few decisions (need at least 100)")
return True
log.info("Starting ML training pipeline...")
log.info(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
log("Starting ML training pipeline...")
log(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
try:
start_training(
@@ -458,21 +416,21 @@ def run_overnight(cfg):
batch_size=cfg["train_batch_size"],
patience=cfg["train_patience"],
)
log.info("Training started, monitoring progress...")
log("Training started, monitoring progress...")
success = wait_for_training(base_url)
if success:
log.info("")
log.info("=" * 60)
log.info(" TRAINING COMPLETE — model updated!")
log.info("=" * 60)
log("")
log("=" * 60)
log(" TRAINING COMPLETE — model updated!")
log("=" * 60)
else:
log.info("Training finished with errors. Check the Training tab for details.")
log("Training finished with errors. Check the Training tab for details.")
except Exception as e:
log.error(f"ERROR starting training: {e}")
log(f"ERROR starting training: {e}")
return False
log.info("")
log.info("Overnight run finished. You can close this window.")
log("")
log("Overnight run finished. You can close this window.")
return True
-7
View File
@@ -67,13 +67,6 @@
<version>3.45.1.0</version>
</dependency>
<!-- WebSocket server for Unity/Godot client -->
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.5.6</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
-24
View File
@@ -10,32 +10,8 @@ readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
dependencies = [
"fastapi>=0.100.0",
"uvicorn[standard]>=0.23.0",
"pydantic>=2.0.0",
"httpx>=0.24.0",
"websockets>=11.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-asyncio>=0.21",
"httpx>=0.24.0",
"ruff>=0.1.0",
]
[tool.setuptools.packages.find]
where = ["src"]
[project.scripts]
commander-sim = "commander_ai_lab.lab.experiments:_cli_main"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = [".", "src"]
[tool.ruff]
line-length = 120
target-version = "py310"
-24
View File
@@ -1,24 +0,0 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test* Bench*
python_functions = test_*
# Asyncio mode for pytest-asyncio
asyncio_mode = auto
# Markers
markers =
slow: marks tests as slow (deselect with -m "not slow")
integration: marks integration tests
benchmark: marks benchmark tests
unit: marks fast unit tests
# Output
addopts =
--tb=short
-q
# Coverage (used when running: pytest --cov)
# pip install pytest-cov
# pytest --cov=src/commander_ai_lab --cov-report=term-missing
-11
View File
@@ -1,11 +0,0 @@
# Commander AI Lab — Web server dependencies (Phase 5)
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
httpx>=0.27.0 # async Scryfall art proxy
websockets>=12.0 # used by uvicorn WebSocket transport
pydantic>=2.6.0
# Dev / test
pytest>=8.0
pytest-asyncio>=0.23
httpx>=0.27.0 # TestClient uses httpx transport
-15
View File
@@ -1,15 +0,0 @@
# Core runtime dependencies
# Install with: pip install -r requirements.txt
# Or for dev tools: pip install -e ".[dev]"
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
pydantic>=2.0.0
httpx>=0.24.0
websockets>=11.0
# Deck Builder dependencies
requests>=2.31.0
beautifulsoup4>=4.12.0
ollama>=0.2.0
scrython>=2.0.0
-6
View File
@@ -1,6 +0,0 @@
"""
routes package — API routers for Commander AI Lab.
Each module defines a FastAPI APIRouter with real endpoint implementations.
All shared helpers, models, and state live in routes.shared.
"""
-671
View File
@@ -1,671 +0,0 @@
"""
routes/coach.py
===============
LLM-powered deck coaching endpoints:
GET /api/coach/status
GET /api/coach/decks
GET /api/coach/decks/{deck_id}/report
POST /api/coach/decks/{deck_id}
GET /api/coach/sessions
GET /api/coach/sessions/{session_id}
POST /api/coach/embeddings/download
GET /api/coach/embeddings/search
POST /api/coach/chat
POST /api/coach/apply
GET /api/coach/cards-like
POST /api/coach/reports/generate
Globals:
_coach_service, _coach_embeddings, _coach_llm
_deck_gen_v3, _deck_gen_v3_error
init_coach_service(), _build_deck_report_from_db()
"""
from __future__ import annotations
import json
import os
import time
import traceback
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from models.requests import CoachRequestBody, CoachChatMessage, CoachChatRequest, CoachApplyRequest, CoachGoalsRequest
from routes.shared import (
CFG,
log_coach, log_deckgen,
_get_db_conn,
_compute_deck_analysis,
)
router = APIRouter(tags=["coach"])
# ══════════════════════════════════════════════════════════════
# Coach Service (LLM-powered deck coaching)
# ══════════════════════════════════════════════════════════════
# Global coach instances (initialized at startup)
_coach_service = None
_coach_embeddings = None
_coach_llm = None
_deck_gen_v3 = None # V3 Deck Generator (Perplexity structured output)
_deck_gen_v3_error = None # Error message if V3 init failed
def init_coach_service():
"""Initialize the coach service with LLM client and embeddings."""
global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
try:
from coach.llm_client import LLMClient
from coach.embeddings import MTGEmbeddingIndex
from coach.coach_service import CoachService
from coach.config import ensure_dirs
ensure_dirs()
_coach_llm = LLMClient()
_coach_embeddings = MTGEmbeddingIndex()
# Try to load embeddings (non-blocking — download happens on first use)
try:
from coach.config import EMBEDDINGS_NPZ
if EMBEDDINGS_NPZ.exists():
_coach_embeddings.load()
log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
else:
log_coach.warning(" Coach: Embeddings not yet downloaded (will download on first use)")
except Exception as e:
log_coach.error(f" Coach: Embeddings load failed: {e}")
_coach_service = CoachService(_coach_llm, _coach_embeddings)
# Check LLM connection
llm_status = _coach_llm.check_connection()
if llm_status.get("connected"):
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
else:
log_coach.warning(f" Coach LLM: Not connected (start Ollama on 192.168.0.240:11434)")
# Initialize V3 Deck Generator (Ollama-based)
_deck_gen_v3_error = None
try:
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
_deck_gen_v3 = DeckBuilderAdapter(
db_conn_factory=_get_db_conn,
model="gpt-oss:20b",
)
log_deckgen.info(" Deck Gen V3: Initialized (Ollama DeckBuilderAdapter)")
except ImportError as e:
_deck_gen_v3_error = f"Missing dependency: {e}"
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
_deck_gen_v3 = None
except Exception as e:
_deck_gen_v3_error = str(e)
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
_deck_gen_v3 = None
except Exception as e:
log_coach.error(f" Coach: Failed to initialize: {e}\n{traceback.format_exc()}")
_coach_service = None
@router.get("/api/coach/status")
async def coach_status():
"""Check coach subsystem health."""
if _coach_service is None:
return {"llmConnected": False, "embeddingsLoaded": False,
"embeddingCards": 0, "deckReportsAvailable": 0,
"error": "Coach service not initialized"}
status = _coach_service.get_status()
return status.model_dump()
@router.get("/api/coach/decks")
async def coach_list_decks():
"""List all decks available for coaching (from deck builder DB)."""
conn = _get_db_conn()
rows = conn.execute(
"""SELECT d.id as deck_id, d.name as deck_name, d.commander_name,
COUNT(dc.id) as card_count
FROM decks d
LEFT JOIN deck_cards dc ON dc.deck_id = d.id
GROUP BY d.id
ORDER BY d.name"""
).fetchall()
# Also get report availability from coach service
report_ids = set()
if _coach_service:
report_ids = set(_coach_service.list_deck_reports())
decks = []
for r in rows:
deck_name = r["deck_name"]
# Check if a report exists (by deck name slug match)
has_report = any(
deck_name.lower().replace(" ", "-") == rid.lower() or
deck_name.lower() == rid.lower()
for rid in report_ids
)
decks.append({
"deck_id": r["deck_id"],
"deck_name": r["deck_name"],
"commander": r["commander_name"] or "",
"card_count": r["card_count"],
"has_report": has_report,
"report_count": 1 if has_report else 0,
"last_report_date": None,
})
return decks
@router.get("/api/coach/decks/{deck_id}/report")
async def coach_get_report(deck_id: str):
"""Get the latest DeckReport for a deck."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
report = _coach_service.load_deck_report(deck_id)
if report is None:
raise HTTPException(404, f"Deck report not found: {deck_id}")
return report.model_dump()
@router.post("/api/coach/decks/{deck_id}")
async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
"""Trigger a coaching session for a deck."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
# Load embeddings on first use if not loaded
if _coach_embeddings and not _coach_embeddings.loaded:
try:
_coach_embeddings.load(force_download=True)
except Exception as e:
log_coach.error(f" Coach: Embeddings download failed: {e}")
goals = None
if body and body.goals:
from coach.models import CoachGoals
try:
goals = CoachGoals(**body.goals)
except Exception:
goals = None
# Build a fallback DeckReport from the DB if no simulation report exists
fallback_report = None
try:
fallback_report = _build_deck_report_from_db(deck_id)
except Exception as e:
log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
try:
session = await _coach_service.run_coaching_session(deck_id, goals, fallback_report=fallback_report)
return session.model_dump()
except ValueError as e:
raise HTTPException(404, str(e))
except ConnectionError as e:
raise HTTPException(503, f"LLM connection failed: {e}")
except Exception as e:
raise HTTPException(500, f"Coach session failed: {e}")
def _build_deck_report_from_db(deck_slug: str):
"""
Build a lightweight DeckReport from the deck builder DB when no
simulation report exists. Allows the coach to analyze deck composition
even without simulation data.
"""
from coach.models import DeckReport, CardPerformance, DeckStructure
conn = _get_db_conn()
# --- Input validation ---
import re
if not deck_slug or len(deck_slug) > 200:
return None
deck_slug = re.sub(r'[^a-zA-Z0-9 _-]', '', deck_slug)
if not deck_slug:
return None
# Find the deck by parameterized query instead of full-table scan
slug_lower = deck_slug.lower()
slug_hyphenated = slug_lower.replace(' ', '-')
clean_slug = re.sub(r'[^a-z0-9]+', '-', slug_lower).strip('-')
if slug_lower.isdigit():
# Numeric slug -- match by ID directly
matched_deck = conn.execute(
"SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
(int(slug_lower),)
).fetchone()
else:
# Try exact name match, hyphenated match, or clean-slug match
matched_deck = conn.execute(
"""SELECT id, name, commander_name, color_identity FROM decks
WHERE LOWER(name) = ?
OR LOWER(REPLACE(name, ' ', '-')) = ?
OR LOWER(REPLACE(name, ' ', '-')) = ?
LIMIT 1""",
(slug_lower, slug_hyphenated, clean_slug)
).fetchone()
if matched_deck is None:
return None
deck_id = matched_deck["id"]
deck_name = matched_deck["name"]
commander = matched_deck["commander_name"] or ""
# Parse color identity
ci_raw = matched_deck["color_identity"] or "[]"
try:
color_identity = json.loads(ci_raw) if isinstance(ci_raw, str) else ci_raw
except Exception:
color_identity = []
# Load all cards in this deck
card_rows = conn.execute(
"""SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
ce.type_line, ce.cmc, ce.oracle_text
FROM deck_cards dc
LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name)
WHERE dc.deck_id = ?""",
(deck_id,)
).fetchall()
cards = []
type_counts = {}
cmc_buckets = [0] * 8
land_count = 0
for cr in card_rows:
card_name = cr["card_name"] or ""
type_line = cr["type_line"] or ""
cmc = cr["cmc"] or 0
qty = cr["quantity"] or 1
role_tag = cr["role_tag"] or ""
# Build tags from type_line and role_tag
tags = []
if role_tag:
tags.append(role_tag)
if "Land" in type_line:
tags.append("land")
land_count += qty
elif "Creature" in type_line:
tags.append("creature")
elif "Instant" in type_line:
tags.append("instant")
elif "Sorcery" in type_line:
tags.append("sorcery")
elif "Artifact" in type_line:
tags.append("artifact")
elif "Enchantment" in type_line:
tags.append("enchantment")
elif "Planeswalker" in type_line:
tags.append("planeswalker")
# Type count
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
if t in type_line:
type_counts[t] = type_counts.get(t, 0) + qty
# CMC bucket
bucket = min(int(cmc), 7)
cmc_buckets[bucket] += qty
# CardPerformance with zeroed-out sim stats
cards.append(CardPerformance(
name=card_name,
drawnRate=0.0,
castRate=0.0,
impactScore=0.0,
tags=tags,
))
slug = deck_slug
return DeckReport(
deckId=slug,
commander=commander,
colorIdentity=color_identity,
cards=cards,
structure=DeckStructure(
landCount=land_count,
curveBuckets=cmc_buckets,
cardTypeCounts=type_counts,
),
)
@router.get("/api/coach/sessions")
async def coach_list_sessions(deck_id: str = None):
"""List all coaching sessions, optionally filtered by deck."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
return {"sessions": _coach_service.list_sessions(deck_id)}
@router.get("/api/coach/sessions/{session_id}")
async def coach_get_session(session_id: str):
"""Get a specific coaching session."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
session = _coach_service.load_session(session_id)
if session is None:
raise HTTPException(404, f"Session not found: {session_id}")
return session.model_dump()
@router.post("/api/coach/embeddings/download")
async def coach_download_embeddings():
"""Download and convert MTG card embeddings from HuggingFace."""
if _coach_embeddings is None:
raise HTTPException(500, "Coach service not initialized")
try:
_coach_embeddings.load(force_download=True)
return {
"success": True,
"cards": _coach_embeddings.card_count,
"message": f"Loaded {_coach_embeddings.card_count} card embeddings"
}
except Exception as e:
raise HTTPException(500, f"Failed to download embeddings: {e}")
@router.get("/api/coach/embeddings/search")
async def coach_search_similar(card: str, colors: str = None, top_n: int = 10):
"""Search for similar cards by name."""
if _coach_embeddings is None or not _coach_embeddings.loaded:
raise HTTPException(503, "Embeddings not loaded")
color_filter = list(colors.upper()) if colors else None
matches = _coach_embeddings.search_similar(
query_card=card, color_filter=color_filter, top_n=top_n
)
return {"query": card, "matches": [m.to_dict() for m in matches]}
@router.post("/api/coach/chat")
async def coach_chat(body: CoachChatRequest):
"""Multi-turn coaching chat. Sends conversation history to LLM and returns response."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
# Load deck report for context
report = _coach_service.load_deck_report(body.deck_id)
# Build system prompt with report context
from coach.prompt_template import build_system_prompt
from coach.models import CoachGoals
goals = None
if body.goals:
try:
goals = CoachGoals(**body.goals)
except Exception:
goals = None
system_prompt = ""
if report:
system_prompt = build_system_prompt(report, goals)
else:
system_prompt = (
"You are an expert Magic: The Gathering Commander deck coach. "
"Answer questions about deck building, strategy, card choices, and game theory. "
"Be specific and actionable in your advice."
)
# Build messages array for LLM
messages = [{"role": "system", "content": system_prompt}]
for msg in body.messages:
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
# Call LLM with multi-turn messages
try:
import json
from urllib.request import urlopen, Request as UrlRequest
from coach.config import LLM_URL, LLM_TIMEOUT
model_name = _coach_llm._resolve_model()
llm_body = {
"model": model_name,
"messages": messages,
"temperature": 0.7,
"max_tokens": 4096,
}
if body.stream:
# SSE streaming response
llm_body["stream"] = True
async def generate():
import asyncio
def _stream():
req = UrlRequest(
f"{_coach_llm.base_url}/chat/completions",
data=json.dumps(llm_body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
import http.client
import urllib.parse
parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions")
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LLM_TIMEOUT)
conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"),
headers={"Content-Type": "application/json"})
resp = conn.getresponse()
chunks = []
while True:
line = resp.readline()
if not line:
break
line = line.decode("utf-8").strip()
if line.startswith("data: "):
data = line[6:]
if data == "[DONE]":
chunks.append("[DONE]")
break
chunks.append(data)
conn.close()
return chunks
loop = asyncio.get_event_loop()
chunks = await loop.run_in_executor(None, _stream)
for chunk_str in chunks:
if chunk_str == "[DONE]":
yield f"data: [DONE]\n\n"
break
try:
chunk = json.loads(chunk_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
content = delta.get("content", "")
if content:
# Strip think tags from streaming chunks
yield f"data: {json.dumps({'content': content})}\n\n"
except json.JSONDecodeError:
continue
return StreamingResponse(generate(), media_type="text/event-stream")
else:
# Non-streaming: regular call
req = UrlRequest(
f"{_coach_llm.base_url}/chat/completions",
data=json.dumps(llm_body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
import asyncio
loop = asyncio.get_event_loop()
def _call():
with urlopen(req, timeout=LLM_TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
raw = await loop.run_in_executor(None, _call)
content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
# Strip think tags
content = _coach_llm._strip_think_tags(content)
usage = raw.get("usage", {})
return {
"content": content,
"model": raw.get("model", ""),
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
}
except Exception as e:
raise HTTPException(500, f"Chat failed: {e}")
@router.post("/api/coach/apply")
async def coach_apply_suggestions(body: CoachApplyRequest):
"""Apply accepted coaching suggestions to a deck — remove cuts, add adds."""
conn = _get_db_conn()
# Verify deck exists
deck = conn.execute("SELECT * FROM decks WHERE id = ?", (body.deck_id,)).fetchone()
if not deck:
raise HTTPException(404, f"Deck {body.deck_id} not found")
results = {"cuts": [], "adds": [], "errors": []}
# Process cuts: remove cards by name
for card_name in body.accepted_cuts:
row = conn.execute(
"SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1",
(body.deck_id, card_name)
).fetchone()
if row:
conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
results["cuts"].append({"name": card_name, "status": "removed"})
else:
# Try case-insensitive
row = conn.execute(
"SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1",
(body.deck_id, card_name)
).fetchone()
if row:
conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
results["cuts"].append({"name": row["card_name"], "status": "removed"})
else:
results["errors"].append({"name": card_name, "error": "Card not found in deck"})
# Process adds: look up in collection first, then Scryfall
for card_name in body.accepted_adds:
# Try to find in collection
ce = conn.execute(
"SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
(card_name,)
).fetchone()
scryfall_id = None
resolved_name = card_name
if ce:
scryfall_id = ce["scryfall_id"]
resolved_name = ce["name"]
else:
# Try Scryfall API lookup
try:
import urllib.parse
from urllib.request import urlopen, Request as UrlRequest
encoded = urllib.parse.quote(card_name)
scry_req = UrlRequest(
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
headers={"User-Agent": "commander-ai-lab/1.0"}
)
with urlopen(scry_req, timeout=10) as resp:
card_data = json.loads(resp.read().decode("utf-8"))
scryfall_id = card_data.get("id")
resolved_name = card_data.get("name", card_name)
except Exception:
pass
if scryfall_id:
# Check if already in deck
existing = conn.execute(
"SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
(body.deck_id, scryfall_id)
).fetchone()
if not existing:
conn.execute(
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)",
(body.deck_id, scryfall_id, resolved_name)
)
results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"})
else:
results["adds"].append({"name": resolved_name, "status": "already_in_deck"})
else:
results["errors"].append({"name": card_name, "error": "Could not resolve card"})
conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,))
conn.commit()
results["total_cuts"] = len(results["cuts"])
results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"])
return results
@router.get("/api/coach/cards-like")
async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
"""Find cards similar to a given card using embeddings. UI-friendly version."""
if _coach_embeddings is None or not _coach_embeddings.loaded:
raise HTTPException(503, "Embeddings not loaded")
color_filter = list(colors.upper()) if colors else None
matches = _coach_embeddings.search_similar(
query_card=card, color_filter=color_filter, top_n=top_n
)
conn = _get_db_conn()
results = []
for m in matches:
# Check if card is in collection
owned = conn.execute(
"SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)",
(m.name,)
).fetchone()
owned_qty = (owned["qty"] or 0) if owned else 0
# Get image URL and price
ce = conn.execute(
"SELECT scryfall_id, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
(m.name,)
).fetchone()
results.append({
"name": m.name,
"similarity": round(m.similarity, 4),
"types": m.types,
"mana_value": m.mana_value,
"mana_cost": m.mana_cost,
"text": m.text[:200] if m.text else "",
"owned_qty": owned_qty,
"image_url": f"https://api.scryfall.com/cards/{ce['scryfall_id']}?format=image&version=normal" if ce and ce["scryfall_id"] else None,
"tcg_price": ce["tcg_price"] if ce else None,
})
return {"query": card, "results": results}
@router.post("/api/coach/reports/generate")
async def coach_generate_reports():
"""Rebuild all deck reports from batch result JSONs in results/."""
try:
from coach.report_generator import generate_deck_reports
lab_root = Path(__file__).parent.parent
results_dir = str(lab_root / CFG.results_dir)
reports_dir = str(lab_root / "deck-reports")
updated = generate_deck_reports(results_dir, reports_dir)
return {
"status": "ok",
"decksUpdated": updated,
"count": len(updated),
"message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found",
}
except Exception as e:
raise HTTPException(500, f"Report generation failed: {e}")
-487
View File
@@ -1,487 +0,0 @@
"""
routes/collection.py
====================
Collection management endpoints:
GET /api/collection
GET /api/collection/export
GET /api/collection/sets
GET /api/collection/keywords
POST /api/collection/import
GET /api/collection/{cardId}
PATCH /api/collection/{cardId}
GET /api/cache/scryfall
DELETE /api/cache/scryfall
POST /api/cache/scryfall/evict
"""
from __future__ import annotations
import csv
import io
import json
import re
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
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/export")
async def export_collection(
format: str = "INTERNAL_CSV",
q: Optional[str] = None,
colors: Optional[str] = None,
types: Optional[str] = None,
isLegendary: Optional[bool] = None,
isBasic: Optional[bool] = None,
isGameChanger: Optional[bool] = None,
highSalt: Optional[bool] = None,
finish: Optional[str] = None,
cmcMin: Optional[float] = None,
cmcMax: Optional[float] = None,
priceMin: Optional[float] = None,
priceMax: Optional[float] = None,
category: Optional[str] = None,
):
"""Export collection in various formats with optional filters."""
where_str, params = _build_collection_filters(
q=q, colors=colors, types=types, isLegendary=isLegendary,
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
priceMin=priceMin, priceMax=priceMax, category=category,
)
sql = f"SELECT * FROM collection_entries {where_str} ORDER BY name ASC"
conn = _get_db_conn()
rows = [_row_to_dict(r) for r in conn.execute(sql, params).fetchall()]
fmt = format.upper()
output = io.StringIO()
if fmt == "INTERNAL_CSV":
if rows:
fieldnames = list(rows[0].keys())
else:
fieldnames = [
"id", "name", "type_line", "subtypes", "is_legendary", "is_basic",
"color_identity", "cmc", "oracle_text", "keywords", "tcg_price",
"salt_score", "is_game_changer", "category", "scryfall_id", "tcgplayer_id",
"quantity", "finish", "condition", "language", "notes", "tags",
"set_code", "collector_number", "created_at", "updated_at",
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
filename = "collection_export.csv"
media_type = "text/csv"
elif fmt == "MOXFIELD_CSV":
fieldnames = ["Count", "Name", "Edition", "Condition", "Language", "Foil", "Collector Number"]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
finish_val = row.get("finish", "NORMAL").upper()
foil_val = "foil" if finish_val == "FOIL" else ("etched" if finish_val == "ETCHED" else "")
writer.writerow({
"Count": row.get("quantity", 1),
"Name": row.get("name", ""),
"Edition": row.get("set_code", "").upper(),
"Condition": row.get("condition", ""),
"Language": row.get("language", ""),
"Foil": foil_val,
"Collector Number": row.get("collector_number", ""),
})
filename = "collection_moxfield.csv"
media_type = "text/csv"
elif fmt == "TEXT":
lines = []
for row in rows:
qty = row.get("quantity", 1)
name = row.get("name", "")
set_code = row.get("set_code", "").upper()
coll_num = row.get("collector_number", "")
if set_code and coll_num:
lines.append(f"{qty} {name} ({set_code}) {coll_num}")
elif set_code:
lines.append(f"{qty} {name} ({set_code})")
else:
lines.append(f"{qty} {name}")
output.write("\n".join(lines))
filename = "collection_export.txt"
media_type = "text/plain"
else:
raise HTTPException(400, f"Unknown format: {format}. Use INTERNAL_CSV, MOXFIELD_CSV, or TEXT.")
content = output.getvalue()
def iter_content():
yield content.encode("utf-8")
return StreamingResponse(
iter_content(),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/api/collection/sets")
async def collection_sets():
"""Return distinct set codes + names in the collection (for filter autocomplete)."""
conn = _get_db_conn()
rows = conn.execute(
"SELECT DISTINCT UPPER(set_code) as code, set_name as name FROM collection_entries WHERE set_code != '' ORDER BY set_name ASC"
).fetchall()
result = []
for r in rows:
code = r["code"] if "code" in r.keys() else r[0]
name = r["name"] if "name" in r.keys() else r[1]
if code:
result.append({"code": code, "name": name or code})
return JSONResponse(result)
@router.get("/api/collection/keywords")
async def collection_keywords():
"""Return distinct keywords found in the collection."""
conn = _get_db_conn()
rows = conn.execute("SELECT keywords FROM collection_entries WHERE keywords != '' AND keywords != '[]'").fetchall()
kw_set = set()
for r in rows:
try:
kws = json.loads(r["keywords"]) if isinstance(r["keywords"], str) else r["keywords"]
if isinstance(kws, list):
for k in kws:
if k:
kw_set.add(k)
except (json.JSONDecodeError, TypeError):
pass
return JSONResponse(sorted(kw_set))
@router.get("/api/collection")
async def list_collection(
page: int = 1,
pageSize: int = 50,
q: Optional[str] = None,
sortField: Optional[str] = None,
sortDir: Optional[str] = None,
colors: Optional[str] = None,
types: Optional[str] = None,
isLegendary: Optional[bool] = None,
isBasic: Optional[bool] = None,
isGameChanger: Optional[bool] = None,
highSalt: Optional[bool] = None,
finish: Optional[str] = None,
cmcMin: Optional[float] = None,
cmcMax: Optional[float] = None,
priceMin: Optional[float] = None,
priceMax: Optional[float] = None,
category: Optional[str] = None,
deck_id: Optional[int] = 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,
):
"""List collection entries with search, sort, and filter.
Optional deck_id: when provided, includes in_deck_quantity for each result.
"""
where_str, params = _build_collection_filters(
q=q, colors=colors, types=types, isLegendary=isLegendary,
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,
)
# Validate sort
sort_field = sortField if sortField in VALID_SORT_FIELDS else "name"
sort_dir = "DESC" if (sortDir or "").upper() == "DESC" else "ASC"
conn = _get_db_conn()
# Count total
count_sql = f"SELECT COUNT(*) FROM collection_entries {where_str}"
total = conn.execute(count_sql, params).fetchone()[0]
# Paginate
offset = (max(1, page) - 1) * pageSize
data_sql = f"""
SELECT * FROM collection_entries {where_str}
ORDER BY {sort_field} {sort_dir}
LIMIT ? OFFSET ?
"""
rows = conn.execute(data_sql, params + [pageSize, offset]).fetchall()
items = [_add_image_url(_row_to_dict(r)) for r in rows]
# If deck_id provided, annotate each item with how many copies are already in that deck
if deck_id is not None:
# Build a map of scryfall_id -> quantity in deck
deck_card_rows = conn.execute(
"SELECT scryfall_id, SUM(quantity) as qty FROM deck_cards WHERE deck_id = ? GROUP BY scryfall_id",
(deck_id,)
).fetchall()
in_deck_map = {row["scryfall_id"]: row["qty"] for row in deck_card_rows}
for item in items:
item["in_deck_quantity"] = in_deck_map.get(item.get("scryfall_id", ""), 0)
return {"items": items, "page": page, "pageSize": pageSize, "total": total}
@router.get("/api/collection/{cardId}")
async def get_collection_card(cardId: int):
"""Get a single collection entry by ID."""
conn = _get_db_conn()
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
if not row:
raise HTTPException(404, f"Card with id {cardId} not found")
return _add_image_url(_row_to_dict(row))
@router.patch("/api/collection/{cardId}")
async def update_collection_card(cardId: int, body: dict):
"""Update mutable fields of a collection entry."""
conn = _get_db_conn()
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
if not row:
raise HTTPException(404, f"Card with id {cardId} not found")
updates = {}
if "category" in body:
cat = body["category"]
if isinstance(cat, list):
updates["category"] = json.dumps(cat)
else:
updates["category"] = cat
if "tags" in body:
updates["tags"] = str(body["tags"])
if "notes" in body:
updates["notes"] = str(body["notes"])
if "finish" in body:
finish_val = str(body["finish"]).upper()
if finish_val not in ("NORMAL", "FOIL", "ETCHED"):
raise HTTPException(400, "finish must be NORMAL, FOIL, or ETCHED")
updates["finish"] = finish_val
if not updates:
return _add_image_url(_row_to_dict(row))
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [cardId]
conn.execute(f"UPDATE collection_entries SET {set_clause} WHERE id = ?", values)
conn.commit()
updated_row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
return _add_image_url(_row_to_dict(updated_row))
# ══════════════════════════════════════════════════════════════
# Collection Import Endpoint
# ══════════════════════════════════════════════════════════════
@router.post("/api/collection/import")
async def import_collection(body: dict):
"""
Import cards into the collection from CSV, Moxfield, Archidekt, or plain text.
Body:
{
"source": "CSV" | "MOXFIELD" | "ARCHIDEKT" | "TEXT",
"mode": "MERGE" | "REPLACE",
"content": "...csv or text content...",
"mapping": { ... optional column mapping ... }
}
"""
source = str(body.get("source", "CSV")).upper()
mode = str(body.get("mode", "MERGE")).upper()
content = str(body.get("content", ""))
mapping = body.get("mapping", {})
if not content.strip():
raise HTTPException(400, "content is required")
imported_count = 0
updated_count = 0
failed_count = 0
errors = []
conn = _get_db_conn()
# REPLACE mode: clear existing entries
if mode == "REPLACE":
conn.execute("DELETE FROM collection_entries")
conn.commit()
# Parse the content into raw rows
try:
raw_rows = _parse_csv_content(content, source, mapping if mapping else None)
except Exception as e:
raise HTTPException(400, f"Failed to parse content: {str(e)}")
for raw in raw_rows:
name = raw.get("name", "").strip()
if not name:
continue
quantity = int(raw.get("quantity", 1))
set_code = raw.get("set_code", "").lower()
collector_number = raw.get("collector_number", "")
finish = _parse_finish(raw.get("finish", ""))
condition = raw.get("condition", "")
language = raw.get("language", "")
notes = raw.get("notes", "")
tags = raw.get("tags", "")
# Scryfall enrichment
try:
enriched = _enrich_from_scryfall(name, set_code, collector_number)
except Exception as e:
errors.append(f"Scryfall error for '{name}': {str(e)}")
failed_count += 1
continue
if not enriched or "_error" in enriched:
err_msg = enriched.get("_error", f"Card not found on Scryfall: '{name}'") if enriched else f"Card not found on Scryfall: '{name}'"
errors.append(err_msg)
failed_count += 1
continue
# Use Scryfall-resolved name if available
resolved_name = enriched.get("name", name)
# Check for existing entry (identity key)
existing = conn.execute(
"""SELECT id, quantity FROM collection_entries
WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""",
(resolved_name, set_code, collector_number, finish),
).fetchone()
if existing and mode == "MERGE":
# Update quantity
new_qty = existing["quantity"] + quantity
conn.execute(
"UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
(new_qty, existing["id"]),
)
conn.commit()
updated_count += 1
else:
# Insert new row
conn.execute(
"""INSERT INTO collection_entries
(name, type_line, subtypes, is_legendary, is_basic, color_identity,
cmc, mana_cost, oracle_text, keywords,
power, toughness, rarity, set_name, edhrec_rank,
tcg_price, salt_score, is_game_changer,
category, scryfall_id, tcgplayer_id,
quantity, finish, condition, language, notes, tags,
set_code, collector_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
resolved_name,
enriched.get("type_line", ""),
enriched.get("subtypes", "[]"),
enriched.get("is_legendary", 0),
enriched.get("is_basic", 0),
enriched.get("color_identity", "[]"),
enriched.get("cmc", 0.0),
enriched.get("mana_cost", ""),
enriched.get("oracle_text", ""),
enriched.get("keywords", "[]"),
enriched.get("power", ""),
enriched.get("toughness", ""),
enriched.get("rarity", ""),
enriched.get("set_name", ""),
enriched.get("edhrec_rank", 0),
enriched.get("tcg_price", 0.0),
enriched.get("salt_score", 0.0),
enriched.get("is_game_changer", 0),
enriched.get("category", "[]"),
enriched.get("scryfall_id", ""),
enriched.get("tcgplayer_id", ""),
quantity,
finish,
condition,
language,
notes,
tags,
set_code,
collector_number,
),
)
conn.commit()
imported_count += 1
return {
"importedCount": imported_count,
"updatedCount": updated_count,
"failedCount": failed_count,
"errors": errors,
}
# ══════════════════════════════════════════════════════════════
# Scryfall Cache Management Endpoints
# ══════════════════════════════════════════════════════════════
@router.get("/api/cache/scryfall")
async def scryfall_cache_stats():
"""Return Scryfall response cache statistics."""
return _scryfall_cache.stats()
@router.delete("/api/cache/scryfall")
async def scryfall_cache_clear():
"""Clear all cached Scryfall responses."""
deleted = _scryfall_cache.clear()
return {"cleared": deleted, "message": f"Deleted {deleted} cached entries"}
@router.post("/api/cache/scryfall/evict")
async def scryfall_cache_evict_expired():
"""Remove only expired entries (older than TTL) from the cache."""
evicted = _scryfall_cache.evict_expired()
return {"evicted": evicted, "message": f"Evicted {evicted} expired entries"}
File diff suppressed because it is too large. Load diff
-1561
View File
File diff suppressed because it is too large. Load diff
-811
View File
@@ -1,811 +0,0 @@
"""
routes/deepseek.py
==================
Python simulator & DeepSeek AI opponent endpoints:
POST /api/sim/run
GET /api/sim/status
GET /api/sim/result
POST /api/sim/run-from-deck
POST /api/sim/run-deepseek
POST /api/deepseek/connect
GET /api/deepseek/status
POST /api/deepseek/configure
GET /api/deepseek/logs
"""
from __future__ import annotations
import json
import logging
import os
import sys
import time
import threading as _threading
import traceback
import uuid as _uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi import Request as FastAPIRequest
from fastapi.responses import JSONResponse
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()
def _get_validator_brain() -> ValidatorBrain | None:
global _validator_brain
if os.environ.get("VALIDATOR_ENABLED", "false").lower() != "true":
return None
with _validator_lock:
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:14b"),
max_tokens=int(os.environ.get("VALIDATOR_MAX_TOKENS", "2048")),
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')
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
_SRC_PATH_ADDED = True
def _build_deck_from_card_data(card_data: list[dict]) -> list:
"""Build a list of Card objects from DB card dicts. Used by v2 + deepseek threads."""
_ensure_src_path()
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.rules import enrich_card
deck = []
for cd in card_data:
c = Card(name=cd['name'])
if cd.get('type_line'):
c.type_line = cd['type_line']
if cd.get('cmc'):
c.cmc = float(cd['cmc'])
if cd.get('power') and cd.get('toughness'):
c.power = str(cd['power'])
c.toughness = str(cd['toughness'])
c.pt = c.power + '/' + c.toughness
if cd.get('oracle_text'):
c.oracle_text = cd['oracle_text']
if cd.get('mana_cost'):
c.mana_cost = cd['mana_cost']
if cd.get('keywords'):
kw = cd['keywords']
if isinstance(kw, str):
try:
kw = json.loads(kw)
except Exception:
kw = []
if isinstance(kw, list):
c.keywords = kw
enrich_card(c)
deck.append(c)
return deck
def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
num_games, sim_id, **run_kwargs):
"""Run *num_games* and accumulate stats. Returns (game_results, stats, elapsed)."""
start = time.time()
wins = losses = total_turns = 0
total_damage_dealt = total_damage_received = 0
total_spells_cast = total_creatures_played = 0
total_removal_used = total_ramp_played = 0
total_cards_drawn = total_max_board = 0
game_results = []
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}'
result = engine.run(deck_a, deck_b, name_a=deck_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'):
turn_entry['player_explanation'] = v['player_explanation']
game_results.append(game_data)
if result.winner == 0:
wins += 1
else:
losses += 1
total_turns += result.turns
if result.player_a_stats:
s = result.player_a_stats
total_damage_dealt += s.damage_dealt
total_damage_received += s.damage_received
total_spells_cast += s.spells_cast
total_creatures_played += s.creatures_played
total_removal_used += s.removal_used
total_ramp_played += s.ramp_played
total_cards_drawn += s.cards_drawn
total_max_board += s.max_board_size
with _sim_lock:
_sim_runs[sim_id]['completed'] = i + 1
elapsed = time.time() - start
stats = dict(
wins=wins, losses=losses, total_turns=total_turns,
total_damage_dealt=total_damage_dealt,
total_damage_received=total_damage_received,
total_spells_cast=total_spells_cast,
total_creatures_played=total_creatures_played,
total_removal_used=total_removal_used,
total_ramp_played=total_ramp_played,
total_cards_drawn=total_cards_drawn,
total_max_board=total_max_board,
)
return game_results, stats, elapsed
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,
'opponentName': opponent_name,
'totalGames': n,
'wins': stats['wins'],
'losses': stats['losses'],
'winRate': round(stats['wins'] / n * 100, 1) if n > 0 else 0.0,
'avgTurns': round(stats['total_turns'] / n, 1) if n > 0 else 0.0,
'avgDamageDealt': round(stats['total_damage_dealt'] / n, 1) if n > 0 else 0.0,
'avgDamageReceived': round(stats['total_damage_received'] / n, 1) if n > 0 else 0.0,
'avgSpellsCast': round(stats['total_spells_cast'] / n, 1) if n > 0 else 0.0,
'avgCreaturesPlayed': round(stats['total_creatures_played'] / n, 1) if n > 0 else 0.0,
'avgRemovalUsed': round(stats['total_removal_used'] / n, 1) if n > 0 else 0.0,
'avgRampPlayed': round(stats['total_ramp_played'] / n, 1) if n > 0 else 0.0,
'avgCardsDrawn': round(stats['total_cards_drawn'] / n, 1) if n > 0 else 0.0,
'avgMaxBoardSize': round(stats['total_max_board'] / n, 1) if n > 0 else 0.0,
'elapsedSeconds': round(elapsed, 3),
}
summary.update(extra)
return summary
def _finish_sim(sim_id, summary, game_results, engine=None):
"""Mark a sim run as complete."""
with _sim_lock:
_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')
batch_data = {
'format': 'python-sim',
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'simId': sim_id,
'decks': [
{
'deckName': deck_name,
'seatIndex': 0,
'commanderName': summary.get('commanderName', ''),
'colorIdentity': summary.get('colorIdentity', []),
'games': game_results,
},
{
'deckName': opponent_name,
'seatIndex': 1,
'commanderName': '',
'colorIdentity': [],
},
],
'summary': summary,
}
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'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')
os.makedirs(reports_dir, exist_ok=True)
generate_deck_reports(str(results_dir), str(reports_dir))
logger.info(f'Generated deck reports after sim {sim_id}')
except ImportError:
logger.warning('reports module not available; skipping deck report generation')
except Exception as e:
logger.warning(f'Could not generate deck report: {e}')
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:
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'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
from commander_ai_lab.lab.experiments import build_deck, _generate_training_deck
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
deck_a = build_deck(decklist)
deck_b = _generate_training_deck()
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
game_results, stats, elapsed = _run_game_loop(
engine, deck_a, deck_b, deck_name, 'Training Deck',
num_games, sim_id)
summary = _build_summary(stats, deck_name, 'Training Deck',
num_games, elapsed)
_finish_sim(sim_id, summary, game_results, engine=engine)
except Exception as e:
_fail_sim(sim_id, e)
traceback.print_exc()
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
from commander_ai_lab.lab.experiments import _generate_training_deck
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
deck_a = _build_deck_from_card_data(card_data)
deck_b = _generate_training_deck()
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
game_results, stats, elapsed = _run_game_loop(
engine, deck_a, deck_b, deck_name, 'Training Deck',
num_games, sim_id)
summary = _build_summary(stats, deck_name, 'Training Deck',
num_games, elapsed)
_finish_sim(sim_id, summary, game_results, engine=engine)
except Exception as e:
_fail_sim(sim_id, e)
traceback.print_exc()
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
from commander_ai_lab.lab.experiments import _generate_training_deck
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
deck_a = _build_deck_from_card_data(card_data)
deck_b = _generate_training_deck()
brain = _get_deepseek_brain()
if brain and not brain._connected:
brain.check_connection()
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,
validator=validator)
game_results, stats, elapsed = _run_game_loop(
engine, deck_a, deck_b, deck_name, 'DeepSeek AI',
num_games, sim_id,
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:
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')
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=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
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
n_players = len(decks_data)
built_decks = [_build_deck_from_card_data(cd) for cd in decks_data]
if engine_type == 'deepseek':
brain = _get_deepseek_brain()
if brain and not brain._connected:
brain.check_connection()
engine = DeepSeekGameEngine(
brain=brain, ai_player_index=1,
max_turns=25, record_log=record_logs, ml_log=True)
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
seat_spells_cast = [0] * n_players
seat_creatures_played = [0] * n_players
seat_removal_used = [0] * n_players
seat_ramp_played = [0] * n_players
seat_cards_drawn = [0] * n_players
seat_max_board = [0] * n_players
game_results = []
start = time.time()
for i in range(num_games):
result = engine.run_n(decks=built_decks, names=deck_names)
game_data = result.to_dict()
game_data['gameNumber'] = i + 1
game_results.append(game_data)
if 0 <= result.winner_seat < n_players:
seat_wins[result.winner_seat] += 1
for pr in result.players:
si = pr.seat_index
seat_turns[si] += result.turns
if pr.stats:
seat_damage_dealt[si] += pr.stats.damage_dealt
seat_spells_cast[si] += pr.stats.spells_cast
seat_creatures_played[si] += pr.stats.creatures_played
seat_removal_used[si] += pr.stats.removal_used
seat_ramp_played[si] += pr.stats.ramp_played
seat_cards_drawn[si] += pr.stats.cards_drawn
seat_max_board[si] += pr.stats.max_board_size
with _sim_lock:
_sim_runs[sim_id]['completed'] = i + 1
elapsed = time.time() - start
n = num_games if num_games > 0 else 1
players_summary = []
for si in range(n_players):
players_summary.append({
'seat': si,
'deckName': deck_names[si],
'wins': seat_wins[si],
'winRate': round(seat_wins[si] / n * 100, 1),
'avgTurns': round(seat_turns[si] / n, 1),
'avgDamageDealt': round(seat_damage_dealt[si] / n, 1),
'avgSpellsCast': round(seat_spells_cast[si] / n, 1),
'avgCreaturesPlayed': round(seat_creatures_played[si] / n, 1),
'avgRemovalUsed': round(seat_removal_used[si] / n, 1),
'avgRampPlayed': round(seat_ramp_played[si] / n, 1),
'avgCardsDrawn': round(seat_cards_drawn[si] / n, 1),
'avgMaxBoardSize': round(seat_max_board[si] / n, 1),
})
summary = {
'playerCount': n_players,
'totalGames': num_games,
'players': players_summary,
'elapsedSeconds': round(elapsed, 3),
}
_finish_sim(sim_id, summary, game_results, engine=engine)
except Exception as e:
_fail_sim(sim_id, e)
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] = {
'status': 'queued', 'completed': 0,
'total': num_games, 'deckName': deck_name,
'result': None, 'error': None,
}
return sim_id
def _fetch_deck_card_data(deck_id):
"""Load card data from the DB for a given deck ID. Returns (deck_name, card_data) or raises."""
conn = _get_db_conn()
cur = conn.cursor()
cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,))
row = cur.fetchone()
if not row:
return None, None
deck_name = row[0]
cur.execute("""
SELECT dc.card_name, dc.quantity,
ce.type_line, ce.cmc, ce.power, ce.toughness,
ce.oracle_text, ce.keywords, ce.mana_cost
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
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 '',
})
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)
deck_name = body.get('deckName', 'My Deck')
record_logs = body.get('recordLogs', True)
if not decklist:
return JSONResponse({'error': 'decklist is required'}, status_code=400)
if num_games < 1 or num_games > 1000:
return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400)
sim_id = _create_sim_run(num_games, deck_name)
_threading.Thread(target=_run_sim_thread,
args=(sim_id, decklist, num_games, deck_name, record_logs),
daemon=True).start()
return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games})
@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:
return JSONResponse({'error': 'sim not found'}, status_code=404)
return JSONResponse({
'simId': simId, 'status': run['status'],
'completed': run['completed'], 'total': run['total'],
'deckName': run.get('deckName', ''), 'error': run.get('error'),
})
@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:
return JSONResponse({'error': 'sim not found'}, status_code=404)
if run['status'] != 'complete':
return JSONResponse({'error': 'sim not complete', 'status': run['status']},
status_code=400)
return JSONResponse(run['result'])
@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)
record_logs = body.get('recordLogs', True)
if not deck_id:
return JSONResponse({'error': 'deckId required'}, status_code=400)
deck_name, card_data = _fetch_deck_card_data(deck_id)
if deck_name is None:
return JSONResponse({'error': 'deck not found'}, status_code=404)
if not card_data:
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
sim_id = _create_sim_run(num_games, deck_name)
_threading.Thread(target=_run_sim_thread_v2,
args=(sim_id, card_data, num_games, deck_name, record_logs),
daemon=True).start()
return JSONResponse({'simId': sim_id, 'status': 'queued',
'total': num_games, 'deckName': deck_name})
@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)
record_logs = body.get('recordLogs', True)
if not deck_id:
return JSONResponse({'error': 'deckId required'}, status_code=400)
if num_games < 1 or num_games > 50:
return JSONResponse({'error': 'numGames must be 1-50 for DeepSeek mode'},
status_code=400)
deck_name, card_data = _fetch_deck_card_data(deck_id)
if deck_name is None:
return JSONResponse({'error': 'deck not found'}, status_code=404)
if not card_data:
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
sim_id = _create_sim_run(num_games, deck_name)
_threading.Thread(target=_run_sim_thread_deepseek,
args=(sim_id, card_data, num_games, deck_name, record_logs),
daemon=True).start()
return JSONResponse({'simId': sim_id, 'status': 'queued',
'total': num_games, 'deckName': deck_name,
'opponentType': 'deepseek'})
@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)
record_logs = body.get('recordLogs', True)
engine_type = body.get('engineType', 'heuristic')
if len(deck_ids) < 2 or len(deck_ids) > 4:
return JSONResponse({'error': '2-4 decks required'}, status_code=400)
if num_games < 1 or num_games > 1000:
return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400)
decks_data = []
deck_names = []
for did in deck_ids:
name, cards = _fetch_deck_card_data(did)
if name is None:
return JSONResponse({'error': f'Deck {did} not found'}, status_code=404)
if not cards:
return JSONResponse({'error': f'Deck {did} has no cards'}, status_code=400)
deck_names.append(name)
decks_data.append(cards)
sim_id = _create_sim_run(num_games, ' vs '.join(deck_names))
_threading.Thread(target=_run_sim_thread_n_player,
args=(sim_id, decks_data, deck_names, num_games,
record_logs, engine_type),
daemon=True).start()
return JSONResponse({
'simId': sim_id, 'status': 'queued', 'total': num_games,
'deckNames': deck_names, 'playerCount': len(deck_ids),
})
# ══════════════════════════════════════════════════════════════
# 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)
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'error': 'DeepSeek brain failed to initialize'},
status_code=500)
if api_base:
brain.config.api_base = api_base
if model:
brain.config.model = model
connected = brain.check_connection()
return JSONResponse({
'connected': connected,
'apiBase': brain.config.api_base,
'model': brain.config.model,
'stats': brain.get_stats(),
})
@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'})
return JSONResponse(brain.get_stats())
@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:
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
if 'apiBase' in body:
brain.config.api_base = body['apiBase']
if 'model' in body:
brain.config.model = body['model']
if 'temperature' in body:
brain.config.temperature = float(body['temperature'])
if 'maxTokens' in body:
brain.config.max_tokens = int(body['maxTokens'])
if 'timeout' in body:
brain.config.request_timeout = float(body['timeout'])
if 'cacheEnabled' in body:
brain.config.cache_enabled = bool(body['cacheEnabled'])
if 'logDecisions' in body:
brain.config.log_decisions = bool(body['logDecisions'])
if 'fallbackOnTimeout' in body:
brain.config.fallback_on_timeout = bool(body['fallbackOnTimeout'])
connected = brain.check_connection()
return JSONResponse({
'connected': connected,
'config': {
'apiBase': brain.config.api_base,
'model': brain.config.model,
'temperature': brain.config.temperature,
'maxTokens': brain.config.max_tokens,
'timeout': brain.config.request_timeout,
'cacheEnabled': brain.config.cache_enabled,
'logDecisions': brain.config.log_decisions,
'fallbackOnTimeout': brain.config.fallback_on_timeout,
},
})
@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)
pending = len(brain._decision_log)
flushed_path = None
if pending > 0:
try:
flushed_path = brain.flush_log()
except Exception as e:
return JSONResponse({'error': f'Failed to flush: {e}'},
status_code=500)
import glob as _glob
log_dir = brain.config.log_dir
log_files = []
if log_dir and os.path.isdir(log_dir):
for f in sorted(_glob.glob(os.path.join(log_dir, 'decisions_*.jsonl'))):
stat = os.stat(f)
log_files.append({
'filename': os.path.basename(f),
'size_bytes': stat.st_size,
'modified': stat.st_mtime,
})
return JSONResponse({
'flushed': pending,
'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,
"reason": "VALIDATOR_ENABLED is not true"})
return JSONResponse({"enabled": True, **v.get_stats()})
@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:
return JSONResponse({"error": "Validator not enabled"}, status_code=400)
if "model" in body:
v.config.model = body["model"]
if "timeout" in body:
v.config.request_timeout = float(body["timeout"])
if "enabled" in body:
v.config.enabled = bool(body["enabled"])
return JSONResponse(v.get_stats())
-127
View File
@@ -1,127 +0,0 @@
"""
routes/import_routes.py
=======================
Deck import & commander meta endpoints:
POST /api/lab/import/url
POST /api/lab/import/text
GET /api/lab/meta/commanders
GET /api/lab/meta/search
POST /api/lab/meta/fetch
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
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,
)
router = APIRouter(tags=["import"])
@router.post("/api/lab/import/url")
async def import_from_url(req: ImportUrlRequest):
"""Import a deck from Archidekt or EDHREC URL. Returns parsed DeckProfile + saves .dck file."""
url = req.url.strip()
try:
profile = _import_from_url(url)
dck_path = _save_profile_to_dck(profile)
return {
"success": True,
"deckName": profile["name"],
"commander": profile["commander"],
"source": profile["source"],
"sourceUrl": profile["sourceUrl"],
"totalCards": profile["totalCards"],
"dckFile": dck_path.stem,
"colorIdentity": profile.get("colorIdentity", []),
"archetype": profile.get("archetype"),
}
except Exception as e:
raise HTTPException(400, str(e))
@router.post("/api/lab/import/text")
async def import_from_text(req: ImportTextRequest):
"""Import a deck from plain text card list."""
try:
profile = _parse_text_decklist(req.text, req.commander)
dck_path = _save_profile_to_dck(profile)
return {
"success": True,
"deckName": profile["name"],
"commander": profile["commander"],
"source": "Text Import",
"totalCards": profile["totalCards"],
"dckFile": dck_path.stem,
}
except Exception as e:
raise HTTPException(400, str(e))
@router.get("/api/lab/meta/commanders")
async def list_meta_commanders():
"""List available commanders in the meta mapping."""
commanders = []
for name, entries in _shared.COMMANDER_META.items():
entry = entries[0] if entries else {}
commanders.append({
"name": name,
"archetype": entry.get("archetype", ""),
"colorIdentity": entry.get("colorIdentity", []),
"source": entry.get("source", "edhrec"),
})
return {"commanders": commanders}
@router.get("/api/lab/meta/search")
async def search_meta_commanders(q: str = ""):
"""Search commanders by partial name match."""
query = q.lower()
matches = []
for name, entries in _shared.COMMANDER_META.items():
if query in name.lower():
entry = entries[0] if entries else {}
matches.append({
"name": name,
"archetype": entry.get("archetype", ""),
"colorIdentity": entry.get("colorIdentity", []),
})
return {"results": matches}
@router.post("/api/lab/meta/fetch")
async def fetch_meta_deck(req: MetaFetchRequest):
"""Fetch EDHREC average deck for a commander and save as .dck file."""
try:
profile = _fetch_edhrec_average(req.commander)
dck_path = _save_profile_to_dck(profile)
return {
"success": True,
"deckName": profile["name"],
"commander": profile["commander"],
"source": "EDHREC Average",
"sourceUrl": profile.get("sourceUrl", ""),
"totalCards": profile["totalCards"],
"dckFile": dck_path.stem,
"colorIdentity": profile.get("colorIdentity", []),
"sampleSize": profile.get("sampleSize"),
}
except Exception as e:
raise HTTPException(400, f"Failed to fetch meta deck for '{req.commander}': {str(e)}")
# ══════════════════════════════════════════════════════════════
# Existing Endpoints (v1/v2)
# ══════════════════════════════════════════════════════════════
-481
View File
@@ -1,481 +0,0 @@
"""
routes/lab.py
=============
Batch simulation & lab management endpoints:
POST /api/lab/start
POST /api/lab/start-deepseek
GET /api/lab/status
GET /api/lab/result
GET /api/lab/decks
GET /api/lab/history
GET /api/lab/profiles
GET /api/lab/profiles/{name}
GET /api/lab/analytics/{deck_name}
GET /api/lab/trends/{deck_name}
GET /api/lab/log
GET /api/lab/debug-log
"""
from __future__ import annotations
import glob
import json
import math
import os
import re
import threading
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request as FastAPIRequest
from fastapi.responses import JSONResponse
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,
)
router = APIRouter(tags=["lab"])
def _sanitize_floats(obj):
"""Replace NaN/Infinity with None so JSONResponse (allow_nan=False) won't crash."""
if isinstance(obj, float):
if math.isnan(obj) or math.isinf(obj):
return None
return obj
if isinstance(obj, dict):
return {k: _sanitize_floats(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_sanitize_floats(v) for v in obj]
return obj
@router.post("/api/lab/start", response_model=StartResponse)
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
"""Start a new batch simulation run."""
if len(req.decks) < 2 or len(req.decks) > 4:
raise HTTPException(400, "2-4 decks required")
if not all(req.decks):
raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled")
if req.numGames < 1 or req.numGames > 10000:
raise HTTPException(400, "numGames must be 1-10000")
if req.threads < 1 or req.threads > 16:
raise HTTPException(400, "threads must be 1-16")
if not os.path.exists(CFG.forge_jar):
raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}")
if not os.path.isdir(CFG.forge_dir):
raise HTTPException(500, f"Forge dir not found: {CFG.forge_dir}")
batch_id = str(uuid.uuid4())[:12]
state = BatchState(batch_id, req.numGames, req.threads)
active_batches[batch_id] = state
os.makedirs(CFG.results_dir, exist_ok=True)
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
resolved_decks = []
for deck_name in req.decks:
dck_path = os.path.join(CFG.forge_decks_dir, deck_name + ".dck")
if os.path.exists(dck_path):
resolved_decks.append(deck_name)
else:
# Check if it's a deckbuilder deck and auto-export
try:
conn = _get_db_conn()
row = conn.execute(
"SELECT id FROM decks WHERE name = ? LIMIT 1", (deck_name,)
).fetchone()
if row:
from routes.deckbuilder import export_deck_to_sim
result = await export_deck_to_sim(row["id"])
resolved_decks.append(result["deckName"])
log.info(f"Auto-exported deckbuilder deck '{deck_name}' to .dck")
else:
resolved_decks.append(deck_name)
except Exception as e:
log.warning(f"Failed to auto-export deck '{deck_name}': {e}")
resolved_decks.append(deck_name)
background_tasks.add_task(
run_batch_subprocess,
state,
resolved_decks,
req.numGames,
req.threads,
req.seed,
req.clock,
output_path,
req.useLearnedPolicy,
req.policyStyle,
req.policyGreedy,
req.aiSimplified,
req.aiThinkTimeMs,
req.maxQueueDepth,
)
policy_msg = ""
if req.useLearnedPolicy:
policy_msg = f" [Learned Policy: {req.policyStyle}]"
return StartResponse(
batchId=batch_id,
status="started",
message=f"Running {req.numGames} games with {req.threads} threads{policy_msg}",
)
@router.get("/api/lab/status", response_model=StatusResponse)
async def get_status(batchId: Optional[str] = None):
# If no batchId given, find the most recent active (or last) batch
state = None
if batchId:
state = active_batches.get(batchId)
else:
# Return the most recently active batch, or any batch
for s in active_batches.values():
if s.running:
state = s
break
if state is None and active_batches:
state = list(active_batches.values())[-1]
if not state:
return StatusResponse(
batchId="", running=False, completed=0, total=0,
threads=0, elapsedMs=0, error=None, simsPerSec=0.0,
run_id="", games_completed=0, total_games=0, current_decks=[],
)
elapsed = int((datetime.now() - state.start_time).total_seconds() * 1000)
deck_names = getattr(state, 'deck_names', []) if hasattr(state, 'deck_names') else []
return StatusResponse(
batchId=state.batch_id,
running=state.running,
completed=state.completed_games,
total=state.total_games,
threads=state.threads,
elapsedMs=elapsed if state.running else state.elapsed_ms,
error=state.error,
simsPerSec=state.sims_per_sec,
# React SPA fields
run_id=state.batch_id,
games_completed=state.completed_games,
total_games=state.total_games,
current_decks=deck_names,
)
@router.get("/api/lab/result")
async def get_result(batchId: Optional[str] = None):
state = None
if batchId:
state = active_batches.get(batchId)
else:
# Find most recent completed batch
for s in reversed(list(active_batches.values())):
if not s.running and s.result_path:
state = s
break
if not state:
raise HTTPException(404, "No batch result available")
if state.running:
raise HTTPException(409, "Batch still running")
if state.error:
raise HTTPException(500, state.error)
if not state.result_path or not os.path.exists(state.result_path):
raise HTTPException(500, "Result file not found")
try:
with open(state.result_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
raise HTTPException(500, f"Failed to read result file: {e}")
data = _sanitize_floats(data)
return JSONResponse(content=data)
@router.get("/api/lab/decks")
async def list_decks():
decks = []
seen_names = set()
# 1. Forge .dck files from decks directory
decks_dir = CFG.forge_decks_dir
if decks_dir and os.path.isdir(decks_dir):
for f in sorted(Path(decks_dir).glob("*.dck")):
deck_name = f.stem
decks.append({"name": deck_name, "filename": f.name, "source": "forge"})
seen_names.add(deck_name.lower())
# 2. Decks from the Deck Builder database
try:
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, name, commander_name FROM decks ORDER BY id DESC"
).fetchall()
for row in rows:
db_name = row["name"]
if db_name.lower() not in seen_names:
decks.append({
"name": db_name,
"filename": "",
"source": "deckbuilder",
"deck_id": row["id"],
"commander": row["commander_name"] or "",
})
seen_names.add(db_name.lower())
except Exception as e:
log.warning(f" WARNING: Failed to load DB decks for /api/lab/decks: {e}")
return {"decks": decks}
@router.get("/api/lab/history")
async def list_history():
results_dir = Path(CFG.results_dir)
if not results_dir.exists():
return {"results": []}
results = []
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)
meta = data.get("metadata", {})
decks = data.get("decks", [])
results.append({
"filename": f.name,
"batchId": meta.get("batchId", ""),
"timestamp": meta.get("timestamp", ""),
"totalGames": meta.get("completedGames", 0),
"threads": meta.get("threads", 1),
"elapsedMs": meta.get("elapsedMs", 0),
"decks": [{"name": d.get("deckName", ""), "source": d.get("source", "")} for d in decks],
})
except Exception:
continue
return {"results": results[:50]}
# ════════════════════════════════════════════════════════════
# AI Profiles
# ════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {
"name": "default",
"description": "Balanced \u2014 Forge's default AI behavior",
"aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5,
"boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5,
},
"aggro": {
"name": "aggro",
"description": "Aggressive \u2014 attacks early, prioritizes damage",
"aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3,
"boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1,
},
"control": {
"name": "control",
"description": "Control \u2014 defensive, removal-heavy, card advantage",
"aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9,
"boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9,
},
"combo": {
"name": "combo",
"description": "Combo \u2014 ramps, digs for pieces, assembles combos",
"aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4,
"boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7,
},
"midrange": {
"name": "midrange",
"description": "Midrange \u2014 flexible, strong board presence, value-oriented",
"aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6,
"boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5,
},
}
@router.get("/api/lab/profiles")
async def list_profiles():
return {"profiles": list(AI_PROFILES.values())}
@router.get("/api/lab/profiles/{name}")
async def get_profile(name: str):
profile = AI_PROFILES.get(name.lower())
if not profile:
raise HTTPException(404, f"Profile '{name}' not found. Available: {list(AI_PROFILES.keys())}")
return profile
@router.get("/api/lab/analytics/{deck_name}")
async def analyze_deck(deck_name: str):
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
raise HTTPException(500, f"Decks directory not found: {decks_dir}")
deck_path = os.path.join(decks_dir, deck_name + ".dck")
if not os.path.exists(deck_path):
for f in os.listdir(decks_dir):
if f.lower() == deck_name.lower() + ".dck":
deck_path = os.path.join(decks_dir, f)
break
else:
raise HTTPException(404, f"Deck not found: {deck_name}")
try:
analysis = parse_dck_file(deck_path)
return analysis
except Exception as e:
raise HTTPException(500, f"Analysis failed: {str(e)}")
def parse_dck_file(deck_path: str) -> dict:
cards = []
commander = None
section = "Main"
deck_name = Path(deck_path).stem
with open(deck_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("//"):
continue
if line.startswith("[") and line.endswith("]"):
section = line[1:-1]
continue
if line.startswith("Name="):
deck_name = line[5:].strip()
continue
m = re.match(r"^(\d+)\s+(.+?)(?:\|(.+))?$", line)
if m:
qty = int(m.group(1))
name = m.group(2).strip()
set_code = m.group(3).strip() if m.group(3) else ""
cards.append({
"quantity": qty, "name": name, "set": set_code,
"section": section,
"is_commander": 1 if section == "Commander" else 0,
})
if section == "Commander":
commander = name
total = sum(c["quantity"] for c in cards)
return {
"deckName": deck_name,
"commanderName": commander,
"totalCards": total,
"cardCount": len(cards),
"cards": cards[:200],
}
@router.get("/api/lab/trends/{deck_name}")
async def get_deck_trends(deck_name: str):
results_dir = Path(CFG.results_dir)
if not results_dir.exists():
return {"deckName": deck_name, "history": []}
history = []
for f in sorted(results_dir.glob("batch-*.json")):
try:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
summary = data.get("summary", {})
meta = data.get("metadata", {})
per_deck = summary.get("perDeck", [])
for ds in per_deck:
if ds.get("deckName", "").lower() == deck_name.lower():
history.append({
"batchId": meta.get("batchId"),
"timestamp": meta.get("timestamp"),
"winRate": ds.get("winRate", 0),
"wins": ds.get("wins", 0),
"losses": ds.get("losses", 0),
"draws": ds.get("draws", 0),
"totalGames": meta.get("completedGames", 0),
})
break
except Exception:
continue
return {"deckName": deck_name, "history": history}
@router.get("/api/lab/log")
async def get_log(batchId: str):
state = active_batches.get(batchId)
if not state:
raise HTTPException(404, f"Batch {batchId} not found")
return {"lines": state.log_lines[-200:]}
@router.get("/api/lab/debug-log")
async def get_debug_log():
"""Return the raw Forge subprocess debug log for diagnosing simulation issues."""
# Check multiple possible locations for the debug log
candidates = []
if CFG.forge_dir:
candidates.append(Path(CFG.forge_dir).parent / "forge-sim-debug.log")
candidates.append(Path(CFG.forge_dir) / "forge-sim-debug.log")
candidates.append(Path("forge-sim-debug.log"))
for log_path in candidates:
if log_path.exists():
text = log_path.read_text(encoding="utf-8", errors="replace")
# Return last 50KB to avoid huge responses
if len(text) > 50000:
text = "... (truncated) ...\n" + text[-50000:]
return {"path": str(log_path), "content": text}
return {"path": None, "content": "No debug log found. Run a simulation first."}
@router.post('/api/lab/start-deepseek')
async def start_batch_deepseek(request: FastAPIRequest, background_tasks: BackgroundTasks):
"""Start a batch simulation using the Python sim engine + DeepSeek LLM opponent."""
body = await request.json()
decks = body.get('decks', [])
num_games = body.get('numGames', 30)
# Note: threads not applicable for Python sim (single-threaded LLM calls)
if not decks or len(decks) < 1:
raise HTTPException(400, 'At least 1 deck required')
if num_games < 1 or num_games > 500:
raise HTTPException(400, 'numGames must be 1-500')
# Filter empty deck slots
decks = [d for d in decks if d]
if not decks:
raise HTTPException(400, 'No valid decks provided')
batch_id = str(uuid.uuid4())[:12]
state = BatchState(batch_id, num_games, 1)
active_batches[batch_id] = state
os.makedirs(CFG.results_dir, exist_ok=True)
output_path = os.path.join(CFG.results_dir, f'batch-{batch_id}.json')
state.result_path = output_path
# Use threading instead of BackgroundTasks to avoid async issues with DB
t = threading.Thread(
target=_run_deepseek_batch_thread,
args=(state, decks, num_games, output_path),
daemon=True,
)
t.start()
return JSONResponse({
'batchId': batch_id,
'status': 'started',
'message': f'Running {num_games} games across {len(decks)} decks with DeepSeek AI',
'engine': 'deepseek',
})
-1200
View File
File diff suppressed because it is too large. Load diff
-96
View File
@@ -1,96 +0,0 @@
"""
routes/precon.py
================
Precon deck management endpoints:
GET /api/lab/precons
POST /api/lab/precons/install
POST /api/lab/precons/install-batch
POST /api/lab/precons/refresh
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
from fastapi import APIRouter, HTTPException
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"])
@router.get("/api/lab/precons")
async def list_precons():
"""List all available precon decks."""
return {"precons": _shared.PRECON_INDEX} # reads current value every call
@router.post("/api/lab/precons/install")
async def install_precon(req: dict):
"""Install a precon deck to the Forge decks directory.
Body: {"fileName": "Elven_Empire.dck"} or {"name": "Elven_Empire.dck"}
"""
file_name = req.get("fileName", "") or req.get("name", "")
if not file_name:
raise HTTPException(400, "fileName is required")
src = _get_precon_dir() / file_name
if not src.exists():
raise HTTPException(404, f"Precon not found: {file_name}")
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
dst = Path(decks_dir) / file_name
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
return {
"installed": True,
"deckName": deck_name,
"destination": str(dst),
"message": f"Installed {deck_name} to Forge decks",
}
@router.post("/api/lab/precons/install-batch")
async def install_precons_batch(req: dict):
"""Install multiple precon decks at once.
Body: {"fileNames": ["Elven_Empire.dck", ...]} or {"names": ["Elven_Empire.dck", ...]}
"""
file_names = req.get("fileNames", []) or req.get("names", [])
if not file_names:
raise HTTPException(400, "fileNames list is required")
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
results = []
for file_name in file_names:
src = _get_precon_dir() / file_name
if not src.exists():
results.append({"fileName": file_name, "installed": False, "error": "not found"})
continue
dst = Path(decks_dir) / file_name
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
return {"results": results}
@router.post("/api/lab/precons/refresh")
async def refresh_precons():
"""Force re-download of all Commander precon decks from GitHub."""
result = download_precon_database(force=True)
if result.get("error"):
raise HTTPException(502, result["error"])
return {
"message": f"Downloaded {result['downloaded']} Commander precon decks",
"total": result.get("total", 0),
}
-481
View File
@@ -1,481 +0,0 @@
"""
routes/scanner.py
=================
Card scanner & collection enrichment endpoints:
POST /api/collection/scan
POST /api/collection/scan/add
POST /api/collection/re-enrich
POST /api/collection/auto-classify
POST /api/collection/auto-classify-all
GET /api/collection/{cardId}/edhrec
"""
from __future__ import annotations
import json
import os
import re
import time
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import URLError
from fastapi import APIRouter, HTTPException
from fastapi import Request as FastAPIRequest
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"])
@router.post("/api/collection/scan")
async def scan_card_image(request: FastAPIRequest):
"""
Scan a card image and return recognized card name(s).
Uses Ximilar Visual AI for card recognition.
Accepts multipart/form-data with:
- file: image file (JPEG/PNG)
- mode: "single" (default) or "multi"
Returns:
{ "results": [ { raw_ocr, matched_name, set_code, scryfall_id, confidence, image_uri, error, collector_number, rarity, tcgplayer_url }, ... ] }
"""
try:
from scanner.pipeline import scan_single, scan_multi
except ImportError as e:
raise HTTPException(500, f"Scanner module not available: {e}")
# Try CFG first, then fall back to env var
api_key = CFG.ximilar_api_key or os.environ.get("XIMILAR_API_KEY", "")
if not api_key:
raise HTTPException(500, "Card scanner not configured. Start the server with --ximilar-key YOUR_KEY or set XIMILAR_API_KEY env var.")
# Parse multipart form
form = await request.form()
upload = form.get("file")
if upload is None:
raise HTTPException(400, "No file uploaded. Send an image as 'file' in multipart/form-data.")
mode = str(form.get("mode", "single")).lower()
image_bytes = await upload.read()
if len(image_bytes) == 0:
raise HTTPException(400, "Uploaded file is empty")
# Limit file size (20 MB)
if len(image_bytes) > 20 * 1024 * 1024:
raise HTTPException(400, "Image file too large (max 20 MB)")
log_scan.info(f" Processing {len(image_bytes)} bytes, mode={mode} (Ximilar AI)")
if mode == "multi":
results = scan_multi(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
else:
result = scan_single(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
results = [result]
return {
"results": [r.to_dict() for r in results]
}
@router.post("/api/collection/scan/add")
async def scan_add_cards(body: dict):
"""
Add scanned cards to the collection.
Body:
{
"cards": [
{ "name": "Lightning Bolt", "set_code": "m11", "quantity": 1 },
...
]
}
"""
cards = body.get("cards", [])
if not cards:
raise HTTPException(400, "No cards provided")
conn = _get_db_conn()
imported = 0
updated = 0
errors = []
for card_req in cards:
name = str(card_req.get("name", "")).strip()
if not name:
continue
quantity = int(card_req.get("quantity", 1))
set_code = str(card_req.get("set_code", "")).lower()
collector_number = str(card_req.get("collector_number", ""))
finish = "NORMAL"
try:
enriched = _enrich_from_scryfall(name, set_code, collector_number)
except Exception as e:
errors.append(f"Scryfall error for '{name}': {e}")
continue
if not enriched or "_error" in enriched:
err_msg = enriched.get("_error", f"Not found: '{name}'") if enriched else f"Not found: '{name}'"
errors.append(err_msg)
continue
resolved_name = enriched.get("name", name)
# Check for existing entry
existing = conn.execute(
"""SELECT id, quantity FROM collection_entries
WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""",
(resolved_name, set_code, collector_number, finish),
).fetchone()
if existing:
new_qty = existing["quantity"] + quantity
conn.execute(
"UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
(new_qty, existing["id"]),
)
conn.commit()
updated += 1
else:
conn.execute(
"""INSERT INTO collection_entries
(name, type_line, subtypes, is_legendary, is_basic, color_identity,
cmc, mana_cost, oracle_text, keywords,
power, toughness, rarity, set_name, edhrec_rank,
tcg_price, salt_score, is_game_changer,
category, scryfall_id, tcgplayer_id,
quantity, finish, condition, language, notes, tags,
set_code, collector_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
resolved_name,
enriched.get("type_line", ""),
enriched.get("subtypes", "[]"),
enriched.get("is_legendary", 0),
enriched.get("is_basic", 0),
enriched.get("color_identity", "[]"),
enriched.get("cmc", 0.0),
enriched.get("mana_cost", ""),
enriched.get("oracle_text", ""),
enriched.get("keywords", "[]"),
enriched.get("power", ""),
enriched.get("toughness", ""),
enriched.get("rarity", ""),
enriched.get("set_name", ""),
enriched.get("edhrec_rank", 0),
enriched.get("tcg_price", 0.0),
enriched.get("salt_score", 0.0),
enriched.get("is_game_changer", 0),
enriched.get("category", "[]"),
enriched.get("scryfall_id", ""),
enriched.get("tcgplayer_id", ""),
quantity,
finish,
"", # condition
"", # language
"", # notes
"", # tags
set_code,
collector_number,
),
)
conn.commit()
imported += 1
return {
"importedCount": imported,
"updatedCount": updated,
"failedCount": len(errors),
"errors": errors,
}
# ══════════════════════════════════════════════════════════════
# Re-enrich Collection from Scryfall
# ══════════════════════════════════════════════════════════════
@router.post("/api/collection/re-enrich")
async def re_enrich_collection():
"""
Re-fetch Scryfall data for all collection entries to backfill
missing fields (mana_cost, power, toughness, rarity, set_name, edhrec_rank).
Also refreshes: type_line, oracle_text, keywords, cmc, color_identity, prices.
Runs synchronously — may take a while for large collections.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, name, set_code, collector_number FROM collection_entries ORDER BY id"
).fetchall()
total = len(rows)
enriched_count = 0
skipped_count = 0
errors = []
for row in rows:
card_id = row["id"]
name = row["name"]
set_code = row["set_code"] or ""
collector_number = row["collector_number"] or ""
try:
data = _enrich_from_scryfall(name, set_code, collector_number)
except Exception as e:
errors.append(f"Error for '{name}': {e}")
skipped_count += 1
continue
if not data or "_error" in data:
err_msg = data.get("_error", f"Not found: '{name}'") if data else f"Not found: '{name}'"
errors.append(err_msg)
skipped_count += 1
continue
conn.execute(
"""UPDATE collection_entries SET
type_line = ?, subtypes = ?, is_legendary = ?, is_basic = ?,
color_identity = ?, cmc = ?, mana_cost = ?,
oracle_text = ?, keywords = ?,
power = ?, toughness = ?,
rarity = ?, set_name = ?, edhrec_rank = ?,
tcg_price = ?, scryfall_id = ?, tcgplayer_id = ?,
category = ?,
updated_at = datetime('now')
WHERE id = ?""",
(
data.get("type_line", ""),
data.get("subtypes", "[]"),
data.get("is_legendary", 0),
data.get("is_basic", 0),
data.get("color_identity", "[]"),
data.get("cmc", 0.0),
data.get("mana_cost", ""),
data.get("oracle_text", ""),
data.get("keywords", "[]"),
data.get("power", ""),
data.get("toughness", ""),
data.get("rarity", ""),
data.get("set_name", ""),
data.get("edhrec_rank", 0),
data.get("tcg_price", 0.0),
data.get("scryfall_id", ""),
data.get("tcgplayer_id", ""),
data.get("category", "[]"),
card_id,
),
)
conn.commit()
enriched_count += 1
return {
"total": total,
"enrichedCount": enriched_count,
"skippedCount": skipped_count,
"errors": errors[:50], # cap error list
}
# ══════════════════════════════════════════════════════════════
# Auto-Classify Collection
# ══════════════════════════════════════════════════════════════
@router.post("/api/collection/auto-classify")
async def auto_classify_collection():
"""
Run auto-classification on all collection entries.
Uses oracle_text, type_line, and keywords to detect functional roles
(Ramp, Draw, Removal, Board Wipe, Anthem, Stax, etc.).
Only updates cards whose current category is empty or '[]'.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, oracle_text, type_line, keywords, category FROM collection_entries ORDER BY id"
).fetchall()
total = len(rows)
classified_count = 0
skipped_count = 0
for row in rows:
card_id = row["id"]
existing_cats = row["category"] or "[]"
# Parse existing categories
try:
cats = json.loads(existing_cats)
except Exception:
cats = []
# Skip cards that already have manually-set categories
if cats:
skipped_count += 1
continue
oracle_text = row["oracle_text"] or ""
type_line = row["type_line"] or ""
keywords = row["keywords"] or "[]"
roles = _detect_card_roles(oracle_text, type_line, keywords)
if roles:
conn.execute(
"UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?",
(json.dumps(roles), card_id),
)
classified_count += 1
conn.commit()
return {
"total": total,
"classifiedCount": classified_count,
"skippedCount": skipped_count,
"message": f"Classified {classified_count} cards, skipped {skipped_count} (already had categories)",
}
@router.post("/api/collection/auto-classify-all")
async def auto_classify_all_collection():
"""
Force re-classify ALL collection entries, overwriting existing categories.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, oracle_text, type_line, keywords FROM collection_entries ORDER BY id"
).fetchall()
total = len(rows)
classified_count = 0
for row in rows:
card_id = row["id"]
oracle_text = row["oracle_text"] or ""
type_line = row["type_line"] or ""
keywords = row["keywords"] or "[]"
roles = _detect_card_roles(oracle_text, type_line, keywords)
conn.execute(
"UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?",
(json.dumps(roles), card_id),
)
classified_count += 1
conn.commit()
return {
"total": total,
"classifiedCount": classified_count,
"message": f"Re-classified all {classified_count} cards",
}
# ══════════════════════════════════════════════════════════════
# EDHREC Recommendations Cache + Endpoint
# ══════════════════════════════════════════════════════════════
@router.get("/api/collection/{cardId}/edhrec")
async def get_card_edhrec(cardId: int):
"""
Get EDHREC recommendations for a collection card.
For legendary creatures (potential commanders): fetch commander synergy data.
For other cards: fetch 'also played with' data.
"""
conn = _get_db_conn()
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
if not row:
raise HTTPException(404, f"Card with id {cardId} not found")
card = _row_to_dict(row)
name = card["name"]
type_line = card.get("type_line", "")
is_legendary = card.get("is_legendary", 0)
# Determine if it's a legendary creature (potential commander)
is_commander = bool(
is_legendary and
re.search(r"\bCreature\b", type_line, re.IGNORECASE)
)
slug = _to_edhrec_slug(name)
cache_key = f"edhrec:{'cmd' if is_commander else 'card'}:{slug}"
cached = _edhrec_cache_get(cache_key)
if cached:
return cached
recommendations = []
links = {
"edhrecPage": (
f"https://edhrec.com/commanders/{slug}"
if is_commander
else f"https://edhrec.com/cards/{slug}"
),
"archidektSearch": f"https://archidekt.com/search/cards?q={name.replace(' ', '+')}",
"moxfieldSearch": f"https://www.moxfield.com/search?q={name.replace(' ', '+')}",
}
try:
if is_commander:
url = f"https://json.edhrec.com/pages/commanders/{slug}.json"
else:
url = f"https://json.edhrec.com/pages/cards/{slug}.json"
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
if is_commander:
# Extract top cards from cardlists
container = data.get("container", {})
json_dict = container.get("json_dict", {})
for cardlist in json_dict.get("cardlists", []):
tag = cardlist.get("tag", "")
for cv in cardlist.get("cardviews", [])[:10]:
rec_name = cv.get("name", "")
if not rec_name:
continue
synergy = cv.get("synergy", 0.0)
recommendations.append({
"name": rec_name,
"synergy": synergy,
"role": tag or "Recommended",
})
else:
# Extract "also played with" cards
container = data.get("container", {})
json_dict = container.get("json_dict", {})
for cardlist in json_dict.get("cardlists", []):
tag = cardlist.get("tag", "")
for cv in cardlist.get("cardviews", [])[:10]:
rec_name = cv.get("name", "")
if not rec_name:
continue
synergy = cv.get("synergy", 0.0)
recommendations.append({
"name": rec_name,
"synergy": synergy,
"role": tag or "Also Played With",
})
except Exception as e:
log_deckgen.error(f" Error fetching data for '{name}': {e}")
# Graceful degradation — return empty results
result = {
"recommendations": recommendations[:30],
"links": links,
}
_edhrec_cache_set(cache_key, result)
return result
-204
View File
@@ -1,204 +0,0 @@
"""
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
+2 -6
View File
@@ -13,10 +13,6 @@ No local OCR or OpenCV needed — all recognition is done by the
Ximilar cloud API using visual AI.
"""
from typing import Callable, Optional
import logging
log = logging.getLogger("commander_ai_lab.scanner")
from .ximilar_client import identify_cards, XimilarResult
@@ -164,7 +160,7 @@ def _ximilar_to_scan_result(xr: XimilarResult,
try:
scryfall_data = scryfall_lookup(xr.name)
except Exception as e:
log.error(f" Scryfall lookup failed for '{xr.name}': {e}")
print(f" [SCAN] Scryfall lookup failed for '{xr.name}': {e}")
if scryfall_data and scryfall_data.get("object") != "error":
result.scryfall_id = scryfall_data.get("id", "")
@@ -185,6 +181,6 @@ def _ximilar_to_scan_result(xr: XimilarResult,
# Extract image from scryfall URL pattern
# e.g., https://scryfall.com/card/m11/149/lightning-bolt
result.image_uri = "" # Will be fetched by frontend via scryfall_id
log.info(f" No Scryfall match for '{xr.name}' — using Ximilar data only")
print(f" [SCAN] No Scryfall match for '{xr.name}' — using Ximilar data only")
return result
+4 -8
View File
@@ -13,10 +13,6 @@ import json
from typing import Optional, List
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import logging
log = logging.getLogger("commander_ai_lab.scanner.ximilar")
XIMILAR_ENDPOINT = "https://api.ximilar.com/collectibles/v2/tcg_id"
@@ -86,7 +82,7 @@ def identify_cards(image_bytes: bytes, api_key: str,
)
try:
log.info(f" Sending {len(image_bytes):,} bytes to API (analyze_all={analyze_all})")
print(f" [XIMILAR] Sending {len(image_bytes):,} bytes to API (analyze_all={analyze_all})")
with urlopen(req, timeout=60) as resp:
resp_data = json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
@@ -172,12 +168,12 @@ def _parse_response(resp_data: dict) -> List[XimilarResult]:
if not results:
results.append(XimilarResult(error="Ximilar could not identify any cards"))
log.info(f" Identified {len(results)} card(s)")
print(f" [XIMILAR] Identified {len(results)} card(s)")
for r in results:
if r.name:
log.info(f" - {r.name} ({r.set_code}) conf={r.confidence:.2f}")
print(f" [XIMILAR] - {r.name} ({r.set_code}) conf={r.confidence:.2f}")
elif r.error:
log.error(f" - ERROR: {r.error}")
print(f" [XIMILAR] - ERROR: {r.error}")
return results
-222
View File
@@ -1,222 +0,0 @@
#!/usr/bin/env python3
"""
scripts/bench_deckgen.py
========================
Benchmark for _get_collection_for_colors() and _build_collection_summary().
Seeds an in-memory SQLite DB with randomised collection_entries rows,
then times the SQL-filtered query for various commander colour identities.
Usage:
python scripts/bench_deckgen.py [--rows 1000]
"""
from __future__ import annotations
import argparse
import json
import random
import sqlite3
import time
from typing import List
# ── Helpers ──────────────────────────────────────────────────────────────────
ALL_COLORS = ["W", "U", "B", "R", "G"]
CARD_TYPE_LINES = [
"Creature - Human Soldier",
"Creature - Elf Druid",
"Instant",
"Sorcery",
"Artifact",
"Enchantment",
"Land",
"Artifact Creature - Golem",
"Legendary Creature - Dragon",
"Planeswalker - Jace",
]
def _random_color_identity() -> list:
"""Return a random colour identity (including ~15% chance of colorless)."""
if random.random() < 0.15:
return []
n = random.randint(1, 3)
return sorted(random.sample(ALL_COLORS, n))
def _seed_db(conn: sqlite3.Connection, num_rows: int):
"""Create collection_entries table and insert random rows."""
conn.execute("""
CREATE TABLE IF NOT EXISTS collection_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type_line TEXT DEFAULT '',
color_identity TEXT DEFAULT '[]',
cmc REAL DEFAULT 0,
mana_cost TEXT DEFAULT '',
oracle_text TEXT DEFAULT '',
keywords TEXT DEFAULT '[]',
quantity INTEGER DEFAULT 1,
edhrec_rank INTEGER DEFAULT 0,
scryfall_id TEXT DEFAULT '',
tcg_price REAL DEFAULT 0,
salt_score REAL DEFAULT 0,
is_game_changer INTEGER DEFAULT 0,
category TEXT DEFAULT ''
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_ce_quantity
ON collection_entries (quantity) WHERE quantity > 0
""")
rows = []
for i in range(num_rows):
ci = _random_color_identity()
qty = random.choice([0, 1, 1, 1, 2, 3]) # ~17% zero quantity
rows.append((
f"Card_{i:05d}",
random.choice(CARD_TYPE_LINES),
json.dumps(ci),
round(random.uniform(0, 8), 1),
"",
f"Sample oracle text for card {i}",
json.dumps([]),
qty,
random.randint(1, 20000),
f"scryfall-{i:05d}" if random.random() > 0.02 else "",
round(random.uniform(0, 50), 2),
round(random.uniform(0, 5), 2),
1 if random.random() < 0.05 else 0,
random.choice(["", "ramp", "draw", "removal", ""]),
))
conn.executemany("""
INSERT INTO collection_entries
(name, type_line, color_identity, cmc, mana_cost, oracle_text,
keywords, quantity, edhrec_rank, scryfall_id, tcg_price,
salt_score, is_game_changer, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", rows)
conn.commit()
print(f" Seeded {num_rows} rows ({sum(1 for r in rows if r[7] > 0)} with qty > 0)")
# ── Query under test (same as routes/deckgen.py) ────────────────────────────
def get_collection_for_colors(conn: sqlite3.Connection, color_identity: list) -> list:
"""Mirrors the new SQL-filtered implementation."""
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
rows = conn.execute("""
SELECT scryfall_id, name, type_line, mana_cost, cmc,
oracle_text, keywords, quantity, edhrec_rank, color_identity
FROM collection_entries
WHERE quantity > 0
AND scryfall_id != ''
AND NOT EXISTS (
SELECT 1 FROM json_each(
CASE WHEN collection_entries.color_identity IS NULL
OR collection_entries.color_identity = ''
THEN '[]'
ELSE collection_entries.color_identity
END
) AS jc
WHERE jc.value NOT IN (
SELECT value FROM json_each(?)
)
)
""", (allowed_json,)).fetchall()
valid = []
for row in rows:
card = dict(row)
ci = card.get("color_identity", "[]")
try:
card["color_identity"] = json.loads(ci) if isinstance(ci, str) else ci
except Exception:
card["color_identity"] = []
valid.append(card)
return valid
def get_collection_for_colors_old(conn: sqlite3.Connection, color_identity: list) -> list:
"""Old implementation: SELECT * + Python-side filter."""
rows = conn.execute(
"SELECT * FROM collection_entries WHERE quantity > 0"
).fetchall()
valid = []
for row in rows:
card = dict(row)
card_ci = card.get("color_identity", "[]")
if isinstance(card_ci, str):
try:
card_ci = json.loads(card_ci)
except Exception:
card_ci = []
if color_identity and card_ci:
if not all(c in color_identity for c in card_ci):
continue
if not card.get("scryfall_id"):
continue
card["color_identity"] = card_ci
valid.append(card)
return valid
# ── Main ─────────────────────────────────────────────────────────────────────
COMMANDERS = [
("Mono-W", ["W"]),
("WUB", ["W", "U", "B"]),
("WUBRG", ["W", "U", "B", "R", "G"]),
("Colorless", []),
("Mono-G", ["G"]),
]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--rows", type=int, default=1000)
args = parser.parse_args()
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
_seed_db(conn, args.rows)
print(f"\n{'Commander':<14} {'Old ms':>8} {'New ms':>8} {'Old#':>6} {'New#':>6} {'Match':>6}")
print("-" * 60)
for label, ci in COMMANDERS:
# Old
t0 = time.perf_counter()
old_result = get_collection_for_colors_old(conn, ci)
old_ms = (time.perf_counter() - t0) * 1000
# New
t0 = time.perf_counter()
new_result = get_collection_for_colors(conn, ci)
new_ms = (time.perf_counter() - t0) * 1000
match = len(old_result) == len(new_result)
# NOTE: Colorless ([]) intentionally differs — old code had a bug that
# skipped the colour check entirely when color_identity was empty,
# returning ALL cards. The new SQL correctly restricts to colourless.
if not ci: # colorless commander
marker = "FIX" if len(new_result) < len(old_result) else "OK"
else:
marker = "OK" if match else "FAIL"
print(f" {label:<12} {old_ms:>7.2f} {new_ms:>7.2f} {len(old_result):>6} {len(new_result):>6} {marker:>6}")
if not match and ci: # only flag non-colorless mismatches
old_names = {c["name"] for c in old_result}
new_names = {c["name"] for c in new_result}
print(f" Only in old: {len(old_names - new_names)} cards")
print(f" Only in new: {len(new_names - old_names)} cards")
print()
if __name__ == "__main__":
main()
-275
View File
@@ -1,275 +0,0 @@
"""
Commander AI Lab — Overnight Batch Sim Runner
═══════════════════════════════════════════════
Runs sequential batch simulations across the top precon decks,
rotating playstyles each batch. Generates ML decision data for
training.
Usage:
python scripts/overnight-batch.py
python scripts/overnight-batch.py --hours 8 --games-per-batch 200
python scripts/overnight-batch.py --port 8080 --threads 4
Estimated throughput (varies by hardware):
~3-5 sims/sec typical → ~200 games in ~40-70 sec
8 hours ≈ 400-600 batches ≈ 80K-120K games ≈ 200K-400K decisions
"""
import argparse
import json
import os
import sys
import time
import urllib.request
import urllib.error
from datetime import datetime, timedelta
from itertools import combinations
# ── Configuration ────────────────────────────────────────────
PRECON_DECKS = [
"Undead_Unleashed.dck", # Wilhelt — Zombie tribal (UB)
"Elven_Empire.dck", # Lathril — Elf tribal (BG)
"Planar_Portal.dck", # Prosper — Exile/Treasure (BR)
"Necron_Dynasties.dck", # Szarekh — Artifact recursion (B)
"Creative_Energy.dck", # Satya — Energy/Artifacts (WUR)
"Veloci-Ramp-Tor.dck", # Pantlaza — Dinosaur tribal (RGW)
"Mutant_Menace.dck", # Wise Mothman — Rad/Proliferate (UBG)
"Scrappy_Survivors.dck", # Dogmeat — Aura/Equipment (WRG)
"Explorers_of_the_Deep.dck", # Hakbal — Merfolk/Explore (UG)
"Science.dck", # Dr. Madison Li — Artifact synergies (WUR)
]
PLAYSTYLES = ["midrange", "aggro", "control", "combo"]
def api_call(base_url, path, method="GET", body=None):
"""Make an API call to the local server."""
url = f"{base_url}{path}"
headers = {"Content-Type": "application/json"} if body else {}
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
error_body = e.read().decode() if e.fp else str(e)
print(f" API Error {e.code}: {error_body}")
return None
except Exception as e:
print(f" Connection error: {e}")
return None
def ensure_ml_logging(base_url):
"""Make sure ML logging is enabled."""
result = api_call(base_url, "/api/ml/toggle?enable=true", method="POST")
if result:
print(f" ML logging: {'enabled' if result.get('ml_logging_enabled') else 'DISABLED'}")
return result
def start_batch(base_url, decks, num_games, playstyle, threads):
"""Start a batch simulation."""
body = {
"decks": decks,
"numGames": num_games,
"threads": threads,
"policyStyle": playstyle,
"clock": 6000,
}
return api_call(base_url, "/api/lab/start", method="POST", body=body)
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/{batch_id}")
if result is None:
time.sleep(poll_interval)
continue
completed = result.get("completedGames", 0)
total = result.get("totalGames", 0)
running = result.get("running", False)
sps = result.get("simsPerSec", 0)
error = result.get("error")
if error:
print(f" ERROR: {error}")
return result
if not running and completed >= total and total > 0:
return result
if not running and total == 0:
# Still starting up
time.sleep(poll_interval)
continue
pct = (completed / total * 100) if total > 0 else 0
speed = f" ({sps:.1f} sims/sec)" if sps > 0 else ""
print(f" Progress: {completed}/{total} ({pct:.0f}%){speed}", end="\r")
time.sleep(poll_interval)
def generate_matchups(decks, pod_size=4):
"""Generate 4-player pods from deck list, cycling through all combos."""
combos = list(combinations(decks, pod_size))
return combos
def main():
parser = argparse.ArgumentParser(
description="Commander AI Lab — Overnight Batch Runner"
)
parser.add_argument(
"--hours", type=float, default=8,
help="Maximum hours to run (default: 8)",
)
parser.add_argument(
"--games-per-batch", type=int, default=200,
help="Games per batch (default: 200)",
)
parser.add_argument(
"--threads", type=int, default=4,
help="Simulation threads (default: 4)",
)
parser.add_argument(
"--port", type=int, default=8080,
help="Lab server port (default: 8080)",
)
parser.add_argument(
"--pod-size", type=int, default=4, choices=[2, 3, 4],
help="Players per pod (default: 4)",
)
args = parser.parse_args()
base_url = f"http://localhost:{args.port}"
print()
print("=" * 60)
print(" Commander AI Lab — Overnight Batch Runner")
print("=" * 60)
print(f" Duration: {args.hours} hours")
print(f" Games/batch: {args.games_per_batch}")
print(f" Threads: {args.threads}")
print(f" Pod size: {args.pod_size}")
print(f" Decks: {len(PRECON_DECKS)}")
print(f" Playstyles: {', '.join(PLAYSTYLES)}")
print(f" Server: {base_url}")
print("=" * 60)
print()
# Check server is up
print("Checking server connectivity...")
status = api_call(base_url, "/api/ml/status")
if status is None:
print("ERROR: Cannot reach the lab server. Is it running?")
print(f" Expected at: {base_url}")
sys.exit(1)
print(f" Server OK — {status.get('total_decisions', 0):,} existing decisions")
# Enable ML logging
print("Enabling ML logging...")
ensure_ml_logging(base_url)
# Generate matchups
matchups = generate_matchups(PRECON_DECKS, args.pod_size)
print(f" Generated {len(matchups)} unique {args.pod_size}-player matchups")
print()
# Calculate stop time
stop_time = datetime.now() + timedelta(hours=args.hours)
print(f" Will run until: {stop_time.strftime('%I:%M %p')}")
print()
# ── Main Loop ────────────────────────────────────────────
batch_count = 0
total_games = 0
total_decisions_start = status.get("total_decisions", 0)
matchup_idx = 0
playstyle_idx = 0
try:
while datetime.now() < stop_time:
# Pick next matchup and playstyle
matchup = matchups[matchup_idx % len(matchups)]
playstyle = PLAYSTYLES[playstyle_idx % len(PLAYSTYLES)]
deck_names = [os.path.splitext(d)[0].replace("_", " ") for d in matchup]
batch_count += 1
remaining = stop_time - datetime.now()
hrs_left = remaining.total_seconds() / 3600
print(f"─── Batch {batch_count} ({hrs_left:.1f}h remaining) ───")
print(f" Playstyle: {playstyle}")
print(f" Decks: {', '.join(deck_names)}")
# Start batch
result = start_batch(
base_url,
list(matchup),
args.games_per_batch,
playstyle,
args.threads,
)
if result is None or "batchId" not in result:
print(" Failed to start batch — waiting 30s and retrying...")
time.sleep(30)
continue
batch_id = result["batchId"]
print(f" Batch ID: {batch_id}")
# Poll until complete
final = poll_batch(base_url, batch_id)
print() # Clear progress line
if final:
games = final.get("completedGames", 0)
elapsed_s = final.get("elapsedMs", 0) / 1000
total_games += games
print(f" Done: {games} games in {elapsed_s:.0f}s")
else:
print(" Batch completed (no status)")
# Rotate
matchup_idx += 1
playstyle_idx += 1
# Brief pause between batches
time.sleep(2)
print()
except KeyboardInterrupt:
print("\n\nStopped by user (Ctrl+C)")
# ── Summary ──────────────────────────────────────────────
# Check final decision count
final_status = api_call(base_url, "/api/ml/status")
total_decisions_end = final_status.get("total_decisions", 0) if final_status else 0
new_decisions = total_decisions_end - total_decisions_start
print()
print("=" * 60)
print(" OVERNIGHT BATCH COMPLETE")
print("=" * 60)
print(f" Batches run: {batch_count}")
print(f" Total games: {total_games:,}")
print(f" New decisions: {new_decisions:,}")
print(f" Total decisions: {total_decisions_end:,}")
print(f" Decision files: {final_status.get('total_files', '?')}")
print("=" * 60)
print()
print(" Next step: Run Closed-Loop Distillation from the Distill tab")
print(" to train on this expanded dataset.")
print()
if __name__ == "__main__":
main()
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff. Show more