Compare commits
263
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c34cb0966 | ||
|
|
c67ce422d8 | ||
|
|
cb4b48e362 | ||
|
|
4a8db4db6b | ||
|
|
9c0fa64a69 | ||
|
|
c338f16965 | ||
|
|
a7ab0e43a5 | ||
|
|
8b527d7c30 | ||
|
|
36760dd922 | ||
|
|
963a5db638 | ||
|
|
b1996a8deb | ||
|
|
c44d0b32bb | ||
|
|
a78df75014 | ||
|
|
9020d29745 | ||
|
|
b0a34ae9e2 | ||
|
|
4c4f5ce0ff | ||
|
|
1e7f4b4e31 | ||
|
|
488575f3df | ||
|
|
fd7f0258f3 | ||
|
|
7284b8617d | ||
|
|
678cc13fe0 | ||
|
|
19f83a546b | ||
|
|
c53e82bfeb | ||
|
|
f8b13703b8 | ||
|
|
eb59a2cd2e | ||
|
|
ff6040b53e | ||
|
|
db7bc62d7e | ||
|
|
fbffe81846 | ||
|
|
9369cf5450 | ||
|
|
e38401a740 | ||
|
|
111764615d | ||
|
|
2088da6fd8 | ||
|
|
a2fbc30b2b | ||
|
|
a62640668b | ||
|
|
b8e12dff1b | ||
|
|
ea0a785174 | ||
|
|
9f1277f233 | ||
|
|
66ce887fe3 | ||
|
|
80f63f192a | ||
|
|
46ed2f5606 | ||
|
|
93a421b90a | ||
|
|
ee95f26bc4 | ||
|
|
2ee574f678 | ||
|
|
473a2bdcc7 | ||
|
|
a6e50ee0f0 | ||
|
|
e2a6a75fd9 | ||
|
|
030bdf2b81 | ||
|
|
c83f7e0141 | ||
|
|
e5377ef4bc | ||
|
|
56ea2602b4 | ||
|
|
d86109c5c0 | ||
|
|
d9dd098ca3 | ||
|
|
69ce5b340e | ||
|
|
103a74a0e1 | ||
|
|
147121582b | ||
|
|
8be96ab47d | ||
|
|
e2653b3f72 | ||
|
|
7efac34279 | ||
|
|
b00df14f30 | ||
|
|
3542efd982 | ||
|
|
1f93b5510d | ||
|
|
5c31be6023 | ||
|
|
76139477be | ||
|
|
0e5d7ba7d3 | ||
|
|
4ec6ed57d5 | ||
|
|
08d81a4336 | ||
|
|
f6ed40a253 | ||
|
|
9fd39e42c7 | ||
|
|
72107df2ae | ||
|
|
2738bbdba4 | ||
|
|
0bbc78ef23 | ||
|
|
c2b470e251 | ||
|
|
6135feb644 | ||
|
|
b3b664872b | ||
|
|
a80e8908e1 | ||
|
|
a8ef10d5b7 | ||
|
|
86af4b6de5 | ||
|
|
332606d6b5 | ||
|
|
70dfa3d3e0 | ||
|
|
f970b6d571 | ||
|
|
5ade916207 | ||
|
|
cc53c58344 | ||
|
|
3e64aa67eb | ||
|
|
70a7c07b81 | ||
|
|
1b4e9f5a66 | ||
|
|
c962d1f16a | ||
|
|
dcdf15c56a | ||
|
|
b0214009f4 | ||
|
|
5fe0ac2bd6 | ||
|
|
fc09249ad3 | ||
|
|
d2c7783ce1 | ||
|
|
0ccf35f241 | ||
|
|
9a5105b0d6 | ||
|
|
6a688d1cd0 | ||
|
|
d9639a8414 | ||
|
|
633fd43b28 | ||
|
|
45c72aadc9 | ||
|
|
81ab7ac34a | ||
|
|
e0fe4144ae | ||
|
|
68aa6eac3f | ||
|
|
c870dd35fb | ||
|
|
35ce67ed6f | ||
|
|
232750a951 | ||
|
|
46c1c8cb01 | ||
|
|
64a4cd3c7c | ||
|
|
3f6b66fce3 | ||
|
|
2887c09550 | ||
|
|
84d84a211f | ||
|
|
12f90dd276 | ||
|
|
0b755c8428 | ||
|
|
50f36f284c | ||
|
|
369cc9243f | ||
|
|
22d3b64c2d | ||
|
|
beb3dac4d5 | ||
|
|
330801107c | ||
|
|
95c5a93584 | ||
|
|
377a704a90 | ||
|
|
cc481bd71b | ||
|
|
c6d02fd38c | ||
|
|
0f2d3583ac | ||
|
|
2b355ed9ba | ||
|
|
b4d8c2f50e | ||
|
|
9db1ed112f | ||
|
|
75568bfdb1 | ||
|
|
d834247885 | ||
|
|
310a408509 | ||
|
|
ef7bde841f | ||
|
|
87abfd801e | ||
|
|
8eba39cca7 | ||
|
|
bc9656a59f | ||
|
|
86b96c09f9 | ||
|
|
9a46b0f80c | ||
|
|
0d2da18783 | ||
|
|
6a84708c65 | ||
|
|
9b892e5289 | ||
|
|
f2a782f0bd | ||
|
|
da67879491 | ||
|
|
18beb9192e | ||
|
|
ce80b6d9c5 | ||
|
|
d8e355de2b | ||
|
|
9d2a043d3f | ||
|
|
85224f082e | ||
|
|
c6c3bfabf8 | ||
|
|
c6329d377a | ||
|
|
3f7f40d626 | ||
|
|
bc023ab6f6 | ||
|
|
83a8f288b7 | ||
|
|
e3f0787c73 | ||
|
|
3802bc3d78 | ||
|
|
2936fd37eb | ||
|
|
cad2f00ec7 | ||
|
|
6b50c6ee7d | ||
|
|
40a166188a | ||
|
|
a501a9755d | ||
|
|
2d8a55fdc6 | ||
|
|
b0d0b75dbb | ||
|
|
94b20f1347 | ||
|
|
c980e1b6d5 | ||
|
|
7c7be013c2 | ||
|
|
14eb72aaff | ||
|
|
81a1bc9022 | ||
|
|
6534380f67 | ||
|
|
1ee55d4428 | ||
|
|
432a16e87d | ||
|
|
c5a1f65e13 | ||
|
|
a95ae45e18 | ||
|
|
ed255c1a21 | ||
|
|
9899da9b98 | ||
|
|
86ae69112f | ||
|
|
1cc0b696d3 | ||
|
|
b248be867b | ||
|
|
2a9b043a24 | ||
|
|
9863f7590b | ||
|
|
9436276b69 | ||
|
|
d06618031a | ||
|
|
58c6908b85 | ||
|
|
682c4191f9 | ||
|
|
2add53d3c2 | ||
|
|
918d760dcb | ||
|
|
18ddd3027b | ||
|
|
a82a7d4dc6 | ||
|
|
d5fdc150f7 | ||
|
|
173a7f4f3b | ||
|
|
a988779bb4 | ||
|
|
7901fef495 | ||
|
|
d21928da53 | ||
|
|
9b1654a81e | ||
|
|
2435e67a6e | ||
|
|
69fe6cbffd | ||
|
|
3fe3ba0e62 | ||
|
|
643529e874 | ||
|
|
9746a41178 | ||
|
|
b0f48cde62 | ||
|
|
ec9719659a | ||
|
|
729283c3bd | ||
|
|
52dcf95c59 | ||
|
|
4f5135e85b | ||
|
|
ff3b134cb9 | ||
|
|
1c85733743 | ||
|
|
a71abfa7d0 | ||
|
|
abb1e08249 | ||
|
|
57b0598717 | ||
|
|
292801d445 | ||
|
|
71c90a588f | ||
|
|
5550c27744 | ||
|
|
986c501410 | ||
|
|
d3431ff246 | ||
|
|
36dba9e90a | ||
|
|
b9369af52f | ||
|
|
6dd1de894c | ||
|
|
3f1bcb4d12 | ||
|
|
52762ee0aa | ||
|
|
af8e59ed9a | ||
|
|
0923b75d79 | ||
|
|
20172c90b4 | ||
|
|
089c1abc8d | ||
|
|
646da59e2a | ||
|
|
10a7662d51 | ||
|
|
a6171b5180 | ||
|
|
b09fe4b48b | ||
|
|
cb8867a808 | ||
|
|
3d6365cfb4 | ||
|
|
397d02240e | ||
|
|
0c842b97fd | ||
|
|
7e94b29c6d | ||
|
|
fbc3bdb6b5 | ||
|
|
a5b9d70f04 | ||
|
|
8103a06992 | ||
|
|
85df46364a | ||
|
|
32f3c9dfa3 | ||
|
|
7bc4aac0aa | ||
|
|
70ad16fc79 | ||
|
|
6298f83abd | ||
|
|
d1422d6f64 | ||
|
|
4e8a999ebd | ||
|
|
aa5e381c59 | ||
|
|
3f66a8ed15 | ||
|
|
f4da7674b6 | ||
|
|
0b623ec39e | ||
|
|
d489f84825 | ||
|
|
d1bed9ac20 | ||
|
|
3f38a9885f | ||
|
|
940836db7e | ||
|
|
fd74f157e8 | ||
|
|
179a53c0af | ||
|
|
554b166787 | ||
|
|
874561f423 | ||
|
|
c7309d1ace | ||
|
|
5aee93dfae | ||
|
|
e14a6f9500 | ||
|
|
7baae153ae | ||
|
|
eadf54c670 | ||
|
|
312d13949d | ||
|
|
192c307ca7 | ||
|
|
5cb6e6ce81 | ||
|
|
01af4d7bcf | ||
|
|
301b9d38c8 | ||
|
|
09b0708c4d | ||
|
|
1121e1cb26 | ||
|
|
4bade21220 | ||
|
|
46421fe8c4 | ||
|
|
da5e9bd74f | ||
|
|
019a710acd |
No files matched your search
@@ -9,3 +9,7 @@ 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
|
||||
@@ -0,0 +1,68 @@
|
||||
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
|
||||
@@ -0,0 +1,60 @@
|
||||
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
|
||||
@@ -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
|
||||
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean frontend-dev frontend-build test-frontend
|
||||
|
||||
test:
|
||||
$(PYTHON) -m pytest tests/ -q
|
||||
@@ -63,3 +63,12 @@ clean:
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name '*.pyc' -delete
|
||||
rm -rf .coverage htmlcov .mypy_cache .ruff_cache dist build
|
||||
|
||||
frontend-dev:
|
||||
cd frontend && npm run dev
|
||||
|
||||
frontend-build:
|
||||
cd frontend && npm run build
|
||||
|
||||
test-frontend:
|
||||
cd frontend && npx vitest run
|
||||
@@ -354,6 +354,25 @@ 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
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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.
|
||||
@@ -56,15 +56,15 @@ class PerplexityClient:
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, model: str = SONAR,
|
||||
timeout: int = 120):
|
||||
if not api_key:
|
||||
raise ValueError("Perplexity API key is required")
|
||||
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)")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self._client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://api.perplexity.ai",
|
||||
base_url=base_url or "https://api.perplexity.ai",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@@ -72,26 +72,30 @@ class PerplexityClient:
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
json_schema: dict,
|
||||
json_schema: dict = None,
|
||||
schema_name: str = "response",
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 8192,
|
||||
) -> PerplexityResponse:
|
||||
"""
|
||||
Send a chat request with structured JSON output.
|
||||
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.
|
||||
|
||||
Args:
|
||||
system_prompt: System message
|
||||
system_prompt: System message (should instruct JSON output)
|
||||
user_prompt: User message
|
||||
json_schema: JSON Schema dict for response_format
|
||||
schema_name: Name for the schema (used in response_format)
|
||||
json_schema: (ignored) Kept for call-site compatibility
|
||||
schema_name: (ignored) Kept for call-site compatibility
|
||||
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 guaranteed by schema
|
||||
PerplexityResponse with parsed_json from the model's output
|
||||
"""
|
||||
use_model = model or self.model
|
||||
|
||||
@@ -106,14 +110,7 @@ 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 ""
|
||||
|
||||
@@ -22,7 +22,7 @@ from .models import (
|
||||
DeckReport, CoachGoals, CoachSession,
|
||||
SuggestedCut, SuggestedAdd, CoachStatus,
|
||||
)
|
||||
from .llm_client import LMStudioClient
|
||||
from .llm_client import LLMClient
|
||||
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 = LMStudioClient()
|
||||
llm = LLMClient()
|
||||
coach = CoachService(llm, embeddings)
|
||||
session = await coach.run_coaching_session("edgar-markov")
|
||||
"""
|
||||
|
||||
def __init__(self, llm: LMStudioClient, embeddings: MTGEmbeddingIndex):
|
||||
def __init__(self, llm: LLMClient, embeddings: MTGEmbeddingIndex):
|
||||
self.llm = llm
|
||||
self.embeddings = embeddings
|
||||
ensure_dirs()
|
||||
|
||||
+11
-10
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Coach Configuration
|
||||
═══════════════════════════════════════
|
||||
Central config for LM Studio, embeddings, and coach service paths.
|
||||
Central config for Ollama, 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))
|
||||
|
||||
# ── 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
|
||||
# ── 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
|
||||
|
||||
# ── 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 — LM Studio) ──────────────
|
||||
# ── LLM Generation Settings (Coach — Ollama) ──────────────
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
|
||||
# so we need a generous limit to avoid truncation
|
||||
@@ -41,9 +41,10 @@ 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", "perplexity")
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
|
||||
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "sonar")
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
|
||||
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://192.168.0.240:11434/v1")
|
||||
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
|
||||
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
|
||||
|
||||
@@ -53,7 +54,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", "sonar")
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "gpt-oss:20b")
|
||||
# Enable Perplexity fallback for low-confidence embedding matches
|
||||
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
|
||||
|
||||
|
||||
+19
-19
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — LM Studio Client
|
||||
Commander AI Lab — Ollama Client
|
||||
════════════════════════════════════
|
||||
Async HTTP client targeting LM Studio's OpenAI-compatible
|
||||
Async HTTP client targeting Ollama'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 (
|
||||
LM_STUDIO_URL, LM_STUDIO_MODEL,
|
||||
LM_STUDIO_TIMEOUT, LM_STUDIO_MAX_RETRIES,
|
||||
LLM_URL, LLM_MODEL,
|
||||
LLM_TIMEOUT, LLM_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
@@ -34,12 +34,12 @@ class LLMResponse:
|
||||
self.model = model
|
||||
|
||||
|
||||
class LMStudioClient:
|
||||
class LLMClient:
|
||||
"""
|
||||
Client for LM Studio's OpenAI-compatible API.
|
||||
Client for Ollama's OpenAI-compatible API.
|
||||
|
||||
Usage:
|
||||
client = LMStudioClient()
|
||||
client = LLMClient()
|
||||
response = await client.chat(system_prompt, user_prompt)
|
||||
if response.parsed_json:
|
||||
suggestions = response.parsed_json
|
||||
@@ -47,20 +47,20 @@ class LMStudioClient:
|
||||
|
||||
def __init__(self, base_url: str = None, model: str = None,
|
||||
timeout: int = None):
|
||||
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.base_url = (base_url or LLM_URL).rstrip("/")
|
||||
self.model = model or LLM_MODEL
|
||||
self.timeout = timeout or LLM_TIMEOUT
|
||||
self._resolved_model = None # actual model name from /models endpoint
|
||||
|
||||
def _resolve_model(self) -> str:
|
||||
"""Get the actual loaded model name from LM Studio /models endpoint."""
|
||||
"""Get the actual loaded model name from Ollama /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 LM Studio model: %s", self._resolved_model)
|
||||
logger.info("Resolved Ollama model: %s", self._resolved_model)
|
||||
return self._resolved_model
|
||||
except Exception:
|
||||
pass
|
||||
@@ -196,8 +196,8 @@ class LMStudioClient:
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
"""
|
||||
Synchronous chat call to LM Studio.
|
||||
Retries up to LM_STUDIO_MAX_RETRIES on failure or non-JSON response.
|
||||
Synchronous chat call to Ollama.
|
||||
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
|
||||
"""
|
||||
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
||||
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
||||
@@ -205,9 +205,9 @@ class LMStudioClient:
|
||||
temp, tokens, force_json)
|
||||
|
||||
last_error = None
|
||||
for attempt in range(1, LM_STUDIO_MAX_RETRIES + 1):
|
||||
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||
try:
|
||||
logger.info("LLM call attempt %d/%d", attempt, LM_STUDIO_MAX_RETRIES)
|
||||
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
|
||||
req = Request(
|
||||
f"{self.base_url}/chat/completions",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
@@ -239,12 +239,12 @@ class LMStudioClient:
|
||||
body = self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False)
|
||||
|
||||
if attempt < LM_STUDIO_MAX_RETRIES:
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
import time
|
||||
time.sleep(2 * attempt) # Simple backoff
|
||||
|
||||
raise ConnectionError(
|
||||
f"LLM call failed after {LM_STUDIO_MAX_RETRIES} attempts: {last_error}"
|
||||
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
|
||||
)
|
||||
|
||||
async def chat(self, system_prompt: str, user_prompt: str,
|
||||
@@ -263,7 +263,7 @@ class LMStudioClient:
|
||||
|
||||
def check_connection(self) -> dict:
|
||||
"""
|
||||
Check if LM Studio is reachable. Returns status dict.
|
||||
Check if Ollama is reachable. Returns status dict.
|
||||
"""
|
||||
try:
|
||||
req = Request(
|
||||
|
||||
@@ -25,6 +25,9 @@ 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 = ""
|
||||
|
||||
@@ -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),
|
||||
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
|
||||
" (TRUNCATED — response may have hit token limit)" if truncated else "")
|
||||
|
||||
# Step 5: Check ownership and build enriched card list
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
name,quantity,set_code
|
||||
Sol Ring,1,C21
|
||||
Command Tower,1,C21
|
||||
Arcane Signet,1,C21
|
||||
@@ -1,21 +1,133 @@
|
||||
"""
|
||||
Moxfield Adapter (stub)
|
||||
════════════════════════
|
||||
|
||||
Moxfield Adapter
|
||||
================
|
||||
Fetches template decklists from Moxfield for a given commander.
|
||||
Currently returns empty (experimental — not yet implemented).
|
||||
Uses the undocumented but usable api2.moxfield.com API.
|
||||
Rate-limited to <=10 req/sec. Errors are caught and logged.
|
||||
"""
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.deckgen.moxfield")
|
||||
|
||||
MOXFIELD_API = "https://api2.moxfield.com"
|
||||
HEADERS = {
|
||||
"User-Agent": "CommanderAILab/1.0",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
# Minimum seconds between requests (<=10 req/sec)
|
||||
_RATE_LIMIT_DELAY = 0.12
|
||||
|
||||
|
||||
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
|
||||
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:
|
||||
"""
|
||||
Fetch template decks from Moxfield for the given commander.
|
||||
Experimental — not yet implemented.
|
||||
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.
|
||||
"""
|
||||
log.info(f" Stub: Not yet implemented for '{commander_name}'")
|
||||
return []
|
||||
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
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -133,7 +133,7 @@ export function SimulatorPage() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Base URL (e.g. http://192.168.0.122:1234)"
|
||||
placeholder="Base URL (e.g. http://192.168.0.240:11434)"
|
||||
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,5 +559,4 @@ export interface CommanderSearchResult {
|
||||
|
||||
export interface PplxStatus {
|
||||
configured: boolean
|
||||
key_prefix: string
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend-tmp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5077
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<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
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,184 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
@@ -0,0 +1,35 @@
|
||||
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]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
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.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
||||
<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
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,54 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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',
|
||||
];
|
||||
@@ -0,0 +1,132 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/** @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: [],
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
/// <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',
|
||||
},
|
||||
})
|
||||
+163
-1944
File diff suppressed because it is too large.
Load diff
+205
-953
File diff suppressed because it is too large.
Load diff
-8872
File diff suppressed because it is too large.
Load diff
-4487
File diff suppressed because it is too large.
Load diff
@@ -154,17 +154,17 @@ def build_dataset(
|
||||
|
||||
# Print game outcome distribution
|
||||
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
|
||||
log.info(f"\n{'Outcome':<25} {'Count':>8}")
|
||||
log.info("─" * 35)
|
||||
logger.info(f"\n{'Outcome':<25} {'Count':>8}")
|
||||
logger.info("─" * 35)
|
||||
for o, c in sorted(zip(unique_outcomes, outcome_counts), key=lambda x: -x[1]):
|
||||
log.info(f" {str(o):<23} {c:>8}")
|
||||
logger.info(f" {str(o):<23} {c:>8}")
|
||||
|
||||
# Print playstyle distribution
|
||||
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
|
||||
log.info(f"\n{'Playstyle':<25} {'Count':>8}")
|
||||
log.info("─" * 35)
|
||||
logger.info(f"\n{'Playstyle':<25} {'Count':>8}")
|
||||
logger.info("─" * 35)
|
||||
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
|
||||
log.info(f" {str(s):<23} {c:>8}")
|
||||
logger.info(f" {str(s):<23} {c:>8}")
|
||||
|
||||
return {
|
||||
"states": states,
|
||||
@@ -288,7 +288,7 @@ def main():
|
||||
max_samples=args.max_samples,
|
||||
)
|
||||
if not dataset:
|
||||
log.info("\nNo data to save. Run simulations with --ml-log first.")
|
||||
logger.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")
|
||||
|
||||
log.info("\nDataset build complete.")
|
||||
logger.info("\nDataset build complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,175 @@
|
||||
"""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
|
||||
@@ -0,0 +1,45 @@
|
||||
"""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
@@ -0,0 +1,101 @@
|
||||
"""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},
|
||||
}
|
||||
@@ -67,6 +67,13 @@
|
||||
<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>
|
||||
|
||||
@@ -7,3 +7,9 @@ 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
|
||||
+54
-81
@@ -25,12 +25,13 @@ 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 pydantic import BaseModel
|
||||
from models.requests import CoachRequestBody, CoachChatMessage, CoachChatRequest, CoachApplyRequest, CoachGoalsRequest
|
||||
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
@@ -58,14 +59,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 LMStudioClient
|
||||
from coach.llm_client import LLMClient
|
||||
from coach.embeddings import MTGEmbeddingIndex
|
||||
from coach.coach_service import CoachService
|
||||
from coach.config import ensure_dirs
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
_coach_llm = LMStudioClient()
|
||||
_coach_llm = LLMClient()
|
||||
_coach_embeddings = MTGEmbeddingIndex()
|
||||
|
||||
# Try to load embeddings (non-blocking — download happens on first use)
|
||||
@@ -86,42 +87,28 @@ 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 LM Studio on 192.168.0.122:1234)")
|
||||
log_coach.warning(f" Coach LLM: Not connected (start Ollama on 192.168.0.240:11434)")
|
||||
|
||||
log_coach.info(" Coach: Service initialized")
|
||||
|
||||
# Initialize V3 Deck Generator (Perplexity)
|
||||
# Initialize V3 Deck Generator (Ollama-based)
|
||||
_deck_gen_v3_error = None
|
||||
if CFG.pplx_api_key:
|
||||
try:
|
||||
from coach.clients.perplexity_client import PerplexityClient
|
||||
from coach.services.deck_generator import DeckGeneratorV3
|
||||
from coach.config import DECK_GEN_MODEL
|
||||
|
||||
pplx_client = PerplexityClient(
|
||||
api_key=CFG.pplx_api_key,
|
||||
model=DECK_GEN_MODEL,
|
||||
)
|
||||
_deck_gen_v3 = DeckGeneratorV3(
|
||||
pplx_client=pplx_client,
|
||||
db_conn_factory=_get_db_conn,
|
||||
embedding_index=_coach_embeddings,
|
||||
)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})")
|
||||
except ImportError as e:
|
||||
_deck_gen_v3_error = f"Missing dependency: {e}. Run: pip install openai"
|
||||
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||
_deck_gen_v3 = None
|
||||
except Exception as e:
|
||||
_deck_gen_v3_error = str(e)
|
||||
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
||||
_deck_gen_v3 = None
|
||||
else:
|
||||
_deck_gen_v3_error = "PPLX_API_KEY not set"
|
||||
log_deckgen.info(" Deck Gen V3: Skipped (no PPLX_API_KEY)")
|
||||
try:
|
||||
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
|
||||
_deck_gen_v3 = DeckBuilderAdapter(
|
||||
db_conn_factory=_get_db_conn,
|
||||
model="gpt-oss:20b",
|
||||
)
|
||||
log_deckgen.info(" Deck Gen V3: Initialized (Ollama DeckBuilderAdapter)")
|
||||
except ImportError as e:
|
||||
_deck_gen_v3_error = f"Missing dependency: {e}"
|
||||
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||
_deck_gen_v3 = None
|
||||
except Exception as e:
|
||||
_deck_gen_v3_error = str(e)
|
||||
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
||||
_deck_gen_v3 = None
|
||||
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Failed to initialize: {e}")
|
||||
log_coach.error(f" Coach: Failed to initialize: {e}\n{traceback.format_exc()}")
|
||||
_coach_service = None
|
||||
|
||||
|
||||
@@ -186,33 +173,6 @@ 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."""
|
||||
@@ -261,21 +221,35 @@ def _build_deck_report_from_db(deck_slug: str):
|
||||
from coach.models import DeckReport, CardPerformance, DeckStructure
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Find the deck by slug match against the deck name
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, commander_name, color_identity FROM decks ORDER BY id"
|
||||
).fetchall()
|
||||
# --- 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
|
||||
|
||||
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
|
||||
# Find the deck by parameterized query instead of full-table scan
|
||||
slug_lower = deck_slug.lower()
|
||||
slug_hyphenated = slug_lower.replace(' ', '-')
|
||||
clean_slug = re.sub(r'[^a-z0-9]+', '-', slug_lower).strip('-')
|
||||
|
||||
if slug_lower.isdigit():
|
||||
# Numeric slug -- match by ID directly
|
||||
matched_deck = conn.execute(
|
||||
"SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
|
||||
(int(slug_lower),)
|
||||
).fetchone()
|
||||
else:
|
||||
# Try exact name match, hyphenated match, or clean-slug match
|
||||
matched_deck = conn.execute(
|
||||
"""SELECT id, name, commander_name, color_identity FROM decks
|
||||
WHERE LOWER(name) = ?
|
||||
OR LOWER(REPLACE(name, ' ', '-')) = ?
|
||||
OR LOWER(REPLACE(name, ' ', '-')) = ?
|
||||
LIMIT 1""",
|
||||
(slug_lower, slug_hyphenated, clean_slug)
|
||||
).fetchone()
|
||||
|
||||
if matched_deck is None:
|
||||
return None
|
||||
@@ -451,7 +425,7 @@ async def coach_chat(body: CoachChatRequest):
|
||||
try:
|
||||
import json
|
||||
from urllib.request import urlopen, Request as UrlRequest
|
||||
from coach.config import LM_STUDIO_URL, LM_STUDIO_TIMEOUT
|
||||
from coach.config import LLM_URL, LLM_TIMEOUT
|
||||
|
||||
model_name = _coach_llm._resolve_model()
|
||||
llm_body = {
|
||||
@@ -477,7 +451,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=LM_STUDIO_TIMEOUT)
|
||||
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LLM_TIMEOUT)
|
||||
conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
resp = conn.getresponse()
|
||||
@@ -526,7 +500,7 @@ async def coach_chat(body: CoachChatRequest):
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
def _call():
|
||||
with urlopen(req, timeout=LM_STUDIO_TIMEOUT) as resp:
|
||||
with urlopen(req, timeout=LLM_TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
raw = await loop.run_in_executor(None, _call)
|
||||
@@ -598,6 +572,7 @@ 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}",
|
||||
@@ -694,5 +669,3 @@ async def coach_generate_reports():
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Report generation failed: {e}")
|
||||
|
||||
|
||||
+1
-490
@@ -18,13 +18,8 @@ from __future__ import annotations
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -320,472 +315,6 @@ 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
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -956,21 +485,3 @@ 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],
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ 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
|
||||
@@ -1085,7 +1087,6 @@ 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)
|
||||
@@ -1098,4 +1099,3 @@ def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
+124
-164
@@ -29,10 +29,11 @@ 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 pydantic import BaseModel
|
||||
from models.requests import DeckResearchRequest, DeckGenerateAIRequest
|
||||
|
||||
import routes.coach as _coach # for _deck_gen_v3 / _deck_gen_v3_error module-level access
|
||||
from routes.shared import (
|
||||
@@ -43,6 +44,7 @@ from routes.shared import (
|
||||
DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest,
|
||||
GeneratedDeckCard, DeckGenerationSourceConfig,
|
||||
)
|
||||
from services.deck_service import _write_dck_file, _build_dck_lines
|
||||
|
||||
router = APIRouter(tags=["deckgen"])
|
||||
|
||||
@@ -147,37 +149,48 @@ def _scryfall_to_commander(data: dict) -> dict:
|
||||
|
||||
|
||||
def _get_collection_for_colors(color_identity: list) -> list:
|
||||
"""
|
||||
Fetch all collection cards compatible with the given color identity.
|
||||
Returns list of dicts with card details.
|
||||
"""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 *).
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_entries WHERE quantity > 0"
|
||||
).fetchall()
|
||||
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 = _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
|
||||
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
|
||||
|
||||
|
||||
@@ -540,29 +553,8 @@ 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:
|
||||
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}")
|
||||
_write_dck_file(deck_name, commander['name'], cards, fallback_id=deck_id)
|
||||
except Exception as e:
|
||||
log_deckgen.warning(f" Warning: Failed to export .dck file: {e}")
|
||||
|
||||
@@ -655,56 +647,44 @@ 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."""
|
||||
"""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.
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Base query: all collection entries with oracle text
|
||||
where = "WHERE quantity > 0"
|
||||
params = []
|
||||
allowed_json = json.dumps([c.upper() for c in color_identity]) 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
|
||||
# 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 ()
|
||||
|
||||
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}
|
||||
FROM collection_entries
|
||||
WHERE quantity > 0
|
||||
{ci_filter}
|
||||
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()
|
||||
@@ -746,12 +726,9 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
|
||||
'ramp': [], 'card_draw': [], 'removal': [], 'board_wipes': [],
|
||||
'lands': [], 'win_conditions': [], 'creatures': [], 'utility': []
|
||||
}
|
||||
filtered_count = 0
|
||||
filtered_count = len(rows) # SQL already filtered
|
||||
|
||||
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'],
|
||||
@@ -789,40 +766,57 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
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.')
|
||||
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
|
||||
|
||||
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': 'sonar',
|
||||
'model': model,
|
||||
'messages': messages,
|
||||
'max_tokens': max_tokens,
|
||||
'temperature': temperature,
|
||||
'return_related_questions': False,
|
||||
}
|
||||
|
||||
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:
|
||||
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}')
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(502, f'Perplexity API returned invalid JSON: {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
|
||||
|
||||
|
||||
def _extract_json_from_response(text: str) -> dict:
|
||||
@@ -1082,7 +1076,7 @@ DECKLIST ({len(card_rows)} cards):
|
||||
|
||||
Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4 per Commander Rules Committee), ALL synergy packages between cards, ALL win conditions, game plan by phase (early/mid/late), mana base health, every role gap. Suggest specific cuts with severity and specific adds with priority and synergy tags. For adds, prioritize cards from the COLLECTION SUMMARY when available."""
|
||||
|
||||
content = _call_pplx_api([
|
||||
content = await _call_pplx_api([
|
||||
{'role': 'system', 'content': system_msg},
|
||||
{'role': 'user', 'content': user_msg},
|
||||
], max_tokens=8192)
|
||||
@@ -1192,7 +1186,7 @@ COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'}
|
||||
|
||||
Build the deck as JSON. Prioritize collection cards when they fit the strategy."""
|
||||
|
||||
content = _call_pplx_api([
|
||||
content = await _call_pplx_api([
|
||||
{'role': 'system', 'content': system_msg},
|
||||
{'role': 'user', 'content': user_msg},
|
||||
], max_tokens=8192, temperature=0.3)
|
||||
@@ -1224,7 +1218,6 @@ 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 '',
|
||||
}
|
||||
|
||||
|
||||
@@ -1238,13 +1231,9 @@ async def deck_gen_v3_status():
|
||||
return {
|
||||
'initialized': _coach._deck_gen_v3 is not None,
|
||||
'pplx_configured': bool(CFG.pplx_api_key),
|
||||
'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
|
||||
),
|
||||
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'pplx', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
|
||||
'embeddings_loaded': False,
|
||||
'embedding_cards': 0,
|
||||
'error': _coach._deck_gen_v3_error,
|
||||
}
|
||||
|
||||
@@ -1263,7 +1252,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. Check PPLX_API_KEY.')
|
||||
raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure Ollama is running with gpt-oss:20b model.')
|
||||
|
||||
if not req.commander_name or len(req.commander_name.strip()) < 2:
|
||||
raise HTTPException(400, 'Commander name is required (min 2 chars)')
|
||||
@@ -1305,9 +1294,7 @@ 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'Error: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
log_deckgen.error(f'V3 generate error: {e}', exc_info=True)
|
||||
raise HTTPException(500, f'Deck generation failed: {str(e)}')
|
||||
|
||||
|
||||
@@ -1394,25 +1381,8 @@ async def deck_gen_v3_commit(req: DeckGenV3Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Export .dck for Forge
|
||||
try:
|
||||
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}")
|
||||
_write_dck_file(deck_name, commander['name'], cards, fallback_id=deck_id, resolve_substitutes=True)
|
||||
except Exception as e:
|
||||
log_deckgen.error(f" .dck export failed: {e}")
|
||||
|
||||
@@ -1497,22 +1467,12 @@ async def deck_gen_v3_export_dck(req: DeckGenV3Request):
|
||||
cards = result.get('cards', [])
|
||||
commander = result.get('commander', {})
|
||||
|
||||
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}")
|
||||
|
||||
lines = _build_dck_lines(
|
||||
f"{commander.get('name', 'Deck')} - V3 Auto",
|
||||
commander.get('name', ''),
|
||||
cards,
|
||||
resolve_substitutes=True,
|
||||
)
|
||||
dck_content = '\n'.join(lines)
|
||||
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_')
|
||||
return StreamingResponse(
|
||||
|
||||
+565
-534
File diff suppressed because it is too large.
Load diff
+21
-5
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
@@ -38,8 +39,6 @@ from routes.shared import (
|
||||
StartResponse,
|
||||
StatusResponse,
|
||||
AI_PROFILES,
|
||||
PRECON_DIR,
|
||||
PRECON_INDEX,
|
||||
build_java_command,
|
||||
run_batch_subprocess,
|
||||
_run_deepseek_batch_thread,
|
||||
@@ -54,6 +53,19 @@ 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."""
|
||||
@@ -164,8 +176,12 @@ 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")
|
||||
with open(state.result_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
try:
|
||||
with open(state.result_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
raise HTTPException(500, f"Failed to read result file: {e}")
|
||||
data = _sanitize_floats(data)
|
||||
return JSONResponse(content=data)
|
||||
|
||||
|
||||
@@ -441,5 +457,5 @@ async def start_batch_deepseek(request: FastAPIRequest, background_tasks: Backgr
|
||||
|
||||
|
||||
# Global ML logging toggle (can be enabled via API)
|
||||
_ml_logging_enabled = False
|
||||
_ml_logging_enabled = True
|
||||
|
||||
+729
@@ -0,0 +1,729 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
+8
-7
@@ -16,7 +16,8 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
import routes.shared as _shared # module reference, not value import
|
||||
from routes.shared import CFG, PRECON_DIR, download_precon_database, log
|
||||
from routes.shared import CFG, download_precon_database, log
|
||||
from services.precon_service import _get_precon_dir
|
||||
|
||||
router = APIRouter(tags=["precon"])
|
||||
|
||||
@@ -30,13 +31,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"}
|
||||
Body: {"fileName": "Elven_Empire.dck"} or {"name": "Elven_Empire.dck"}
|
||||
"""
|
||||
file_name = req.get("fileName", "")
|
||||
file_name = req.get("fileName", "") or req.get("name", "")
|
||||
if not file_name:
|
||||
raise HTTPException(400, "fileName is required")
|
||||
|
||||
src = PRECON_DIR / file_name
|
||||
src = _get_precon_dir() / file_name
|
||||
if not src.exists():
|
||||
raise HTTPException(404, f"Precon not found: {file_name}")
|
||||
|
||||
@@ -59,9 +60,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", "Necron_Dynasties.dck"]}
|
||||
Body: {"fileNames": ["Elven_Empire.dck", ...]} or {"names": ["Elven_Empire.dck", ...]}
|
||||
"""
|
||||
file_names = req.get("fileNames", [])
|
||||
file_names = req.get("fileNames", []) or req.get("names", [])
|
||||
if not file_names:
|
||||
raise HTTPException(400, "fileNames list is required")
|
||||
|
||||
@@ -71,7 +72,7 @@ async def install_precons_batch(req: dict):
|
||||
|
||||
results = []
|
||||
for file_name in file_names:
|
||||
src = PRECON_DIR / file_name
|
||||
src = _get_precon_dir() / file_name
|
||||
if not src.exists():
|
||||
results.append({"fileName": file_name, "installed": False, "error": "not found"})
|
||||
continue
|
||||
|
||||
+67
-1768
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,222 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Services for Commander AI Lab."""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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
|
||||
@@ -0,0 +1,304 @@
|
||||
"""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
|
||||
@@ -0,0 +1,265 @@
|
||||
"""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
|
||||
@@ -0,0 +1,591 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""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()}
|
||||
@@ -0,0 +1,110 @@
|
||||
"""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
|
||||
@@ -0,0 +1,230 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,216 @@
|
||||
"""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
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
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__"]
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
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}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
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"
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
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
Reference in new issue
Block a user