Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa43fe3ecd | ||
|
|
d0a1c10372 | ||
|
|
91d1e9449a | ||
|
|
1b2707014b | ||
|
|
91279d1d67 | ||
|
|
3ec0b73503 | ||
|
|
f9114b6118 | ||
|
|
1e3669af42 | ||
|
|
c41ca8f09b | ||
|
|
4189289945 | ||
|
|
f47b5182d2 | ||
|
|
5f606fee22 | ||
|
|
f5dae81193 | ||
|
|
36b3c8c55b | ||
|
|
a098524b98 | ||
|
|
acc9d7cd47 | ||
|
|
588314b282 | ||
|
|
7132b6f205 | ||
|
|
1704d7ca9c | ||
|
|
f639703fb4 | ||
|
|
cbedfd1ed2 | ||
|
|
be55a02860 |
No files matched your search
-21
@@ -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
|
||||
@@ -1,11 +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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
@@ -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 []
|
||||
@@ -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 []
|
||||
@@ -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 []
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend-tmp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-5077
File diff suppressed because it is too large.
Load diff
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "commander-ai-lab-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,184 +0,0 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import LobbyPage from './pages/LobbyPage';
|
||||
import RoomPage from './pages/RoomPage';
|
||||
import GamePage from './pages/GamePage';
|
||||
import SimulatorPage from './pages/SimulatorPage';
|
||||
|
||||
function Nav() {
|
||||
const location = useLocation();
|
||||
// Hide nav on active game pages
|
||||
if (location.pathname.startsWith('/game/')) return null;
|
||||
const links = [
|
||||
{ to: '/', label: 'Lobby' },
|
||||
{ to: '/simulator', label: 'Simulator' },
|
||||
];
|
||||
return (
|
||||
<nav className="bg-bg-bar border-b border-border-zone px-4 py-2 flex items-center gap-4">
|
||||
<span className="text-sm font-bold text-white mr-4">Commander AI Lab</span>
|
||||
{links.map((l) => (
|
||||
<Link
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
className={`text-sm px-2 py-1 rounded transition-colors ${
|
||||
location.pathname === l.to
|
||||
? 'text-white bg-accent-blue/20'
|
||||
: 'text-text-muted hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Nav />
|
||||
<Routes>
|
||||
<Route path="/" element={<LobbyPage />} />
|
||||
<Route path="/room/:roomId" element={<RoomPage />} />
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
<Route path="/simulator" element={<SimulatorPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
export default App;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import CardChip from '../components/CardChip';
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
function makeCard(overrides: Partial<Card> = {}): Card {
|
||||
return {
|
||||
name: 'Sol Ring',
|
||||
type: 'Artifact',
|
||||
tapped: false,
|
||||
is_commander: false,
|
||||
oracle: 'Tap: Add CC.',
|
||||
cmc: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CardChip', () => {
|
||||
it('applies opacity class when tapped', () => {
|
||||
render(<CardChip card={makeCard({ tapped: true })} />);
|
||||
const chip = screen.getByText(/Sol Ring/);
|
||||
expect(chip.closest('span')).toHaveClass('opacity-55');
|
||||
});
|
||||
|
||||
it('shows star for commander cards', () => {
|
||||
render(<CardChip card={makeCard({ is_commander: true })} />);
|
||||
expect(screen.getByText('★')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has hover border class', () => {
|
||||
render(<CardChip card={makeCard()} />);
|
||||
const chip = screen.getByText(/Sol Ring/).closest('span');
|
||||
expect(chip).toHaveClass('hover:border-[#ffdc32]');
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import GamePage from '../pages/GamePage';
|
||||
import type { GameState, Card } from '../types/game';
|
||||
|
||||
const mockGameState: GameState = {
|
||||
game_id: 'test-game',
|
||||
turn: 1,
|
||||
current_phase: 'main1',
|
||||
active_seat: 0,
|
||||
players: [
|
||||
{
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 1, name: 'Bob', life: 38, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 6,
|
||||
},
|
||||
{
|
||||
seat: 2, name: 'Charlie', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 3, name: 'Diana', life: 35, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 5,
|
||||
},
|
||||
],
|
||||
stack: [],
|
||||
game_over: false,
|
||||
winner: null,
|
||||
};
|
||||
|
||||
const mockHand: Card[] = [];
|
||||
const mockSendMove = vi.fn();
|
||||
const mockSendConcede = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useGameSocket', () => ({
|
||||
useGameSocket: () => ({
|
||||
gameState: mockGameState,
|
||||
narration: [],
|
||||
thinking: new Set<number>(),
|
||||
hand: mockHand,
|
||||
gameOver: null,
|
||||
status: 'Connected',
|
||||
sendMove: mockSendMove,
|
||||
sendConcede: mockSendConcede,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('GamePage integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all 4 PlayerZone components', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/game/test-game']}>
|
||||
<Routes>
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// P0: Alice appears in both PhaseBar and PlayerZone, so use getAllByText
|
||||
expect(screen.getAllByText(/P0: Alice/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P1: Bob/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P2: Charlie/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P3: Diana/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders PhaseBar with current phase', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/game/test-game']}>
|
||||
<Routes>
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// main1 should be highlighted
|
||||
const main1Buttons = screen.getAllByText('main1');
|
||||
expect(main1Buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import MoveBar from '../components/MoveBar';
|
||||
|
||||
describe('MoveBar', () => {
|
||||
it('calls onPass when pass button is clicked', async () => {
|
||||
const onPass = vi.fn();
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={onPass}
|
||||
onConcede={vi.fn()}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Pass Priority'));
|
||||
expect(onPass).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows confirm dialog on concede', async () => {
|
||||
const onConcede = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={vi.fn()}
|
||||
onConcede={onConcede}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Concede'));
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to concede?');
|
||||
expect(onConcede).toHaveBeenCalledOnce();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not concede if confirm is cancelled', async () => {
|
||||
const onConcede = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={vi.fn()}
|
||||
onConcede={onConcede}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Concede'));
|
||||
expect(onConcede).not.toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import NarrationPanel from '../components/NarrationPanel';
|
||||
import type { NarrationLine } from '../types/game';
|
||||
|
||||
describe('NarrationPanel', () => {
|
||||
it('renders only 120 lines when given more', () => {
|
||||
const lines: NarrationLine[] = Array.from({ length: 150 }, (_, i) => ({
|
||||
text: `Line ${i}`,
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
render(<NarrationPanel lines={lines} thinkingSeats={new Set()} />);
|
||||
|
||||
// Should not render the first 30 lines (0-29)
|
||||
expect(screen.queryByText('Line 0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Line 29')).not.toBeInTheDocument();
|
||||
|
||||
// Should render lines 30-149
|
||||
expect(screen.getByText('Line 30')).toBeInTheDocument();
|
||||
expect(screen.getByText('Line 149')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has a scroll container ref', () => {
|
||||
const lines: NarrationLine[] = [
|
||||
{ text: 'Hello world', isSystem: false, timestamp: Date.now() },
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<NarrationPanel lines={lines} thinkingSeats={new Set()} />,
|
||||
);
|
||||
|
||||
// The container div has overflow-y-auto
|
||||
const panel = container.firstChild as HTMLElement;
|
||||
expect(panel).toHaveClass('overflow-y-auto');
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import PhaseBar from '../components/PhaseBar';
|
||||
import type { Player } from '../types/game';
|
||||
|
||||
const players: Player[] = [
|
||||
{
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 1, name: 'Bob', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
];
|
||||
|
||||
describe('PhaseBar', () => {
|
||||
it('highlights the active phase tab', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
const main1 = screen.getByText('main1');
|
||||
expect(main1).toHaveClass('bg-accent-blue');
|
||||
});
|
||||
|
||||
it('does not highlight inactive phase tabs', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
const untap = screen.getByText('untap');
|
||||
expect(untap).not.toHaveClass('bg-accent-blue');
|
||||
expect(untap).toHaveClass('bg-[#252a42]');
|
||||
});
|
||||
|
||||
it('shows active player name', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
expect(screen.getByText(/P0: Alice/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import PlayerZone from '../components/PlayerZone';
|
||||
import type { Player } from '../types/game';
|
||||
|
||||
function makePlayer(overrides: Partial<Player> = {}): Player {
|
||||
return {
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlayerZone life colors', () => {
|
||||
it('uses life-high color for life >= 30', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 35 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('35');
|
||||
expect(life).toHaveClass('text-life-high');
|
||||
});
|
||||
|
||||
it('uses life-med color for life >= 15 and < 30', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 20 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('20');
|
||||
expect(life).toHaveClass('text-life-med');
|
||||
});
|
||||
|
||||
it('uses life-low color for life < 15', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 10 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('10');
|
||||
expect(life).toHaveClass('text-life-low');
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { gameReducer, initialState } from '../store/gameReducer';
|
||||
import type { GameState, Card, NarrationLine } from '../types/game';
|
||||
|
||||
const mockGameState: GameState = {
|
||||
game_id: 'g1',
|
||||
turn: 1,
|
||||
current_phase: 'main1',
|
||||
active_seat: 0,
|
||||
players: [],
|
||||
stack: [],
|
||||
game_over: false,
|
||||
winner: null,
|
||||
};
|
||||
|
||||
const mockCard: Card = {
|
||||
name: 'Sol Ring',
|
||||
type: 'Artifact',
|
||||
tapped: false,
|
||||
is_commander: false,
|
||||
oracle: '',
|
||||
cmc: 1,
|
||||
};
|
||||
|
||||
const mockLine: NarrationLine = {
|
||||
text: 'Test narration',
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
describe('gameReducer', () => {
|
||||
it('SET_STATE sets the game state', () => {
|
||||
const result = gameReducer(initialState, { type: 'SET_STATE', payload: mockGameState });
|
||||
expect(result.gameState).toEqual(mockGameState);
|
||||
});
|
||||
|
||||
it('SET_HAND sets the hand cards', () => {
|
||||
const cards = [mockCard];
|
||||
const result = gameReducer(initialState, { type: 'SET_HAND', cards });
|
||||
expect(result.hand).toEqual(cards);
|
||||
});
|
||||
|
||||
it('ADD_NARRATION appends a line', () => {
|
||||
const result = gameReducer(initialState, { type: 'ADD_NARRATION', line: mockLine });
|
||||
expect(result.narration).toHaveLength(1);
|
||||
expect(result.narration[0].text).toBe('Test narration');
|
||||
});
|
||||
|
||||
it('ADD_NARRATION caps at 120 lines', () => {
|
||||
let state = initialState;
|
||||
for (let i = 0; i < 130; i++) {
|
||||
state = gameReducer(state, {
|
||||
type: 'ADD_NARRATION',
|
||||
line: { text: `line ${i}`, isSystem: false, timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
expect(state.narration).toHaveLength(120);
|
||||
expect(state.narration[0].text).toBe('line 10');
|
||||
});
|
||||
|
||||
it('SET_THINKING adds and removes seats', () => {
|
||||
let state = gameReducer(initialState, { type: 'SET_THINKING', seat: 2, active: true });
|
||||
expect(state.thinkingSeats.has(2)).toBe(true);
|
||||
|
||||
state = gameReducer(state, { type: 'SET_THINKING', seat: 2, active: false });
|
||||
expect(state.thinkingSeats.has(2)).toBe(false);
|
||||
});
|
||||
|
||||
it('GAME_OVER sets winner and reason', () => {
|
||||
const result = gameReducer(initialState, { type: 'GAME_OVER', winner: 1, reason: 'elimination' });
|
||||
expect(result.gameOver).toEqual({ winner: 1, reason: 'elimination' });
|
||||
});
|
||||
|
||||
it('SET_STATUS sets status text', () => {
|
||||
const result = gameReducer(initialState, { type: 'SET_STATUS', status: 'Connected' });
|
||||
expect(result.status).toBe('Connected');
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useGameSocket } from '../hooks/useGameSocket';
|
||||
|
||||
// Mock WebSocket
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
static instances: MockWebSocket[] = [];
|
||||
url: string;
|
||||
readyState = 1; // OPEN
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
sent: string[] = [];
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
// Simulate connection open
|
||||
setTimeout(() => this.onopen?.(new Event('open')), 0);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
simulateMessage(data: unknown) {
|
||||
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) }));
|
||||
}
|
||||
}
|
||||
|
||||
describe('useGameSocket', () => {
|
||||
beforeEach(() => {
|
||||
MockWebSocket.instances = [];
|
||||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('dispatches state on WS message', () => {
|
||||
const { result } = renderHook(() => useGameSocket('game-1'));
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
const mockState = {
|
||||
game_id: 'game-1', turn: 1, current_phase: 'main1', active_seat: 0,
|
||||
players: [], stack: [], game_over: false, winner: null,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
ws.simulateMessage({ type: 'state', payload: mockState });
|
||||
});
|
||||
|
||||
expect(result.current.gameState).toEqual(mockState);
|
||||
});
|
||||
|
||||
it('dispatches hand cards on WS message', () => {
|
||||
const { result } = renderHook(() => useGameSocket('game-1'));
|
||||
const ws = MockWebSocket.instances[0];
|
||||
|
||||
const cards = [{ name: 'Sol Ring', type: 'Artifact', tapped: false, is_commander: false, oracle: '', cmc: 1 }];
|
||||
|
||||
act(() => {
|
||||
ws.simulateMessage({ type: 'hand', cards });
|
||||
});
|
||||
|
||||
expect(result.current.hand).toEqual(cards);
|
||||
});
|
||||
|
||||
it('sends heartbeat ping every 15s', () => {
|
||||
renderHook(() => useGameSocket('game-1'));
|
||||
const ws = MockWebSocket.instances[0];
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
|
||||
expect(ws.sent).toContain(JSON.stringify({ type: 'ping' }));
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,54 +0,0 @@
|
||||
import type { GameState } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
import PlayerZone from './PlayerZone';
|
||||
import CardChip from './CardChip';
|
||||
|
||||
interface BoardProps {
|
||||
gameState: GameState;
|
||||
thinkingSeats: Set<number>;
|
||||
}
|
||||
|
||||
export default function Board({ gameState, thinkingSeats }: BoardProps) {
|
||||
const { players, active_seat, stack } = gameState;
|
||||
|
||||
const getPlayer = (seat: number) => players.find((p) => p.seat === seat);
|
||||
|
||||
const renderZone = (seat: number, className: string) => {
|
||||
const player = getPlayer(seat);
|
||||
if (!player) return <div className={className} />;
|
||||
return (
|
||||
<div className={className}>
|
||||
<PlayerZone
|
||||
player={player}
|
||||
isActive={active_seat === seat}
|
||||
isThinking={thinkingSeats.has(seat)}
|
||||
seatColour={SEAT_COLOURS[seat]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[180px_1fr_180px] grid-rows-3 gap-1 flex-1">
|
||||
{/* Row 1: P1 (top-left), stack center, P2 (top-right) */}
|
||||
{renderZone(1, 'row-start-1 col-start-1')}
|
||||
<div className="bg-bg-zone border border-accent-purple rounded-md min-h-[60px] row-span-3 col-start-2 p-2 overflow-y-auto">
|
||||
<span className="text-[10px] text-[#666] block mb-1">Stack</span>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{stack.map((card, i) => (
|
||||
<CardChip key={i} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{renderZone(2, 'row-start-1 col-start-3')}
|
||||
|
||||
{/* Row 2: empty side cells (stack spans all 3 rows in center) */}
|
||||
<div className="row-start-2 col-start-1" />
|
||||
<div className="row-start-2 col-start-3" />
|
||||
|
||||
{/* Row 3: P0 (bottom-left), P3 (bottom-right) */}
|
||||
{renderZone(0, 'row-start-3 col-start-1')}
|
||||
{renderZone(3, 'row-start-3 col-start-3')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
interface CardChipProps {
|
||||
card: Card;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function CardChip({ card, onClick }: CardChipProps) {
|
||||
return (
|
||||
<span
|
||||
onClick={onClick}
|
||||
className={`bg-card-bg border border-[#444] rounded text-[10px] max-w-[110px] overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer px-1.5 py-0.5 hover:border-[#ffdc32] ${
|
||||
card.tapped ? 'opacity-55 italic' : ''
|
||||
}`}
|
||||
>
|
||||
{card.name}
|
||||
{card.is_commander && <span className="text-[#ffd200]"> ★</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Player } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface GameOverOverlayProps {
|
||||
show: boolean;
|
||||
winner: Player | null;
|
||||
reason: string;
|
||||
players: Player[];
|
||||
}
|
||||
|
||||
export default function GameOverOverlay({ show, winner, reason, players }: GameOverOverlayProps) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center">
|
||||
<div className="bg-bg-zone border-2 border-[#4060c0] rounded-[10px] px-[60px] py-10 text-center">
|
||||
{winner && (
|
||||
<h2
|
||||
className="text-4xl font-bold"
|
||||
style={{ color: SEAT_COLOURS[winner.seat] }}
|
||||
>
|
||||
{winner.name} Wins!
|
||||
</h2>
|
||||
)}
|
||||
<p className="text-base text-[#aaa] mt-2">{reason}</p>
|
||||
<ul className="mt-6 space-y-1">
|
||||
{players.map((p) => (
|
||||
<li key={p.seat} className="text-sm">
|
||||
<span style={{ color: SEAT_COLOURS[p.seat] }}>P{p.seat}</span>{' '}
|
||||
{p.name} — {p.life} life
|
||||
{p.eliminated && (
|
||||
<span className="text-red-500 ml-1 font-bold">[ELIM]</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
interface HandCardProps {
|
||||
card: Card;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function HandCard({ card, isSelected, onClick }: HandCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`rounded text-[11px] px-2 py-1 cursor-pointer ${
|
||||
isSelected
|
||||
? 'border border-[#ffdc32] bg-card-selected'
|
||||
: 'bg-card-hand border border-[#5060a0]'
|
||||
}`}
|
||||
>
|
||||
{card.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { Card } from '../types/game';
|
||||
import HandCard from './HandCard';
|
||||
|
||||
interface MoveBarProps {
|
||||
hand: Card[];
|
||||
humanSeat: number;
|
||||
onPass: () => void;
|
||||
onConcede: () => void;
|
||||
onPlayCard: (index: number) => void;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function MoveBar({ hand, onPass, onConcede, onPlayCard, status }: MoveBarProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const handleCardClick = (index: number) => {
|
||||
if (selectedIndex === index) {
|
||||
onPlayCard(index);
|
||||
setSelectedIndex(null);
|
||||
} else {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-bg-bar border-t border-[#2a2e48] px-2.5 py-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPass}
|
||||
className="bg-[#2a5090] border border-[#4080d0] text-white rounded px-3 py-1 text-sm cursor-pointer hover:brightness-110"
|
||||
>
|
||||
Pass Priority
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Are you sure you want to concede?')) onConcede();
|
||||
}}
|
||||
className="bg-[#2a3868] border border-[#4060a0] text-white rounded px-3 py-1 text-sm cursor-pointer"
|
||||
>
|
||||
Concede
|
||||
</button>
|
||||
{hand.map((card, i) => (
|
||||
<HandCard
|
||||
key={i}
|
||||
card={card}
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => handleCardClick(i)}
|
||||
/>
|
||||
))}
|
||||
<span className="text-[11px] text-[#778] ml-auto">{status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import type { NarrationLine } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface NarrationPanelProps {
|
||||
lines: NarrationLine[];
|
||||
thinkingSeats: Set<number>;
|
||||
}
|
||||
|
||||
export default function NarrationPanel({ lines, thinkingSeats }: NarrationPanelProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dots, setDots] = useState('.');
|
||||
|
||||
// Auto-scroll on new lines
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines.length]);
|
||||
|
||||
// Cycling dots for thinking animation
|
||||
useEffect(() => {
|
||||
if (thinkingSeats.size === 0) return;
|
||||
const interval = setInterval(() => {
|
||||
setDots((prev) => (prev.length >= 3 ? '.' : prev + '.'));
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [thinkingSeats.size]);
|
||||
|
||||
const displayed = lines.slice(-120);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-[260px] bg-bg-panel border-l border-[#2a2e48] overflow-y-auto p-2 flex flex-col"
|
||||
>
|
||||
{displayed.map((line, i) => {
|
||||
let colorClass = 'text-[#e8e8e8]';
|
||||
let style: React.CSSProperties | undefined;
|
||||
|
||||
if (line.isSystem) {
|
||||
colorClass = 'text-[#556]';
|
||||
} else if (line.seat !== undefined) {
|
||||
colorClass = '';
|
||||
style = { color: SEAT_COLOURS[line.seat] };
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`text-[11px] leading-[1.4] break-words ${colorClass}`}
|
||||
style={style}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{thinkingSeats.size > 0 &&
|
||||
Array.from(thinkingSeats).map((seat) => (
|
||||
<div
|
||||
key={`thinking-${seat}`}
|
||||
className="text-[11px] leading-[1.4] break-words text-accent-think italic"
|
||||
>
|
||||
P{seat} thinking{dots}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Phase, Player } from '../types/game';
|
||||
import { PHASES, SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface PhaseBarProps {
|
||||
phase: Phase;
|
||||
activeSeat: number;
|
||||
players: Player[];
|
||||
}
|
||||
|
||||
export default function PhaseBar({ phase, activeSeat, players }: PhaseBarProps) {
|
||||
const activePlayer = players.find((p) => p.seat === activeSeat);
|
||||
|
||||
return (
|
||||
<div className="h-[34px] bg-[#1e2338] px-2 py-1 flex items-center gap-1">
|
||||
{PHASES.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`text-[11px] rounded px-2 py-0.5 ${
|
||||
p === phase
|
||||
? 'bg-accent-blue text-white'
|
||||
: 'bg-[#252a42] text-[#888]'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{activePlayer && (
|
||||
<span
|
||||
className="text-[11px] ml-auto font-bold"
|
||||
style={{ color: SEAT_COLOURS[activeSeat] }}
|
||||
>
|
||||
P{activeSeat}: {activePlayer.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Player } from '../types/game';
|
||||
import CardChip from './CardChip';
|
||||
|
||||
interface PlayerZoneProps {
|
||||
player: Player;
|
||||
isActive: boolean;
|
||||
isThinking: boolean;
|
||||
seatColour: string;
|
||||
}
|
||||
|
||||
function lifeColor(life: number): string {
|
||||
if (life >= 30) return 'text-life-high';
|
||||
if (life >= 15) return 'text-life-med';
|
||||
return 'text-life-low';
|
||||
}
|
||||
|
||||
export default function PlayerZone({ player, isActive, isThinking, seatColour }: PlayerZoneProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-bg-zone border border-[#3a4060] rounded-md p-1.5 ${
|
||||
isThinking ? 'animate-pulse' : ''
|
||||
} ${player.eliminated ? 'opacity-40' : ''}`}
|
||||
style={isActive ? { borderColor: seatColour } : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[11px] text-[#666]">
|
||||
P{player.seat}: {player.name}
|
||||
</span>
|
||||
<span className={`text-[28px] font-bold ${lifeColor(player.life)}`}>
|
||||
{player.life}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{player.battlefield.map((card, i) => (
|
||||
<CardChip key={i} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Phase } from './types/game';
|
||||
|
||||
export const SEAT_COLOURS = ['#4696ff', '#ff6450', '#50d278', '#dcaa32'] as const;
|
||||
|
||||
export const PHASES: Phase[] = [
|
||||
'untap', 'upkeep', 'draw', 'main1',
|
||||
'begin_combat', 'declare_attackers', 'declare_blockers', 'combat_damage',
|
||||
'main2', 'end_step', 'cleanup',
|
||||
];
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useReducer, useRef, useEffect, useCallback } from 'react';
|
||||
import { gameReducer, initialState } from '../store/gameReducer';
|
||||
import type { WsIncoming, NarrationLine } from '../types/game';
|
||||
|
||||
export function useGameSocket(gameId: string) {
|
||||
const [state, dispatch] = useReducer(gameReducer, initialState);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const url = `ws://${window.location.host}/ws/game/${gameId}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Connected' });
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg: WsIncoming = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'state':
|
||||
dispatch({ type: 'SET_STATE', payload: msg.payload });
|
||||
break;
|
||||
|
||||
case 'hand':
|
||||
dispatch({ type: 'SET_HAND', cards: msg.cards });
|
||||
break;
|
||||
|
||||
case 'event': {
|
||||
const line: NarrationLine = {
|
||||
text: msg.text,
|
||||
seat: msg.seat,
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch({ type: 'ADD_NARRATION', line });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'phase': {
|
||||
const line: NarrationLine = {
|
||||
text: `Phase: ${msg.phase} (P${msg.active_seat})`,
|
||||
isSystem: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch({ type: 'ADD_NARRATION', line });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'thinking':
|
||||
dispatch({ type: 'SET_THINKING', seat: msg.seat, active: msg.active });
|
||||
break;
|
||||
|
||||
case 'game_over':
|
||||
dispatch({ type: 'GAME_OVER', winner: msg.winner, reason: msg.reason });
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
dispatch({ type: 'SET_STATUS', status: `Error: ${msg.message}` });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!intentionalClose.current) {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Disconnected — reconnecting…' });
|
||||
reconnectTimeout.current = setTimeout(() => {
|
||||
connect();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Connection error' });
|
||||
};
|
||||
}, [gameId]);
|
||||
|
||||
// Connect on mount
|
||||
useEffect(() => {
|
||||
intentionalClose.current = false;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
intentionalClose.current = true;
|
||||
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// Heartbeat ping every 15s
|
||||
useEffect(() => {
|
||||
heartbeatInterval.current = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendMove = useCallback((move: string, data?: Record<string, unknown>) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'move', move, ...data }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendConcede = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'concede' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
gameState: state.gameState,
|
||||
narration: state.narration,
|
||||
thinking: state.thinkingSeats,
|
||||
hand: state.hand,
|
||||
gameOver: state.gameOver,
|
||||
status: state.status,
|
||||
sendMove,
|
||||
sendConcede,
|
||||
};
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { useReducer, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface Seat {
|
||||
seat: number;
|
||||
name: string;
|
||||
ready: boolean;
|
||||
isAi: boolean;
|
||||
}
|
||||
|
||||
export interface RoomState {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
host: string;
|
||||
seats: (Seat | null)[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'PLAYER' | 'AI' | 'SYSTEM';
|
||||
name: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type LobbyAction =
|
||||
| { type: 'SET_ROOM'; payload: RoomState }
|
||||
| { type: 'ADD_CHAT'; message: ChatMessage }
|
||||
| { type: 'SET_CHAT_HISTORY'; messages: ChatMessage[] }
|
||||
| { type: 'LAUNCH'; gameId: string };
|
||||
|
||||
interface LobbyState {
|
||||
roomState: RoomState | null;
|
||||
chat: ChatMessage[];
|
||||
launchedGameId: string | null;
|
||||
}
|
||||
|
||||
const initialState: LobbyState = {
|
||||
roomState: null,
|
||||
chat: [],
|
||||
launchedGameId: null,
|
||||
};
|
||||
|
||||
function lobbyReducer(state: LobbyState, action: LobbyAction): LobbyState {
|
||||
switch (action.type) {
|
||||
case 'SET_ROOM':
|
||||
return { ...state, roomState: action.payload };
|
||||
case 'ADD_CHAT':
|
||||
return { ...state, chat: [...state.chat, action.message].slice(-50) };
|
||||
case 'SET_CHAT_HISTORY':
|
||||
return { ...state, chat: action.messages.slice(-50) };
|
||||
case 'LAUNCH':
|
||||
return { ...state, launchedGameId: action.gameId };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useLobbySock(roomId: string, playerName: string) {
|
||||
const [state, dispatch] = useReducer(lobbyReducer, initialState);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const url = `ws://${window.location.host}/ws/lobby/${roomId}?name=${encodeURIComponent(playerName)}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'lobby_state':
|
||||
dispatch({ type: 'SET_ROOM', payload: msg.payload });
|
||||
break;
|
||||
case 'chat':
|
||||
dispatch({
|
||||
type: 'ADD_CHAT',
|
||||
message: {
|
||||
role: msg.role ?? 'PLAYER',
|
||||
name: msg.name ?? '',
|
||||
text: msg.text,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'chat_history':
|
||||
dispatch({
|
||||
type: 'SET_CHAT_HISTORY',
|
||||
messages: (msg.messages ?? []).map((m: { role?: string; name?: string; text: string }) => ({
|
||||
role: m.role ?? 'SYSTEM',
|
||||
name: m.name ?? '',
|
||||
text: m.text,
|
||||
timestamp: Date.now(),
|
||||
})),
|
||||
});
|
||||
break;
|
||||
case 'launch':
|
||||
dispatch({ type: 'LAUNCH', gameId: msg.game_id });
|
||||
break;
|
||||
case 'pong':
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!intentionalClose.current) {
|
||||
reconnectTimeout.current = setTimeout(() => connect(), 3000);
|
||||
}
|
||||
};
|
||||
}, [roomId, playerName]);
|
||||
|
||||
useEffect(() => {
|
||||
intentionalClose.current = false;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
intentionalClose.current = true;
|
||||
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// Heartbeat ping every 15s
|
||||
useEffect(() => {
|
||||
heartbeatInterval.current = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendReady = useCallback((seat: number) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ready', seat }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendChat = useCallback((text: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'chat', text }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendLaunch = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'launch' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
roomState: state.roomState,
|
||||
chat: state.chat,
|
||||
launchedGameId: state.launchedGameId,
|
||||
sendReady,
|
||||
sendChat,
|
||||
sendLaunch,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -1,10 +0,0 @@
|
||||
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>,
|
||||
)
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGameSocket } from '../hooks/useGameSocket';
|
||||
import PhaseBar from '../components/PhaseBar';
|
||||
import Board from '../components/Board';
|
||||
import NarrationPanel from '../components/NarrationPanel';
|
||||
import MoveBar from '../components/MoveBar';
|
||||
import GameOverOverlay from '../components/GameOverOverlay';
|
||||
|
||||
export default function GamePage() {
|
||||
const { gameId } = useParams<{ gameId: string }>();
|
||||
const { gameState, narration, thinking, hand, gameOver, status, sendMove, sendConcede } =
|
||||
useGameSocket(gameId!);
|
||||
|
||||
if (!gameState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
Connecting…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const winnerPlayer = gameOver
|
||||
? gameState.players.find((p) => p.seat === gameOver.winner) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-bg-base text-white">
|
||||
<PhaseBar
|
||||
phase={gameState.current_phase}
|
||||
activeSeat={gameState.active_seat}
|
||||
players={gameState.players}
|
||||
/>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Board gameState={gameState} thinkingSeats={thinking} />
|
||||
<NarrationPanel lines={narration} thinkingSeats={thinking} />
|
||||
</div>
|
||||
<MoveBar
|
||||
hand={hand}
|
||||
humanSeat={0}
|
||||
onPass={() => sendMove('pass')}
|
||||
onConcede={sendConcede}
|
||||
onPlayCard={(index) => sendMove('play_card', { index })}
|
||||
status={status}
|
||||
/>
|
||||
<GameOverOverlay
|
||||
show={gameOver !== null}
|
||||
winner={winnerPlayer}
|
||||
reason={gameOver?.reason ?? ''}
|
||||
players={gameState.players}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface RoomSummary {
|
||||
roomId: string;
|
||||
name: string;
|
||||
host: string;
|
||||
playerCount: number;
|
||||
}
|
||||
|
||||
export default function LobbyPage() {
|
||||
const navigate = useNavigate();
|
||||
const [rooms, setRooms] = useState<RoomSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Create room form state
|
||||
const [roomName, setRoomName] = useState('');
|
||||
const [hostName, setHostName] = useState('');
|
||||
const [aiSlots, setAiSlots] = useState(0);
|
||||
const [password, setPassword] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/lobby/rooms')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setRooms(data))
|
||||
.catch(() => setRooms([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/lobby/rooms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: roomName,
|
||||
host: hostName,
|
||||
ai_slots: aiSlots,
|
||||
password: password || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
navigate(`/room/${data.roomId}?name=${encodeURIComponent(hostName)}`);
|
||||
} catch {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-base text-white p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">Commander AI Lab — Lobby</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
|
||||
{/* Room List */}
|
||||
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Open Rooms</h2>
|
||||
{loading ? (
|
||||
<p className="text-text-muted text-sm">Loading...</p>
|
||||
) : rooms.length === 0 ? (
|
||||
<p className="text-text-muted text-sm">No rooms available. Create one!</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rooms.map((room) => (
|
||||
<li key={room.roomId}>
|
||||
<button
|
||||
onClick={() => navigate(`/room/${room.roomId}`)}
|
||||
className="w-full text-left bg-bg-panel border border-border-zone rounded px-3 py-2 hover:border-accent-blue transition-colors"
|
||||
>
|
||||
<span className="font-medium">{room.name}</span>
|
||||
<span className="text-text-muted text-sm ml-2">
|
||||
Host: {room.host} · {room.playerCount}/4 players
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Room Form */}
|
||||
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Create Room</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Room Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={roomName}
|
||||
onChange={(e) => setRoomName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Your Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hostName}
|
||||
onChange={(e) => setHostName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">AI Players</label>
|
||||
<select
|
||||
value={aiSlots}
|
||||
onChange={(e) => setAiSlots(Number(e.target.value))}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value={0}>0 AI players</option>
|
||||
<option value={1}>1 AI player</option>
|
||||
<option value={2}>2 AI players</option>
|
||||
<option value={3}>3 AI players</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Password (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Room'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useLobbySock } from '../hooks/useLobbySock';
|
||||
import type { ChatMessage } from '../hooks/useLobbySock';
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
PLAYER: 'text-accent-blue',
|
||||
AI: 'text-accent-purple',
|
||||
SYSTEM: 'text-text-muted',
|
||||
};
|
||||
|
||||
function ChatBadge({ role }: { role: string }) {
|
||||
return (
|
||||
<span className={`text-[10px] font-bold mr-1 ${ROLE_COLORS[role] ?? 'text-text-muted'}`}>
|
||||
[{role}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoomPage() {
|
||||
const { roomId } = useParams<{ roomId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isSpectator = searchParams.get('spectate') === '1';
|
||||
const nameFromUrl = searchParams.get('name') ?? '';
|
||||
const [playerName, setPlayerName] = useState(nameFromUrl);
|
||||
const [nameConfirmed, setNameConfirmed] = useState(!!nameFromUrl);
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { roomState, chat, launchedGameId, sendReady, sendChat, sendLaunch } =
|
||||
useLobbySock(roomId!, nameConfirmed ? playerName : '');
|
||||
|
||||
// Redirect when game launches
|
||||
useEffect(() => {
|
||||
if (launchedGameId) {
|
||||
navigate(`/game/${launchedGameId}`);
|
||||
}
|
||||
}, [launchedGameId, navigate]);
|
||||
|
||||
// Auto-scroll chat
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [chat.length]);
|
||||
|
||||
// Determine own seat
|
||||
const mySeat = roomState?.seats.findIndex(
|
||||
(s) => s && !s.isAi && s.name === playerName,
|
||||
) ?? -1;
|
||||
const isHost = mySeat === 0;
|
||||
|
||||
if (!nameConfirmed) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
<div className="bg-bg-zone border border-border-zone rounded-lg p-6 w-80">
|
||||
<h2 className="text-lg font-semibold mb-3">Enter your name</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && playerName.trim()) setNameConfirmed(true);
|
||||
}}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white mb-3 focus:outline-none focus:border-accent-blue"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => playerName.trim() && setNameConfirmed(true)}
|
||||
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
|
||||
>
|
||||
Join Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!roomState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
Connecting to room...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSendChat = () => {
|
||||
if (chatInput.trim()) {
|
||||
sendChat(chatInput.trim());
|
||||
setChatInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg-base text-white">
|
||||
{/* Header */}
|
||||
<div className="bg-bg-bar border-b border-border-sep px-4 py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">{roomState.roomName}</h1>
|
||||
<span className="text-text-muted text-sm">Host: {roomState.host}</span>
|
||||
</div>
|
||||
{isSpectator && (
|
||||
<span className="text-text-muted text-xs bg-bg-panel px-2 py-1 rounded">
|
||||
SPECTATING
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Seat Grid */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="grid grid-cols-2 gap-3 max-w-lg">
|
||||
{[0, 1, 2, 3].map((seatIdx) => {
|
||||
const seat = roomState.seats[seatIdx];
|
||||
return (
|
||||
<div
|
||||
key={seatIdx}
|
||||
className="bg-bg-zone border border-border-zone rounded-lg p-4 min-h-[100px] flex flex-col items-center justify-center"
|
||||
>
|
||||
{seat ? (
|
||||
<>
|
||||
<span className="text-sm font-medium">
|
||||
{seat.isAi ? '🤖 AI' : seat.name}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted mt-1">
|
||||
Seat {seatIdx}
|
||||
</span>
|
||||
{!seat.isAi && (
|
||||
<span
|
||||
className={`text-xs mt-1 font-bold ${
|
||||
seat.ready ? 'text-life-high' : 'text-life-med'
|
||||
}`}
|
||||
>
|
||||
{seat.ready ? 'READY' : 'Not Ready'}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-text-muted text-sm">Waiting...</span>
|
||||
<span className="text-xs text-text-muted mt-1">
|
||||
Seat {seatIdx}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{!isSpectator && (
|
||||
<div className="mt-4 flex gap-3">
|
||||
{mySeat >= 0 && (
|
||||
<button
|
||||
onClick={() => sendReady(mySeat)}
|
||||
className="bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
|
||||
>
|
||||
Toggle Ready
|
||||
</button>
|
||||
)}
|
||||
{isHost && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/lobby/rooms/${roomId}/launch`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.gameId) {
|
||||
navigate(`/game/${data.gameId}`);
|
||||
}
|
||||
} catch {
|
||||
// launch via WS fallback
|
||||
sendLaunch();
|
||||
}
|
||||
}}
|
||||
className="bg-life-high border border-life-high text-white rounded px-4 py-2 text-sm font-bold hover:brightness-110"
|
||||
>
|
||||
Launch Game
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat Panel */}
|
||||
<div className="w-[280px] bg-bg-panel border-l border-border-sep flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border-sep text-sm font-semibold">
|
||||
Chat
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{chat.map((msg: ChatMessage, i: number) => (
|
||||
<div key={i} className="text-[11px] leading-[1.4] break-words">
|
||||
<ChatBadge role={msg.role} />
|
||||
{msg.name && <span className="font-medium mr-1">{msg.name}:</span>}
|
||||
<span className="text-text-body">{msg.text}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
{!isSpectator && (
|
||||
<div className="border-t border-border-sep p-2">
|
||||
<input
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSendChat();
|
||||
}}
|
||||
placeholder="Type a message..."
|
||||
className="w-full bg-bg-base border border-border-zone rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
import type { SimDeck, SimResult } from '../types/simulator';
|
||||
|
||||
const SEAT_LABELS = ['Seat 1 (You)', 'Seat 2', 'Seat 3', 'Seat 4'];
|
||||
|
||||
type SimStatus = 'idle' | 'running' | 'complete' | 'error';
|
||||
|
||||
export default function SimulatorPage() {
|
||||
// Deck list from API
|
||||
const [decks, setDecks] = useState<SimDeck[]>([]);
|
||||
const [loadingDecks, setLoadingDecks] = useState(true);
|
||||
|
||||
// Slot selections (4 slots, null = empty)
|
||||
const [slots, setSlots] = useState<(number | null)[]>([null, null, null, null]);
|
||||
|
||||
// Config
|
||||
const [numGames, setNumGames] = useState(10);
|
||||
const [engineType, setEngineType] = useState('heuristic');
|
||||
const [recordLogs, setRecordLogs] = useState(true);
|
||||
|
||||
// Sim state
|
||||
const [simId, setSimId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<SimStatus>('idle');
|
||||
const [completed, setCompleted] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [result, setResult] = useState<SimResult | null>(null);
|
||||
const [games, setGames] = useState<unknown[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Fetch decks on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/decks')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const list = (data.decks || []).map((d: Record<string, unknown>) => ({
|
||||
id: d.id as number,
|
||||
name: d.name as string,
|
||||
commander_name: (d.commander_name || '') as string,
|
||||
color_identity: (d.color_identity || []) as string[],
|
||||
}));
|
||||
setDecks(list);
|
||||
})
|
||||
.catch(() => setDecks([]))
|
||||
.finally(() => setLoadingDecks(false));
|
||||
}, []);
|
||||
|
||||
// Polling
|
||||
useEffect(() => {
|
||||
if (status !== 'running' || !simId) return;
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sim/status?simId=${simId}`);
|
||||
const data = await res.json();
|
||||
setCompleted(data.completed ?? 0);
|
||||
setTotal(data.total ?? 0);
|
||||
if (data.status === 'complete') {
|
||||
setStatus('complete');
|
||||
// Fetch results
|
||||
const rres = await fetch(`/api/sim/result?simId=${simId}`);
|
||||
const rdata = await rres.json();
|
||||
setResult(rdata.summary ?? null);
|
||||
setGames(rdata.games ?? []);
|
||||
} else if (data.status === 'error') {
|
||||
setStatus('error');
|
||||
setError(data.error || 'Simulation failed');
|
||||
}
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
}, 1000);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, [status, simId]);
|
||||
|
||||
const selectedCount = slots.filter((s) => s !== null).length;
|
||||
const canRun = selectedCount >= 2 && status !== 'running';
|
||||
|
||||
const handleRun = async () => {
|
||||
const deckIds = slots.filter((s): s is number => s !== null);
|
||||
setStatus('running');
|
||||
setCompleted(0);
|
||||
setTotal(numGames);
|
||||
setResult(null);
|
||||
setGames([]);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sim/run-n-player', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
deckIds,
|
||||
numGames,
|
||||
recordLogs,
|
||||
engineType,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setStatus('error');
|
||||
setError(data.error || 'Failed to start simulation');
|
||||
return;
|
||||
}
|
||||
setSimId(data.simId);
|
||||
setTotal(data.total);
|
||||
} catch {
|
||||
setStatus('error');
|
||||
setError('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
const setSlot = (idx: number, value: number | null) => {
|
||||
setSlots((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = value;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getDeckById = (id: number) => decks.find((d) => d.id === id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-base text-white p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-2">Batch Simulator</h1>
|
||||
<p className="text-text-muted text-sm mb-6">
|
||||
Run N-player Monte Carlo simulations with your decks.
|
||||
</p>
|
||||
|
||||
{/* Deck Selection */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-3">Deck Slots</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{SEAT_LABELS.map((label, idx) => {
|
||||
const selected = slots[idx];
|
||||
const deck = selected !== null ? getDeckById(selected) : null;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-bg-zone rounded-md p-4"
|
||||
style={{
|
||||
border: `2px solid ${selected !== null ? SEAT_COLOURS[idx] : '#3a4060'}`,
|
||||
boxShadow: selected !== null ? `0 0 8px ${SEAT_COLOURS[idx]}40` : 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-medium mb-2"
|
||||
style={{ color: SEAT_COLOURS[idx] }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<select
|
||||
value={selected ?? ''}
|
||||
onChange={(e) => setSlot(idx, e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value="">— Empty —</option>
|
||||
{decks.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{deck && (
|
||||
<div className="mt-2 text-xs text-text-muted">
|
||||
{deck.commander_name && (
|
||||
<div className="truncate">{deck.commander_name}</div>
|
||||
)}
|
||||
{deck.color_identity.length > 0 && (
|
||||
<div>{deck.color_identity.join(' ')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{loadingDecks && (
|
||||
<p className="text-text-muted text-sm mt-2">Loading decks...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config Row */}
|
||||
<div className="flex flex-wrap items-end gap-4 mb-6 bg-bg-zone rounded-md border border-border-zone p-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Games</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={numGames}
|
||||
onChange={(e) => setNumGames(Math.max(1, Math.min(1000, Number(e.target.value))))}
|
||||
className="w-24 bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Engine</label>
|
||||
<select
|
||||
value={engineType}
|
||||
onChange={(e) => setEngineType(e.target.value)}
|
||||
className="bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value="heuristic">Heuristic AI</option>
|
||||
<option value="deepseek">DeepSeek AI</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="recordLogs"
|
||||
checked={recordLogs}
|
||||
onChange={(e) => setRecordLogs(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="recordLogs" className="text-sm text-text-muted">
|
||||
Record Logs
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={!canRun}
|
||||
className="bg-accent-blue text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === 'running' ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Running...
|
||||
</span>
|
||||
) : (
|
||||
'Run Simulation'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{status === 'running' && (
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-text-muted mb-1">
|
||||
<span>Progress</span>
|
||||
<span>
|
||||
{completed} / {total} games
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-bg-panel rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-blue rounded-full transition-all duration-300"
|
||||
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{status === 'error' && error && (
|
||||
<div className="mb-6 bg-red-900/30 border border-red-700 rounded-md p-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{status === 'complete' && result && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4 text-sm text-text-muted">
|
||||
<span>{result.totalGames} games</span>
|
||||
<span>{result.playerCount} players</span>
|
||||
<span>{result.elapsedSeconds}s elapsed</span>
|
||||
</div>
|
||||
|
||||
{/* Win Rate Bar Chart */}
|
||||
<div className="bg-bg-zone rounded-md border border-border-zone p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">Win Rates</h3>
|
||||
<div className="space-y-2">
|
||||
{result.players.map((p) => (
|
||||
<div key={p.seat} className="flex items-center gap-3">
|
||||
<div className="w-28 text-sm truncate" style={{ color: SEAT_COLOURS[p.seat] }}>
|
||||
{p.deckName}
|
||||
</div>
|
||||
<div className="flex-1 bg-bg-panel rounded-full h-5 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${p.winRate}%`,
|
||||
backgroundColor: SEAT_COLOURS[p.seat],
|
||||
minWidth: p.winRate > 0 ? '2px' : '0',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-16 text-right text-sm font-mono">
|
||||
{p.winRate}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Table */}
|
||||
<div className="bg-bg-zone rounded-md border border-border-zone overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-zone text-text-muted">
|
||||
<th className="text-left px-3 py-2">Seat</th>
|
||||
<th className="text-left px-3 py-2">Deck</th>
|
||||
<th className="text-right px-3 py-2">Wins</th>
|
||||
<th className="text-right px-3 py-2">Win%</th>
|
||||
<th className="text-right px-3 py-2">Avg Turns</th>
|
||||
<th className="text-right px-3 py-2">Avg Dmg</th>
|
||||
<th className="text-right px-3 py-2">Avg Spells</th>
|
||||
<th className="text-right px-3 py-2">Avg Creatures</th>
|
||||
<th className="text-right px-3 py-2">Avg Removal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.players.map((p) => {
|
||||
const isWinner = result.players.every((o) => o.wins <= p.wins);
|
||||
return (
|
||||
<tr
|
||||
key={p.seat}
|
||||
className={`border-b border-border-zone ${isWinner ? 'font-semibold' : ''}`}
|
||||
style={{
|
||||
backgroundColor: `${SEAT_COLOURS[p.seat]}${isWinner ? '20' : '0a'}`,
|
||||
}}
|
||||
>
|
||||
<td className="px-3 py-2" style={{ color: SEAT_COLOURS[p.seat] }}>
|
||||
P{p.seat + 1}
|
||||
</td>
|
||||
<td className="px-3 py-2">{p.deckName}</td>
|
||||
<td className="text-right px-3 py-2">{p.wins}</td>
|
||||
<td className="text-right px-3 py-2">{p.winRate}%</td>
|
||||
<td className="text-right px-3 py-2">{p.avgTurns}</td>
|
||||
<td className="text-right px-3 py-2">{p.avgDamageDealt}</td>
|
||||
<td className="text-right px-3 py-2">{p.avgSpellsCast}</td>
|
||||
<td className="text-right px-3 py-2">{p.avgCreaturesPlayed}</td>
|
||||
<td className="text-right px-3 py-2">{p.avgRemovalUsed}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Per-Game Logs (collapsible) */}
|
||||
{games.length > 0 && (
|
||||
<div className="bg-bg-zone rounded-md border border-border-zone">
|
||||
<button
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
className="w-full text-left px-4 py-3 text-sm font-medium flex justify-between items-center hover:bg-bg-panel transition-colors"
|
||||
>
|
||||
<span>Per-Game Results ({games.length} games)</span>
|
||||
<span className="text-text-muted">{showLogs ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{showLogs && (
|
||||
<div className="px-4 pb-3 max-h-96 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{games.map((g: unknown, i: number) => {
|
||||
const game = g as Record<string, unknown>;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 text-xs text-text-muted py-1 border-b border-border-zone"
|
||||
>
|
||||
<span className="w-12 font-mono">#{game.gameNumber as number}</span>
|
||||
<span>
|
||||
Winner:{' '}
|
||||
<span
|
||||
style={{
|
||||
color: SEAT_COLOURS[(game.winner ?? game.winningSeat ?? 0) as number],
|
||||
}}
|
||||
>
|
||||
P{((game.winner ?? game.winningSeat ?? 0) as number) + 1}
|
||||
</span>
|
||||
</span>
|
||||
<span>{game.turns as number} turns</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { GameState, Card, NarrationLine } from '../types/game';
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'SET_STATE'; payload: GameState }
|
||||
| { type: 'SET_HAND'; cards: Card[] }
|
||||
| { type: 'ADD_NARRATION'; line: NarrationLine }
|
||||
| { type: 'SET_THINKING'; seat: number; active: boolean }
|
||||
| { type: 'GAME_OVER'; winner: number; reason: string }
|
||||
| { type: 'SET_STATUS'; status: string };
|
||||
|
||||
export interface ReducerState {
|
||||
gameState: GameState | null;
|
||||
hand: Card[];
|
||||
narration: NarrationLine[];
|
||||
thinkingSeats: Set<number>;
|
||||
gameOver: { winner: number; reason: string } | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const initialState: ReducerState = {
|
||||
gameState: null,
|
||||
hand: [],
|
||||
narration: [],
|
||||
thinkingSeats: new Set(),
|
||||
gameOver: null,
|
||||
status: '',
|
||||
};
|
||||
|
||||
export function gameReducer(state: ReducerState, action: GameAction): ReducerState {
|
||||
switch (action.type) {
|
||||
case 'SET_STATE':
|
||||
return { ...state, gameState: action.payload };
|
||||
|
||||
case 'SET_HAND':
|
||||
return { ...state, hand: action.cards };
|
||||
|
||||
case 'ADD_NARRATION': {
|
||||
const narration = [...state.narration, action.line];
|
||||
return { ...state, narration: narration.slice(-120) };
|
||||
}
|
||||
|
||||
case 'SET_THINKING': {
|
||||
const thinkingSeats = new Set(state.thinkingSeats);
|
||||
if (action.active) {
|
||||
thinkingSeats.add(action.seat);
|
||||
} else {
|
||||
thinkingSeats.delete(action.seat);
|
||||
}
|
||||
return { ...state, thinkingSeats };
|
||||
}
|
||||
|
||||
case 'GAME_OVER':
|
||||
return { ...state, gameOver: { winner: action.winner, reason: action.reason } };
|
||||
|
||||
case 'SET_STATUS':
|
||||
return { ...state, status: action.status };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -1,55 +0,0 @@
|
||||
export type Phase =
|
||||
| 'untap' | 'upkeep' | 'draw' | 'main1'
|
||||
| 'begin_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage'
|
||||
| 'main2' | 'end_step' | 'cleanup';
|
||||
|
||||
export interface Card {
|
||||
name: string;
|
||||
type: string;
|
||||
tapped: boolean;
|
||||
is_commander: boolean;
|
||||
oracle: string;
|
||||
cmc: number;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
seat: number;
|
||||
name: string;
|
||||
life: number;
|
||||
eliminated: boolean;
|
||||
hand: Card[];
|
||||
battlefield: Card[];
|
||||
graveyard: Card[];
|
||||
exile: Card[];
|
||||
command_zone: Card[];
|
||||
hand_count: number;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
game_id: string;
|
||||
turn: number;
|
||||
current_phase: Phase;
|
||||
active_seat: number;
|
||||
players: Player[];
|
||||
stack: Card[];
|
||||
game_over: boolean;
|
||||
winner: Player | null;
|
||||
}
|
||||
|
||||
export type WsIncoming =
|
||||
| { type: 'state'; payload: GameState }
|
||||
| { type: 'event'; text: string; seat?: number }
|
||||
| { type: 'phase'; phase: Phase; active_seat: number }
|
||||
| { type: 'thinking'; seat: number; active: boolean }
|
||||
| { type: 'elimination'; seat: number }
|
||||
| { type: 'game_over'; winner: number; reason: string }
|
||||
| { type: 'hand'; cards: Card[] }
|
||||
| { type: 'pong' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
export interface NarrationLine {
|
||||
text: string;
|
||||
seat?: number;
|
||||
isSystem: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export interface SimDeck {
|
||||
id: number;
|
||||
name: string;
|
||||
commander_name: string;
|
||||
color_identity: string[];
|
||||
}
|
||||
|
||||
export interface SimPlayerResult {
|
||||
seat: number;
|
||||
deckName: string;
|
||||
wins: number;
|
||||
winRate: number;
|
||||
avgTurns: number;
|
||||
avgDamageDealt: number;
|
||||
avgSpellsCast: number;
|
||||
avgCreaturesPlayed: number;
|
||||
avgRemovalUsed: number;
|
||||
avgRampPlayed: number;
|
||||
avgCardsDrawn: number;
|
||||
avgMaxBoardSize: number;
|
||||
}
|
||||
|
||||
export interface SimResult {
|
||||
playerCount: number;
|
||||
totalGames: number;
|
||||
players: SimPlayerResult[];
|
||||
elapsedSeconds: number;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: {
|
||||
base: '#12141e',
|
||||
zone: '#1c2034',
|
||||
panel: '#161820',
|
||||
bar: '#1a1e30',
|
||||
phase: '#1e2338',
|
||||
hand: '#1a2030',
|
||||
},
|
||||
accent: {
|
||||
blue: '#3c82dc',
|
||||
think: '#64b4ff',
|
||||
purple: '#7060c0',
|
||||
},
|
||||
life: {
|
||||
high: '#50c87a',
|
||||
med: '#dcb432',
|
||||
low: '#dc3c3c',
|
||||
},
|
||||
seat: {
|
||||
0: '#4696ff',
|
||||
1: '#ff6450',
|
||||
2: '#50d278',
|
||||
3: '#dcaa32',
|
||||
},
|
||||
card: {
|
||||
bg: '#2a3050',
|
||||
hand: '#2a3868',
|
||||
selected: '#3a4878',
|
||||
},
|
||||
border: {
|
||||
zone: '#3a4060',
|
||||
sep: '#2a2e48',
|
||||
btn: '#4060a0',
|
||||
overlay: '#4060c0',
|
||||
'btn-hover': '#6080d0',
|
||||
'btn-primary': '#4080d0',
|
||||
'hand-card': '#5060a0',
|
||||
chip: '#444444',
|
||||
},
|
||||
phase: {
|
||||
tab: '#252a42',
|
||||
text: '#888888',
|
||||
},
|
||||
btn: {
|
||||
primary: '#2a5090',
|
||||
},
|
||||
text: {
|
||||
body: '#e8e8e8',
|
||||
muted: '#888888',
|
||||
system: '#555566',
|
||||
'zone-label': '#666666',
|
||||
status: '#777788',
|
||||
sub: '#aaaaaa',
|
||||
btn: '#cccccc',
|
||||
},
|
||||
highlight: {
|
||||
gold: '#ffdc32',
|
||||
commander: '#ffd200',
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
pulse: {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.7' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
pulse: 'pulse 1s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
// Dev-only proxy — in production FastAPI serves the built assets
|
||||
// and handles /api and /ws routes directly.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
},
|
||||
})
|
||||
+1944
-163
File diff suppressed because it is too large.
Load diff
+8519
-92
File diff suppressed because it is too large.
Load diff
-8872
File diff suppressed because it is too large.
Load diff
-4487
File diff suppressed because it is too large.
Load diff
@@ -240,12 +240,12 @@ def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
|
||||
dist[action.value] = count
|
||||
|
||||
total = len(labels)
|
||||
log.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
|
||||
log.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
|
||||
log.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
|
||||
log.info(f"{'─' * 43}")
|
||||
log.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
|
||||
@@ -154,17 +154,17 @@ def build_dataset(
|
||||
|
||||
# Print game outcome distribution
|
||||
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
|
||||
log.info(f"\n{'Outcome':<25} {'Count':>8}")
|
||||
log.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]):
|
||||
log.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)
|
||||
log.info(f"\n{'Playstyle':<25} {'Count':>8}")
|
||||
log.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]):
|
||||
log.info(f" {str(s):<23} {c:>8}")
|
||||
print(f" {str(s):<23} {c:>8}")
|
||||
|
||||
return {
|
||||
"states": states,
|
||||
@@ -288,7 +288,7 @@ def main():
|
||||
max_samples=args.max_samples,
|
||||
)
|
||||
if not dataset:
|
||||
log.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:
|
||||
@@ -300,7 +300,7 @@ def main():
|
||||
save_dataset(val, f"{base}-val.npz")
|
||||
save_dataset(test, f"{base}-test.npz")
|
||||
|
||||
log.info("\nDataset build complete.")
|
||||
print("\nDataset build complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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())
|
||||
@@ -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"):
|
||||
|
||||
+27
-26
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -1,119 +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 = 120
|
||||
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] = ""
|
||||
|
||||
|
||||
class DeckGenV3SubstituteRequest(BaseModel):
|
||||
card_name: str
|
||||
substitute_name: str
|
||||
@@ -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
|
||||
-100
@@ -1,100 +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 = ""
|
||||
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."""
|
||||
global COMMANDER_META
|
||||
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 = 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 = 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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -1,9 +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
|
||||
@@ -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.
|
||||
"""
|
||||
-698
@@ -1,698 +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
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
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 LMStudioClient
|
||||
from coach.embeddings import MTGEmbeddingIndex
|
||||
from coach.coach_service import CoachService
|
||||
from coach.config import ensure_dirs
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
_coach_llm = LMStudioClient()
|
||||
_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 LM Studio on 192.168.0.122:1234)")
|
||||
|
||||
log_coach.info(" Coach: Service initialized")
|
||||
|
||||
# Initialize V3 Deck Generator (Perplexity)
|
||||
_deck_gen_v3_error = None
|
||||
if CFG.pplx_api_key:
|
||||
try:
|
||||
from coach.clients.perplexity_client import PerplexityClient
|
||||
from coach.services.deck_generator import DeckGeneratorV3
|
||||
from coach.config import DECK_GEN_MODEL
|
||||
|
||||
pplx_client = PerplexityClient(
|
||||
api_key=CFG.pplx_api_key,
|
||||
model=DECK_GEN_MODEL,
|
||||
)
|
||||
_deck_gen_v3 = DeckGeneratorV3(
|
||||
pplx_client=pplx_client,
|
||||
db_conn_factory=_get_db_conn,
|
||||
embedding_index=_coach_embeddings,
|
||||
)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})")
|
||||
except ImportError as e:
|
||||
_deck_gen_v3_error = f"Missing dependency: {e}. Run: pip install openai"
|
||||
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
|
||||
else:
|
||||
_deck_gen_v3_error = "PPLX_API_KEY not set"
|
||||
log_deckgen.info(" Deck Gen V3: Skipped (no PPLX_API_KEY)")
|
||||
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Failed to initialize: {e}")
|
||||
_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()
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
# Find the deck by slug match against the deck name
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, commander_name, color_identity FROM decks ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
matched_deck = None
|
||||
for r in rows:
|
||||
name = r["name"] or ""
|
||||
slug = name.lower().replace(" ", "-")
|
||||
# Also try a more thorough slugify
|
||||
import re
|
||||
clean_slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
|
||||
if slug == deck_slug.lower() or clean_slug == deck_slug.lower() or name.lower() == deck_slug.lower():
|
||||
matched_deck = r
|
||||
break
|
||||
|
||||
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 LM_STUDIO_URL, LM_STUDIO_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=LM_STUDIO_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=LM_STUDIO_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
|
||||
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 image_url, 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": ce["image_url"] if ce 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
|
||||
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}")
|
||||
|
||||
|
||||
@@ -1,976 +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 os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
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 Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Scryfall Response Cache ─────────────────────────────────
|
||||
# SQLite-backed cache for Scryfall API responses.
|
||||
# Keyed by (name, set_code, collector_number). Default TTL: 7 days.
|
||||
# Scryfall data changes infrequently (set releases ~every 3 months).
|
||||
# Eliminates redundant HTTP calls across imports and re-enrichments.
|
||||
|
||||
SCRYFALL_CACHE_DB_PATH = Path(__file__).parent / "scryfall_cache.db"
|
||||
SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600)) # 7 days
|
||||
|
||||
|
||||
class _ScryfallCache:
|
||||
"""
|
||||
Thread-safe SQLite cache for raw Scryfall JSON responses.
|
||||
|
||||
Schema:
|
||||
cache_key TEXT PRIMARY KEY — "name|set|cn" normalised lowercase
|
||||
json_blob TEXT — raw Scryfall API JSON response
|
||||
fetched_at TEXT — ISO-8601 UTC timestamp
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path = None, ttl_seconds: int = None):
|
||||
self._db_path = db_path or SCRYFALL_CACHE_DB_PATH
|
||||
self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS
|
||||
self._lock = threading.Lock()
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._init_db()
|
||||
|
||||
# ── internal ────────────────────────────────────────────
|
||||
|
||||
def _init_db(self):
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scryfall_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
json_blob TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _make_key(name: str, set_code: str, collector_number: str) -> str:
|
||||
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
|
||||
|
||||
# ── public API ──────────────────────────────────────────
|
||||
|
||||
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
|
||||
"""Return cached Scryfall JSON dict, or None if missing / expired."""
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT json_blob, fetched_at FROM scryfall_cache WHERE cache_key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
self._misses += 1
|
||||
return None
|
||||
# TTL check
|
||||
try:
|
||||
fetched = datetime.fromisoformat(row[1])
|
||||
age = (datetime.utcnow() - fetched).total_seconds()
|
||||
if age > self._ttl:
|
||||
self._misses += 1
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
self._misses += 1
|
||||
return None
|
||||
self._hits += 1
|
||||
try:
|
||||
return json.loads(row[0])
|
||||
except json.JSONDecodeError:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
def put(self, name: str, set_code: str, collector_number: str, card_data: dict):
|
||||
"""Store a Scryfall response in the cache."""
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
blob = json.dumps(card_data, separators=(",", ":"))
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob, fetched_at) "
|
||||
"VALUES (?, ?, datetime('now'))",
|
||||
(key, blob),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Return cache statistics."""
|
||||
with self._lock:
|
||||
total = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
oldest_row = self._conn.execute(
|
||||
"SELECT MIN(fetched_at) FROM scryfall_cache"
|
||||
).fetchone()
|
||||
newest_row = self._conn.execute(
|
||||
"SELECT MAX(fetched_at) FROM scryfall_cache"
|
||||
).fetchone()
|
||||
db_size_bytes = os.path.getsize(str(self._db_path)) if self._db_path.exists() else 0
|
||||
return {
|
||||
"total_entries": total,
|
||||
"hits": self._hits,
|
||||
"misses": self._misses,
|
||||
"hit_rate": round(self._hits / max(self._hits + self._misses, 1) * 100, 1),
|
||||
"ttl_seconds": self._ttl,
|
||||
"ttl_days": round(self._ttl / 86400, 1),
|
||||
"oldest_entry": oldest_row[0] if oldest_row else None,
|
||||
"newest_entry": newest_row[0] if newest_row else None,
|
||||
"db_size_kb": round(db_size_bytes / 1024, 1),
|
||||
"db_path": str(self._db_path),
|
||||
}
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Delete all cached entries. Returns count of deleted rows."""
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
self._conn.execute("DELETE FROM scryfall_cache")
|
||||
self._conn.commit()
|
||||
self._conn.execute("VACUUM") # must run outside transaction
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
return count
|
||||
|
||||
def evict_expired(self) -> int:
|
||||
"""Remove entries older than TTL. Returns count of evicted rows."""
|
||||
cutoff = datetime.utcnow().isoformat()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"DELETE FROM scryfall_cache WHERE "
|
||||
"(julianday(?) - julianday(fetched_at)) * 86400 > ?",
|
||||
(cutoff, self._ttl),
|
||||
)
|
||||
deleted = self._conn.total_changes
|
||||
self._conn.commit()
|
||||
return deleted
|
||||
|
||||
|
||||
_scryfall_cache = _ScryfallCache()
|
||||
|
||||
|
||||
# Scryfall rate-limit semaphore (max 10 req/sec)
|
||||
_scryfall_lock = threading.Lock()
|
||||
_scryfall_last_call = 0.0
|
||||
|
||||
|
||||
def _scryfall_rate_limit():
|
||||
"""Enforce at most 10 Scryfall requests per second via simple sleep."""
|
||||
global _scryfall_last_call
|
||||
with _scryfall_lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - _scryfall_last_call
|
||||
if elapsed < 0.1:
|
||||
time.sleep(0.1 - elapsed)
|
||||
_scryfall_last_call = time.monotonic()
|
||||
|
||||
|
||||
def _parse_finish(raw: str) -> str:
|
||||
"""Normalize finish string to NORMAL|FOIL|ETCHED."""
|
||||
if not raw:
|
||||
return "NORMAL"
|
||||
r = raw.strip().lower()
|
||||
if r in ("etched", "foil etched", "foil_etched", "etched foil"):
|
||||
return "ETCHED"
|
||||
if r in ("yes", "foil", "true", "1"):
|
||||
return "FOIL"
|
||||
return "NORMAL"
|
||||
|
||||
|
||||
def _parse_text_line(line: str) -> dict:
|
||||
"""
|
||||
Parse a text import line into {name, quantity, set_code, collector_number}.
|
||||
Supports formats:
|
||||
- "3 Sol Ring (CMR) 123"
|
||||
- "1x Sol Ring (CMR)"
|
||||
- "Sol Ring [CMR:123]"
|
||||
- "1 Sol Ring"
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("//"):
|
||||
return {}
|
||||
|
||||
result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""}
|
||||
|
||||
# Strip quantity prefix
|
||||
qty_match = re.match(r"^(\d+)x?\s+", line)
|
||||
if qty_match:
|
||||
result["quantity"] = int(qty_match.group(1))
|
||||
line = line[qty_match.end():]
|
||||
|
||||
# Format: "Name [SET:CollNum]"
|
||||
bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line)
|
||||
if bracket_match:
|
||||
result["set_code"] = bracket_match.group(1).lower()
|
||||
result["collector_number"] = bracket_match.group(2)
|
||||
line = line[:bracket_match.start()].strip()
|
||||
result["name"] = line
|
||||
return result
|
||||
|
||||
# Format: "Name (SET) CollNum"
|
||||
paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line)
|
||||
if paren_coll_match:
|
||||
result["set_code"] = paren_coll_match.group(1).lower()
|
||||
result["collector_number"] = paren_coll_match.group(2)
|
||||
line = line[:paren_coll_match.start()].strip()
|
||||
result["name"] = line
|
||||
return result
|
||||
|
||||
# Format: "Name (SET)"
|
||||
paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line)
|
||||
if paren_match:
|
||||
result["set_code"] = paren_match.group(1).lower()
|
||||
line = line[:paren_match.start()].strip()
|
||||
result["name"] = line
|
||||
return result
|
||||
|
||||
result["name"] = line.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _auto_infer_mapping(headers: list) -> dict:
|
||||
"""
|
||||
Auto-infer column → field mapping from header names.
|
||||
Returns dict of {header: field_key}.
|
||||
Field keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
|
||||
"""
|
||||
mapping = {}
|
||||
header_lower = {h: h.lower().strip() for h in headers}
|
||||
field_patterns = {
|
||||
"name": ["name", "card name", "card_name", "cardname", "title"],
|
||||
"quantity": ["quantity", "qty", "count", "amount", "number", "#"],
|
||||
"set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"],
|
||||
"collector_number": [
|
||||
"collector number", "collector_number", "collectornumber",
|
||||
"col #", "col#", "number", "card number",
|
||||
],
|
||||
"finish": ["finish", "foil", "printing", "treatment"],
|
||||
"condition": ["condition", "cond", "grade"],
|
||||
"language": ["language", "lang", "locale"],
|
||||
"notes": ["notes", "note", "comments", "comment"],
|
||||
"tags": ["tags", "tag", "labels", "label"],
|
||||
}
|
||||
for header, lower_h in header_lower.items():
|
||||
for field, patterns in field_patterns.items():
|
||||
if lower_h in patterns:
|
||||
mapping[header] = field
|
||||
break
|
||||
return mapping
|
||||
|
||||
|
||||
def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list:
|
||||
"""
|
||||
Parse CSV/text content into a list of RawImportRow dicts.
|
||||
Returns list of dicts with keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
|
||||
"""
|
||||
rows = []
|
||||
source_upper = (source or "").upper()
|
||||
|
||||
if source_upper == "TEXT":
|
||||
for line in content.splitlines():
|
||||
parsed = _parse_text_line(line)
|
||||
if parsed.get("name"):
|
||||
rows.append(parsed)
|
||||
return rows
|
||||
|
||||
# CSV-based sources
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
headers = reader.fieldnames or []
|
||||
|
||||
# Build effective mapping
|
||||
if mapping:
|
||||
col_map = {h: mapping.get(h, "") for h in headers}
|
||||
else:
|
||||
col_map = _auto_infer_mapping(headers)
|
||||
|
||||
# Normalize col_map field values to lowercase so both "NAME" (UI) and "name" (auto-infer) work
|
||||
col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()}
|
||||
|
||||
for csv_row in reader:
|
||||
row = {
|
||||
"name": "",
|
||||
"quantity": 1,
|
||||
"set_code": "",
|
||||
"collector_number": "",
|
||||
"finish": "NORMAL",
|
||||
"condition": "",
|
||||
"language": "",
|
||||
"notes": "",
|
||||
"tags": "",
|
||||
}
|
||||
for header, field in col_map.items():
|
||||
val = csv_row.get(header, "").strip()
|
||||
if not val or field == "ignore" or not field:
|
||||
continue
|
||||
if field == "name":
|
||||
# Strip finish suffix like "(Foil Etched)"
|
||||
val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip()
|
||||
row["name"] = val
|
||||
elif field == "quantity":
|
||||
try:
|
||||
row["quantity"] = int(float(val))
|
||||
except ValueError:
|
||||
row["quantity"] = 1
|
||||
elif field in ("set_code", "set_code_secondary"):
|
||||
row["set_code"] = val.lower()
|
||||
elif field in ("collector_number", "collector_number_secondary"):
|
||||
row["collector_number"] = val
|
||||
elif field == "finish":
|
||||
row["finish"] = _parse_finish(val)
|
||||
elif field == "condition":
|
||||
row["condition"] = val
|
||||
elif field == "language":
|
||||
row["language"] = val
|
||||
elif field == "notes":
|
||||
row["notes"] = val
|
||||
elif field == "tags":
|
||||
row["tags"] = val
|
||||
|
||||
if row["name"]:
|
||||
rows.append(row)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
"""
|
||||
Fetch raw card data from Scryfall API (with caching).
|
||||
Returns the raw Scryfall JSON dict, or a dict with '_error' on failure.
|
||||
Cache is checked first; live HTTP call only on miss.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
# ── Cache check ──────────────────────────────────────
|
||||
cached = _scryfall_cache.get(name, set_code, collector_number)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# ── Live fetch ───────────────────────────────────────
|
||||
card_data = None
|
||||
last_error = ""
|
||||
|
||||
# Try set + collector number first
|
||||
if set_code and collector_number:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}"
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
card_data = None
|
||||
|
||||
# Fall back to exact name lookup
|
||||
if not card_data:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
encoded_name = quote(name)
|
||||
url = f"https://api.scryfall.com/cards/named?exact={encoded_name}"
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
return {"_error": f"Scryfall lookup failed for '{name}': {last_error}"}
|
||||
|
||||
if not card_data or card_data.get("object") == "error":
|
||||
err_detail = card_data.get("details", "unknown error") if card_data else last_error
|
||||
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
|
||||
|
||||
# ── Cache the successful response ────────────────────
|
||||
_scryfall_cache.put(name, set_code, collector_number, card_data)
|
||||
# Also cache under the Scryfall-resolved name (handles spelling normalisation)
|
||||
resolved_name = card_data.get("name", "")
|
||||
if resolved_name and resolved_name.lower() != name.strip().lower():
|
||||
_scryfall_cache.put(resolved_name, set_code, collector_number, card_data)
|
||||
|
||||
return card_data
|
||||
|
||||
|
||||
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
"""
|
||||
Fetch card data from Scryfall (cached) and return enriched fields dict.
|
||||
Tries set+collectorNumber first, then exact name lookup.
|
||||
Returns empty dict on failure.
|
||||
"""
|
||||
card_data = _fetch_scryfall_api(name, set_code, collector_number)
|
||||
|
||||
if not card_data or "_error" in card_data:
|
||||
return card_data or {}
|
||||
|
||||
# Extract fields
|
||||
type_line = card_data.get("type_line", "")
|
||||
color_identity = card_data.get("color_identity", [])
|
||||
keywords = card_data.get("keywords", [])
|
||||
|
||||
# Parse subtypes (after em-dash or hyphen)
|
||||
subtypes = []
|
||||
for sep in [" \u2014 ", " - "]:
|
||||
if sep in type_line:
|
||||
parts = type_line.split(sep, 1)
|
||||
subtypes = [s.strip() for s in parts[1].split() if s.strip()]
|
||||
break
|
||||
|
||||
is_legendary = 1 if "Legendary" in type_line else 0
|
||||
is_basic = 1 if "Basic" in type_line else 0
|
||||
|
||||
# Get price
|
||||
prices = card_data.get("prices", {})
|
||||
tcg_price = 0.0
|
||||
try:
|
||||
price_val = prices.get("usd") or prices.get("usd_foil") or "0"
|
||||
tcg_price = float(price_val) if price_val else 0.0
|
||||
except (ValueError, TypeError):
|
||||
tcg_price = 0.0
|
||||
|
||||
# Handle double-faced cards: oracle_text may be on card_faces
|
||||
oracle_text = card_data.get("oracle_text", "")
|
||||
if not oracle_text and card_data.get("card_faces"):
|
||||
face_texts = [f.get("oracle_text", "") for f in card_data["card_faces"]]
|
||||
oracle_text = "\n//\n".join(t for t in face_texts if t)
|
||||
|
||||
# Power/toughness (may be on card_faces for DFCs)
|
||||
power = card_data.get("power", "")
|
||||
toughness = card_data.get("toughness", "")
|
||||
if not power and card_data.get("card_faces"):
|
||||
power = card_data["card_faces"][0].get("power", "")
|
||||
toughness = card_data["card_faces"][0].get("toughness", "")
|
||||
|
||||
# Mana cost (may be on card_faces for DFCs)
|
||||
mana_cost = card_data.get("mana_cost", "")
|
||||
if not mana_cost and card_data.get("card_faces"):
|
||||
mana_cost = card_data["card_faces"][0].get("mana_cost", "")
|
||||
|
||||
# Auto-detect functional categories
|
||||
auto_roles = _detect_card_roles(oracle_text, type_line, keywords)
|
||||
|
||||
return {
|
||||
"name": card_data.get("name", name),
|
||||
"type_line": type_line,
|
||||
"subtypes": json.dumps(subtypes),
|
||||
"is_legendary": is_legendary,
|
||||
"is_basic": is_basic,
|
||||
"color_identity": json.dumps(color_identity),
|
||||
"cmc": card_data.get("cmc", 0.0),
|
||||
"mana_cost": mana_cost,
|
||||
"oracle_text": oracle_text,
|
||||
"keywords": json.dumps(keywords),
|
||||
"power": power or "",
|
||||
"toughness": toughness or "",
|
||||
"rarity": card_data.get("rarity", ""),
|
||||
"set_name": card_data.get("set_name", ""),
|
||||
"edhrec_rank": card_data.get("edhrec_rank", 0) or 0,
|
||||
"tcg_price": tcg_price,
|
||||
"salt_score": 0.0,
|
||||
"is_game_changer": 0,
|
||||
"category": json.dumps(auto_roles),
|
||||
"scryfall_id": card_data.get("id", ""),
|
||||
"tcgplayer_id": str(card_data.get("tcgplayer_id", "")),
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 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"}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Deck Builder Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# Type classification priority order
|
||||
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
|
||||
|
||||
# Target ranges: {type: [min, max]}
|
||||
_TYPE_TARGETS = {
|
||||
"Land": [36, 38],
|
||||
"Instant": [9, 11],
|
||||
"Sorcery": [7, 9],
|
||||
"Artifact": [9, 11],
|
||||
"Creature": [20, 30],
|
||||
"Enchantment": [5, 10],
|
||||
"Planeswalker": [0, 5],
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
-1601
File diff suppressed because it is too large.
Load diff
@@ -1,699 +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 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,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["deepseek"])
|
||||
|
||||
# In-memory store for simulation runs
|
||||
_sim_runs = {} # sim_id -> { status, result, error }
|
||||
_sim_lock = _threading.Lock()
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 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
|
||||
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):
|
||||
"""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}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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)
|
||||
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()
|
||||
|
||||
engine = DeepSeekGameEngine(
|
||||
brain=brain, ai_player_index=1,
|
||||
max_turns=25, record_log=record_logs)
|
||||
|
||||
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)
|
||||
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)
|
||||
else:
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs)
|
||||
|
||||
# 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)
|
||||
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:],
|
||||
})
|
||||
@@ -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)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
-445
@@ -1,445 +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 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,
|
||||
PRECON_DIR,
|
||||
PRECON_INDEX,
|
||||
build_java_command,
|
||||
run_batch_subprocess,
|
||||
_run_deepseek_batch_thread,
|
||||
parse_dck_file,
|
||||
_get_db_conn,
|
||||
_load_deck_cards_by_name,
|
||||
_ml_logging_enabled,
|
||||
log,
|
||||
log_batch,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["lab"])
|
||||
|
||||
|
||||
@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
|
||||
|
||||
background_tasks.add_task(
|
||||
run_batch_subprocess,
|
||||
state,
|
||||
req.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")
|
||||
with open(state.result_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
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',
|
||||
})
|
||||
|
||||
|
||||
# Global ML logging toggle (can be enabled via API)
|
||||
_ml_logging_enabled = False
|
||||
|
||||
-626
@@ -1,626 +0,0 @@
|
||||
"""
|
||||
routes/ml.py
|
||||
============
|
||||
ML training data, policy inference, supervised training,
|
||||
PPO training, and tournament endpoints.
|
||||
|
||||
GET /api/ml/status
|
||||
GET /api/ml/decisions/{filename}
|
||||
GET /api/ml/data/status
|
||||
POST /api/ml/toggle
|
||||
POST /api/ml/predict
|
||||
POST /api/ml/predict/batch
|
||||
GET /api/ml/model
|
||||
POST /api/ml/reload
|
||||
POST /api/ml/train
|
||||
GET /api/ml/train/status
|
||||
POST /api/ml/train/ppo
|
||||
GET /api/ml/train/ppo/status
|
||||
POST /api/ml/tournament
|
||||
GET /api/ml/tournament/status
|
||||
GET /api/ml/tournament/results
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
|
||||
|
||||
from routes.shared import CFG, _ml_logging_enabled
|
||||
|
||||
log_ml = logging.getLogger("commander_ai_lab.ml")
|
||||
|
||||
router = APIRouter(tags=["ml"])
|
||||
|
||||
# ---- module-level state (moved from lab_api.py) ----
|
||||
|
||||
_policy_service = None
|
||||
_policy_service_init_attempted = False
|
||||
|
||||
_training_state = {
|
||||
"running": False,
|
||||
"progress": 0,
|
||||
"total_epochs": 0,
|
||||
"current_epoch": 0,
|
||||
"phase": "idle",
|
||||
"message": "",
|
||||
"metrics": None,
|
||||
"result": None,
|
||||
"error": None,
|
||||
"started_at": None,
|
||||
}
|
||||
|
||||
_ppo_state = {
|
||||
"running": False,
|
||||
"iteration": 0,
|
||||
"total_iterations": 0,
|
||||
"phase": "idle",
|
||||
"message": "",
|
||||
"metrics": None,
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
_tournament_state = {
|
||||
"running": False,
|
||||
"phase": "idle",
|
||||
"message": "",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# module-level toggle (local copy; the shared one is for batch runs)
|
||||
_ml_logging_enabled_local = _ml_logging_enabled
|
||||
|
||||
|
||||
def _get_policy_service():
|
||||
"""Get or initialize the policy inference service (lazy loading)."""
|
||||
global _policy_service, _policy_service_init_attempted
|
||||
if _policy_service is None and not _policy_service_init_attempted:
|
||||
_policy_service_init_attempted = True
|
||||
try:
|
||||
from ml.serving.policy_server import PolicyInferenceService
|
||||
_policy_service = PolicyInferenceService()
|
||||
if _policy_service.load():
|
||||
log_ml.info(f"Policy model loaded on {_policy_service.device}")
|
||||
else:
|
||||
log_ml.error(f"Policy model not available: {_policy_service._load_error}")
|
||||
except Exception as e:
|
||||
log_ml.error(f"Policy service init failed: {e}")
|
||||
return _policy_service
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# ML Training Data Endpoints
|
||||
# ==============================================================
|
||||
|
||||
@router.get("/api/ml/status")
|
||||
async def ml_status():
|
||||
"""Get ML decision logging status and available training data."""
|
||||
import routes.shared as _shared
|
||||
lab_root = Path(__file__).resolve().parent.parent
|
||||
results_dir = lab_root / CFG.results_dir
|
||||
ml_files = []
|
||||
if results_dir.exists():
|
||||
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
|
||||
lines = 0
|
||||
try:
|
||||
with open(f) as fh:
|
||||
lines = sum(1 for _ in fh)
|
||||
except Exception:
|
||||
pass
|
||||
ml_files.append({
|
||||
"file": f.name,
|
||||
"decisions": lines,
|
||||
"size_kb": round(f.stat().st_size / 1024, 1),
|
||||
})
|
||||
total_decisions = sum(f["decisions"] for f in ml_files)
|
||||
return {
|
||||
"ml_logging_enabled": _shared._ml_logging_enabled,
|
||||
"training_files": ml_files,
|
||||
"total_decisions": total_decisions,
|
||||
"total_files": len(ml_files),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/ml/toggle")
|
||||
async def ml_toggle(enable: bool = True):
|
||||
"""Enable or disable ML decision logging for future batch runs."""
|
||||
import routes.shared as _shared
|
||||
_shared._ml_logging_enabled = enable
|
||||
return {
|
||||
"ml_logging_enabled": _shared._ml_logging_enabled,
|
||||
"message": f"ML decision logging {'enabled' if enable else 'disabled'} for future batches",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/ml/decisions/{filename}")
|
||||
async def ml_get_decisions(filename: str, limit: int = 100, offset: int = 0):
|
||||
"""Read decision snapshots from a training data file."""
|
||||
lab_root = Path(__file__).resolve().parent.parent
|
||||
filepath = lab_root / CFG.results_dir / filename
|
||||
if not filepath.exists() or not filename.startswith("ml-decisions-"):
|
||||
raise HTTPException(404, f"ML decisions file not found: {filename}")
|
||||
decisions = []
|
||||
try:
|
||||
with open(filepath) as f:
|
||||
for i, line in enumerate(f):
|
||||
if i < offset:
|
||||
continue
|
||||
if len(decisions) >= limit:
|
||||
break
|
||||
try:
|
||||
decisions.append(json.loads(line.strip()))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to read decisions: {e}")
|
||||
return {
|
||||
"file": filename,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"count": len(decisions),
|
||||
"decisions": decisions,
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# ML Policy Inference Endpoints
|
||||
# ==============================================================
|
||||
|
||||
@router.post("/api/ml/predict")
|
||||
async def ml_predict(request: FastAPIRequest):
|
||||
"""Predict a macro-action from a game state snapshot."""
|
||||
svc = _get_policy_service()
|
||||
if svc is None or not svc._loaded:
|
||||
detail = "Policy model not loaded. "
|
||||
if svc:
|
||||
detail += svc._load_error or "Train a model first."
|
||||
else:
|
||||
detail += "PyTorch may not be installed."
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
body = await request.json()
|
||||
playstyle = body.pop("archetype", "midrange")
|
||||
temperature = body.pop("temperature", 1.0)
|
||||
greedy = body.pop("greedy", False)
|
||||
result = svc.predict(
|
||||
decision_snapshot=body,
|
||||
playstyle=playstyle,
|
||||
temperature=temperature,
|
||||
greedy=greedy,
|
||||
)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/ml/predict/batch")
|
||||
async def ml_predict_batch(request: FastAPIRequest):
|
||||
"""Predict actions for multiple snapshots at once."""
|
||||
svc = _get_policy_service()
|
||||
if svc is None or not svc._loaded:
|
||||
raise HTTPException(status_code=503, detail="Policy model not loaded")
|
||||
body = await request.json()
|
||||
snapshots = body.get("snapshots", [])
|
||||
greedy = body.get("greedy", True)
|
||||
if not snapshots:
|
||||
return {"results": []}
|
||||
results = svc.predict_batch(snapshots, greedy=greedy)
|
||||
return {"results": results, "count": len(results)}
|
||||
|
||||
|
||||
@router.get("/api/ml/model")
|
||||
async def ml_model_info():
|
||||
"""Get information about the loaded policy model."""
|
||||
svc = _get_policy_service()
|
||||
if svc is None:
|
||||
return {
|
||||
"loaded": False,
|
||||
"error": "Policy service not initialized",
|
||||
"torch_available": False,
|
||||
}
|
||||
return svc.get_status()
|
||||
|
||||
|
||||
@router.post("/api/ml/reload")
|
||||
async def ml_reload_model(checkpoint: str = None):
|
||||
"""Hot-reload a policy model checkpoint."""
|
||||
svc = _get_policy_service()
|
||||
if svc is None:
|
||||
raise HTTPException(status_code=503, detail="Policy service not available")
|
||||
if not svc._loaded:
|
||||
ok = svc.load()
|
||||
else:
|
||||
ok = svc.reload(checkpoint)
|
||||
return {
|
||||
"success": ok,
|
||||
"status": svc.get_status(),
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# ML Training Management Endpoints
|
||||
# ==============================================================
|
||||
|
||||
def _run_training_pipeline(
|
||||
results_dir: str, epochs: int, lr: float,
|
||||
batch_size: int, patience: int, rebuild_dataset: bool,
|
||||
):
|
||||
"""Run the full ML training pipeline in a background thread."""
|
||||
global _training_state, _policy_service, _policy_service_init_attempted
|
||||
try:
|
||||
import sys
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
_training_state["running"] = True
|
||||
_training_state["started_at"] = datetime.now().isoformat()
|
||||
_training_state["error"] = None
|
||||
_training_state["result"] = None
|
||||
data_dir = os.path.join(project_root, "ml", "models")
|
||||
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
os.makedirs(ckpt_dir, exist_ok=True)
|
||||
# Phase 1: Build Dataset
|
||||
train_path = os.path.join(data_dir, "train.npz")
|
||||
if rebuild_dataset or not os.path.exists(train_path):
|
||||
_training_state["phase"] = "building"
|
||||
_training_state["message"] = "Loading card embeddings & building dataset..."
|
||||
log_ml.info("Building dataset (loading embeddings, may auto-download)...")
|
||||
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
|
||||
dataset = build_dataset(results_dir=results_dir)
|
||||
if not dataset:
|
||||
raise RuntimeError("No training data produced. Check server log for details.")
|
||||
train_ds, val_ds, test_ds = split_dataset(dataset)
|
||||
save_dataset(train_ds, os.path.join(data_dir, "train.npz"))
|
||||
save_dataset(val_ds, os.path.join(data_dir, "val.npz"))
|
||||
save_dataset(test_ds, os.path.join(data_dir, "test.npz"))
|
||||
total_samples = len(dataset["states"])
|
||||
_training_state["message"] = f"Dataset built: {total_samples} samples"
|
||||
log_ml.info(f"Dataset built: {total_samples} samples")
|
||||
# Phase 2: Train
|
||||
_training_state["phase"] = "training"
|
||||
_training_state["total_epochs"] = epochs
|
||||
_training_state["current_epoch"] = 0
|
||||
_training_state["message"] = f"Training policy network ({epochs} epochs)..."
|
||||
log_ml.info(f"Starting training: {epochs} epochs, lr={lr}, bs={batch_size}")
|
||||
import numpy as np
|
||||
import torch
|
||||
from ml.training.policy_network import PolicyNetwork
|
||||
from ml.training.trainer import SupervisedTrainer
|
||||
def load_npz_split(path):
|
||||
data = np.load(path)
|
||||
return data["states"].astype(np.float32), data["labels"].astype(np.int64)
|
||||
train_states, train_labels = load_npz_split(train_path)
|
||||
val_path = os.path.join(data_dir, "val.npz")
|
||||
val_states, val_labels = load_npz_split(val_path)
|
||||
actual_dim = train_states.shape[1]
|
||||
model = PolicyNetwork(input_dim=actual_dim)
|
||||
device = "cpu"
|
||||
if torch.cuda.is_available():
|
||||
device = "cuda"
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
device = "mps"
|
||||
trainer = SupervisedTrainer(
|
||||
model=model, device=device, learning_rate=lr,
|
||||
batch_size=batch_size, epochs=epochs, patience=patience,
|
||||
checkpoint_dir=ckpt_dir,
|
||||
)
|
||||
summary = trainer.train(train_states, train_labels, val_states, val_labels)
|
||||
_training_state["current_epoch"] = summary.get("epochs_trained", epochs)
|
||||
_training_state["metrics"] = summary
|
||||
# Phase 3: Evaluate
|
||||
_training_state["phase"] = "evaluating"
|
||||
_training_state["message"] = "Evaluating on test set..."
|
||||
test_path = os.path.join(data_dir, "test.npz")
|
||||
eval_results = None
|
||||
if os.path.exists(test_path):
|
||||
from ml.training.trainer import load_checkpoint, evaluate_model
|
||||
test_states, test_labels = load_npz_split(test_path)
|
||||
best_model, _ = load_checkpoint(summary["checkpoint_path"], device)
|
||||
eval_results = evaluate_model(best_model, test_states, test_labels, device)
|
||||
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
||||
with open(eval_path, "w") as f:
|
||||
json.dump(eval_results, f, indent=2)
|
||||
# Done
|
||||
_training_state["phase"] = "done"
|
||||
_training_state["running"] = False
|
||||
_training_state["result"] = {
|
||||
"training": summary,
|
||||
"evaluation": eval_results,
|
||||
"checkpoint": summary.get("checkpoint_path", ""),
|
||||
"device": device,
|
||||
}
|
||||
_training_state["message"] = f"Training complete! Best val acc: {summary.get('best_val_accuracy', 0):.1%}"
|
||||
log_ml.info(f"Complete: {summary.get('best_val_accuracy', 0):.1%} val accuracy")
|
||||
# Auto-reload policy server
|
||||
if _policy_service and _policy_service._loaded:
|
||||
_policy_service.reload(summary.get("checkpoint_path"))
|
||||
log_ml.info("Policy server reloaded with new checkpoint")
|
||||
elif not _policy_service_init_attempted:
|
||||
_policy_service_init_attempted = False
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_training_state["phase"] = "error"
|
||||
_training_state["running"] = False
|
||||
_training_state["error"] = str(e)
|
||||
_training_state["message"] = f"Training failed: {e}"
|
||||
log_ml.error(f"ERROR: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@router.post("/api/ml/train")
|
||||
async def ml_start_training(request: FastAPIRequest):
|
||||
"""Trigger ML training pipeline from the web UI."""
|
||||
if _training_state["running"]:
|
||||
raise HTTPException(409, "Training already in progress")
|
||||
body = await request.json() if await request.body() else {}
|
||||
epochs = body.get("epochs", 50)
|
||||
lr = body.get("lr", 0.001)
|
||||
batch_size = body.get("batchSize", 256)
|
||||
patience = body.get("patience", 10)
|
||||
rebuild = body.get("rebuildDataset", True)
|
||||
results_dir = os.path.join(str(Path(__file__).resolve().parent.parent), "results")
|
||||
_training_state.update({
|
||||
"running": True, "progress": 0, "total_epochs": epochs,
|
||||
"current_epoch": 0, "phase": "starting",
|
||||
"message": "Initializing training pipeline...",
|
||||
"metrics": None, "result": None, "error": None,
|
||||
})
|
||||
thread = threading.Thread(
|
||||
target=_run_training_pipeline,
|
||||
args=(results_dir, epochs, lr, batch_size, patience, rebuild),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return {
|
||||
"status": "started",
|
||||
"config": {
|
||||
"epochs": epochs, "lr": lr, "batchSize": batch_size,
|
||||
"patience": patience, "rebuildDataset": rebuild,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/ml/train/status")
|
||||
async def ml_training_status():
|
||||
"""Get current training pipeline status."""
|
||||
return dict(_training_state)
|
||||
|
||||
|
||||
@router.get("/api/ml/data/status")
|
||||
async def ml_data_status():
|
||||
"""Get status of available training data and model checkpoints."""
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
results_dir = project_root / "results"
|
||||
data_dir = project_root / "ml" / "models"
|
||||
ckpt_dir = data_dir / "checkpoints"
|
||||
decision_files = []
|
||||
if results_dir.exists():
|
||||
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
|
||||
line_count = 0
|
||||
try:
|
||||
with open(f, "r") as fh:
|
||||
line_count = sum(1 for _ in fh)
|
||||
except Exception:
|
||||
pass
|
||||
decision_files.append({"name": f.name, "size": f.stat().st_size, "decisions": line_count})
|
||||
datasets = {}
|
||||
for split in ["train", "val", "test"]:
|
||||
path = data_dir / f"{split}.npz"
|
||||
if path.exists():
|
||||
try:
|
||||
import numpy as np
|
||||
data = np.load(str(path))
|
||||
datasets[split] = {
|
||||
"samples": int(data["states"].shape[0]),
|
||||
"features": int(data["states"].shape[1]),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
except Exception:
|
||||
datasets[split] = {"size": path.stat().st_size}
|
||||
checkpoints = []
|
||||
if ckpt_dir.exists():
|
||||
for f in sorted(ckpt_dir.glob("*.pt")):
|
||||
checkpoints.append({
|
||||
"name": f.name,
|
||||
"size": f.stat().st_size,
|
||||
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
||||
})
|
||||
eval_path = ckpt_dir / "eval_results.json"
|
||||
eval_results = None
|
||||
if eval_path.exists():
|
||||
try:
|
||||
with open(eval_path, "r") as f:
|
||||
eval_results = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"decisionFiles": decision_files,
|
||||
"totalDecisions": sum(d["decisions"] for d in decision_files),
|
||||
"datasets": datasets,
|
||||
"checkpoints": checkpoints,
|
||||
"evalResults": eval_results,
|
||||
"policyLoaded": _policy_service is not None and _policy_service._loaded if _policy_service else False,
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# PPO Training + Tournament Endpoints
|
||||
# ==============================================================
|
||||
|
||||
def _run_ppo_pipeline(
|
||||
iterations: int, episodes_per_iter: int, ppo_epochs: int,
|
||||
batch_size: int, lr: float, clip_epsilon: float,
|
||||
entropy_coeff: float, opponent: str, playstyle: str,
|
||||
load_supervised: str,
|
||||
):
|
||||
"""Run PPO training in a background thread."""
|
||||
global _ppo_state
|
||||
try:
|
||||
import sys
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
_ppo_state["running"] = True
|
||||
_ppo_state["error"] = None
|
||||
_ppo_state["result"] = None
|
||||
from ml.training.ppo_trainer import PPOTrainer, PPOConfig
|
||||
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
||||
config = PPOConfig(
|
||||
iterations=iterations, episodes_per_iter=episodes_per_iter,
|
||||
ppo_epochs=ppo_epochs, batch_size=batch_size,
|
||||
learning_rate=lr, clip_epsilon=clip_epsilon,
|
||||
entropy_coeff=entropy_coeff, opponent=opponent,
|
||||
playstyle=playstyle, checkpoint_dir=ckpt_dir,
|
||||
load_supervised=load_supervised if load_supervised else None,
|
||||
)
|
||||
trainer = PPOTrainer(config)
|
||||
def progress_cb(iteration, metrics):
|
||||
_ppo_state["iteration"] = iteration
|
||||
_ppo_state["phase"] = "training"
|
||||
_ppo_state["message"] = f"Iteration {iteration}/{iterations} | WR: {metrics.get('win_rate', 0):.0%}"
|
||||
_ppo_state["metrics"] = metrics
|
||||
summary = trainer.train(progress_callback=progress_cb)
|
||||
_ppo_state["phase"] = "done"
|
||||
_ppo_state["running"] = False
|
||||
_ppo_state["result"] = summary
|
||||
_ppo_state["message"] = f"PPO complete! Best win rate: {summary.get('best_win_rate', 0):.0%}"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_ppo_state["phase"] = "error"
|
||||
_ppo_state["running"] = False
|
||||
_ppo_state["error"] = str(e)
|
||||
_ppo_state["message"] = f"PPO failed: {e}"
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@router.post("/api/ml/train/ppo")
|
||||
async def ml_start_ppo(request: FastAPIRequest):
|
||||
"""Start PPO training pipeline."""
|
||||
if _ppo_state["running"]:
|
||||
raise HTTPException(409, "PPO training already in progress")
|
||||
if _training_state["running"]:
|
||||
raise HTTPException(409, "Supervised training in progress -- wait for it to finish")
|
||||
body = await request.json() if await request.body() else {}
|
||||
iterations = body.get("iterations", 100)
|
||||
episodes = body.get("episodesPerIter", 64)
|
||||
ppo_epochs = body.get("ppoEpochs", 4)
|
||||
batch_size = body.get("batchSize", 256)
|
||||
lr = body.get("lr", 3e-4)
|
||||
clip_eps = body.get("clipEpsilon", 0.2)
|
||||
entropy = body.get("entropyCoeff", 0.01)
|
||||
opponent = body.get("opponent", "heuristic")
|
||||
playstyle = body.get("playstyle", "midrange")
|
||||
load_sup = body.get("loadSupervised", "")
|
||||
_ppo_state.update({
|
||||
"running": True, "iteration": 0, "total_iterations": iterations,
|
||||
"phase": "starting", "message": "Initializing PPO...",
|
||||
"metrics": None, "result": None, "error": None,
|
||||
})
|
||||
thread = threading.Thread(
|
||||
target=_run_ppo_pipeline,
|
||||
args=(iterations, episodes, ppo_epochs, batch_size, lr,
|
||||
clip_eps, entropy, opponent, playstyle, load_sup),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return {"status": "started", "iterations": iterations}
|
||||
|
||||
|
||||
@router.get("/api/ml/train/ppo/status")
|
||||
async def ml_ppo_status():
|
||||
"""Get PPO training status."""
|
||||
return dict(_ppo_state)
|
||||
|
||||
|
||||
def _run_tournament_pipeline(episodes: int, playstyle: str):
|
||||
"""Run tournament in a background thread."""
|
||||
global _tournament_state
|
||||
try:
|
||||
import sys
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
_tournament_state["running"] = True
|
||||
_tournament_state["error"] = None
|
||||
_tournament_state["result"] = None
|
||||
_tournament_state["phase"] = "running"
|
||||
_tournament_state["message"] = "Running tournament..."
|
||||
from ml.eval.tournament import (
|
||||
run_tournament, HeuristicPolicy, RandomPolicy, LearnedPolicy,
|
||||
)
|
||||
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
||||
policies = {
|
||||
"heuristic": HeuristicPolicy(),
|
||||
"random": RandomPolicy(),
|
||||
}
|
||||
sup_path = os.path.join(ckpt_dir, "best_policy.pt")
|
||||
if os.path.exists(sup_path):
|
||||
policies["supervised"] = LearnedPolicy(sup_path)
|
||||
ppo_path = os.path.join(ckpt_dir, "best_ppo.pt")
|
||||
if os.path.exists(ppo_path):
|
||||
policies["ppo"] = LearnedPolicy(ppo_path)
|
||||
result = run_tournament(
|
||||
policies=policies,
|
||||
episodes_per_matchup=episodes,
|
||||
playstyle=playstyle,
|
||||
)
|
||||
output_path = os.path.join(ckpt_dir, "tournament_results.json")
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(result.to_dict(), f, indent=2)
|
||||
_tournament_state["phase"] = "done"
|
||||
_tournament_state["running"] = False
|
||||
_tournament_state["result"] = result.to_dict()
|
||||
_tournament_state["message"] = f"Tournament complete! {result.total_matches} matches"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_tournament_state["phase"] = "error"
|
||||
_tournament_state["running"] = False
|
||||
_tournament_state["error"] = str(e)
|
||||
_tournament_state["message"] = f"Tournament failed: {e}"
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@router.post("/api/ml/tournament")
|
||||
async def ml_start_tournament(request: FastAPIRequest):
|
||||
"""Start a tournament evaluation."""
|
||||
if _tournament_state["running"]:
|
||||
raise HTTPException(409, "Tournament already in progress")
|
||||
body = await request.json() if await request.body() else {}
|
||||
episodes = body.get("episodes", 50)
|
||||
playstyle = body.get("playstyle", "midrange")
|
||||
_tournament_state.update({
|
||||
"running": True, "phase": "starting",
|
||||
"message": "Initializing tournament...",
|
||||
"result": None, "error": None,
|
||||
})
|
||||
thread = threading.Thread(
|
||||
target=_run_tournament_pipeline,
|
||||
args=(episodes, playstyle),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return {"status": "started", "episodes": episodes}
|
||||
|
||||
|
||||
@router.get("/api/ml/tournament/status")
|
||||
async def ml_tournament_status():
|
||||
"""Get tournament status."""
|
||||
return dict(_tournament_state)
|
||||
|
||||
|
||||
@router.get("/api/ml/tournament/results")
|
||||
async def ml_tournament_results():
|
||||
"""Get latest tournament results."""
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
results_path = project_root / "ml" / "models" / "checkpoints" / "tournament_results.json"
|
||||
if results_path.exists():
|
||||
with open(results_path, "r") as f:
|
||||
return json.load(f)
|
||||
return {"error": "No tournament results found. Run a tournament first."}
|
||||
@@ -1,95 +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, PRECON_DIR, download_precon_database, log
|
||||
|
||||
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"}
|
||||
"""
|
||||
file_name = req.get("fileName", "")
|
||||
if not file_name:
|
||||
raise HTTPException(400, "fileName is required")
|
||||
|
||||
src = 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", "Necron_Dynasties.dck"]}
|
||||
"""
|
||||
file_names = req.get("fileNames", [])
|
||||
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 = 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),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,203 +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,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 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, parse_dck_file,
|
||||
run_batch_subprocess, _run_process_blocking,
|
||||
_run_deepseek_batch_thread, _get_deepseek_brain,
|
||||
)
|
||||
|
||||
_ml_logging_enabled = False
|
||||
+2
-6
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Services for Commander AI Lab."""
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Card role detection, type classification, and deck composition analysis."""
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
|
||||
roles = []
|
||||
ot = (oracle_text or "").lower()
|
||||
tl = (type_line or "").lower()
|
||||
kw_raw = keywords or []
|
||||
if isinstance(kw_raw, str):
|
||||
try:
|
||||
kw_raw = json.loads(kw_raw)
|
||||
except Exception:
|
||||
kw_raw = []
|
||||
kw = " ".join(str(k).lower() for k in kw_raw)
|
||||
|
||||
if ("add {" in ot or "add one mana" in ot
|
||||
or ("search your library for a" in ot and "land" in ot)
|
||||
or "treasure token" in ot or "add mana" in ot):
|
||||
roles.append("Ramp")
|
||||
if ("draw a card" in ot or "draw cards" in ot or "draws a card" in ot
|
||||
or "draw two cards" in ot or "draw three cards" in ot
|
||||
or re.search(r"draw \d+ cards?", ot)
|
||||
or ("whenever" in ot and "draw" in ot)):
|
||||
roles.append("Draw")
|
||||
if ("destroy target" in ot or "exile target" in ot
|
||||
or re.search(r"deals? \d+ damage to (target|any target)", ot)
|
||||
or re.search(r"deals? x damage to (target|any target)", ot)
|
||||
or "target creature gets -" in ot or "fights target" in ot):
|
||||
roles.append("Removal")
|
||||
if ("destroy all" in ot or "exile all" in ot
|
||||
or re.search(r"all creatures get -\d+/-\d+", ot)
|
||||
or re.search(r"each creature gets -\d+/-\d+", ot)
|
||||
or ("deals" in ot and "to each creature" in ot)):
|
||||
roles.append("Board Wipe")
|
||||
if "search your library" in ot and "land" not in ot:
|
||||
roles.append("Tutor")
|
||||
if ("counter target" in ot or "counter that" in ot
|
||||
or "counters" in kw):
|
||||
roles.append("Counter")
|
||||
if ("create" in ot and ("token" in ot or "tokens" in ot)):
|
||||
roles.append("Token")
|
||||
if ("each creature you control gets" in ot or "creatures you control get" in ot
|
||||
or "all creatures you control" in ot or "anthem" in tl.lower()):
|
||||
roles.append("Anthem")
|
||||
if ("target creature gains" in ot and ("hexproof" in ot or "indestructible" in ot or "shroud" in ot))\
|
||||
or ("protection" in kw):
|
||||
roles.append("Protection")
|
||||
if ("sacrifice" in ot and ("whenever" in ot or "you may" in ot)):
|
||||
roles.append("Sacrifice")
|
||||
if ("return" in ot and ("from your graveyard" in ot and "onto the battlefield" in ot)
|
||||
or ("put" in ot and "from a graveyard" in ot and "onto the battlefield" in ot)):
|
||||
roles.append("Recursion")
|
||||
if (("graveyard" in ot and ("mill" in ot or "put" in ot and "into" in ot and "graveyard" in ot))
|
||||
or "mill" in kw):
|
||||
roles.append("Graveyard")
|
||||
if ("gain" in ot and "life" in ot) or "lifelink" in ot or "lifelink" in kw:
|
||||
roles.append("Lifegain")
|
||||
if (re.search(r"deals? \d+ damage to (each|any|target) (opponent|player)", ot)
|
||||
or "each opponent loses" in ot and "life" in ot
|
||||
or "deals damage to each opponent" in ot):
|
||||
roles.append("Burn")
|
||||
if ("can't cast" in ot or "can't attack" in ot or "can't activate" in ot
|
||||
or ("enters the battlefield tapped" in ot and "opponents" in ot)
|
||||
or "each player can't" in ot or "players can't" in ot
|
||||
or ("cost {" in ot and "more to cast" in ot)):
|
||||
roles.append("Stax")
|
||||
if ("flying" in kw or "trample" in kw or "menace" in kw or "shadow" in kw
|
||||
or "fear" in kw or "intimidate" in kw
|
||||
or "can't be blocked" in ot or "unblockable" in ot):
|
||||
roles.append("Evasion")
|
||||
if ("you win the game" in ot or "extra turn" in ot or "infinite" in ot
|
||||
or "loses the game" in ot
|
||||
or ("damage equal to" in ot and ("number" in ot or "total" in ot))):
|
||||
roles.append("Finisher")
|
||||
if ("untap all" in ot or ("copy" in ot and "spell" in ot)
|
||||
or "take an extra" in ot
|
||||
or ("double" in ot and ("damage" in ot or "counters" in ot or "tokens" in ot))):
|
||||
roles.append("Combo")
|
||||
return roles
|
||||
@@ -1,267 +0,0 @@
|
||||
"""SQLite database management and collection utility helpers."""
|
||||
from __future__ import annotations
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
log_collect = logging.getLogger("commander_ai_lab.collection")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Database
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
COLLECTION_DB_PATH = Path(__file__).parent.parent / "collection.db"
|
||||
_db_local = threading.local()
|
||||
|
||||
|
||||
def _get_db_conn() -> sqlite3.Connection:
|
||||
if not hasattr(_db_local, "conn") or _db_local.conn is None:
|
||||
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
_db_local.conn = conn
|
||||
return _db_local.conn
|
||||
|
||||
|
||||
def init_collection_db():
|
||||
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS card_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type_line TEXT DEFAULT '',
|
||||
subtypes TEXT DEFAULT '',
|
||||
is_legendary INTEGER DEFAULT 0,
|
||||
is_basic INTEGER DEFAULT 0,
|
||||
color_identity TEXT DEFAULT '',
|
||||
cmc REAL DEFAULT 0,
|
||||
oracle_text TEXT DEFAULT '',
|
||||
keywords TEXT DEFAULT '',
|
||||
tcg_price REAL DEFAULT 0,
|
||||
salt_score REAL DEFAULT 0,
|
||||
is_game_changer INTEGER DEFAULT 0,
|
||||
category TEXT DEFAULT '',
|
||||
scryfall_id TEXT DEFAULT '',
|
||||
tcgplayer_id TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type_line TEXT DEFAULT '',
|
||||
subtypes TEXT DEFAULT '',
|
||||
is_legendary INTEGER DEFAULT 0,
|
||||
is_basic INTEGER DEFAULT 0,
|
||||
color_identity TEXT DEFAULT '',
|
||||
cmc REAL DEFAULT 0,
|
||||
mana_cost TEXT DEFAULT '',
|
||||
oracle_text TEXT DEFAULT '',
|
||||
keywords TEXT DEFAULT '',
|
||||
power TEXT DEFAULT '',
|
||||
toughness TEXT DEFAULT '',
|
||||
rarity TEXT DEFAULT '',
|
||||
set_name TEXT DEFAULT '',
|
||||
edhrec_rank INTEGER DEFAULT 0,
|
||||
tcg_price REAL DEFAULT 0,
|
||||
salt_score REAL DEFAULT 0,
|
||||
is_game_changer INTEGER DEFAULT 0,
|
||||
category TEXT DEFAULT '',
|
||||
scryfall_id TEXT DEFAULT '',
|
||||
tcgplayer_id TEXT DEFAULT '',
|
||||
quantity INTEGER DEFAULT 1,
|
||||
finish TEXT DEFAULT 'NORMAL',
|
||||
condition TEXT DEFAULT '',
|
||||
language TEXT DEFAULT '',
|
||||
notes TEXT DEFAULT '',
|
||||
tags TEXT DEFAULT '',
|
||||
set_code TEXT DEFAULT '',
|
||||
collector_number TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_name ON collection_entries(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_color_identity ON collection_entries(color_identity);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_type_line ON collection_entries(type_line);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_is_legendary ON collection_entries(is_legendary);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_cmc ON collection_entries(cmc);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_category ON collection_entries(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_set_code ON collection_entries(set_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_collector_number ON collection_entries(collector_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_finish ON collection_entries(finish);
|
||||
CREATE INDEX IF NOT EXISTS idx_cr_name ON card_records(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
commander_scryfall_id TEXT DEFAULT '',
|
||||
commander_name TEXT DEFAULT '',
|
||||
color_identity TEXT DEFAULT '[]',
|
||||
strategy_tag TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deck_cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
deck_id INTEGER NOT NULL,
|
||||
scryfall_id TEXT NOT NULL,
|
||||
card_name TEXT DEFAULT '',
|
||||
quantity INTEGER DEFAULT 1,
|
||||
is_commander INTEGER DEFAULT 0,
|
||||
role_tag TEXT DEFAULT '',
|
||||
FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id);
|
||||
""")
|
||||
conn.commit()
|
||||
_migrate_columns = [
|
||||
("mana_cost", "TEXT DEFAULT ''"),
|
||||
("power", "TEXT DEFAULT ''"),
|
||||
("toughness", "TEXT DEFAULT ''"),
|
||||
("rarity", "TEXT DEFAULT ''"),
|
||||
("set_name", "TEXT DEFAULT ''"),
|
||||
("edhrec_rank", "INTEGER DEFAULT 0"),
|
||||
]
|
||||
existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(collection_entries)").fetchall()}
|
||||
for col_name, col_def in _migrate_columns:
|
||||
if col_name not in existing_cols:
|
||||
conn.execute(f"ALTER TABLE collection_entries ADD COLUMN {col_name} {col_def}")
|
||||
log_collect.info(f" Migration: added column '{col_name}' to collection_entries")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
log_collect.info(f" Collection DB: {COLLECTION_DB_PATH}")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Utility Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
|
||||
|
||||
VALID_SORT_FIELDS = {
|
||||
"name", "cmc", "tcg_price", "salt_score", "category", "color_identity",
|
||||
"quantity", "type_line", "finish", "rarity", "set_code", "power",
|
||||
"toughness", "edhrec_rank",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def _row_to_dict(row) -> dict:
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
for f in _JSON_FIELDS:
|
||||
if f in d and isinstance(d[f], str):
|
||||
try:
|
||||
d[f] = json.loads(d[f])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
d[f] = []
|
||||
return d
|
||||
|
||||
|
||||
def _snake_to_camel(s: str) -> str:
|
||||
parts = s.split("_")
|
||||
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
||||
|
||||
|
||||
def _add_image_url(card: dict) -> dict:
|
||||
scryfall_id = card.get("scryfall_id", "")
|
||||
if scryfall_id:
|
||||
card["imageUrl"] = f"https://api.scryfall.com/cards/{scryfall_id}?format=image&version=normal"
|
||||
else:
|
||||
card["imageUrl"] = None
|
||||
for key in list(card.keys()):
|
||||
if "_" in key:
|
||||
camel = _snake_to_camel(key)
|
||||
if camel not in card:
|
||||
card[camel] = card[key]
|
||||
return card
|
||||
|
||||
|
||||
def _build_collection_filters(
|
||||
q=None, colors=None, types=None, isLegendary=None, isBasic=None,
|
||||
isGameChanger=None, highSalt=None, finish=None, cmcMin=None, cmcMax=None,
|
||||
priceMin=None, priceMax=None, category=None, rarity=None, setCode=None,
|
||||
powerMin=None, powerMax=None, toughMin=None, toughMax=None, keyword=None,
|
||||
edhrecMin=None, edhrecMax=None, qtyMin=None, qtyMax=None,
|
||||
) -> tuple:
|
||||
conditions = []
|
||||
params = []
|
||||
if q:
|
||||
like_q = f"%{q}%"
|
||||
conditions.append("(name LIKE ? OR type_line LIKE ? OR oracle_text LIKE ?)")
|
||||
params.extend([like_q, like_q, like_q])
|
||||
if colors:
|
||||
for color in [c.strip().upper() for c in colors.split(",") if c.strip()]:
|
||||
conditions.append("color_identity LIKE ?")
|
||||
params.append(f"%{color}%")
|
||||
if types:
|
||||
tc = []
|
||||
for t in [t.strip() for t in types.split(",") if t.strip()]:
|
||||
tc.append("type_line LIKE ?")
|
||||
params.append(f"%{t}%")
|
||||
if tc:
|
||||
conditions.append(f"({' OR '.join(tc)})")
|
||||
if isLegendary is not None:
|
||||
conditions.append("is_legendary = ?"); params.append(1 if isLegendary else 0)
|
||||
if isBasic is not None:
|
||||
conditions.append("is_basic = ?"); params.append(1 if isBasic else 0)
|
||||
if isGameChanger is not None:
|
||||
conditions.append("is_game_changer = ?"); params.append(1 if isGameChanger else 0)
|
||||
if highSalt:
|
||||
conditions.append("salt_score > 2.0")
|
||||
if finish:
|
||||
conditions.append("finish = ?"); params.append(finish.upper())
|
||||
if cmcMin is not None:
|
||||
conditions.append("cmc >= ?"); params.append(cmcMin)
|
||||
if cmcMax is not None:
|
||||
conditions.append("cmc <= ?"); params.append(cmcMax)
|
||||
if priceMin is not None:
|
||||
conditions.append("tcg_price >= ?"); params.append(priceMin)
|
||||
if priceMax is not None:
|
||||
conditions.append("tcg_price <= ?"); params.append(priceMax)
|
||||
if category:
|
||||
cc = []
|
||||
for c in [c.strip() for c in category.split(",") if c.strip()]:
|
||||
cc.append("category LIKE ?"); params.append(f"%{c}%")
|
||||
if cc:
|
||||
conditions.append(f"({' OR '.join(cc)})")
|
||||
if rarity:
|
||||
rl = [r.strip().lower() for r in rarity.split(",") if r.strip()]
|
||||
if rl:
|
||||
conditions.append(f"LOWER(rarity) IN ({','.join(['?']*len(rl))})"); params.extend(rl)
|
||||
if setCode:
|
||||
sl = [s.strip().upper() for s in setCode.split(",") if s.strip()]
|
||||
if sl:
|
||||
conditions.append(f"UPPER(set_code) IN ({','.join(['?']*len(sl))})"); params.extend(sl)
|
||||
if powerMin is not None:
|
||||
conditions.append("CAST(power AS REAL) >= ?"); params.append(float(powerMin))
|
||||
if powerMax is not None:
|
||||
conditions.append("CAST(power AS REAL) <= ?"); params.append(float(powerMax))
|
||||
if toughMin is not None:
|
||||
conditions.append("CAST(toughness AS REAL) >= ?"); params.append(float(toughMin))
|
||||
if toughMax is not None:
|
||||
conditions.append("CAST(toughness AS REAL) <= ?"); params.append(float(toughMax))
|
||||
if keyword:
|
||||
for kw in [k.strip() for k in keyword.split(",") if k.strip()]:
|
||||
conditions.append("keywords LIKE ?"); params.append(f"%{kw}%")
|
||||
if edhrecMin is not None:
|
||||
conditions.append("edhrec_rank >= ?"); params.append(edhrecMin)
|
||||
if edhrecMax is not None:
|
||||
conditions.append("edhrec_rank <= ?"); params.append(edhrecMax)
|
||||
if qtyMin is not None:
|
||||
conditions.append("quantity >= ?"); params.append(qtyMin)
|
||||
if qtyMax is not None:
|
||||
conditions.append("quantity <= ?"); params.append(qtyMax)
|
||||
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
return where_str, params
|
||||
@@ -1,194 +0,0 @@
|
||||
"""Deck service: .dck parsing, deck profile saving, deck loading."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from models.state import CFG
|
||||
from services.database import _get_db_conn, _row_to_dict
|
||||
|
||||
log_collect = logging.getLogger("lab.collection")
|
||||
|
||||
|
||||
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
|
||||
_TYPE_TARGETS = {
|
||||
"Land": [36, 38],
|
||||
"Instant": [9, 11],
|
||||
"Sorcery": [7, 9],
|
||||
"Artifact": [9, 11],
|
||||
"Creature": [20, 30],
|
||||
"Enchantment": [5, 10],
|
||||
"Planeswalker": [0, 5],
|
||||
}
|
||||
|
||||
|
||||
def _classify_card_type(type_line: str) -> str:
|
||||
tl = type_line or ""
|
||||
for t in _TYPE_PRIORITY:
|
||||
if t in tl:
|
||||
return t
|
||||
return "Other"
|
||||
|
||||
|
||||
def _get_deck_or_404(deck_id: int):
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute("SELECT * FROM decks WHERE id = ?", (deck_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Deck {deck_id} not found")
|
||||
d = dict(row)
|
||||
try:
|
||||
d["color_identity"] = json.loads(d.get("color_identity", "[]"))
|
||||
except Exception:
|
||||
d["color_identity"] = []
|
||||
return d
|
||||
|
||||
|
||||
def _compute_deck_analysis(deck_id: int) -> dict:
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute("""
|
||||
SELECT dc.id, dc.scryfall_id, dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
|
||||
ce.type_line, ce.oracle_text, ce.keywords, ce.cmc, ce.color_identity
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, oracle_text, keywords, cmc, color_identity
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,)).fetchall()
|
||||
counts_by_type = {t: 0 for t in _TYPE_PRIORITY}
|
||||
counts_by_type["Other"] = 0
|
||||
mana_curve = {"0": 0, "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6+": 0}
|
||||
color_pips = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
|
||||
total_cards = 0
|
||||
roles_count = {}
|
||||
for row in rows:
|
||||
r = dict(row)
|
||||
qty = r.get("quantity", 1)
|
||||
total_cards += qty
|
||||
card_type = _classify_card_type(r.get("type_line", ""))
|
||||
counts_by_type[card_type] = counts_by_type.get(card_type, 0) + qty
|
||||
cmc = int(r.get("cmc", 0) or 0)
|
||||
if card_type != "Land":
|
||||
key = str(min(cmc, 6)) if cmc < 6 else "6+"
|
||||
mana_curve[key] = mana_curve.get(key, 0) + qty
|
||||
ci_raw = r.get("color_identity", "[]")
|
||||
if isinstance(ci_raw, str):
|
||||
try:
|
||||
ci_raw = json.loads(ci_raw)
|
||||
except Exception:
|
||||
ci_raw = []
|
||||
for c in (ci_raw or []):
|
||||
if c in color_pips:
|
||||
color_pips[c] += qty
|
||||
role = r.get("role_tag") or "Unknown"
|
||||
roles_count[role] = roles_count.get(role, 0) + qty
|
||||
return {
|
||||
"total_cards": total_cards,
|
||||
"counts_by_type": counts_by_type,
|
||||
"mana_curve": mana_curve,
|
||||
"color_pips": color_pips,
|
||||
"total_cards": total_cards,
|
||||
"roles": roles_count,
|
||||
}
|
||||
|
||||
|
||||
def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool:
|
||||
analysis = _compute_deck_analysis(deck_id)
|
||||
current = analysis["counts_by_type"].get(card_type, 0)
|
||||
target_max = _TYPE_TARGETS.get(card_type, [0, 9999])[1]
|
||||
return (current + count_to_add) <= target_max
|
||||
|
||||
|
||||
def _to_edhrec_slug(name: str) -> str:
|
||||
slug = name.lower()
|
||||
slug = re.sub(r"[',.]" , "", slug)
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
||||
return slug.strip("-")
|
||||
|
||||
|
||||
def parse_dck_file(deck_path: str) -> dict:
|
||||
"""Parse a Forge .dck file into {name, commanders, mainboard, colorIdentity, totalCards}."""
|
||||
path = Path(deck_path)
|
||||
if not path.exists():
|
||||
return {"error": f"File not found: {deck_path}"}
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
name = path.stem
|
||||
section = "main"
|
||||
commanders: dict = {}
|
||||
mainboard: dict = {}
|
||||
commander_name = None
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line == "[metadata]":
|
||||
section = "metadata"; continue
|
||||
if line.startswith("Name="):
|
||||
name = line.split("=", 1)[1].strip(); continue
|
||||
if line == "[Commander]":
|
||||
section = "commander"; continue
|
||||
if line == "[Main]":
|
||||
section = "main"; continue
|
||||
if line.lower().startswith("sideboard") or line == "[Sideboard]":
|
||||
section = "sideboard"; continue
|
||||
clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip()
|
||||
clean = re.sub(r"\s*\*\.\*$", "", clean).strip()
|
||||
m = re.match(r"^(\d+)x?\s+(.+)$", clean)
|
||||
qty, card_name = (int(m.group(1)), m.group(2).strip()) if m else (1, clean)
|
||||
if not card_name:
|
||||
continue
|
||||
if section == "commander":
|
||||
commanders[card_name] = qty
|
||||
if not commander_name:
|
||||
commander_name = card_name
|
||||
elif section != "sideboard":
|
||||
mainboard[card_name] = qty
|
||||
if commander_name:
|
||||
name = f"{commander_name} - Text Import"
|
||||
total = sum(commanders.values()) + sum(mainboard.values())
|
||||
return {"name": name, "commander": commander_name, "commanders": commanders, "mainboard": mainboard, "colorIdentity": [], "totalCards": total}
|
||||
|
||||
|
||||
def _save_profile_to_dck(profile: dict) -> Path:
|
||||
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
|
||||
for name, qty in profile.get("commanders", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
lines += ["", "[Main]"]
|
||||
for name, qty in profile.get("mainboard", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
content = "\n".join(lines)
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", profile.get("name", "imported")).replace(" ", "_").strip()
|
||||
if not safe_name:
|
||||
safe_name = "imported_deck"
|
||||
save_dir = CFG.forge_decks_dir
|
||||
if not save_dir or not os.path.isdir(save_dir):
|
||||
save_dir = os.path.join(Path(__file__).parent.parent, "imported-decks")
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
out_path = Path(save_dir) / f"{safe_name}.dck"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
log_collect.info(f" Saved .dck: {out_path}")
|
||||
return out_path
|
||||
|
||||
|
||||
def _load_deck_cards_by_name(deck_name: str) -> list:
|
||||
"""Load cards from a deck by name, returning list of card dicts."""
|
||||
conn = _get_db_conn()
|
||||
deck_row = conn.execute("SELECT id FROM decks WHERE name = ?", (deck_name,)).fetchone()
|
||||
if not deck_row:
|
||||
return []
|
||||
deck_id = deck_row[0]
|
||||
rows = conn.execute("""
|
||||
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
|
||||
ce.type_line, ce.oracle_text, ce.cmc, ce.color_identity, ce.mana_cost
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,)).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
@@ -1,141 +0,0 @@
|
||||
"""Forge batch simulation runner: Java discovery, command building, process management."""
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from models.state import CFG, BatchState
|
||||
from services.deck_service import parse_dck_file
|
||||
|
||||
log = logging.getLogger("lab.batch")
|
||||
|
||||
|
||||
def _find_java17() -> str:
|
||||
search_dirs = [r'C:\Program Files\Eclipse Adoptium', r'C:\Program Files\Java']
|
||||
for d in search_dirs:
|
||||
if os.path.isdir(d):
|
||||
for child in os.listdir(d):
|
||||
if child.startswith('jdk-17'):
|
||||
candidate = os.path.join(d, child, 'bin', 'java.exe')
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return 'java'
|
||||
|
||||
|
||||
_JAVA17_PATH = None
|
||||
|
||||
|
||||
def get_java17() -> str:
|
||||
global _JAVA17_PATH
|
||||
if _JAVA17_PATH is None:
|
||||
_JAVA17_PATH = _find_java17()
|
||||
return _JAVA17_PATH
|
||||
|
||||
|
||||
def build_java_command(
|
||||
decks: list, num_games: int, threads: int, seed: Optional[int],
|
||||
clock: int, output_path: str, use_learned_policy: bool = False,
|
||||
policy_server: str = "http://localhost:8080", policy_style: str = "midrange",
|
||||
policy_greedy: bool = False, ai_simplified: bool = False,
|
||||
ai_think_time_ms: int = -1, max_queue_depth: int = -1,
|
||||
) -> list:
|
||||
java17 = get_java17()
|
||||
forge_jar = CFG.forge_jar
|
||||
cmd = [
|
||||
java17, '-jar', forge_jar, '-sim',
|
||||
'-decks', ','.join(str(d) for d in decks),
|
||||
'-games', str(num_games),
|
||||
'-threads', str(threads),
|
||||
'-clock', str(clock),
|
||||
'-output', output_path,
|
||||
]
|
||||
if seed is not None:
|
||||
cmd += ['-seed', str(seed)]
|
||||
if use_learned_policy:
|
||||
cmd += ['-policy', policy_style, '-policyServer', policy_server]
|
||||
if policy_greedy:
|
||||
cmd += ['-policyGreedy']
|
||||
if ai_simplified:
|
||||
cmd += ['-aiSimplified']
|
||||
if ai_think_time_ms > 0:
|
||||
cmd += ['-aiThinkTimeMs', str(ai_think_time_ms)]
|
||||
if max_queue_depth > 0:
|
||||
cmd += ['-maxQueueDepth', str(max_queue_depth)]
|
||||
return cmd
|
||||
|
||||
|
||||
def _run_process_blocking(state: BatchState, cmd: list):
|
||||
"""Run Forge subprocess, parse progress, generate deck reports."""
|
||||
log.info(f"Running: {' '.join(cmd)}")
|
||||
state.started_at = datetime.datetime.now().isoformat()
|
||||
state.status = "running"
|
||||
env = os.environ.copy()
|
||||
java17 = get_java17()
|
||||
java_bin = os.path.dirname(java17)
|
||||
if java_bin:
|
||||
env["JAVA_HOME"] = os.path.dirname(java_bin)
|
||||
env["PATH"] = java_bin + os.pathsep + env.get("PATH", "")
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1, env=env,
|
||||
)
|
||||
except Exception as e:
|
||||
state.status = "error"
|
||||
state.error = str(e)
|
||||
log.error(f"Failed to start subprocess: {e}")
|
||||
return
|
||||
last_activity = [time.monotonic()]
|
||||
stall_limit = 300 # 5 min
|
||||
def watchdog():
|
||||
while proc.poll() is None:
|
||||
if (time.monotonic() - last_activity[0]) > stall_limit:
|
||||
log.warning("Stall detected, killing process")
|
||||
proc.kill()
|
||||
state.status = "error"
|
||||
state.error = "Stall detected (5 min no output)"
|
||||
return
|
||||
time.sleep(5)
|
||||
wd = threading.Thread(target=watchdog, daemon=True)
|
||||
wd.start()
|
||||
output_lines = []
|
||||
for line in proc.stdout:
|
||||
last_activity[0] = time.monotonic()
|
||||
line = line.rstrip()
|
||||
output_lines.append(line)
|
||||
gm = re.match(r'\[Game (\d+)/(\d+)\]', line)
|
||||
if gm:
|
||||
state.games_done = int(gm.group(1))
|
||||
state.games_total = int(gm.group(2))
|
||||
pm = re.match(r'\[PROGRESS\].*?(\d+\.?\d*)\s*sims/sec', line)
|
||||
if pm:
|
||||
state.throughput = float(pm.group(1))
|
||||
proc.wait()
|
||||
state.log = '\n'.join(output_lines)
|
||||
if proc.returncode != 0:
|
||||
state.status = "error"
|
||||
state.error = f"Exit code {proc.returncode}"
|
||||
else:
|
||||
state.status = "done"
|
||||
state.games_done = state.games_total
|
||||
state.ended_at = datetime.datetime.now().isoformat()
|
||||
|
||||
|
||||
async def run_batch_subprocess(
|
||||
state: BatchState, decks: list, num_games: int, threads: int,
|
||||
seed: Optional[int] = None, clock: int = 6000, output_path: str = "results",
|
||||
use_learned_policy: bool = False, **kwargs,
|
||||
):
|
||||
"""Async wrapper that runs Forge subprocess in executor."""
|
||||
cmd = build_java_command(
|
||||
decks, num_games, threads, seed, clock, output_path,
|
||||
use_learned_policy=use_learned_policy, **kwargs,
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _run_process_blocking, state, cmd)
|
||||
@@ -1,193 +0,0 @@
|
||||
"""URL-based deck import helpers: Archidekt, EDHREC, text decklist parsing."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
from models.state import CFG
|
||||
from services.deck_service import _to_edhrec_slug
|
||||
from services.scryfall import _API_HEADERS
|
||||
|
||||
log_collect = logging.getLogger("commander_ai_lab.collection")
|
||||
|
||||
|
||||
def _http_get(url: str) -> str:
|
||||
with urlopen(Request(url, headers=_API_HEADERS), timeout=30) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
|
||||
|
||||
def _fetch_archidekt_deck(deck_id: str) -> dict:
|
||||
url = f"https://archidekt.com/api/decks/{deck_id}/"
|
||||
data = json.loads(_http_get(url))
|
||||
profile = {
|
||||
"name": data.get("name", f"Archidekt {deck_id}"),
|
||||
"commander": None,
|
||||
"source": "Archidekt",
|
||||
"sourceUrl": f"https://archidekt.com/decks/{deck_id}",
|
||||
"commanders": {},
|
||||
"mainboard": {},
|
||||
"colorIdentity": [],
|
||||
"totalCards": 0,
|
||||
}
|
||||
for card_entry in data.get("cards", []):
|
||||
qty = card_entry.get("quantity", 1)
|
||||
oracle = card_entry.get("card", {}).get("oracleCard", {})
|
||||
card_name = oracle.get("name", "Unknown")
|
||||
is_commander = any(c.lower() == "commander" for c in card_entry.get("categories", []))
|
||||
if is_commander:
|
||||
profile["commanders"][card_name] = qty
|
||||
if not profile["commander"]:
|
||||
profile["commander"] = card_name
|
||||
if oracle.get("colorIdentity"):
|
||||
profile["colorIdentity"] = oracle["colorIdentity"]
|
||||
else:
|
||||
profile["mainboard"][card_name] = qty
|
||||
profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values())
|
||||
if not profile["name"] or profile["name"] == f"Archidekt {deck_id}":
|
||||
profile["name"] = f"{profile['commander']} \u2014 Archidekt"
|
||||
return profile
|
||||
|
||||
|
||||
def _fetch_edhrec_average(commander_name: str) -> dict:
|
||||
slug = _to_edhrec_slug(commander_name)
|
||||
url = f"https://json.edhrec.com/pages/average-decks/{slug}.json"
|
||||
data = json.loads(_http_get(url))
|
||||
container = data.get("container", {})
|
||||
json_dict = container.get("json_dict", {})
|
||||
card_info = json_dict.get("card", {})
|
||||
real_name = card_info.get("name", commander_name.title())
|
||||
profile = {
|
||||
"name": f"{real_name} \u2014 EDHREC Average",
|
||||
"commander": real_name,
|
||||
"source": "EDHREC Average",
|
||||
"sourceUrl": f"https://edhrec.com/average-decks/{slug}",
|
||||
"commanders": {real_name: 1},
|
||||
"mainboard": {},
|
||||
"colorIdentity": card_info.get("color_identity", []),
|
||||
"sampleSize": data.get("num_decks_avg"),
|
||||
"totalCards": 0,
|
||||
}
|
||||
for cl in json_dict.get("cardlists", []):
|
||||
for cv in cl.get("cardviews", []):
|
||||
card_name = cv.get("name", "")
|
||||
if card_name:
|
||||
profile["mainboard"][card_name] = 1
|
||||
archidekt_data = data.get("archidekt", [])
|
||||
if archidekt_data:
|
||||
basic_names = []
|
||||
for cl in json_dict.get("cardlists", []):
|
||||
if cl.get("tag") == "basics":
|
||||
for cv in cl.get("cardviews", []):
|
||||
basic_names.append(cv.get("name", ""))
|
||||
basic_quantities = [e["q"] for e in archidekt_data if e.get("q", 1) > 1]
|
||||
for i, name in enumerate(basic_names):
|
||||
if i < len(basic_quantities):
|
||||
profile["mainboard"][name] = basic_quantities[i]
|
||||
profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values())
|
||||
return profile
|
||||
|
||||
|
||||
def _import_from_url(url: str) -> dict:
|
||||
url = url.strip()
|
||||
if "archidekt.com/decks/" in url:
|
||||
m = re.search(r"/decks/(\d+)", url)
|
||||
if not m:
|
||||
raise ValueError("Could not parse Archidekt deck ID from URL")
|
||||
return _fetch_archidekt_deck(m.group(1))
|
||||
if "edhrec.com/average-decks/" in url:
|
||||
slug = re.search(r"/average-decks/([^/?#]+)", url).group(1)
|
||||
return _fetch_edhrec_average(slug.replace("-", " "))
|
||||
if "edhrec.com/commanders/" in url:
|
||||
slug = re.search(r"/commanders/([^/?#]+)", url).group(1)
|
||||
return _fetch_edhrec_average(slug.replace("-", " "))
|
||||
raise ValueError(f"Unsupported URL: {url}. Supported: archidekt.com/decks/..., edhrec.com/average-decks/..., edhrec.com/commanders/...")
|
||||
|
||||
|
||||
def _parse_text_decklist(text: str, commander_override: str = None) -> dict:
|
||||
profile = {
|
||||
"name": "Text Import",
|
||||
"commander": None,
|
||||
"source": "Text Import",
|
||||
"sourceUrl": None,
|
||||
"commanders": {},
|
||||
"mainboard": {},
|
||||
"totalCards": 0,
|
||||
}
|
||||
section = "main"
|
||||
for line in text.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("//") or line.startswith("#"):
|
||||
continue
|
||||
if line.lower().startswith("commander") or line == "[Commander]":
|
||||
section = "commander"; continue
|
||||
if line.lower().startswith("main") or line.lower().startswith("deck") or line == "[Main]":
|
||||
section = "main"; continue
|
||||
if line.lower().startswith("sideboard") or line == "[Sideboard]":
|
||||
section = "sideboard"; continue
|
||||
clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip()
|
||||
clean = re.sub(r"\s*\*.*$", "", clean).strip()
|
||||
m = re.match(r"^(\d+)x?\s+(.+)$", clean)
|
||||
qty, card_name = (int(m.group(1)), m.group(2).strip()) if m else (1, clean)
|
||||
if not card_name:
|
||||
continue
|
||||
if section == "commander":
|
||||
profile["commanders"][card_name] = qty
|
||||
if not profile["commander"]:
|
||||
profile["commander"] = card_name
|
||||
elif section != "sideboard":
|
||||
profile["mainboard"][card_name] = qty
|
||||
if commander_override:
|
||||
profile["commander"] = commander_override
|
||||
if not profile["commanders"]:
|
||||
if commander_override in profile["mainboard"]:
|
||||
qty = profile["mainboard"].pop(commander_override)
|
||||
profile["commanders"][commander_override] = qty
|
||||
else:
|
||||
profile["commanders"][commander_override] = 1
|
||||
if profile["commander"]:
|
||||
profile["name"] = f"{profile['commander']} \u2014 Text Import"
|
||||
profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values())
|
||||
return profile
|
||||
|
||||
|
||||
def _save_profile_to_dck(profile: dict) -> Path:
|
||||
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
|
||||
for name, qty in profile.get("commanders", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
lines += ["", "[Main]"]
|
||||
for name, qty in profile.get("mainboard", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
content = "\n".join(lines)
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", profile.get("name", "imported")).replace(" ", "_").strip()
|
||||
if not safe_name:
|
||||
safe_name = "imported_deck"
|
||||
save_dir = CFG.forge_decks_dir
|
||||
if not save_dir or not os.path.isdir(save_dir):
|
||||
save_dir = os.path.join(Path(__file__).parent.parent, "imported-decks")
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
out_path = Path(save_dir) / f"{safe_name}.dck"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
log_collect.info(f" Saved .dck: {out_path}")
|
||||
return out_path
|
||||
|
||||
|
||||
# EDHREC in-memory cache
|
||||
_edhrec_cache: dict = {}
|
||||
_EDHREC_CACHE_TTL = 3600
|
||||
|
||||
|
||||
def _edhrec_cache_get(key: str):
|
||||
entry = _edhrec_cache.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
if time.time() - entry["ts"] > _EDHREC_CACHE_TTL:
|
||||
del _edhrec_cache[key]
|
||||
return None
|
||||
return entry["data"]
|
||||
|
||||
|
||||
def _edhrec_cache_set(key: str, data):
|
||||
_edhrec_cache[key] = {"data": data, "ts": time.time()}
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Collection import service: CSV/text parsing helpers."""
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _parse_finish(raw: str) -> str:
|
||||
if not raw:
|
||||
return "NORMAL"
|
||||
r = raw.strip().lower()
|
||||
if r in ("etched", "foil etched", "foil_etched", "etched foil"):
|
||||
return "ETCHED"
|
||||
if r in ("yes", "foil", "true", "1"):
|
||||
return "FOIL"
|
||||
return "NORMAL"
|
||||
|
||||
|
||||
def _parse_text_line(line: str) -> dict:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("//"):
|
||||
return {}
|
||||
result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""}
|
||||
qty_match = re.match(r"^(\d+)x?\s+", line)
|
||||
if qty_match:
|
||||
result["quantity"] = int(qty_match.group(1))
|
||||
line = line[qty_match.end():]
|
||||
bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line)
|
||||
if bracket_match:
|
||||
result["set_code"] = bracket_match.group(1).lower()
|
||||
result["collector_number"] = bracket_match.group(2)
|
||||
result["name"] = line[:bracket_match.start()].strip()
|
||||
return result
|
||||
paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line)
|
||||
if paren_coll_match:
|
||||
result["set_code"] = paren_coll_match.group(1).lower()
|
||||
result["collector_number"] = paren_coll_match.group(2)
|
||||
result["name"] = line[:paren_coll_match.start()].strip()
|
||||
return result
|
||||
paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line)
|
||||
if paren_match:
|
||||
result["set_code"] = paren_match.group(1).lower()
|
||||
result["name"] = line[:paren_match.start()].strip()
|
||||
return result
|
||||
result["name"] = line.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _auto_infer_mapping(headers: list) -> dict:
|
||||
mapping = {}
|
||||
header_lower = {h: h.lower().strip() for h in headers}
|
||||
field_patterns = {
|
||||
"name": ["name", "card name", "card_name", "cardname", "title"],
|
||||
"quantity": ["quantity", "qty", "count", "amount", "number", "#"],
|
||||
"set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"],
|
||||
"collector_number": ["collector number", "collector_number", "collectornumber", "col #", "col#", "number", "card number"],
|
||||
"finish": ["finish", "foil", "printing", "treatment"],
|
||||
"condition": ["condition", "cond", "grade"],
|
||||
"language": ["language", "lang", "locale"],
|
||||
"notes": ["notes", "note", "comments", "comment"],
|
||||
"tags": ["tags", "tag", "labels", "label"],
|
||||
}
|
||||
for header, lower_h in header_lower.items():
|
||||
for field, patterns in field_patterns.items():
|
||||
if lower_h in patterns:
|
||||
mapping[header] = field
|
||||
break
|
||||
return mapping
|
||||
|
||||
|
||||
def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list:
|
||||
rows = []
|
||||
if (source or "").upper() == "TEXT":
|
||||
for line in content.splitlines():
|
||||
parsed = _parse_text_line(line)
|
||||
if parsed.get("name"):
|
||||
rows.append(parsed)
|
||||
return rows
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
headers = reader.fieldnames or []
|
||||
col_map = {h: (mapping.get(h, "") if mapping else "") for h in headers}
|
||||
if not mapping:
|
||||
col_map = _auto_infer_mapping(headers)
|
||||
col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()}
|
||||
for csv_row in reader:
|
||||
row = {"name": "", "quantity": 1, "set_code": "", "collector_number": "",
|
||||
"finish": "NORMAL", "condition": "", "language": "", "notes": "", "tags": ""}
|
||||
for header, field in col_map.items():
|
||||
val = csv_row.get(header, "").strip()
|
||||
if not val or field == "ignore" or not field:
|
||||
continue
|
||||
if field == "name":
|
||||
val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip()
|
||||
row["name"] = val
|
||||
elif field == "quantity":
|
||||
try:
|
||||
row["quantity"] = int(float(val))
|
||||
except ValueError:
|
||||
row["quantity"] = 1
|
||||
elif field in ("set_code", "set_code_secondary"):
|
||||
row["set_code"] = val.lower()
|
||||
elif field in ("collector_number", "collector_number_secondary"):
|
||||
row["collector_number"] = val
|
||||
elif field == "finish":
|
||||
row["finish"] = _parse_finish(val)
|
||||
elif field in ("condition", "language", "notes", "tags"):
|
||||
row[field] = val
|
||||
if row["name"]:
|
||||
rows.append(row)
|
||||
return rows
|
||||
@@ -1,125 +0,0 @@
|
||||
"""Precon deck download, caching, and conversion logic."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
PRECON_DIR = Path(__file__).parent.parent / "precon-decks"
|
||||
PRECON_INDEX: list[dict] = []
|
||||
GITHUB_PRECON_URL = (
|
||||
"https://raw.githubusercontent.com/taw/magic-preconstructed-decks-data/"
|
||||
"master/decks_v2.json"
|
||||
)
|
||||
PRECON_CACHE_HOURS = 168
|
||||
|
||||
|
||||
def load_precon_index():
|
||||
global PRECON_INDEX
|
||||
idx_path = PRECON_DIR / "precon-index.json"
|
||||
if idx_path.exists():
|
||||
with open(idx_path, "r", encoding="utf-8") as f:
|
||||
PRECON_INDEX = json.load(f)
|
||||
log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
|
||||
else:
|
||||
PRECON_INDEX = []
|
||||
log.info(f" Precons: index not found at {idx_path}")
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
safe = re.sub(r'[<>:"/\\|?*]', '', name)
|
||||
safe = safe.replace(' ', '_').replace("'", '').replace('!', '')
|
||||
return re.sub(r'_+', '_', safe).strip('_')
|
||||
|
||||
|
||||
def _deck_to_dck(deck_data: dict) -> str:
|
||||
lines = ["[metadata]", f"Name={deck_data['name']}"]
|
||||
commanders = deck_data.get('commander', [])
|
||||
if commanders:
|
||||
lines.append("[Commander]")
|
||||
for card in commanders:
|
||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||
cards = deck_data.get('cards', [])
|
||||
if cards:
|
||||
lines.append("[Main]")
|
||||
for card in cards:
|
||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||
sideboard = deck_data.get('sideboard', [])
|
||||
if sideboard:
|
||||
lines.append("[Sideboard]")
|
||||
for card in sideboard:
|
||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def download_precon_database(force: bool = False) -> dict:
|
||||
global PRECON_INDEX
|
||||
idx_path = PRECON_DIR / "precon-index.json"
|
||||
if not force and idx_path.exists():
|
||||
try:
|
||||
with open(idx_path, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
if len(existing) > 50:
|
||||
age_hours = (time.time() - idx_path.stat().st_mtime) / 3600
|
||||
if age_hours < PRECON_CACHE_HOURS:
|
||||
log.info(f" Precons: {len(existing)} decks cached ({age_hours:.0f}h old)")
|
||||
PRECON_INDEX = existing
|
||||
return {"downloaded": 0, "skipped": True, "total": len(existing), "error": None}
|
||||
except Exception:
|
||||
pass
|
||||
log.info(" Precons: Downloading full precon database from GitHub...")
|
||||
try:
|
||||
req = Request(GITHUB_PRECON_URL, headers={"User-Agent": "CommanderAILab/3.0"})
|
||||
with urlopen(req, timeout=120) as resp:
|
||||
all_decks = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
msg = f"Failed to download precon database: {e}"
|
||||
log.error(f" Precons: ERROR - {msg}")
|
||||
if idx_path.exists():
|
||||
load_precon_index()
|
||||
return {"downloaded": 0, "skipped": False, "error": msg}
|
||||
commander_decks = [
|
||||
d for d in all_decks
|
||||
if d.get('type') == 'Commander Deck'
|
||||
and (d.get('format') or '').lower() == 'commander'
|
||||
]
|
||||
log.info(f" Precons: Found {len(commander_decks)} Commander precon decks")
|
||||
PRECON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name_counts = Counter(_sanitize_filename(d['name']) for d in commander_decks)
|
||||
dup_names = {n for n, c in name_counts.items() if c > 1}
|
||||
index = []
|
||||
written = 0
|
||||
for deck in sorted(commander_decks, key=lambda d: (d.get('release_date', ''), d.get('name', ''))):
|
||||
safe_name = _sanitize_filename(deck['name'])
|
||||
if safe_name in dup_names:
|
||||
sc = (deck.get('set_code') or 'unk').upper()
|
||||
safe_name = f"{safe_name}_{sc}"
|
||||
file_name = f"{safe_name}.dck"
|
||||
dck_path = PRECON_DIR / file_name
|
||||
commanders = deck.get('commander', [])
|
||||
cmdr_names = [c['name'] for c in commanders] if commanders else []
|
||||
total_cards = sum(c.get('count', 1) for c in deck.get('cards', [])) + sum(c.get('count', 1) for c in commanders)
|
||||
with open(dck_path, "w", encoding="utf-8") as f:
|
||||
f.write(_deck_to_dck(deck))
|
||||
written += 1
|
||||
release = deck.get('release_date', '')
|
||||
year = int(release[:4]) if release and len(release) >= 4 else 0
|
||||
index.append({
|
||||
"name": deck['name'], "commander": cmdr_names[0] if cmdr_names else "Unknown",
|
||||
"commanders": cmdr_names, "colors": [],
|
||||
"set": deck.get('set_name', ''), "setCode": deck.get('set_code', ''),
|
||||
"year": year, "releaseDate": release, "theme": "",
|
||||
"fileName": file_name, "cardCount": total_cards,
|
||||
})
|
||||
with open(idx_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=2, ensure_ascii=False)
|
||||
PRECON_INDEX = index
|
||||
log.info(f" Precons: {written} .dck files written, index saved")
|
||||
return {"downloaded": written, "skipped": False, "total": written, "error": None}
|
||||
@@ -1,216 +0,0 @@
|
||||
"""Scryfall API client and cache service."""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
# Lazy import to avoid circular dependency
|
||||
def _detect_card_roles(oracle_text, type_line, keywords):
|
||||
from services.card_analysis import _detect_card_roles as _dcr
|
||||
return _dcr(oracle_text, type_line, keywords)
|
||||
|
||||
|
||||
SCRYFALL_CACHE_DB_PATH = Path(__file__).parent.parent / "scryfall_cache.db"
|
||||
SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600))
|
||||
|
||||
_API_HEADERS = {"User-Agent": "CommanderAILab/3.0", "Accept": "application/json"}
|
||||
|
||||
|
||||
class _ScryfallCache:
|
||||
def __init__(self, db_path: Path = None, ttl_seconds: int = None):
|
||||
self._db_path = db_path or SCRYFALL_CACHE_DB_PATH
|
||||
self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS
|
||||
self._lock = threading.Lock()
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scryfall_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
json_blob TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _make_key(name: str, set_code: str, collector_number: str) -> str:
|
||||
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
|
||||
|
||||
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT json_blob FROM scryfall_cache WHERE cache_key = ?", (key,)
|
||||
).fetchone()
|
||||
if row:
|
||||
self._hits += 1
|
||||
return json.loads(row[0])
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
def put(self, name: str, set_code: str, collector_number: str, data: dict):
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
blob = json.dumps(data)
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob) VALUES (?, ?)",
|
||||
(key, blob),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def stats(self) -> dict:
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
return {"cached": count, "hits": self._hits, "misses": self._misses}
|
||||
|
||||
def clear(self) -> int:
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
self._conn.execute("DELETE FROM scryfall_cache")
|
||||
self._conn.commit()
|
||||
self._conn.execute("VACUUM")
|
||||
self._hits = 0; self._misses = 0
|
||||
return count
|
||||
|
||||
def evict_expired(self) -> int:
|
||||
cutoff = datetime.datetime.utcnow().isoformat()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"DELETE FROM scryfall_cache WHERE (julianday(?) - julianday(fetched_at)) * 86400 > ?",
|
||||
(cutoff, self._ttl),
|
||||
)
|
||||
deleted = self._conn.total_changes
|
||||
self._conn.commit()
|
||||
return deleted
|
||||
|
||||
|
||||
_scryfall_cache = _ScryfallCache()
|
||||
_scryfall_lock = threading.Lock()
|
||||
_scryfall_last_call = 0.0
|
||||
|
||||
|
||||
def _scryfall_rate_limit():
|
||||
global _scryfall_last_call
|
||||
with _scryfall_lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - _scryfall_last_call
|
||||
if elapsed < 0.1:
|
||||
time.sleep(0.1 - elapsed)
|
||||
_scryfall_last_call = time.monotonic()
|
||||
|
||||
|
||||
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
from urllib.parse import quote
|
||||
cached = _scryfall_cache.get(name, set_code, collector_number)
|
||||
if cached is not None:
|
||||
return cached
|
||||
card_data = None
|
||||
last_error = ""
|
||||
if set_code and collector_number:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}"
|
||||
with urlopen(Request(url, headers=_API_HEADERS), timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e); card_data = None
|
||||
if not card_data:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
url = f"https://api.scryfall.com/cards/named?exact={quote(name)}"
|
||||
with urlopen(Request(url, headers=_API_HEADERS), timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
if not card_data or card_data.get("object") == "error":
|
||||
err_detail = card_data.get("details", "unknown error") if card_data else last_error
|
||||
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
|
||||
_scryfall_cache.put(name, set_code, collector_number, card_data)
|
||||
resolved_name = card_data.get("name", "")
|
||||
if resolved_name and resolved_name.lower() != name.strip().lower():
|
||||
_scryfall_cache.put(resolved_name, set_code, collector_number, card_data)
|
||||
return card_data
|
||||
|
||||
|
||||
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
card_data = _fetch_scryfall_api(name, set_code, collector_number)
|
||||
if not card_data or "_error" in card_data:
|
||||
return card_data or {}
|
||||
type_line = card_data.get("type_line", "")
|
||||
color_identity = card_data.get("color_identity", [])
|
||||
keywords = card_data.get("keywords", [])
|
||||
subtypes = []
|
||||
for sep in [" \u2014 ", " - "]:
|
||||
if sep in type_line:
|
||||
subtypes = [s.strip() for s in type_line.split(sep, 1)[1].split() if s.strip()]
|
||||
break
|
||||
is_legendary = 1 if "Legendary" in type_line else 0
|
||||
is_basic = 1 if "Basic" in type_line else 0
|
||||
prices = card_data.get("prices", {})
|
||||
try:
|
||||
tcg_price = float(prices.get("usd") or prices.get("usd_foil") or "0") or 0.0
|
||||
except (ValueError, TypeError):
|
||||
tcg_price = 0.0
|
||||
oracle_text = card_data.get("oracle_text", "")
|
||||
if not oracle_text and card_data.get("card_faces"):
|
||||
oracle_text = "\n//\n".join(f.get("oracle_text", "") for f in card_data["card_faces"] if f.get("oracle_text"))
|
||||
power = card_data.get("power", "")
|
||||
toughness = card_data.get("toughness", "")
|
||||
if not power and card_data.get("card_faces"):
|
||||
power = card_data["card_faces"][0].get("power", "")
|
||||
toughness = card_data["card_faces"][0].get("toughness", "")
|
||||
mana_cost = card_data.get("mana_cost", "")
|
||||
if not mana_cost and card_data.get("card_faces"):
|
||||
mana_cost = card_data["card_faces"][0].get("mana_cost", "")
|
||||
auto_roles = _detect_card_roles(oracle_text, type_line, keywords)
|
||||
return {
|
||||
"name": card_data.get("name", name),
|
||||
"type_line": type_line,
|
||||
"subtypes": json.dumps(subtypes),
|
||||
"is_legendary": is_legendary,
|
||||
"is_basic": is_basic,
|
||||
"color_identity": json.dumps(color_identity),
|
||||
"cmc": card_data.get("cmc", 0.0),
|
||||
"mana_cost": mana_cost,
|
||||
"oracle_text": oracle_text,
|
||||
"keywords": json.dumps(keywords),
|
||||
"power": power or "",
|
||||
"toughness": toughness or "",
|
||||
"rarity": card_data.get("rarity", ""),
|
||||
"set_name": card_data.get("set_name", ""),
|
||||
"edhrec_rank": card_data.get("edhrec_rank", 0) or 0,
|
||||
"tcg_price": tcg_price,
|
||||
"salt_score": 0.0,
|
||||
"is_game_changer": 0,
|
||||
"category": json.dumps(auto_roles),
|
||||
"scryfall_id": card_data.get("id", ""),
|
||||
"tcgplayer_id": str(card_data.get("tcgplayer_id", "")),
|
||||
}
|
||||
|
||||
|
||||
def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
|
||||
"""Fuzzy-search Scryfall for a card name. Returns raw Scryfall JSON dict, or None on failure."""
|
||||
from urllib.parse import quote
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
encoded = quote(name)
|
||||
url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}"
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
if data.get("object") == "error":
|
||||
return None
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
@@ -1,14 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab — Pygame Prototype GUI (Phase 4)
|
||||
"""
|
||||
from commander_ai_lab.gui.constants import SCREEN_W, SCREEN_H, FPS
|
||||
from commander_ai_lab.gui.board import BoardRenderer
|
||||
from commander_ai_lab.gui.card_renderer import CardRenderer
|
||||
from commander_ai_lab.gui.hud import HUD
|
||||
from commander_ai_lab.gui.narration_panel import NarrationPanel
|
||||
from commander_ai_lab.gui.game_window import GameWindow
|
||||
|
||||
__all__ = [
|
||||
"SCREEN_W", "SCREEN_H", "FPS",
|
||||
"BoardRenderer", "CardRenderer", "HUD", "NarrationPanel", "GameWindow",
|
||||
]
|
||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user