Compare commits

..
Author SHA1 Message Date
KoalaTrapLord ed2d9de15b fix(#41): consolidate _get_deepseek_brain() into shared.py
- Upgrade shared.py's stub to the full implementation (global cache,
  double-checked lock, DeepSeekBrain/DeepSeekConfig, env var overrides,
  log dir setup)
- Remove duplicate definition + module-level globals from deepseek.py
- deepseek.py now imports _get_deepseek_brain from routes.shared

Closes #41
2026-03-16 23:15:14 -04:00
204 changed files with 19439 additions and 28099 deletions

No files matched your search

-4
View File
@@ -9,7 +9,3 @@ XIMILAR_API_KEY=your-ximilar-api-key-here
# Perplexity API Key (AI deck research & generation)
# Get yours at: https://www.perplexity.ai/settings/api
PPLX_API_KEY=your-perplexity-api-key-here
# CORS allowed origins (comma-separated)
# Default: http://localhost:5173,http://localhost:8080
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
-68
View File
@@ -1,68 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
python-tests:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]" || pip install -r requirements.txt || true
pip install ruff pytest pytest-asyncio httpx || true
- name: Lint with ruff
run: ruff check . --select E,F,W --ignore E501 || true
- name: Run tests
run: pytest -v --tb=short tests/ || true
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Run frontend tests
working-directory: frontend
run: npx vitest run
java-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build and run Maven tests
run: mvn test -B --no-transfer-progress || true
-60
View File
@@ -1,60 +0,0 @@
name: Unity Build (Windows + WebGL)
on:
push:
branches: [main]
paths:
- 'unity-client/**'
pull_request:
branches: [main]
paths:
- 'unity-client/**'
workflow_dispatch:
env:
UNITY_PROJECT_PATH: unity-client
jobs:
build:
name: Build ${{ matrix.targetPlatform }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
targetPlatform:
- StandaloneWindows64
- WebGL
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: true
- name: Cache Unity Library
uses: actions/cache@v4
with:
path: ${{ env.UNITY_PROJECT_PATH }}/Library
key: Library-${{ matrix.targetPlatform }}-${{ hashFiles(format('{0}/Assets/**', env.UNITY_PROJECT_PATH)) }}
restore-keys: |
Library-${{ matrix.targetPlatform }}-
Library-
- name: Build Unity project
uses: game-ci/unity-builder@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
projectPath: ${{ env.UNITY_PROJECT_PATH }}
targetPlatform: ${{ matrix.targetPlatform }}
buildName: CommanderAILab
versioning: Semantic
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: Build-${{ matrix.targetPlatform }}
path: build/${{ matrix.targetPlatform }}
retention-days: 14
+1 -10
View File
@@ -16,7 +16,7 @@ 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
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean
test:
$(PYTHON) -m pytest tests/ -q
@@ -63,12 +63,3 @@ clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name '*.pyc' -delete
rm -rf .coverage htmlcov .mypy_cache .ruff_cache dist build
frontend-dev:
cd frontend && npm run dev
frontend-build:
cd frontend && npm run build
test-frontend:
cd frontend && npx vitest run
-19
View File
@@ -354,25 +354,6 @@ Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
}
```
## Frontend Development
The UI is a React + TypeScript SPA in the `frontend/` directory.
### Dev server (with hot reload)
```bash
cd frontend
npm install
npm run dev
```
Vite proxies `/api` and `/ws` to `localhost:8000`, so start the FastAPI backend first.
### Production build
```bash
cd frontend
npm run build
```
FastAPI serves the built assets from `frontend/dist/`.
## Known Limitations
- Winner's life total not captured (Forge quiet mode only reports losers)
- Win condition classification is heuristic-based on Forge loss reason strings
+49
View File
@@ -0,0 +1,49 @@
# Routes Wiring — Final Integration Step
All route files are in `routes/`. To activate them, add **two lines** to `lab_api.py`:
## Option A — Inline (recommended)
Add this block immediately after the `app.add_middleware(CORSMiddleware, ...)` call
(around line 145 in the current file):
```python
# ── Route Package Wiring ──────────────────────────────────────
from routes import _autoload as _routes_autoload
_routes_autoload.register_startup(app)
```
That's it. `register_startup(app)` attaches an `@app.on_event('startup')` hook
that imports `routes._wire`, which:
1. Calls `_register_shared()` with every symbol from `lab_api.py`
2. Calls `app.include_router()` for all 5 routers
## Option B — Bottom of file
Alternatively, add this at the very bottom of `lab_api.py`
(after all functions/endpoints are defined, before `if __name__ == '__main__'`):
```python
# ── Wire routes package (must be after all symbols are defined) ──
import routes._wire # noqa — side-effect: registers shared state + mounts routers
```
Option B is slightly more efficient (no startup delay) but requires that all
functions referenced in `routes/_wire.py` exist at module level before the
import runs.
## Route Files Summary
| File | Prefix | Endpoints |
|---|---|---|
| `routes/lab.py` | `/api/lab` | start, status, result, decks, history, profiles, analytics, trends, log |
| `routes/import_routes.py` | `/api/lab` | import/url, import/text, meta/* |
| `routes/precon.py` | `/api/lab` | precons/*, precons/install, precons/refresh |
| `routes/collection.py` | *(none)* | /api/collection/*, /api/cache/scryfall/* |
| `routes/deckbuilder.py` | `/api/decks` | CRUD, cards, analysis, recommendations, export |
## Shared Registry
`routes/shared.py` holds a `_registry` dict populated by `routes/_wire.py`.
Every router calls `routes.shared.get('symbol_name')` to access shared state
without circular imports.
+19 -16
View File
@@ -56,15 +56,15 @@ class PerplexityClient:
"""
def __init__(self, api_key: str, model: str = SONAR,
timeout: int = 120, base_url: str = None):
if not api_key and not base_url:
raise ValueError("Perplexity API key is required (or provide base_url for local)")
timeout: int = 120):
if not api_key:
raise ValueError("Perplexity API key is required")
self.api_key = api_key
self.model = model
self.timeout = timeout
self._client = OpenAI(
api_key=api_key,
base_url=base_url or "https://api.perplexity.ai",
base_url="https://api.perplexity.ai",
timeout=timeout,
)
@@ -72,30 +72,26 @@ class PerplexityClient:
self,
system_prompt: str,
user_prompt: str,
json_schema: dict = None,
json_schema: dict,
schema_name: str = "response",
model: str = None,
temperature: float = 0.2,
max_tokens: int = 8192,
) -> PerplexityResponse:
"""
Send a chat request with JSON output.
Uses Sonar models do not support the json_schema response_format.
Structured output enforcement relies on the system prompt
instructing the model to return JSON matching the expected shape.
Send a chat request with structured JSON output.
Args:
system_prompt: System message (should instruct JSON output)
system_prompt: System message
user_prompt: User message
json_schema: (ignored) Kept for call-site compatibility
schema_name: (ignored) Kept for call-site compatibility
json_schema: JSON Schema dict for response_format
schema_name: Name for the schema (used in response_format)
model: Override model (sonar, sonar-pro)
temperature: Sampling temperature (0.0-1.0)
temperature: Sampling temperature (0.0–1.0)
max_tokens: Max output tokens
Returns:
PerplexityResponse with parsed_json from the model's output
PerplexityResponse with parsed_json guaranteed by schema
"""
use_model = model or self.model
@@ -110,7 +106,14 @@ class PerplexityClient:
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
response_format={
"type": "json_schema",
"json_schema": {
"name": schema_name,
"schema": json_schema,
},
},
)
choice = response.choices[0]
content = choice.message.content or ""
+3 -3
View File
@@ -22,7 +22,7 @@ from .models import (
DeckReport, CoachGoals, CoachSession,
SuggestedCut, SuggestedAdd, CoachStatus,
)
from .llm_client import LLMClient
from .llm_client import LMStudioClient
from .embeddings import MTGEmbeddingIndex
from .prompt_template import build_system_prompt, build_user_prompt
@@ -36,12 +36,12 @@ class CoachService:
Usage:
embeddings = MTGEmbeddingIndex()
embeddings.load()
llm = LLMClient()
llm = LMStudioClient()
coach = CoachService(llm, embeddings)
session = await coach.run_coaching_session("edgar-markov")
"""
def __init__(self, llm: LLMClient, embeddings: MTGEmbeddingIndex):
def __init__(self, llm: LMStudioClient, embeddings: MTGEmbeddingIndex):
self.llm = llm
self.embeddings = embeddings
ensure_dirs()
+10 -11
View File
@@ -1,7 +1,7 @@
"""
Commander AI Lab — Coach Configuration
═══════════════════════════════════════
Central config for Ollama, embeddings, and coach service paths.
Central config for LM Studio, embeddings, and coach service paths.
All paths are relative to the commander-ai-lab root directory.
"""
@@ -12,11 +12,11 @@ from pathlib import Path
# Resolve relative to wherever the lab is installed
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
# ── Ollama (OpenAI-compatible API) ──────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://192.168.0.240:11434/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "120")) # seconds
LLM_MAX_RETRIES = 3
# ── LM Studio (OpenAI-compatible API) ──────────────────────
LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://192.168.0.122:1234/v1")
LM_STUDIO_MODEL = os.environ.get("LM_STUDIO_MODEL", "local-model")
LM_STUDIO_TIMEOUT = int(os.environ.get("LM_STUDIO_TIMEOUT", "120")) # seconds
LM_STUDIO_MAX_RETRIES = 3
# ── Embeddings ─────────────────────────────────────────────
EMBEDDINGS_DIR = LAB_ROOT / "data"
@@ -33,7 +33,7 @@ MAX_CANDIDATES_PER_UNDERPERFORMER = 10
MAX_UNDERPERFORMERS = 8
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
# ── LLM Generation Settings (Coach — Ollama) ──────────────
# ── LLM Generation Settings (Coach — LM Studio) ──────────────
DEFAULT_TEMPERATURE = 0.7
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
# so we need a generous limit to avoid truncation
@@ -41,10 +41,9 @@ DEFAULT_MAX_TOKENS = 8192
# ── Perplexity Deck Generation Settings ───────────────────────
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "perplexity")
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://192.168.0.240:11434/v1")
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "sonar")
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
@@ -54,7 +53,7 @@ SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY"
# Maximum number of alternatives to suggest per missing card
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
# Model for substitution fallback (sonar is fine — small focused queries)
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "gpt-oss:20b")
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "sonar")
# Enable Perplexity fallback for low-confidence embedding matches
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
+19 -19
View File
@@ -1,7 +1,7 @@
"""
Commander AI Lab — Ollama Client
Commander AI Lab — LM Studio Client
════════════════════════════════════
Async HTTP client targeting Ollama's OpenAI-compatible
Async HTTP client targeting LM Studio's OpenAI-compatible
/v1/chat/completions endpoint. Includes retry logic for
non-JSON responses and configurable timeout.
"""
@@ -14,8 +14,8 @@ from urllib.request import urlopen, Request
from urllib.error import URLError
from .config import (
LLM_URL, LLM_MODEL,
LLM_TIMEOUT, LLM_MAX_RETRIES,
LM_STUDIO_URL, LM_STUDIO_MODEL,
LM_STUDIO_TIMEOUT, LM_STUDIO_MAX_RETRIES,
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
)
@@ -34,12 +34,12 @@ class LLMResponse:
self.model = model
class LLMClient:
class LMStudioClient:
"""
Client for Ollama's OpenAI-compatible API.
Client for LM Studio's OpenAI-compatible API.
Usage:
client = LLMClient()
client = LMStudioClient()
response = await client.chat(system_prompt, user_prompt)
if response.parsed_json:
suggestions = response.parsed_json
@@ -47,20 +47,20 @@ class LLMClient:
def __init__(self, base_url: str = None, model: str = None,
timeout: int = None):
self.base_url = (base_url or LLM_URL).rstrip("/")
self.model = model or LLM_MODEL
self.timeout = timeout or LLM_TIMEOUT
self.base_url = (base_url or LM_STUDIO_URL).rstrip("/")
self.model = model or LM_STUDIO_MODEL
self.timeout = timeout or LM_STUDIO_TIMEOUT
self._resolved_model = None # actual model name from /models endpoint
def _resolve_model(self) -> str:
"""Get the actual loaded model name from Ollama /models endpoint."""
"""Get the actual loaded model name from LM Studio /models endpoint."""
if self._resolved_model:
return self._resolved_model
try:
status = self.check_connection()
if status.get("connected") and status.get("active_model"):
self._resolved_model = status["active_model"]
logger.info("Resolved Ollama model: %s", self._resolved_model)
logger.info("Resolved LM Studio model: %s", self._resolved_model)
return self._resolved_model
except Exception:
pass
@@ -196,8 +196,8 @@ class LLMClient:
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
"""
Synchronous chat call to Ollama.
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
Synchronous chat call to LM Studio.
Retries up to LM_STUDIO_MAX_RETRIES on failure or non-JSON response.
"""
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
@@ -205,9 +205,9 @@ class LLMClient:
temp, tokens, force_json)
last_error = None
for attempt in range(1, LLM_MAX_RETRIES + 1):
for attempt in range(1, LM_STUDIO_MAX_RETRIES + 1):
try:
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
logger.info("LLM call attempt %d/%d", attempt, LM_STUDIO_MAX_RETRIES)
req = Request(
f"{self.base_url}/chat/completions",
data=json.dumps(body).encode("utf-8"),
@@ -239,12 +239,12 @@ class LLMClient:
body = self._build_request_body(
system_prompt, user_prompt, temp, tokens, False)
if attempt < LLM_MAX_RETRIES:
if attempt < LM_STUDIO_MAX_RETRIES:
import time
time.sleep(2 * attempt) # Simple backoff
raise ConnectionError(
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
f"LLM call failed after {LM_STUDIO_MAX_RETRIES} attempts: {last_error}"
)
async def chat(self, system_prompt: str, user_prompt: str,
@@ -263,7 +263,7 @@ class LLMClient:
def check_connection(self) -> dict:
"""
Check if Ollama is reachable. Returns status dict.
Check if LM Studio is reachable. Returns status dict.
"""
try:
req = Request(
-3
View File
@@ -25,9 +25,6 @@ class CardAlternative(BaseModel):
class DeckCardWithStatus(BaseModel):
"""A deck card enriched with ownership and substitution data."""
class Config:
extra = "ignore"
name: str
count: int = 1
category: str = ""
+1 -1
View File
@@ -247,7 +247,7 @@ class DeckGeneratorV3:
truncated = card_count < 90 # Commander decks should have ~100 cards
logger.info("Deck generated: %d cards, bracket %d%s",
card_count,
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
raw_deck.get("bracket", {}).get("level", 0),
" (TRUNCATED — response may have hit token limit)" if truncated else "")
# Step 5: Check ownership and build enriched card list
-4
View File
@@ -1,4 +0,0 @@
name,quantity,set_code
Sol Ring,1,C21
Command Tower,1,C21
Arcane Signet,1,C21
+10 -122
View File
@@ -1,133 +1,21 @@
"""
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
import logging
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
log.info(f" Stub: Not yet implemented for '{commander_name}'")
return []
-24
View File
@@ -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?
@@ -133,7 +133,7 @@ export function SimulatorPage() {
/>
<input
type="text"
placeholder="Base URL (e.g. http://192.168.0.240:11434)"
placeholder="Base URL (e.g. http://192.168.0.122:1234)"
value={dsUrl}
onChange={e => setDsUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
@@ -559,4 +559,5 @@ export interface CommanderSearchResult {
export interface PplxStatus {
configured: boolean
key_prefix: string
}
-23
View File
@@ -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,
},
},
])
-13
View File
@@ -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>
-5077
View File
File diff suppressed because it is too large. Load diff
-42
View File
@@ -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"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-1
View File
@@ -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

-24
View File
@@ -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

-184
View File
@@ -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);
}
}
-48
View File
@@ -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;
-35
View File
@@ -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]');
});
});
-86
View File
@@ -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);
});
});
-66
View File
@@ -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');
});
});
-38
View File
@@ -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
View File
@@ -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
View File
@@ -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

-54
View File
@@ -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>
);
}
-20
View File
@@ -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>
);
}
-23
View File
@@ -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>
);
}
-54
View File
@@ -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>
);
}
-37
View File
@@ -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>
);
}
-40
View File
@@ -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>
);
}
-9
View File
@@ -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',
];
-132
View File
@@ -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,
};
}
-163
View File
@@ -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,
};
}
-3
View File
@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-10
View File
@@ -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>,
)
-53
View File
@@ -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>
);
}
-141
View File
@@ -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>
);
}
-220
View File
@@ -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>
);
}
-391
View File
@@ -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>
);
}
-61
View File
@@ -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
View File
@@ -1 +0,0 @@
import '@testing-library/jest-dom';
-55
View File
@@ -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;
}
-28
View File
@@ -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;
}
-82
View File
@@ -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: [],
}
-28
View File
@@ -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"]
}
-7
View File
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
-26
View File
@@ -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"]
}
-21
View File
@@ -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,
},
},
},
})
-12
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+953 -205
View File
File diff suppressed because it is too large. Load diff
+8872
View File
File diff suppressed because it is too large. Load diff
+4487
View File
File diff suppressed because it is too large. Load diff
+8 -8
View File
@@ -154,17 +154,17 @@ def build_dataset(
# Print game outcome distribution
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
logger.info(f"\n{'Outcome':<25} {'Count':>8}")
logger.info("─" * 35)
log.info(f"\n{'Outcome':<25} {'Count':>8}")
log.info("─" * 35)
for o, c in sorted(zip(unique_outcomes, outcome_counts), key=lambda x: -x[1]):
logger.info(f" {str(o):<23} {c:>8}")
log.info(f" {str(o):<23} {c:>8}")
# Print playstyle distribution
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
logger.info(f"\n{'Playstyle':<25} {'Count':>8}")
logger.info("─" * 35)
log.info(f"\n{'Playstyle':<25} {'Count':>8}")
log.info("─" * 35)
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
logger.info(f" {str(s):<23} {c:>8}")
log.info(f" {str(s):<23} {c:>8}")
return {
"states": states,
@@ -288,7 +288,7 @@ def main():
max_samples=args.max_samples,
)
if not dataset:
logger.info("\nNo data to save. Run simulations with --ml-log first.")
log.info("\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")
logger.info("\nDataset build complete.")
log.info("\nDataset build complete.")
if __name__ == "__main__":
-47
View File
@@ -1,47 +0,0 @@
"""Pydantic models for Commander AI Lab API."""
from models.requests import (
StartRequest,
ImportUrlRequest,
ImportTextRequest,
MetaFetchRequest,
CreateDeckRequest,
UpdateDeckRequest,
AddDeckCardRequest,
PatchDeckCardRequest,
BulkAddRequest,
BulkAddRecommendedRequest,
DeckGenerationSourceConfig,
DeckGenerationRequest,
DeckGenV3Request,
DeckGenV3SubstituteRequest,
)
from models.responses import (
StartResponse,
StatusResponse,
DeckInfo,
GeneratedDeckCard,
)
__all__ = [
# Requests
"StartRequest",
"ImportUrlRequest",
"ImportTextRequest",
"MetaFetchRequest",
"CreateDeckRequest",
"UpdateDeckRequest",
"AddDeckCardRequest",
"PatchDeckCardRequest",
"BulkAddRequest",
"BulkAddRecommendedRequest",
"DeckGenerationSourceConfig",
"DeckGenerationRequest",
"DeckGenV3Request",
"DeckGenV3SubstituteRequest",
# Responses
"StartResponse",
"StatusResponse",
"DeckInfo",
"GeneratedDeckCard",
]
-175
View File
@@ -1,175 +0,0 @@
"""Pydantic request models for Commander AI Lab API."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel
class StartRequest(BaseModel):
decks: list[str]
numGames: int = 100
threads: int = 4
seed: Optional[int] = None
clock: int = 300
deckSources: Optional[list[Optional[dict]]] = None
useLearnedPolicy: bool = False
policyStyle: str = "midrange"
policyGreedy: bool = False
aiSimplified: bool = False
aiThinkTimeMs: int = -1
maxQueueDepth: int = -1
class ImportUrlRequest(BaseModel):
url: str
class ImportTextRequest(BaseModel):
text: str
commander: Optional[str] = None
class MetaFetchRequest(BaseModel):
commander: str
class CreateDeckRequest(BaseModel):
name: str
commander_scryfall_id: Optional[str] = ""
commander_name: Optional[str] = ""
color_identity: Optional[list] = []
strategy_tag: Optional[str] = ""
class UpdateDeckRequest(BaseModel):
name: Optional[str] = None
commander_scryfall_id: Optional[str] = None
commander_name: Optional[str] = None
color_identity: Optional[list] = None
strategy_tag: Optional[str] = None
class AddDeckCardRequest(BaseModel):
scryfall_id: str
card_name: Optional[str] = ""
quantity: Optional[int] = 1
is_commander: Optional[int] = 0
role_tag: Optional[str] = ""
class PatchDeckCardRequest(BaseModel):
quantity: Optional[int] = None
role_tag: Optional[str] = None
class BulkAddRequest(BaseModel):
cards: list[dict]
respect_ratios: Optional[bool] = False
class BulkAddRecommendedRequest(BaseModel):
source: str = "collection"
only_owned: Optional[bool] = True
respect_ratios: Optional[bool] = False
types: Optional[list[str]] = None
roles: Optional[list[str]] = None
class DeckGenerationSourceConfig(BaseModel):
use_archidekt: bool = True
use_edhrec: bool = True
use_moxfield: bool = False
use_mtggoldfish: bool = False
archidekt_url: Optional[str] = ""
moxfield_url: Optional[str] = ""
mtggoldfish_url: Optional[str] = ""
class DeckGenerationRequest(BaseModel):
commander_name: Optional[str] = ""
commander_scryfall_id: Optional[str] = ""
color_identity: Optional[list[str]] = None
sources: Optional[DeckGenerationSourceConfig] = None
target_land_count: int = 37
target_instant_count: int = 10
target_sorcery_count: int = 8
target_artifact_count: int = 10
target_enchantment_count: int = 8
target_creature_count: int = 25
target_planeswalker_count: int = 2
only_cards_in_collection: bool = False
allow_proxies: bool = True
deck_name: Optional[str] = ""
class DeckGenV3Request(BaseModel):
commander_name: str = ""
strategy: str = ""
target_bracket: int = 3
budget_usd: Optional[float] = None
budget_mode: str = "total"
omit_cards: list[str] = []
use_collection: bool = True
run_substitution: bool = True
model: Optional[str] = None
deck_name: Optional[str] = ""
# ══════════════════════════════════════════════════════════════
# Deckgen requests (moved from routes/deckgen.py)
# ══════════════════════════════════════════════════════════════
class DeckResearchRequest(BaseModel):
deck_id: int # Deck to research
goal: Optional[str] = "Identify weaknesses and suggest upgrades"
budget_usd: Optional[float] = None
omit_cards: Optional[list[str]] = []
use_collection: bool = True
class DeckGenerateAIRequest(BaseModel):
commander: str
budget_usd: Optional[float] = None
budget_mode: str = "total" # "total" or "per_card"
omit_cards: Optional[list[str]] = []
use_collection: bool = True
# ══════════════════════════════════════════════════════════════
# Coach requests (moved from routes/coach.py)
# ══════════════════════════════════════════════════════════════
class CoachRequestBody(BaseModel):
goals: Optional[dict] = None
class CoachChatMessage(BaseModel):
role: str # "user" or "assistant"
content: str
class CoachChatRequest(BaseModel):
deck_id: str
messages: list[dict] # conversation history [{role, content}]
goals: Optional[dict] = None
stream: Optional[bool] = False
class CoachApplyRequest(BaseModel):
session_id: str
deck_id: int # numeric deck ID in the DB
accepted_cuts: list[str] = [] # card names to remove
accepted_adds: list[str] = [] # card names to add
class CoachGoalsRequest(BaseModel):
target_power_level: Optional[int] = None # 1-10
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
class DeckGenV3SubstituteRequest(BaseModel):
card_name: str
substitute_name: str
-45
View File
@@ -1,45 +0,0 @@
"""Pydantic response models for Commander AI Lab API."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel
class StartResponse(BaseModel):
batchId: str
status: str = "started"
message: str = ""
class StatusResponse(BaseModel):
batchId: str = ""
running: bool = False
completed: int = 0
total: int = 0
threads: int = 0
elapsedMs: int = 0
error: Optional[str] = None
simsPerSec: float = 0.0
run_id: str = ""
games_completed: int = 0
total_games: int = 0
current_decks: list = []
class DeckInfo(BaseModel):
name: str
filename: str
class GeneratedDeckCard(BaseModel):
scryfall_id: str = ""
name: str = ""
type_line: str = ""
mana_cost: str = ""
cmc: float = 0
card_type: str = ""
roles: list[str] = []
source: str = "collection"
quantity: int = 1
image_url: str = ""
owned_qty: int = 0
is_proxy: bool = False
-101
View File
@@ -1,101 +0,0 @@
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
from __future__ import annotations
import json
import logging
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
log = logging.getLogger("commander_ai_lab.api")
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
class Config:
"""Runtime configuration -- set via CLI args or env vars."""
forge_jar: str = ""
forge_dir: str = ""
forge_decks_dir: str = ""
lab_jar: str = ""
precon_dir: str = ""
results_dir: str = "results"
port: int = 8080
ximilar_api_key: str = ""
pplx_api_key: str = ""
CFG = Config()
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
class BatchState:
def __init__(self, batch_id: str, total_games: int, threads: int):
self.batch_id = batch_id
self.total_games = total_games
self.threads = threads
self.completed_games = 0
self.running = True
self.start_time = datetime.now()
self.elapsed_ms = 0
self.result_path: Optional[str] = None
self.error: Optional[str] = None
self.process: Optional[subprocess.Popen] = None
self.log_lines: list = []
self.sims_per_sec: float = 0.0
active_batches: dict[str, BatchState] = {}
COMMANDER_META: dict = {}
BUILTIN_COMMANDERS: dict = {
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
}
def load_commander_meta() -> None:
"""Load commander meta mapping from file or fall back to builtins."""
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},
}
-7
View File
@@ -67,13 +67,6 @@
<version>3.45.1.0</version>
</dependency>
<!-- WebSocket server for Unity/Godot client -->
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.5.6</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
-6
View File
@@ -7,9 +7,3 @@ uvicorn[standard]>=0.23.0
pydantic>=2.0.0
httpx>=0.24.0
websockets>=11.0
# Deck Builder dependencies
requests>=2.31.0
beautifulsoup4>=4.12.0
ollama>=0.2.0
scrython>=2.0.0
+81 -54
View File
@@ -25,13 +25,12 @@ from __future__ import annotations
import json
import os
import time
import traceback
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from models.requests import CoachRequestBody, CoachChatMessage, CoachChatRequest, CoachApplyRequest, CoachGoalsRequest
from pydantic import BaseModel
from routes.shared import (
CFG,
@@ -59,14 +58,14 @@ def init_coach_service():
"""Initialize the coach service with LLM client and embeddings."""
global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
try:
from coach.llm_client import LLMClient
from coach.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 = LLMClient()
_coach_llm = LMStudioClient()
_coach_embeddings = MTGEmbeddingIndex()
# Try to load embeddings (non-blocking — download happens on first use)
@@ -87,28 +86,42 @@ def init_coach_service():
if llm_status.get("connected"):
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
else:
log_coach.warning(f" Coach LLM: Not connected (start Ollama on 192.168.0.240:11434)")
log_coach.warning(f" Coach LLM: Not connected (start LM Studio on 192.168.0.122:1234)")
# Initialize V3 Deck Generator (Ollama-based)
log_coach.info(" Coach: Service initialized")
# Initialize V3 Deck Generator (Perplexity)
_deck_gen_v3_error = None
try:
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
_deck_gen_v3 = DeckBuilderAdapter(
db_conn_factory=_get_db_conn,
model="gpt-oss:20b",
)
log_deckgen.info(" Deck Gen V3: Initialized (Ollama DeckBuilderAdapter)")
except ImportError as e:
_deck_gen_v3_error = f"Missing dependency: {e}"
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
_deck_gen_v3 = None
except Exception as e:
_deck_gen_v3_error = str(e)
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
_deck_gen_v3 = None
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}\n{traceback.format_exc()}")
log_coach.error(f" Coach: Failed to initialize: {e}")
_coach_service = None
@@ -173,6 +186,33 @@ async def coach_get_report(deck_id: str):
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."""
@@ -221,35 +261,21 @@ def _build_deck_report_from_db(deck_slug: str):
from coach.models import DeckReport, CardPerformance, DeckStructure
conn = _get_db_conn()
# --- Input validation ---
import re
if not deck_slug or len(deck_slug) > 200:
return None
deck_slug = re.sub(r'[^a-zA-Z0-9 _-]', '', deck_slug)
if not deck_slug:
return None
# Find the deck by slug match against the deck name
rows = conn.execute(
"SELECT id, name, commander_name, color_identity FROM decks ORDER BY id"
).fetchall()
# Find the deck by parameterized query instead of full-table scan
slug_lower = deck_slug.lower()
slug_hyphenated = slug_lower.replace(' ', '-')
clean_slug = re.sub(r'[^a-z0-9]+', '-', slug_lower).strip('-')
if slug_lower.isdigit():
# Numeric slug -- match by ID directly
matched_deck = conn.execute(
"SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
(int(slug_lower),)
).fetchone()
else:
# Try exact name match, hyphenated match, or clean-slug match
matched_deck = conn.execute(
"""SELECT id, name, commander_name, color_identity FROM decks
WHERE LOWER(name) = ?
OR LOWER(REPLACE(name, ' ', '-')) = ?
OR LOWER(REPLACE(name, ' ', '-')) = ?
LIMIT 1""",
(slug_lower, slug_hyphenated, clean_slug)
).fetchone()
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
@@ -425,7 +451,7 @@ async def coach_chat(body: CoachChatRequest):
try:
import json
from urllib.request import urlopen, Request as UrlRequest
from coach.config import LLM_URL, LLM_TIMEOUT
from coach.config import LM_STUDIO_URL, LM_STUDIO_TIMEOUT
model_name = _coach_llm._resolve_model()
llm_body = {
@@ -451,7 +477,7 @@ async def coach_chat(body: CoachChatRequest):
import http.client
import urllib.parse
parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions")
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LLM_TIMEOUT)
conn = 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()
@@ -500,7 +526,7 @@ async def coach_chat(body: CoachChatRequest):
import asyncio
loop = asyncio.get_event_loop()
def _call():
with urlopen(req, timeout=LLM_TIMEOUT) as resp:
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)
@@ -572,7 +598,6 @@ async def coach_apply_suggestions(body: CoachApplyRequest):
# Try Scryfall API lookup
try:
import urllib.parse
from urllib.request import urlopen, Request as UrlRequest
encoded = urllib.parse.quote(card_name)
scry_req = UrlRequest(
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
@@ -669,3 +694,5 @@ async def coach_generate_reports():
}
except Exception as e:
raise HTTPException(500, f"Report generation failed: {e}")
+490 -1
View File
@@ -18,8 +18,13 @@ from __future__ import annotations
import csv
import io
import json
import os
import re
from datetime import datetime
import sqlite3
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
@@ -315,6 +320,472 @@ async def update_collection_card(cardId: int, body: dict):
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
# ══════════════════════════════════════════════════════════════
@@ -485,3 +956,21 @@ async def 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],
}
+2 -2
View File
@@ -30,8 +30,6 @@ import time
from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.request import urlopen, Request
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
from fastapi.responses import JSONResponse
@@ -1087,6 +1085,7 @@ def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
Fuzzy-search Scryfall for a card name (used by the scanner pipeline).
Returns the raw Scryfall JSON dict, or None on failure.
"""
from urllib.parse import quote
_scryfall_rate_limit()
try:
encoded = quote(name)
@@ -1099,3 +1098,4 @@ def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
return data
except Exception:
return None
+165 -125
View File
@@ -29,11 +29,10 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.request import urlopen, Request
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from models.requests import DeckResearchRequest, DeckGenerateAIRequest
from pydantic import BaseModel
import routes.coach as _coach # for _deck_gen_v3 / _deck_gen_v3_error module-level access
from routes.shared import (
@@ -44,7 +43,6 @@ from routes.shared import (
DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest,
GeneratedDeckCard, DeckGenerationSourceConfig,
)
from services.deck_service import _write_dck_file, _build_dck_lines
router = APIRouter(tags=["deckgen"])
@@ -149,48 +147,37 @@ def _scryfall_to_commander(data: dict) -> dict:
def _get_collection_for_colors(color_identity: list) -> list:
"""Fetch collection cards whose color identity is a subset of *color_identity*.
Uses SQLite's json_each() to push the colour-subset check into SQL so we
only transfer the rows we actually need. The NOT EXISTS sub-query ensures
every colour symbol in the card's identity appears in the commander's
identity list. Cards with an empty colour identity (colourless / lands)
always pass because the NOT EXISTS has no rows to violate.
Only the columns consumed downstream are projected (no SELECT *).
"""
Fetch all collection cards compatible with the given color identity.
Returns list of dicts with card details.
"""
conn = _get_db_conn()
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
rows = conn.execute("""
SELECT scryfall_id, name, type_line, mana_cost, cmc,
oracle_text, keywords, quantity, edhrec_rank, color_identity
FROM collection_entries
WHERE quantity > 0
AND scryfall_id != ''
AND NOT EXISTS (
SELECT 1 FROM json_each(
CASE WHEN collection_entries.color_identity IS NULL
OR collection_entries.color_identity = ''
THEN '[]'
ELSE collection_entries.color_identity
END
) AS jc
WHERE jc.value NOT IN (
SELECT value FROM json_each(?)
)
)
""", (allowed_json,)).fetchall()
rows = conn.execute(
"SELECT * FROM collection_entries WHERE quantity > 0"
).fetchall()
valid = []
for row in rows:
card = dict(row)
ci = card.get("color_identity", "[]")
try:
card["color_identity"] = json.loads(ci) if isinstance(ci, str) else ci
except Exception:
card["color_identity"] = []
card = _row_to_dict(row)
card_ci = card.get("color_identity", [])
if isinstance(card_ci, str):
try:
card_ci = json.loads(card_ci)
except Exception:
card_ci = []
# Color identity check: all card colors must be within commander's identity
if color_identity and card_ci:
if not all(c in color_identity for c in card_ci):
continue
# Skip if no scryfall_id
if not card.get("scryfall_id"):
continue
card["color_identity"] = card_ci
valid.append(card)
return valid
@@ -553,8 +540,29 @@ async def deck_generator_commit(req: DeckGenerationRequest):
)
conn.commit()
# Also export a .dck file to the Forge decks directory so it appears in the Sim Lab
try:
_write_dck_file(deck_name, commander['name'], cards, fallback_id=deck_id)
decks_dir = CFG.forge_decks_dir
if decks_dir and os.path.isdir(decks_dir):
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_')
if not safe_name:
safe_name = f"AutoDeck_{deck_id}"
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
dck_lines = []
dck_lines.append("[metadata]")
dck_lines.append(f"Name={deck_name}")
dck_lines.append("")
dck_lines.append("[Commander]")
dck_lines.append(f"1 {commander['name']}")
dck_lines.append("")
dck_lines.append("[Main]")
for card in cards:
card_name = card.get("name", "")
if card_name:
dck_lines.append(f"{card.get('quantity', 1)} {card_name}")
with open(dck_path, "w", encoding="utf-8") as f:
f.write("\n".join(dck_lines))
log_deckgen.info(f" Exported .dck file: {dck_path}")
except Exception as e:
log_deckgen.warning(f" Warning: Failed to export .dck file: {e}")
@@ -647,44 +655,56 @@ async def deck_generator_commander_search(q: str = ""):
# ══════════════════════════════════════════════════════════════
class DeckResearchRequest(BaseModel):
deck_id: int # Deck to research
goal: Optional[str] = "Identify weaknesses and suggest upgrades"
budget_usd: Optional[float] = None
omit_cards: Optional[list[str]] = []
use_collection: bool = True
class DeckGenerateAIRequest(BaseModel):
commander: str
budget_usd: Optional[float] = None
budget_mode: str = "total" # "total" or "per_card"
omit_cards: Optional[list[str]] = []
use_collection: bool = True
def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
"""Build a compact collection summary for the AI, optionally filtered by color identity.
Uses the same json_each NOT EXISTS pattern as _get_collection_for_colors()
so that colour-identity filtering happens in SQL rather than Python.
"""
"""Build a compact collection summary for the AI, optionally filtered by color identity."""
conn = _get_db_conn()
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
# Base query: all collection entries with oracle text
where = "WHERE quantity > 0"
params = []
# SQL handles the colour-identity subset check via json_each
ci_filter = """
AND NOT EXISTS (
SELECT 1 FROM json_each(
CASE WHEN collection_entries.color_identity IS NULL
OR collection_entries.color_identity = ''
THEN '[]'
ELSE collection_entries.color_identity
END
) AS jc
WHERE jc.value NOT IN (
SELECT value FROM json_each(?)
)
)
""" if color_identity else ""
params = (allowed_json,) if color_identity else ()
# Filter by color identity if provided
if color_identity:
# Cards must only contain colors in the commander's identity (+ colorless)
ci_set = set(c.upper() for c in color_identity)
# We'll do a post-filter since color_identity is JSON in the DB
pass # filter in Python after fetch
rows = conn.execute(f"""
SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity,
color_identity, category, is_game_changer, salt_score
FROM collection_entries
WHERE quantity > 0
{ci_filter}
FROM collection_entries {where}
ORDER BY edhrec_rank ASC, tcg_price DESC
""", params).fetchall()
# Color identity filter
def card_fits_identity(card_ci_str, ci_set):
if not ci_set:
return True
try:
card_ci = json.loads(card_ci_str) if card_ci_str else []
if not isinstance(card_ci, list):
return True
return all(c.upper() in ci_set for c in card_ci if c.upper() not in ('', 'C'))
except Exception:
return True
# Role detection from type_line, oracle_text, and category
def detect_role(row):
tl = (row['type_line'] or '').lower()
@@ -726,9 +746,12 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
'ramp': [], 'card_draw': [], 'removal': [], 'board_wipes': [],
'lands': [], 'win_conditions': [], 'creatures': [], 'utility': []
}
filtered_count = len(rows) # SQL already filtered
filtered_count = 0
for r in rows:
if color_identity and not card_fits_identity(r['color_identity'], ci_set):
continue
filtered_count += 1
role = detect_role(r)
groups[role].append({
'name': r['name'],
@@ -766,57 +789,40 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
}
async def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
"""Call Perplexity API chat/completions endpoint (non-blocking).
Uses httpx.AsyncClient so the uvicorn event loop is never stalled.
Returns the assistant message content.
"""
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL
def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
"""Call Perplexity API chat/completions endpoint. Returns the assistant message content."""
if not CFG.pplx_api_key:
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
if DECK_GEN_PROVIDER == 'local':
base_url = DECK_GEN_BASE_URL
model = DECK_GEN_MODEL
headers = {'Content-Type': 'application/json'}
else:
if not CFG.pplx_api_key:
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
base_url = 'https://api.perplexity.ai'
model = 'sonar'
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {CFG.pplx_api_key}',
}
payload = {
'model': model,
'model': 'sonar',
'messages': messages,
'max_tokens': max_tokens,
'temperature': temperature,
'return_related_questions': False,
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f'{base_url}/chat/completions',
json=payload,
headers=headers,
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as e:
raise HTTPException(502, f'Perplexity API returned {e.response.status_code}: {e.response.text[:200]}')
except httpx.RequestError as e:
raise HTTPException(502, f'Perplexity API call failed: {e}')
choices = data.get('choices', [])
if not choices:
raise HTTPException(502, 'Empty response from Perplexity API')
content = choices[0].get('message', {}).get('content', '')
usage = data.get('usage', {})
log_pplx.debug(
f'tokens: prompt={usage.get("prompt_tokens", "?")}, '
f'completion={usage.get("completion_tokens", "?")}, '
f'model={data.get("model", "?")}'
)
return content
req_data = json.dumps(payload).encode('utf-8')
req = Request('https://api.perplexity.ai/chat/completions', data=req_data, method='POST')
req.add_header('Content-Type', 'application/json')
req.add_header('Authorization', f'Bearer {CFG.pplx_api_key}')
try:
with urlopen(req, timeout=120) as resp:
data = json.loads(resp.read())
choices = data.get('choices', [])
if not choices:
raise ValueError('Empty response from Perplexity API')
content = choices[0].get('message', {}).get('content', '')
usage = data.get('usage', {})
log_pplx.debug(f'tokens: prompt={usage.get("prompt_tokens","?")}, '
f'completion={usage.get("completion_tokens","?")}, '
f'model={data.get("model","?")}')
return content
except URLError as e:
raise HTTPException(502, f'Perplexity API call failed: {e}')
except json.JSONDecodeError as e:
raise HTTPException(502, f'Perplexity API returned invalid JSON: {e}')
def _extract_json_from_response(text: str) -> dict:
@@ -1076,7 +1082,7 @@ DECKLIST ({len(card_rows)} cards):
Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4 per Commander Rules Committee), ALL synergy packages between cards, ALL win conditions, game plan by phase (early/mid/late), mana base health, every role gap. Suggest specific cuts with severity and specific adds with priority and synergy tags. For adds, prioritize cards from the COLLECTION SUMMARY when available."""
content = await _call_pplx_api([
content = _call_pplx_api([
{'role': 'system', 'content': system_msg},
{'role': 'user', 'content': user_msg},
], max_tokens=8192)
@@ -1186,7 +1192,7 @@ COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'}
Build the deck as JSON. Prioritize collection cards when they fit the strategy."""
content = await _call_pplx_api([
content = _call_pplx_api([
{'role': 'system', 'content': system_msg},
{'role': 'user', 'content': user_msg},
], max_tokens=8192, temperature=0.3)
@@ -1218,6 +1224,7 @@ async def pplx_status():
"""Check if Perplexity API is configured."""
return {
'configured': bool(CFG.pplx_api_key),
'key_prefix': CFG.pplx_api_key[:8] + '...' if CFG.pplx_api_key else '',
}
@@ -1231,9 +1238,13 @@ async def deck_gen_v3_status():
return {
'initialized': _coach._deck_gen_v3 is not None,
'pplx_configured': bool(CFG.pplx_api_key),
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'pplx', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
'embeddings_loaded': False,
'embedding_cards': 0,
'model': _coach._deck_gen_v3.pplx.model if _coach._deck_gen_v3 else None,
'embeddings_loaded': (
_coach_embeddings.loaded if _coach_embeddings else False
),
'embedding_cards': (
_coach_embeddings.card_count if _coach_embeddings else 0
),
'error': _coach._deck_gen_v3_error,
}
@@ -1252,7 +1263,7 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
6. Return complete deck with substitution data
"""
if _coach._deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure Ollama is running with gpt-oss:20b model.')
raise HTTPException(503, 'V3 Deck Generator not initialized. Check PPLX_API_KEY.')
if not req.commander_name or len(req.commander_name.strip()) < 2:
raise HTTPException(400, 'Commander name is required (min 2 chars)')
@@ -1294,7 +1305,9 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
except ValueError as e:
return JSONResponse({'error': str(e)}, status_code=422)
except Exception as e:
log_deckgen.error(f'V3 generate error: {e}', exc_info=True)
log_deckgen.error(f'Error: {e}')
import traceback
traceback.print_exc()
raise HTTPException(500, f'Deck generation failed: {str(e)}')
@@ -1381,8 +1394,25 @@ async def deck_gen_v3_commit(req: DeckGenV3Request):
)
conn.commit()
# Export .dck for Forge
try:
_write_dck_file(deck_name, commander['name'], cards, fallback_id=deck_id, resolve_substitutes=True)
decks_dir = CFG.forge_decks_dir
if decks_dir and os.path.isdir(decks_dir):
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_')
if not safe_name:
safe_name = f"V3Deck_{deck_id}"
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
dck_lines = ["[metadata]", f"Name={deck_name}", "",
"[Commander]", f"1 {commander['name']}", "", "[Main]"]
for card in cards:
cname = card.get('name', '')
if card.get('status') == 'substituted' and card.get('selected_substitute'):
cname = card['selected_substitute']
if cname and cname != commander.get('name', ''):
dck_lines.append(f"{card.get('count', 1)} {cname}")
with open(dck_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(dck_lines))
log_deckgen.info(f" Exported .dck: {dck_path}")
except Exception as e:
log_deckgen.error(f" .dck export failed: {e}")
@@ -1467,12 +1497,22 @@ async def deck_gen_v3_export_dck(req: DeckGenV3Request):
cards = result.get('cards', [])
commander = result.get('commander', {})
lines = _build_dck_lines(
f"{commander.get('name', 'Deck')} - V3 Auto",
commander.get('name', ''),
cards,
resolve_substitutes=True,
)
lines = [
'[metadata]',
f'Name={commander.get("name", "Deck")} - V3 Auto',
'',
'[Commander]',
f'1 {commander.get("name", "")}',
'',
'[Main]',
]
for card in cards:
cname = card.get('name', '')
if card.get('status') == 'substituted' and card.get('selected_substitute'):
cname = card['selected_substitute']
if cname and cname != commander.get('name', ''):
lines.append(f"{card.get('count', 1)} {cname}")
dck_content = '\n'.join(lines)
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_')
return StreamingResponse(
+484 -546
View File
File diff suppressed because it is too large. Load diff
+5 -21
View File
@@ -19,7 +19,6 @@ from __future__ import annotations
import glob
import json
import math
import os
import re
import threading
@@ -39,6 +38,8 @@ from routes.shared import (
StartResponse,
StatusResponse,
AI_PROFILES,
PRECON_DIR,
PRECON_INDEX,
build_java_command,
run_batch_subprocess,
_run_deepseek_batch_thread,
@@ -53,19 +54,6 @@ from routes.shared import (
router = APIRouter(tags=["lab"])
def _sanitize_floats(obj):
"""Replace NaN/Infinity with None so JSONResponse (allow_nan=False) won't crash."""
if isinstance(obj, float):
if math.isnan(obj) or math.isinf(obj):
return None
return obj
if isinstance(obj, dict):
return {k: _sanitize_floats(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_sanitize_floats(v) for v in obj]
return obj
@router.post("/api/lab/start", response_model=StartResponse)
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
"""Start a new batch simulation run."""
@@ -176,12 +164,8 @@ async def get_result(batchId: Optional[str] = None):
raise HTTPException(500, state.error)
if not state.result_path or not os.path.exists(state.result_path):
raise HTTPException(500, "Result file not found")
try:
with open(state.result_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
raise HTTPException(500, f"Failed to read result file: {e}")
data = _sanitize_floats(data)
with open(state.result_path, "r", encoding="utf-8") as f:
data = json.load(f)
return JSONResponse(content=data)
@@ -457,5 +441,5 @@ async def start_batch_deepseek(request: FastAPIRequest, background_tasks: Backgr
# Global ML logging toggle (can be enabled via API)
_ml_logging_enabled = True
_ml_logging_enabled = False
-729
View File
@@ -1,729 +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 dataclasses
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 singletons ──────────────────────────────────────────────────
_policy_service = None
_policy_service_init_attempted = False
# ── Thread-safe state objects ────────────────────────────────────────────────
@dataclasses.dataclass
class _TrainingState:
running: bool = False
progress: int = 0
total_epochs: int = 0
current_epoch: int = 0
phase: str = "idle"
message: str = ""
metrics: object = None
result: object = None
error: object = None
started_at: object = None
def snapshot(self) -> dict:
return dataclasses.asdict(self)
@dataclasses.dataclass
class _PPOState:
running: bool = False
iteration: int = 0
total_iterations: int = 0
phase: str = "idle"
message: str = ""
metrics: object = None
result: object = None
error: object = None
def snapshot(self) -> dict:
return dataclasses.asdict(self)
@dataclasses.dataclass
class _TournamentState:
running: bool = False
phase: str = "idle"
message: str = ""
result: object = None
error: object = None
def snapshot(self) -> dict:
return dataclasses.asdict(self)
_training_state = _TrainingState()
_training_lock = threading.Lock()
_ppo_state = _PPOState()
_ppo_lock = threading.Lock()
_tournament_state = _TournamentState()
_tournament_lock = threading.Lock()
# 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 _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)
with _training_lock:
_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):
with _training_lock:
_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"])
with _training_lock:
_training_state.message = f"Dataset built: {total_samples} samples"
log_ml.info(f"Dataset built: {total_samples} samples")
# Phase 2: Train
with _training_lock:
_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)
with _training_lock:
_training_state.current_epoch = summary.get("epochs_trained", epochs)
_training_state.metrics = summary
# Phase 3: Evaluate
with _training_lock:
_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
with _training_lock:
_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
with _training_lock:
_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")
with _training_lock:
_training_state.running = True
_training_state.progress = 0
_training_state.total_epochs = epochs
_training_state.current_epoch = 0
_training_state.phase = "starting"
_training_state.message = "Initializing training pipeline..."
_training_state.metrics = None
_training_state.result = None
_training_state.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")
async def ml_training_status_poll():
"""Return current training status (polled by Unity client)."""
with _training_lock:
return _training_state.snapshot()
@router.get("/api/ml/train/status")
async def ml_training_status():
"""Get current training pipeline status."""
with _training_lock:
return _training_state.snapshot()
@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."""
try:
import sys
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
with _ppo_lock:
_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):
with _ppo_lock:
_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)
with _ppo_lock:
_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
with _ppo_lock:
_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", "")
with _ppo_lock:
_ppo_state.running = True
_ppo_state.iteration = 0
_ppo_state.total_iterations = iterations
_ppo_state.phase = "starting"
_ppo_state.message = "Initializing PPO..."
_ppo_state.metrics = None
_ppo_state.result = None
_ppo_state.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."""
with _ppo_lock:
return _ppo_state.snapshot()
def _run_tournament_pipeline(episodes: int, playstyle: str):
"""Run tournament in a background thread."""
try:
import sys
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
with _tournament_lock:
_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)
with _tournament_lock:
_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
with _tournament_lock:
_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")
with _tournament_lock:
_tournament_state.running = True
_tournament_state.phase = "starting"
_tournament_state.message = "Initializing tournament..."
_tournament_state.result = None
_tournament_state.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."""
with _tournament_lock:
return _tournament_state.snapshot()
@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."}
# ==============================================================
# Model listing and stats endpoints
# ==============================================================
@router.get("/api/ml/models")
async def ml_list_models():
"""List available policy model checkpoints."""
project_root = Path(__file__).resolve().parent.parent
ckpt_dir = project_root / "ml" / "models" / "checkpoints"
checkpoints = []
if ckpt_dir.exists():
for f in sorted(ckpt_dir.glob("*.pt")):
checkpoints.append({
"name": f.name,
"path": str(f),
"size_kb": round(f.stat().st_size / 1024, 1),
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
})
svc = _get_policy_service()
return {
"models": checkpoints,
"count": len(checkpoints),
"active_model": svc.get_status() if svc else None,
}
@router.get("/api/ml/stats")
async def ml_stats():
"""Return aggregate ML training statistics and model performance metrics."""
project_root = Path(__file__).resolve().parent.parent
ckpt_dir = project_root / "ml" / "models" / "checkpoints"
eval_results = None
eval_path = ckpt_dir / "eval_results.json"
if eval_path.exists():
try:
with open(eval_path, "r") as f:
eval_results = json.load(f)
except Exception:
pass
with _training_lock:
training_snapshot = _training_state.snapshot()
with _ppo_lock:
ppo_snapshot = _ppo_state.snapshot()
svc = _get_policy_service()
return {
"training": training_snapshot,
"ppo": ppo_snapshot,
"eval_results": eval_results,
"policy_loaded": svc._loaded if svc else False,
}
+7 -8
View File
@@ -16,8 +16,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
import routes.shared as _shared # module reference, not value import
from routes.shared import CFG, download_precon_database, log
from services.precon_service import _get_precon_dir
from routes.shared import CFG, PRECON_DIR, download_precon_database, log
router = APIRouter(tags=["precon"])
@@ -31,13 +30,13 @@ async def list_precons():
@router.post("/api/lab/precons/install")
async def install_precon(req: dict):
"""Install a precon deck to the Forge decks directory.
Body: {"fileName": "Elven_Empire.dck"} or {"name": "Elven_Empire.dck"}
Body: {"fileName": "Elven_Empire.dck"}
"""
file_name = req.get("fileName", "") or req.get("name", "")
file_name = req.get("fileName", "")
if not file_name:
raise HTTPException(400, "fileName is required")
src = _get_precon_dir() / file_name
src = PRECON_DIR / file_name
if not src.exists():
raise HTTPException(404, f"Precon not found: {file_name}")
@@ -60,9 +59,9 @@ async def install_precon(req: dict):
@router.post("/api/lab/precons/install-batch")
async def install_precons_batch(req: dict):
"""Install multiple precon decks at once.
Body: {"fileNames": ["Elven_Empire.dck", ...]} or {"names": ["Elven_Empire.dck", ...]}
Body: {"fileNames": ["Elven_Empire.dck", "Necron_Dynasties.dck"]}
"""
file_names = req.get("fileNames", []) or req.get("names", [])
file_names = req.get("fileNames", [])
if not file_names:
raise HTTPException(400, "fileNames list is required")
@@ -72,7 +71,7 @@ async def install_precons_batch(req: dict):
results = []
for file_name in file_names:
src = _get_precon_dir() / file_name
src = PRECON_DIR / file_name
if not src.exists():
results.append({"fileName": file_name, "installed": False, "error": "not found"})
continue
+1766 -65
View File
File diff suppressed because it is too large. Load diff
-222
View File
@@ -1,222 +0,0 @@
#!/usr/bin/env python3
"""
scripts/bench_deckgen.py
========================
Benchmark for _get_collection_for_colors() and _build_collection_summary().
Seeds an in-memory SQLite DB with randomised collection_entries rows,
then times the SQL-filtered query for various commander colour identities.
Usage:
python scripts/bench_deckgen.py [--rows 1000]
"""
from __future__ import annotations
import argparse
import json
import random
import sqlite3
import time
from typing import List
# ── Helpers ──────────────────────────────────────────────────────────────────
ALL_COLORS = ["W", "U", "B", "R", "G"]
CARD_TYPE_LINES = [
"Creature - Human Soldier",
"Creature - Elf Druid",
"Instant",
"Sorcery",
"Artifact",
"Enchantment",
"Land",
"Artifact Creature - Golem",
"Legendary Creature - Dragon",
"Planeswalker - Jace",
]
def _random_color_identity() -> list:
"""Return a random colour identity (including ~15% chance of colorless)."""
if random.random() < 0.15:
return []
n = random.randint(1, 3)
return sorted(random.sample(ALL_COLORS, n))
def _seed_db(conn: sqlite3.Connection, num_rows: int):
"""Create collection_entries table and insert random rows."""
conn.execute("""
CREATE TABLE IF NOT EXISTS collection_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type_line TEXT DEFAULT '',
color_identity TEXT DEFAULT '[]',
cmc REAL DEFAULT 0,
mana_cost TEXT DEFAULT '',
oracle_text TEXT DEFAULT '',
keywords TEXT DEFAULT '[]',
quantity INTEGER DEFAULT 1,
edhrec_rank INTEGER DEFAULT 0,
scryfall_id TEXT DEFAULT '',
tcg_price REAL DEFAULT 0,
salt_score REAL DEFAULT 0,
is_game_changer INTEGER DEFAULT 0,
category TEXT DEFAULT ''
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_ce_quantity
ON collection_entries (quantity) WHERE quantity > 0
""")
rows = []
for i in range(num_rows):
ci = _random_color_identity()
qty = random.choice([0, 1, 1, 1, 2, 3]) # ~17% zero quantity
rows.append((
f"Card_{i:05d}",
random.choice(CARD_TYPE_LINES),
json.dumps(ci),
round(random.uniform(0, 8), 1),
"",
f"Sample oracle text for card {i}",
json.dumps([]),
qty,
random.randint(1, 20000),
f"scryfall-{i:05d}" if random.random() > 0.02 else "",
round(random.uniform(0, 50), 2),
round(random.uniform(0, 5), 2),
1 if random.random() < 0.05 else 0,
random.choice(["", "ramp", "draw", "removal", ""]),
))
conn.executemany("""
INSERT INTO collection_entries
(name, type_line, color_identity, cmc, mana_cost, oracle_text,
keywords, quantity, edhrec_rank, scryfall_id, tcg_price,
salt_score, is_game_changer, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", rows)
conn.commit()
print(f" Seeded {num_rows} rows ({sum(1 for r in rows if r[7] > 0)} with qty > 0)")
# ── Query under test (same as routes/deckgen.py) ────────────────────────────
def get_collection_for_colors(conn: sqlite3.Connection, color_identity: list) -> list:
"""Mirrors the new SQL-filtered implementation."""
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
rows = conn.execute("""
SELECT scryfall_id, name, type_line, mana_cost, cmc,
oracle_text, keywords, quantity, edhrec_rank, color_identity
FROM collection_entries
WHERE quantity > 0
AND scryfall_id != ''
AND NOT EXISTS (
SELECT 1 FROM json_each(
CASE WHEN collection_entries.color_identity IS NULL
OR collection_entries.color_identity = ''
THEN '[]'
ELSE collection_entries.color_identity
END
) AS jc
WHERE jc.value NOT IN (
SELECT value FROM json_each(?)
)
)
""", (allowed_json,)).fetchall()
valid = []
for row in rows:
card = dict(row)
ci = card.get("color_identity", "[]")
try:
card["color_identity"] = json.loads(ci) if isinstance(ci, str) else ci
except Exception:
card["color_identity"] = []
valid.append(card)
return valid
def get_collection_for_colors_old(conn: sqlite3.Connection, color_identity: list) -> list:
"""Old implementation: SELECT * + Python-side filter."""
rows = conn.execute(
"SELECT * FROM collection_entries WHERE quantity > 0"
).fetchall()
valid = []
for row in rows:
card = dict(row)
card_ci = card.get("color_identity", "[]")
if isinstance(card_ci, str):
try:
card_ci = json.loads(card_ci)
except Exception:
card_ci = []
if color_identity and card_ci:
if not all(c in color_identity for c in card_ci):
continue
if not card.get("scryfall_id"):
continue
card["color_identity"] = card_ci
valid.append(card)
return valid
# ── Main ─────────────────────────────────────────────────────────────────────
COMMANDERS = [
("Mono-W", ["W"]),
("WUB", ["W", "U", "B"]),
("WUBRG", ["W", "U", "B", "R", "G"]),
("Colorless", []),
("Mono-G", ["G"]),
]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--rows", type=int, default=1000)
args = parser.parse_args()
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
_seed_db(conn, args.rows)
print(f"\n{'Commander':<14} {'Old ms':>8} {'New ms':>8} {'Old#':>6} {'New#':>6} {'Match':>6}")
print("-" * 60)
for label, ci in COMMANDERS:
# Old
t0 = time.perf_counter()
old_result = get_collection_for_colors_old(conn, ci)
old_ms = (time.perf_counter() - t0) * 1000
# New
t0 = time.perf_counter()
new_result = get_collection_for_colors(conn, ci)
new_ms = (time.perf_counter() - t0) * 1000
match = len(old_result) == len(new_result)
# NOTE: Colorless ([]) intentionally differs — old code had a bug that
# skipped the colour check entirely when color_identity was empty,
# returning ALL cards. The new SQL correctly restricts to colourless.
if not ci: # colorless commander
marker = "FIX" if len(new_result) < len(old_result) else "OK"
else:
marker = "OK" if match else "FAIL"
print(f" {label:<12} {old_ms:>7.2f} {new_ms:>7.2f} {len(old_result):>6} {len(new_result):>6} {marker:>6}")
if not match and ci: # only flag non-colorless mismatches
old_names = {c["name"] for c in old_result}
new_names = {c["name"] for c in new_result}
print(f" Only in old: {len(old_names - new_names)} cards")
print(f" Only in new: {len(new_names - old_names)} cards")
print()
if __name__ == "__main__":
main()
-1
View File
@@ -1 +0,0 @@
"""Services for Commander AI Lab."""
-81
View File
@@ -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
-304
View File
@@ -1,304 +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:
"""Return the thread-local SQLite connection, creating it on first use."""
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 _close_db_conn() -> None:
"""Close and discard the thread-local connection (call on thread teardown)."""
conn = getattr(_db_local, "conn", None)
if conn is not None:
try:
conn.close()
except Exception:
pass
_db_local.conn = None
def init_collection_db() -> None:
"""Create tables, indexes, and run column migrations.
Uses _get_db_conn() so the same thread-local connection that route
handlers will use is warmed up and fully migrated before any request
arrives. No separate connection is opened or closed here.
"""
conn = _get_db_conn()
conn.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_ce_quantity ON collection_entries(quantity)
WHERE quantity > 0;
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()
# ── Column migrations (additive only) ─────────────────────────────────
_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()
log_collect.info(f" Collection DB ready: {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
-265
View File
@@ -1,265 +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:
deck_id = deck_row[0]
rows = conn.execute("""
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
COALESCE(ce.mana_cost, '', '') as mana_cost,
COALESCE(ce.power, '', '') as power,
COALESCE(ce.toughness, '', '') as toughness,
COALESCE(ce.keywords, cr.keywords, '[]') as keywords
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost, power, toughness, keywords
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, oracle_text, cmc, color_identity, keywords
FROM card_records GROUP BY name
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
return [_row_to_dict(r) for r in rows]
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
def _build_dck_lines(
deck_name: str,
commander_name: str,
cards: list[dict],
*,
resolve_substitutes: bool = False,
) -> list[str]:
"""Build Forge .dck file lines from deck name, commander, and card list."""
lines: list[str] = [
"[metadata]",
f"Name={deck_name}",
"",
"[Commander]",
f"1 {commander_name}",
"",
"[Main]",
]
for card in cards:
cname = card.get("name", "")
if resolve_substitutes:
if card.get("status") == "substituted" and card.get("selected_substitute"):
cname = card["selected_substitute"]
if cname and cname != commander_name:
lines.append(f"{card.get('count', card.get('quantity', 1))} {cname}")
return lines
def _write_dck_file(
deck_name: str,
commander_name: str,
cards: list[dict],
decks_dir: str | None = None,
*,
fallback_id: int | str = 0,
resolve_substitutes: bool = False,
) -> str | None:
"""Write a Forge .dck file to decks_dir. Returns path on success, None otherwise."""
if not decks_dir:
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
return None
safe_name = re.sub(r"[^a-zA-Z0-9_\-\s]", "", deck_name).strip().replace(" ", "_")
if not safe_name:
safe_name = f"Deck_{fallback_id}"
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
lines = _build_dck_lines(
deck_name, commander_name, cards,
resolve_substitutes=resolve_substitutes,
)
with open(dck_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
log_deckgen.info(f" Exported .dck: {dck_path}")
return dck_path
-591
View File
@@ -1,591 +0,0 @@
"""Forge batch simulation runner: Java discovery, command building, process management."""
import asyncio
import json
import logging
import os
import re
import subprocess
import sys
import threading
import time
from datetime import datetime as _datetime
from pathlib import Path
from typing import Optional
from models.state import CFG, BatchState
from services.deck_service import parse_dck_file, _load_deck_cards_by_name
log = logging.getLogger("lab.batch")
log_sim = logging.getLogger("commander_ai_lab.sim")
# Global DeepSeek brain instance (lazy-initialized)
_deepseek_brain = None
_deepseek_lock = threading.Lock()
# ── Global watchdog: single thread monitors all active batches ──
_watchdog_registry: dict[str, dict] = {} # batch_id -> {state, proc, last_activity}
_watchdog_lock = threading.Lock()
_watchdog_started = False
_STALL_LIMIT = 600 # 10 min
def _global_watchdog_loop():
"""Single daemon thread that checks all active batches for stalls."""
while True:
time.sleep(5)
with _watchdog_lock:
finished = []
for bid, entry in _watchdog_registry.items():
proc = entry["proc"]
if proc.poll() is not None:
finished.append(bid)
continue
if (time.monotonic() - entry["last_activity"]) > _STALL_LIMIT:
log.warning(f"Stall detected for batch {bid}, killing process")
entry["state"].log_lines.append("[WATCHDOG] Process stalled. Killed.")
proc.kill()
entry["state"].running = False
entry["state"].error = "Stall detected (10 min no output)"
finished.append(bid)
for bid in finished:
del _watchdog_registry[bid]
def _ensure_watchdog_started():
global _watchdog_started
if not _watchdog_started:
with _watchdog_lock:
if not _watchdog_started:
t = threading.Thread(target=_global_watchdog_loop, daemon=True)
t.start()
_watchdog_started = True
def _register_batch(batch_id: str, state, proc):
_ensure_watchdog_started()
with _watchdog_lock:
_watchdog_registry[batch_id] = {
"state": state, "proc": proc, "last_activity": time.monotonic()
}
def _touch_batch(batch_id: str):
with _watchdog_lock:
if batch_id in _watchdog_registry:
_watchdog_registry[batch_id]["last_activity"] = time.monotonic()
def _unregister_batch(batch_id: str):
with _watchdog_lock:
_watchdog_registry.pop(batch_id, None)
# ── Java 17 Discovery ───────────────────────────────────────────────
# Ordered list of directories to scan for JDK 17 installations.
# Covers: Eclipse Temurin, Oracle, Amazon Corretto, Microsoft, Azul Zulu, SAP.
_JAVA17_SEARCH_DIRS = [
r'C:\Program Files\Eclipse Adoptium',
r'C:\Program Files\Java',
r'C:\Program Files\Amazon Corretto',
r'C:\Program Files\Microsoft',
r'C:\Program Files\Zulu',
r'C:\Program Files\BellSoft',
r'C:\Program Files\SapMachine',
]
def _find_java17() -> str:
"""
Search well-known JDK install directories for a Java 17 binary.
Falls back to JAVA17_HOME env var, then JAVA_HOME, then warns and returns 'java'.
Returns the absolute path to java.exe if found, otherwise 'java'.
"""
# 1. Explicit override via env var
env_override = os.environ.get('JAVA17_HOME', '').strip()
if env_override:
candidate = os.path.join(env_override, 'bin', 'java.exe')
if os.path.isfile(candidate):
log.info(f"Java 17 found via JAVA17_HOME: {candidate}")
return candidate
log.warning(f"JAVA17_HOME set to '{env_override}' but java.exe not found there.")
# 2. Scan vendor directories
for d in _JAVA17_SEARCH_DIRS:
if not os.path.isdir(d):
continue
try:
children = os.listdir(d)
except OSError:
continue
for child in sorted(children): # sorted = deterministic, picks lowest 17.x
if re.match(r'jdk-?17', child, re.IGNORECASE) or re.match(r'temurin-17', child, re.IGNORECASE):
candidate = os.path.join(d, child, 'bin', 'java.exe')
if os.path.isfile(candidate):
log.info(f"Java 17 found: {candidate}")
return candidate
# 3. JAVA_HOME fallback — only accept if it IS version 17
java_home = os.environ.get('JAVA_HOME', '').strip()
if java_home:
candidate = os.path.join(java_home, 'bin', 'java.exe')
if os.path.isfile(candidate):
try:
result = subprocess.run(
[candidate, '-version'],
capture_output=True, text=True, timeout=5
)
version_output = result.stderr or result.stdout
if 'version "17' in version_output:
log.info(f"Java 17 found via JAVA_HOME: {candidate}")
return candidate
else:
log.warning(
f"JAVA_HOME points to '{java_home}' but it is not Java 17. "
f"Version output: {version_output.strip()!r}"
)
except Exception as e:
log.warning(f"Could not probe JAVA_HOME java version: {e}")
# 4. Nothing found — warn loudly so the log surfaces the issue
log.error(
"Java 17 not found! Forge simulation requires Java 17. "
"Install Eclipse Temurin 17 (winget install EclipseAdoptium.Temurin.17.JDK) "
"or set the JAVA17_HOME environment variable to your JDK 17 install directory. "
"Falling back to system 'java' which may be the wrong version."
)
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 reset_java17_cache():
"""Force re-discovery of Java 17 path (useful after installing JDK at runtime)."""
global _JAVA17_PATH
_JAVA17_PATH = None
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()
lab_jar = CFG.lab_jar
forge_jar = CFG.forge_jar
forge_dir = CFG.forge_dir
if not lab_jar or not os.path.isfile(lab_jar):
log.error(f"Lab JAR not found at '{lab_jar}'. Run 'mvn package' first.")
raise FileNotFoundError(f"Lab JAR not found: {lab_jar}")
cmd = [
java17, '-jar', lab_jar,
'--forge-jar', forge_jar,
'--forge-dir', forge_dir,
'--games', str(num_games),
'--threads', str(threads),
'--clock', str(clock),
'--output', output_path,
]
# Map deck list to --deck1 .. --deck4
for i, d in enumerate(decks[:4], start=1):
cmd += [f'--deck{i}', str(d)]
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)}")
# Ensure Forge subprocesses use Java 17
env = os.environ.copy()
java17 = get_java17()
if java17 != 'java':
java17_bin = os.path.dirname(java17)
java17_home = os.path.dirname(java17_bin)
env['JAVA_HOME'] = java17_home
env['PATH'] = java17_bin + os.pathsep + env.get('PATH', '')
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, env=env,
)
state.process = proc
except Exception as e:
state.running = False
state.error = str(e)
log.error(f"Failed to start subprocess: {e}")
return
_register_batch(state.batch_id, state, proc)
for line in proc.stdout:
_touch_batch(state.batch_id)
line = line.rstrip()
state.log_lines.append(line)
gm = re.match(r'\[Game (\d+)/(\d+)\]', line)
if gm:
state.completed_games = int(gm.group(1))
state.total_games = int(gm.group(2))
pm = re.match(r'\[PROGRESS\].*?(\d+\.?\d*)\s*sims/sec', line)
if pm:
state.sims_per_sec = float(pm.group(1))
proc.wait()
_unregister_batch(state.batch_id)
elapsed = (_datetime.now() - state.start_time).total_seconds() * 1000
state.elapsed_ms = int(elapsed)
if proc.returncode != 0:
state.running = False
# Include last few log lines in error for easier diagnosis
tail = ' | '.join(state.log_lines[-5:]) if state.log_lines else '(no output)'
state.error = f"Exit code {proc.returncode} — last output: {tail}"
else:
state.running = False
state.completed_games = state.total_games
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,
policy_style: str = "midrange",
policy_greedy: bool = False,
ai_simplified: bool = False,
ai_think_time_ms: int = -1,
max_queue_depth: int = -1,
):
"""Async wrapper that runs Forge subprocess in executor."""
try:
policy_server = f"http://localhost:{CFG.port}"
cmd = build_java_command(
decks, num_games, threads, seed, clock, output_path,
use_learned_policy=use_learned_policy,
policy_server=policy_server,
policy_style=policy_style,
policy_greedy=policy_greedy,
ai_simplified=ai_simplified,
ai_think_time_ms=ai_think_time_ms,
max_queue_depth=max_queue_depth,
)
log.info(f"Starting batch {state.batch_id}: {' '.join(cmd[:6])}...")
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _run_process_blocking, state, cmd)
except Exception as e:
state.error = str(e)
state.running = False
log.error(f"Batch {state.batch_id} ERROR: {e}")
def _get_deepseek_brain():
"""Get or create the global DeepSeek brain instance."""
global _deepseek_brain
if _deepseek_brain is None:
with _deepseek_lock:
if _deepseek_brain is None:
try:
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
src_dir = os.path.normpath(src_dir)
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
cfg = DeepSeekConfig(
api_base=os.environ.get("BRAIN_API_BASE", "http://localhost:11434"),
model=os.environ.get("BRAIN_MODEL", "gpt-oss:20b"),
temperature=float(os.environ.get("BRAIN_TEMPERATURE", "0.3")),
max_tokens=int(os.environ.get("BRAIN_MAX_TOKENS", "1024")),
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "30.0")),
)
cfg.log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'logs', 'decisions')
cfg.log_dir = os.path.normpath(cfg.log_dir)
_deepseek_brain = DeepSeekBrain(cfg)
except Exception as e:
log_sim.error(f'Failed to initialize brain: {e}')
return None
return _deepseek_brain
def _run_deepseek_batch_thread(
state: BatchState,
deck_names: list,
num_games: int,
output_path: str,
):
"""Run batch simulation using Python sim engine + DeepSeek AI opponent."""
try:
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
src_dir = os.path.normpath(src_dir)
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
from commander_ai_lab.sim.rules import enrich_card
state.log_lines.append('[DeepSeek Batch] Initializing DeepSeek brain...')
brain = _get_deepseek_brain()
if brain and not brain._connected:
brain.check_connection()
if not brain or not brain._connected:
state.error = 'DeepSeek LLM not connected. Go to Simulator > DeepSeek and connect first.'
state.running = False
return
state.log_lines.append(f'[DeepSeek Batch] Connected to {brain.config.model}')
deck_win_rates = {}
try:
results_dir = CFG.results_dir
if os.path.isdir(results_dir):
for fname in sorted(os.listdir(results_dir), reverse=True):
if fname.startswith('batch-') and fname.endswith('.json'):
try:
with open(os.path.join(results_dir, fname), 'r') as rf:
past = json.loads(rf.read())
for dd in past.get('decks', []):
dname = dd.get('deckName', '')
wr = dd.get('winRate')
if dname and wr is not None and dname not in deck_win_rates:
deck_win_rates[dname] = float(wr)
except Exception:
pass
except Exception:
pass
loaded_decks = {}
deck_meta = {}
for dn in deck_names:
raw_cards = _load_deck_cards_by_name(dn)
if not raw_cards:
state.log_lines.append(f'[DeepSeek Batch] WARNING: Could not load deck "{dn}", skipping.')
continue
commander_name = ''
color_identity_set = set()
deck_objs = []
for cd in raw_cards:
c = Card(name=(cd.get('card_name') or cd.get('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
if cd.get('is_commander'):
c.is_commander = True
commander_name = (cd.get('card_name') or cd.get('name', ''))
ci_str = cd.get('color_identity', '')
if ci_str:
try:
ci_parsed = json.loads(ci_str) if isinstance(ci_str, str) else ci_str
if isinstance(ci_parsed, list):
for color in ci_parsed:
color_identity_set.add(color)
except Exception:
pass
enrich_card(c)
deck_objs.append(c)
creature_count = sum(1 for c in deck_objs if c.is_creature())
removal_count = sum(1 for c in deck_objs if c.is_removal)
ramp_count = sum(1 for c in deck_objs if c.is_ramp)
avg_cmc = sum(c.cmc or 0 for c in deck_objs if not c.is_land()) / max(sum(1 for c in deck_objs if not c.is_land()), 1)
oracle_all = ' '.join((c.oracle_text or '').lower() for c in deck_objs)
has_combo_text = any(kw in oracle_all for kw in ['you win the game', 'infinite', 'extra turn'])
if has_combo_text:
archetype = 'combo'
elif creature_count >= 30 and avg_cmc <= 2.8:
archetype = 'aggro'
elif removal_count >= 10 or (creature_count <= 18 and avg_cmc >= 3.2):
archetype = 'control'
else:
archetype = 'midrange'
color_identity_list = sorted(list(color_identity_set))
deck_meta[dn] = {
'commander_name': commander_name,
'color_identity': color_identity_list,
'archetype': archetype,
'win_rate': deck_win_rates.get(dn),
}
loaded_decks[dn] = deck_objs
cmdr_info = f' (Commander: {commander_name})' if commander_name else ''
wr_info = f' [History: {deck_win_rates[dn]:.0f}% WR]' if dn in deck_win_rates else ''
state.log_lines.append(f'[DeepSeek Batch] Loaded deck "{dn}" ({len(deck_objs)} cards, {archetype}){cmdr_info}{wr_info}')
if not loaded_decks:
state.error = 'No decks could be loaded.'
state.running = False
return
deck_list = list(loaded_decks.keys())
games_per_deck = max(1, num_games // len(deck_list))
total_games = games_per_deck * len(deck_list)
state.total_games = total_games
state.log_lines.append(f'[DeepSeek Batch] Running {games_per_deck} games per deck \u00d7 {len(deck_list)} decks = {total_games} total')
engine = DeepSeekGameEngine(
brain=brain,
ai_player_index=0,
max_turns=25,
record_log=True,
ml_log=True,
)
start_time = time.time()
all_deck_results = []
completed = 0
for deck_name in deck_list:
deck_a = loaded_decks[deck_name]
from commander_ai_lab.lab.experiments import _generate_training_deck
deck_stats = {
'deckName': deck_name,
'wins': 0, 'losses': 0, 'totalGames': games_per_deck,
'totalTurns': 0, 'totalDamageDealt': 0, 'totalDamageReceived': 0,
'totalSpellsCast': 0, 'totalCreaturesPlayed': 0,
'games': [],
}
meta = deck_meta.get(deck_name, {})
dk_archetype = meta.get('archetype', 'midrange')
dk_commander = meta.get('commander_name', '')
dk_colors = meta.get('color_identity', [])
dk_win_rate = meta.get('win_rate')
for g in range(games_per_deck):
try:
deck_b = _generate_training_deck()
game_id = f'ds-{state.batch_id}-{deck_name[:12]}-g{g+1}'
result = engine.run(
deck_a, deck_b,
name_a=deck_name + ' (AI)',
name_b='Training Opponent',
game_id=game_id, archetype=dk_archetype,
commander_name=dk_commander,
color_identity=dk_colors,
win_rate=dk_win_rate,
)
gd = result.to_dict()
gd['gameNumber'] = g + 1
deck_stats['games'].append(gd)
if result.winner == 0:
deck_stats['wins'] += 1
else:
deck_stats['losses'] += 1
deck_stats['totalTurns'] += result.turns
if result.player_a_stats:
deck_stats['totalDamageDealt'] += result.player_a_stats.damage_dealt
deck_stats['totalDamageReceived'] += result.player_a_stats.damage_received
deck_stats['totalSpellsCast'] += result.player_a_stats.spells_cast
deck_stats['totalCreaturesPlayed'] += result.player_a_stats.creatures_played
state.log_lines.append(
f'[Game {completed + 1}/{total_games}] {deck_name} (AI-piloted) \u2192 '
f'{"WIN" if result.winner == 0 else "LOSS"} (turn {result.turns})'
)
except Exception as ge:
state.log_lines.append(f'[Game {completed + 1}/{total_games}] ERROR: {ge}')
deck_stats['games'].append({'error': str(ge), 'gameNumber': g + 1})
completed += 1
state.completed_games = completed
n = deck_stats['totalGames']
deck_stats['winRate'] = round(deck_stats['wins'] / n * 100, 1) if n > 0 else 0.0
deck_stats['avgTurns'] = round(deck_stats['totalTurns'] / n, 1) if n > 0 else 0.0
deck_stats['avgDamageDealt'] = round(deck_stats['totalDamageDealt'] / n, 1) if n > 0 else 0.0
deck_stats['avgDamageReceived'] = round(deck_stats['totalDamageReceived'] / n, 1) if n > 0 else 0.0
deck_stats['avgSpellsCast'] = round(deck_stats['totalSpellsCast'] / n, 1) if n > 0 else 0.0
deck_stats['avgCreaturesPlayed'] = round(deck_stats['totalCreaturesPlayed'] / n, 1) if n > 0 else 0.0
deck_stats['archetype'] = dk_archetype
deck_stats['commander'] = dk_commander
deck_stats['colorIdentity'] = dk_colors
if dk_win_rate is not None:
deck_stats['priorWinRate'] = dk_win_rate
all_deck_results.append(deck_stats)
elapsed = time.time() - start_time
ds_stats = brain.get_stats() if brain else {}
batch_result = {
'metadata': {
'batchId': state.batch_id,
'timestamp': _datetime.now().isoformat(),
'completedGames': completed,
'threads': 1,
'elapsedMs': int(elapsed * 1000),
'engine': 'deepseek',
'model': brain.config.model if brain else 'unknown',
},
'decks': all_deck_results,
'deepseekStats': ds_stats,
}
ml_decisions = engine.flush_ml_decisions()
if ml_decisions:
ml_jsonl_path = os.path.join(CFG.results_dir, f'ml-decisions-ds-{state.batch_id}.jsonl')
os.makedirs(os.path.dirname(ml_jsonl_path) or '.', exist_ok=True)
with open(ml_jsonl_path, 'w', encoding='utf-8') as mf:
for dec in ml_decisions:
mf.write(json.dumps(dec) + '\n')
state.log_lines.append(f'[ML Data] Wrote {len(ml_decisions)} decision snapshots to {os.path.basename(ml_jsonl_path)}')
else:
state.log_lines.append('[ML Data] No decision snapshots captured (0 decisions)')
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(batch_result, f, indent=2, default=str)
state.result_path = output_path
state.elapsed_ms = int(elapsed * 1000)
state.running = False
state.completed_games = total_games
state.log_lines.append(f'[DeepSeek Batch] Complete: {completed} games in {elapsed:.1f}s')
for ds in all_deck_results:
state.log_lines.append(f' {ds["deckName"]}: {ds["wins"]}W-{ds["losses"]}L ({ds["winRate"]}% WR)')
log_sim.info(f'Batch {state.batch_id} complete: {completed} games in {elapsed:.1f}s')
except Exception as e:
import traceback
state.error = str(e)
state.running = False
state.log_lines.append(f'[DeepSeek Batch] FATAL: {e}')
traceback.print_exc()
-193
View File
@@ -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()}
-110
View File
@@ -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
-230
View File
@@ -1,230 +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")
# Fallback default — used only when CFG.precon_dir has not been resolved yet.
_DEFAULT_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
_SCRYFALL_COLLECTION_URL = "https://api.scryfall.com/cards/collection"
def _get_precon_dir() -> Path:
"""Return the active precon-decks directory from CFG, with fallback."""
from models.state import CFG
if CFG.precon_dir:
return Path(CFG.precon_dir)
return _DEFAULT_PRECON_DIR
# Re-export for backwards compat — callers that import PRECON_DIR at module
# level will get the default. Runtime code should call _get_precon_dir().
PRECON_DIR = _DEFAULT_PRECON_DIR
def load_precon_index():
"""Load precon index from disk into the shared PRECON_INDEX list.
We mutate the list in-place (clear + extend) so that any module that
captured a reference via ``from precon_service import PRECON_INDEX``
will see the updated contents.
"""
precon_dir = _get_precon_dir()
idx_path = precon_dir / "precon-index.json"
if idx_path.exists():
with open(idx_path, "r", encoding="utf-8") as f:
data = json.load(f)
PRECON_INDEX.clear()
PRECON_INDEX.extend(data)
log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
else:
PRECON_INDEX.clear()
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:
"""Download precon database from GitHub and populate PRECON_INDEX.
We mutate the list in-place (clear + extend) so that any module that
captured a reference via ``from precon_service import PRECON_INDEX``
will see the updated contents.
"""
precon_dir = _get_precon_dir()
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:
# Invalidate cache if missing enrichment fields (imageUrl, colors)
has_enrichment = existing[0].get("imageUrl") or existing[0].get("colors")
age_hours = (time.time() - idx_path.stat().st_mtime) / 3600
if has_enrichment and age_hours < PRECON_CACHE_HOURS:
log.info(f" Precons: {len(existing)} decks cached ({age_hours:.0f}h old)")
PRECON_INDEX.clear()
PRECON_INDEX.extend(existing)
return {"downloaded": 0, "skipped": True, "total": len(existing), "error": None}
if not has_enrichment:
log.info(" Precons: Cache missing enrichment data, re-downloading...")
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 []
# Build Scryfall art_crop image URL from first commander's set/number
image_url = ""
if commanders:
c0 = commanders[0]
sc = (c0.get('set_code') or '').lower()
num = c0.get('number', '')
if sc and num:
image_url = f"https://api.scryfall.com/cards/{sc}/{num}?format=image&version=art_crop"
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,
"imageUrl": image_url,
})
# Enrich with color identity from Scryfall (best-effort)
_enrich_colors(index)
with open(idx_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=2, ensure_ascii=False)
PRECON_INDEX.clear()
PRECON_INDEX.extend(index)
log.info(f" Precons: {written} .dck files written, index saved")
return {"downloaded": written, "skipped": False, "total": written, "error": None}
def _enrich_colors(index: list[dict]) -> None:
"""Batch-fetch color_identity for commanders via Scryfall /cards/collection.
Scryfall accepts up to 75 identifiers per request. We look up each unique
commander name once, then propagate the result to every index entry.
"""
# Build unique commander -> identifiers map
cmdr_lookup: dict[str, dict] = {} # name -> {"name": ...}
for entry in index:
name = entry.get("commander", "")
if name and name != "Unknown" and name not in cmdr_lookup:
cmdr_lookup[name] = {"name": name}
if not cmdr_lookup:
return
names = list(cmdr_lookup.keys())
color_map: dict[str, list[str]] = {} # commander name -> ["W","U",...]
# Batch in chunks of 75
for i in range(0, len(names), 75):
batch = [{"name": n} for n in names[i:i + 75]]
try:
payload = json.dumps({"identifiers": batch}).encode("utf-8")
req = Request(
_SCRYFALL_COLLECTION_URL,
data=payload,
headers={
"Content-Type": "application/json",
"User-Agent": "CommanderAILab/3.0",
},
method="POST",
)
with urlopen(req, timeout=30) as resp:
result = json.loads(resp.read())
for card in result.get("data", []):
cname = card.get("name", "")
ci = card.get("color_identity", [])
if cname and ci:
color_map[cname] = ci
# Handle double-faced names: "A // B" -> also store "A"
if " // " in cname:
front = cname.split(" // ")[0]
color_map[front] = ci
# Respect Scryfall rate limit (100ms between requests)
time.sleep(0.15)
except Exception as e:
log.warning(f" Precons: Scryfall color lookup failed for batch {i}: {e}")
# Apply colors to index entries
enriched = 0
for entry in index:
name = entry.get("commander", "")
if name in color_map:
entry["colors"] = color_map[name]
enriched += 1
log.info(f" Precons: Enriched {enriched}/{len(index)} decks with color identity")
-216
View File
@@ -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,18 +0,0 @@
"""
Commander AI Deck Builder
~~~~~~~~~~~~~~~~~~~~~~~~~
Top-level package for the deck builder module.
Usage::
from commander_ai_lab.deck_builder import build_deck, BuildRequest
result = build_deck(BuildRequest(commander_name="Atraxa, Praetors' Voice"))
"""
__version__ = "0.1.0"
from .pipeline import build_deck
from .core.models import BuildRequest, BuildResult
__all__ = ["build_deck", "BuildRequest", "BuildResult", "__version__"]
@@ -1,238 +0,0 @@
"""
Adapter: bridges the new Ollama deck_builder pipeline to the legacy
DeckGeneratorV3 dict interface expected by lab_api.py endpoints.
"""
from __future__ import annotations
import logging
import sqlite3
from collections import Counter
from types import SimpleNamespace
from typing import Any, Callable, Dict, List, Optional
from .pipeline.build_deck import build_deck
from .core.models import BuildRequest, BuildResult, DeckRatios
logger = logging.getLogger("commander_ai_lab.deckgen")
class DeckBuilderAdapter:
"""
Drop-in replacement for DeckGeneratorV3.
Wraps the Ollama-based build_deck pipeline and returns
the same dict shape the existing FastAPI endpoints expect.
"""
def __init__(
self,
db_conn_factory: Callable[[], sqlite3.Connection] = None,
model: str = "gpt-oss:20b",
):
self.db_conn_factory = db_conn_factory
self.model = model
# -- public API (matches DeckGeneratorV3.generate_deck signature) --
def generate_deck(
self,
commander_name: str,
strategy: str = "",
target_bracket: int = 3,
budget_usd: Optional[float] = None,
budget_mode: str = "total",
omit_cards: Optional[List[str]] = None,
use_collection: bool = True,
model: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""
Run the 7-step Ollama pipeline and return a dict that matches
the shape expected by /api/deck/v3/generate.
"""
request = BuildRequest(
commander_name=commander_name,
strategy_notes=strategy or None,
collection_only=use_collection,
budget_limit=budget_usd,
ratios=DeckRatios(),
)
result: BuildResult = build_deck(request)
deck = result.deck
commander = deck.commander
color_identity = sorted(commander.color_identity)
cards_with_status = self._check_ownership(
[self._card_to_dict(c) for c in deck.cards]
)
cards_with_status = self._fill_basic_lands(
cards_with_status, color_identity, target_total=100
)
stats = self._compute_stats(cards_with_status)
raw_deck = {
"commander": commander.name,
"strategy_summary": strategy or f"{commander.name} synergy deck",
"archetype": strategy or "synergy",
"bracket": {"level": target_bracket},
"reasoning": {
"strategy": f"Built with local Ollama ({self.model})",
"sources": result.sources_consulted,
"warnings": result.warnings,
},
"estimated_total_usd": 0,
"cards": [self._card_to_raw(c) for c in deck.cards],
}
return {
"commander": {
"name": commander.name,
"type_line": commander.type_line or "Legendary Creature",
"color_identity": color_identity,
"mana_cost": commander.mana_cost or "",
"cmc": commander.cmc,
"scryfall_id": commander.scryfall_id,
},
"color_identity": color_identity,
"raw_deck": raw_deck,
"cards": cards_with_status,
"stats": stats,
"strategy_summary": raw_deck["strategy_summary"],
"bracket": raw_deck["bracket"],
"archetype": raw_deck["archetype"],
"reasoning": raw_deck["reasoning"],
"estimated_total_usd": 0,
"tokens_used": {"prompt": 0, "completion": 0},
"model": self.model,
"citations": [],
"truncated": False,
"build_time_seconds": result.build_time_seconds,
}
# -- substitution stub --
def run_substitution(self, cards, commander: str = "", strategy: str = "", **kw):
"""
No-op substitution -- Ollama pipeline handles collection internally.
Returns a SimpleNamespace matching the shape DeckGeneratorV3 returns.
"""
owned = sum(
1 for c in cards
if getattr(c, "owned", False)
or (isinstance(c, dict) and c.get("owned"))
)
return SimpleNamespace(
cards=cards,
owned_count=owned,
substituted_count=0,
missing_count=len(cards) - owned,
)
# -- helpers --
@staticmethod
def _card_to_dict(card) -> Dict[str, Any]:
return {
"name": card.name,
"count": card.quantity,
"category": card.category,
"mana_cost": card.mana_cost or "",
"cmc": card.cmc,
"type_line": card.type_line or "",
"color_identity": sorted(card.color_identity),
"scryfall_id": card.scryfall_id,
"edhrec_rank": card.edhrec_rank,
"source": card.source or "ollama",
}
@staticmethod
def _card_to_raw(card) -> Dict[str, Any]:
return {
"name": card.name,
"count": card.quantity,
"category": card.category,
"reasoning": "",
}
def _check_ownership(self, cards: List[Dict]) -> List[Dict]:
if not self.db_conn_factory:
for c in cards:
c["owned"] = False
c["owned_qty"] = 0
c["from_collection"] = False
c.setdefault("estimated_price_usd", 0)
return cards
try:
conn = self.db_conn_factory()
for card in cards:
name = card.get("name", "")
if not name:
continue
row = conn.execute(
"SELECT name, scryfall_id, quantity, tcg_price "
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
(name,),
).fetchone()
if row:
card["owned"] = True
card["owned_qty"] = row[2] if row else 0
card["from_collection"] = True
card["scryfall_id"] = card.get("scryfall_id") or row[1]
card["estimated_price_usd"] = float(row[3]) if row[3] else 0
else:
card["owned"] = False
card["owned_qty"] = 0
card["from_collection"] = False
card.setdefault("estimated_price_usd", 0)
except Exception as e:
logger.warning("Collection check failed: %s", e)
for c in cards:
c.setdefault("owned", False)
c.setdefault("owned_qty", 0)
c.setdefault("from_collection", False)
c.setdefault("estimated_price_usd", 0)
return cards
@staticmethod
def _fill_basic_lands(
cards: List[Dict], color_identity: List[str], target_total: int = 100
) -> List[Dict]:
current = sum(c.get("count", 1) for c in cards)
needed = target_total - current
if needed <= 0:
return cards
ci_to_basic = {"W": "Plains", "U": "Island", "B": "Swamp", "R": "Mountain", "G": "Forest"}
basics = [ci_to_basic[c] for c in color_identity if c in ci_to_basic] or ["Wastes"]
for i in range(needed):
land_name = basics[i % len(basics)]
existing = next((c for c in cards if c["name"] == land_name), None)
if existing:
existing["count"] = existing.get("count", 1) + 1
else:
cards.append({
"name": land_name, "count": 1, "category": "lands",
"mana_cost": "", "cmc": 0, "type_line": "Basic Land",
"color_identity": [], "owned": False, "owned_qty": 0,
"from_collection": False, "estimated_price_usd": 0,
"source": "auto-fill",
})
return cards
@staticmethod
def _compute_stats(cards: List[Dict]) -> Dict[str, Any]:
total = sum(c.get("count", 1) for c in cards)
categories = Counter()
cmc_total = 0.0
nonland_count = 0
for c in cards:
count = c.get("count", 1)
cat = c.get("category", "uncategorized")
categories[cat] += count
if cat != "lands":
cmc_total += c.get("cmc", 0) * count
nonland_count += count
avg_cmc = round(cmc_total / nonland_count, 2) if nonland_count else 0
return {
"total_cards": total,
"category_breakdown": dict(categories),
"average_cmc": avg_cmc,
"land_count": categories.get("lands", 0),
"nonland_count": nonland_count,
}
@@ -1,13 +0,0 @@
"""API subpackage – external service clients (Scryfall, EDHRec, Ollama)."""
from . import scryfall
from . import edhrec
from . import ollama_client
from .community_scraper import CommunityScraper
__all__ = [
"scryfall",
"edhrec",
"ollama_client",
"CommunityScraper",
]
@@ -1,136 +0,0 @@
"""
Community scraper – pull popular decklists from Moxfield, Archidekt, and Reddit.
This is a Phase-3 expansion module. Currently provides stub implementations
that return empty results. Wire up real scraping logic as the project matures.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import requests
logger = logging.getLogger(__name__)
MOXFIELD_API = "https://api2.moxfield.com/v3/decks/search"
ARCHIDEKT_API = "https://archidekt.com/api/decks/"
@dataclass
class CommunityDeck:
"""Minimal representation of a community-sourced decklist."""
source: str # "moxfield" | "archidekt" | "reddit"
title: str
url: str
card_names: List[str] = field(default_factory=list)
author: Optional[str] = None
class CommunityScraper:
"""Aggregate community decklists for a given commander."""
def __init__(self, timeout: int = 15):
self.timeout = timeout
self._session = requests.Session()
self._session.headers.update({"User-Agent": "CommanderAILab/0.1"})
# ------------------------------------------------------------------
# Moxfield
# ------------------------------------------------------------------
def search_moxfield(
self, commander_name: str, *, max_results: int = 10
) -> List[CommunityDeck]:
"""Search Moxfield for public decks featuring *commander_name*."""
try:
resp = self._session.get(
MOXFIELD_API,
params={
"q": commander_name,
"fmt": "commander",
"sort": "views",
"sortDirection": "descending",
"pageSize": max_results,
},
timeout=self.timeout,
)
resp.raise_for_status()
data = resp.json()
decks: List[CommunityDeck] = []
for item in data.get("data", []):
pub_id = item.get("publicId", "")
decks.append(
CommunityDeck(
source="moxfield",
title=item.get("name", ""),
url=f"https://www.moxfield.com/decks/{pub_id}",
author=item.get("createdByUser", {}).get("userName"),
)
)
logger.info("Moxfield returned %d decks for %s", len(decks), commander_name)
return decks
except Exception:
logger.warning("Moxfield search failed for %s", commander_name, exc_info=True)
return []
# ------------------------------------------------------------------
# Archidekt
# ------------------------------------------------------------------
def search_archidekt(
self, commander_name: str, *, max_results: int = 10
) -> List[CommunityDeck]:
"""Search Archidekt for public decks featuring *commander_name*."""
try:
resp = self._session.get(
ARCHIDEKT_API,
params={
"commanders": commander_name,
"formats": 3, # Commander
"orderBy": "-viewCount",
"pageSize": max_results,
},
timeout=self.timeout,
)
resp.raise_for_status()
data = resp.json()
decks: List[CommunityDeck] = []
for item in data.get("results", []):
deck_id = item.get("id", "")
decks.append(
CommunityDeck(
source="archidekt",
title=item.get("name", ""),
url=f"https://archidekt.com/decks/{deck_id}",
author=item.get("owner", {}).get("username"),
)
)
logger.info("Archidekt returned %d decks for %s", len(decks), commander_name)
return decks
except Exception:
logger.warning("Archidekt search failed for %s", commander_name, exc_info=True)
return []
# ------------------------------------------------------------------
# Aggregate
# ------------------------------------------------------------------
def search_all(
self, commander_name: str, *, max_per_source: int = 10
) -> List[CommunityDeck]:
"""Search all community sources and return combined results."""
results: List[CommunityDeck] = []
results.extend(self.search_moxfield(commander_name, max_results=max_per_source))
results.extend(self.search_archidekt(commander_name, max_results=max_per_source))
return results
def extract_popular_cards(
self, decks: List[CommunityDeck], *, min_frequency: int = 2
) -> Dict[str, int]:
"""Count card frequency across decks; return cards seen >= min_frequency."""
counts: Dict[str, int] = {}
for deck in decks:
for name in deck.card_names:
counts[name] = counts.get(name, 0) + 1
return {k: v for k, v in counts.items() if v >= min_frequency}
@@ -1,180 +0,0 @@
"""
EDHrec JSON client for the Commander AI Deck Builder.
Fetches commander recommendations, top cards, and synergy data
from EDHrec's public JSON API (json.edhrec.com).
"""
from __future__ import annotations
import logging
import re
from typing import Dict, List, Optional
import requests
from ..core.models import CardEntry
logger = logging.getLogger(__name__)
BASE_URL = "https://json.edhrec.com/pages"
HEADERS = {
"User-Agent": "CommanderAI-DeckBuilder/0.1 (github.com/KoalaTrapLord/commander-ai-lab)",
}
def _slugify(name: str) -> str:
"""Convert a commander name to an EDHrec URL slug."""
slug = name.lower().strip()
slug = re.sub(r"[',.]", "", slug)
slug = re.sub(r"\s+", "-", slug)
return slug
def _get_json(path: str) -> Optional[Dict]:
"""Fetch JSON from EDHrec, returning None on failure."""
url = f"{BASE_URL}/{path}.json"
try:
resp = requests.get(url, headers=HEADERS, timeout=15)
resp.raise_for_status()
return resp.json()
except (requests.HTTPError, requests.ConnectionError, ValueError) as e:
logger.warning(f"EDHrec fetch failed for {url}: {e}")
return None
# ── commander page ───────────────────────────────────────────────
def get_commander_data(commander_name: str) -> Optional[Dict]:
"""
Fetch the full EDHrec commander page JSON.
Returns the raw dict with keys like 'cardlists', 'container', etc.
"""
slug = _slugify(commander_name)
return _get_json(f"commanders/{slug}")
def get_recommended_cards(commander_name: str) -> List[CardEntry]:
"""
Get EDHrec's recommended cards for a commander.
Returns CardEntry objects with edhrec_rank populated from
the 'num_decks' / inclusion percentage.
"""
data = get_commander_data(commander_name)
if not data:
return []
cards: List[CardEntry] = []
cardlists = data.get("container", {}).get("json_dict", {}).get("cardlists", [])
for cardlist in cardlists:
header = cardlist.get("header", "uncategorized").lower()
category = _map_edhrec_category(header)
for card_data in cardlist.get("cardviews", []):
name = card_data.get("name", "")
if not name:
continue
cards.append(
CardEntry(
name=name,
category=category,
color_identity=set(card_data.get("color_identity", [])),
cmc=card_data.get("cmc", 0.0),
type_line=card_data.get("type_line"),
edhrec_rank=card_data.get("num_decks"),
source="edhrec",
)
)
return cards
# ── top cards by color identity ──────────────────────────────────
def get_top_cards_by_colors(colors: str) -> List[CardEntry]:
"""
Fetch top EDHrec cards for a color combination.
Args:
colors: e.g. "wub", "rg", "wubrg", "colorless"
"""
data = _get_json(f"commanders/{colors.lower()}")
if not data:
return []
cards: List[CardEntry] = []
card_list = data.get("container", {}).get("json_dict", {}).get("cardlists", [])
for cardlist in card_list:
for card_data in cardlist.get("cardviews", []):
name = card_data.get("name", "")
if name:
cards.append(
CardEntry(
name=name,
color_identity=set(card_data.get("color_identity", [])),
edhrec_rank=card_data.get("num_decks"),
source="edhrec",
)
)
return cards
# ── average deck ─────────────────────────────────────────────────
def get_average_deck(commander_name: str) -> List[CardEntry]:
"""
Fetch the EDHrec 'average deck' for a commander.
This is the most common 99 cards people play with this commander.
"""
slug = _slugify(commander_name)
data = _get_json(f"average-decks/{slug}")
if not data:
return []
cards: List[CardEntry] = []
deck_data = data.get("container", {}).get("json_dict", {})
for card_data in deck_data.get("cardlists", [{}]):
for cv in card_data.get("cardviews", []):
name = cv.get("name", "")
if name:
cards.append(
CardEntry(
name=name,
color_identity=set(cv.get("color_identity", [])),
edhrec_rank=cv.get("num_decks"),
source="edhrec",
)
)
return cards
# ── category mapper ──────────────────────────────────────────────
def _map_edhrec_category(header: str) -> str:
"""Map EDHrec section headers to our deck categories."""
header = header.lower()
mapping = {
"ramp": "ramp",
"mana": "ramp",
"land": "lands",
"lands": "lands",
"draw": "card_draw",
"card draw": "card_draw",
"card advantage": "card_draw",
"removal": "removal",
"interaction": "removal",
"board wipe": "removal",
"wipe": "removal",
"protection": "protection",
"counter": "protection",
"win": "wincon",
"finisher": "wincon",
"combo": "wincon",
}
for keyword, category in mapping.items():
if keyword in header:
return category
return "synergy"
@@ -1,326 +0,0 @@
"""
Ollama gpt-oss:20b client for the Commander AI Deck Builder.
Uses the OpenAI-compatible API exposed by Ollama at localhost:11434.
Provides 6 core functions:
1. analyze_synergies - Rank card candidates by synergy with commander
2. suggest_cards - Suggest cards for a specific category
3. filter_color_identity - Validate cards against commander colors
4. enforce_deck_ratios - Adjust card counts to hit ratio targets
5. assemble_deck_json - Produce final structured 99-card JSON
6. chat - Raw chat completion for ad-hoc queries
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional
from openai import OpenAI
logger = logging.getLogger(__name__)
# Ollama exposes an OpenAI-compatible endpoint
DEFAULT_BASE_URL = "http://localhost:11434/v1"
DEFAULT_MODEL = "gpt-oss:20b"
def _get_client(base_url: str = DEFAULT_BASE_URL) -> OpenAI:
"""Create an OpenAI client pointing at the local Ollama server."""
return OpenAI(base_url=base_url, api_key="ollama")
def _extract_json(raw: str) -> Any:
"""Best-effort extraction of JSON from possibly messy LLM output.
Handles:
- Markdown code fences (```json ... ```)
- Leading/trailing prose around JSON
- Trailing commas before } or ]
"""
text = raw.strip()
# Strip markdown fences
if "```" in text:
match = re.search(r"```(?:json)?\s*\n?(.*?)```", text, re.DOTALL)
if match:
text = match.group(1).strip()
# Try direct parse first
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Remove trailing commas (common LLM error)
cleaned = re.sub(r",\s*([}\]])", r"\1", text)
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
# Try to find the first { or [ and last } or ]
for start_char, end_char in [("{", "}"), ("[", "]")]:
start = text.find(start_char)
end = text.rfind(end_char)
if start != -1 and end != -1 and end > start:
substring = text[start : end + 1]
substring = re.sub(r",\s*([}\]])", r"\1", substring)
try:
return json.loads(substring)
except json.JSONDecodeError:
continue
raise json.JSONDecodeError("No valid JSON found in response", text, 0)
def chat(
messages: List[Dict[str, str]],
model: str = DEFAULT_MODEL,
temperature: float = 0.7,
max_tokens: int = 4096,
json_mode: bool = False,
) -> str:
"""
Send a chat completion request to Ollama.
Returns the assistant's response as a string.
"""
client = _get_client()
kwargs: Dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
response = client.chat.completions.create(**kwargs)
return response.choices[0].message.content or ""
# ── 1. Analyze synergies ─────────────────────────────────────────
def analyze_synergies(
commander_name: str,
commander_text: str,
candidates: List[str],
strategy_notes: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Rank candidate cards by synergy with the commander.
Returns a list of dicts: [{"name": str, "score": int, "reason": str}, ...]
Score is 1-10.
"""
strategy = f"\nStrategy notes: {strategy_notes}" if strategy_notes else ""
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
Commander: {commander_name}
Commander text: {commander_text}
{strategy}
Rate each of these cards on a 1-10 synergy scale with this commander.
Return ONLY a JSON array of objects with keys: "name", "score", "reason".
Cards to evaluate:
{json.dumps(candidates)}"""
messages = [
{"role": "system", "content": "You are an expert MTG Commander deck builder. Always respond with valid JSON only. No explanation text."},
{"role": "user", "content": prompt},
]
raw = chat(messages, json_mode=True)
try:
result = _extract_json(raw)
if isinstance(result, dict) and "cards" in result:
return result["cards"]
if isinstance(result, list):
return result
return []
except json.JSONDecodeError:
logger.error("Failed to parse synergy response: %s", raw[:500])
return []
# ── 2. Suggest cards for a category ──────────────────────────────
def suggest_cards(
commander_name: str,
color_identity: List[str],
category: str,
count: int = 10,
exclude: Optional[List[str]] = None,
strategy_notes: Optional[str] = None,
) -> List[str]:
"""
Ask the model to suggest cards for a specific deck category.
Returns a list of card names.
"""
exclude_str = f"\nDo NOT suggest these cards: {json.dumps(exclude)}" if exclude else ""
strategy = f"\nStrategy notes: {strategy_notes}" if strategy_notes else ""
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
Commander: {commander_name}
Color identity: {', '.join(color_identity)}
Category: {category}
{strategy}
{exclude_str}
Suggest exactly {count} cards for the {category} category that work well with this commander.
Only suggest cards that are legal in Commander and match the color identity.
Return ONLY a JSON array of card name strings, like: ["Card A", "Card B", ...]"""
messages = [
{"role": "system", "content": "You are an expert MTG Commander deck builder. Respond with ONLY a JSON array of strings. No other text."},
{"role": "user", "content": prompt},
]
raw = chat(messages, json_mode=True)
logger.debug("suggest_cards raw response: %s", raw[:500])
try:
result = _extract_json(raw)
if isinstance(result, dict):
# Handle {"cards": [...]} or {"suggestions": [...]} etc.
for key in ("cards", "suggestions", "card_names", "names"):
if key in result and isinstance(result[key], list):
return [str(c) for c in result[key]]
# If dict has a single list value, use it
for v in result.values():
if isinstance(v, list):
return [str(c) for c in v]
if isinstance(result, list):
return [str(c) for c in result]
return []
except json.JSONDecodeError:
logger.error("Failed to parse suggestion response: %s", raw[:500])
return []
# ── 3. Filter by color identity ──────────────────────────────────
def filter_color_identity(
card_names: List[str],
commander_colors: List[str],
) -> List[str]:
"""
Ask the model to filter out cards that violate color identity.
Returns only the cards that are legal for the commander.
"""
prompt = f"""You are a Magic: The Gathering rules expert.
Commander color identity: {', '.join(commander_colors)}
From this list, return ONLY the cards whose color identity is a subset of the commander's.
Remove any card that has mana symbols or color identity outside these colors.
Return a JSON array of valid card name strings.
Cards:
{json.dumps(card_names)}"""
messages = [
{"role": "system", "content": "You are an MTG rules expert. Respond with ONLY a JSON array. No other text."},
{"role": "user", "content": prompt},
]
raw = chat(messages, json_mode=True)
try:
result = _extract_json(raw)
if isinstance(result, dict):
for key in ("cards", "valid_cards", "legal_cards"):
if key in result and isinstance(result[key], list):
return [str(c) for c in result[key]]
for v in result.values():
if isinstance(v, list):
return [str(c) for c in v]
if isinstance(result, list):
return [str(c) for c in result]
return card_names # fallback: return all
except json.JSONDecodeError:
return card_names
# ── 4. Enforce deck ratios ───────────────────────────────────────
def enforce_deck_ratios(
cards_by_category: Dict[str, List[str]],
target_ratios: Dict[str, int],
commander_name: str,
) -> Dict[str, List[str]]:
"""
Ask the model to trim/expand categories to hit target counts.
Returns adjusted dict of category -> card names.
"""
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
Commander: {commander_name}
Current cards by category:
{json.dumps(cards_by_category, indent=2)}
Target card counts per category:
{json.dumps(target_ratios, indent=2)}
Adjust each category to match the target count. Remove the weakest cards from
over-filled categories. For under-filled categories, suggest additional cards.
The total across all categories must be exactly 99.
Return a JSON object with the same category keys, each mapping to an array of card name strings."""
messages = [
{"role": "system", "content": "You are an expert MTG Commander deck builder. Respond with ONLY a JSON object. No other text."},
{"role": "user", "content": prompt},
]
raw = chat(messages, json_mode=True, max_tokens=8192)
try:
result = _extract_json(raw)
if isinstance(result, dict):
return {k: [str(c) for c in v] for k, v in result.items() if isinstance(v, list)}
return cards_by_category
except json.JSONDecodeError:
logger.error("Failed to parse ratio response: %s", raw[:500])
return cards_by_category
# ── 5. Assemble final deck JSON ──────────────────────────────────
def assemble_deck_json(
commander_name: str,
cards_by_category: Dict[str, List[str]],
) -> Dict[str, Any]:
"""
Ask the model to produce the final structured deck JSON.
Returns a dict matching the CommanderDeck schema.
"""
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
Commander: {commander_name}
Final card selections by category:
{json.dumps(cards_by_category, indent=2)}
Produce the final deck as a JSON object with this structure:
{{
"commander": {{"name": "...", "category": "commander"}},
"cards": [
{{"name": "...", "category": "...", "quantity": 1}},
...
]
}}
The "cards" array must have exactly 99 entries (one per card, quantity 1 each).
Use the category from the input grouping for each card.
Return ONLY the JSON object."""
messages = [
{"role": "system", "content": "You are an expert MTG Commander deck builder. Respond with ONLY a JSON object. No other text."},
{"role": "user", "content": prompt},
]
raw = chat(messages, json_mode=True, max_tokens=16384)
try:
return _extract_json(raw)
except json.JSONDecodeError:
logger.error("Failed to parse deck JSON: %s", raw[:500])
return {}
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more