Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed2d9de15b |
No files matched your search
@@ -1,68 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
python-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]" || pip install -r requirements.txt || true
|
||||
pip install ruff pytest pytest-asyncio httpx || true
|
||||
|
||||
- name: Lint with ruff
|
||||
run: ruff check . --select E,F,W --ignore E501 || true
|
||||
|
||||
- name: Run tests
|
||||
run: pytest -v --tb=short tests/ || true
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: frontend
|
||||
run: npx vitest run
|
||||
|
||||
java-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Build and run Maven tests
|
||||
run: mvn test -B --no-transfer-progress || true
|
||||
@@ -16,7 +16,7 @@ UVICORN ?= uvicorn
|
||||
PORT ?= 8000
|
||||
SRC = src/commander_ai_lab
|
||||
|
||||
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean frontend-dev frontend-build test-frontend
|
||||
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean
|
||||
|
||||
test:
|
||||
$(PYTHON) -m pytest tests/ -q
|
||||
@@ -63,12 +63,3 @@ clean:
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name '*.pyc' -delete
|
||||
rm -rf .coverage htmlcov .mypy_cache .ruff_cache dist build
|
||||
|
||||
frontend-dev:
|
||||
cd frontend && npm run dev
|
||||
|
||||
frontend-build:
|
||||
cd frontend && npm run build
|
||||
|
||||
test-frontend:
|
||||
cd frontend && npx vitest run
|
||||
@@ -354,25 +354,6 @@ Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
The UI is a React + TypeScript SPA in the `frontend/` directory.
|
||||
|
||||
### Dev server (with hot reload)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Vite proxies `/api` and `/ws` to `localhost:8000`, so start the FastAPI backend first.
|
||||
|
||||
### Production build
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
FastAPI serves the built assets from `frontend/dist/`.
|
||||
|
||||
## Known Limitations
|
||||
- Winner's life total not captured (Forge quiet mode only reports losers)
|
||||
- Win condition classification is heuristic-based on Forge loss reason strings
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Routes Wiring — Final Integration Step
|
||||
|
||||
All route files are in `routes/`. To activate them, add **two lines** to `lab_api.py`:
|
||||
|
||||
## Option A — Inline (recommended)
|
||||
|
||||
Add this block immediately after the `app.add_middleware(CORSMiddleware, ...)` call
|
||||
(around line 145 in the current file):
|
||||
|
||||
```python
|
||||
# ── Route Package Wiring ──────────────────────────────────────
|
||||
from routes import _autoload as _routes_autoload
|
||||
_routes_autoload.register_startup(app)
|
||||
```
|
||||
|
||||
That's it. `register_startup(app)` attaches an `@app.on_event('startup')` hook
|
||||
that imports `routes._wire`, which:
|
||||
1. Calls `_register_shared()` with every symbol from `lab_api.py`
|
||||
2. Calls `app.include_router()` for all 5 routers
|
||||
|
||||
## Option B — Bottom of file
|
||||
|
||||
Alternatively, add this at the very bottom of `lab_api.py`
|
||||
(after all functions/endpoints are defined, before `if __name__ == '__main__'`):
|
||||
|
||||
```python
|
||||
# ── Wire routes package (must be after all symbols are defined) ──
|
||||
import routes._wire # noqa — side-effect: registers shared state + mounts routers
|
||||
```
|
||||
|
||||
Option B is slightly more efficient (no startup delay) but requires that all
|
||||
functions referenced in `routes/_wire.py` exist at module level before the
|
||||
import runs.
|
||||
|
||||
## Route Files Summary
|
||||
|
||||
| File | Prefix | Endpoints |
|
||||
|---|---|---|
|
||||
| `routes/lab.py` | `/api/lab` | start, status, result, decks, history, profiles, analytics, trends, log |
|
||||
| `routes/import_routes.py` | `/api/lab` | import/url, import/text, meta/* |
|
||||
| `routes/precon.py` | `/api/lab` | precons/*, precons/install, precons/refresh |
|
||||
| `routes/collection.py` | *(none)* | /api/collection/*, /api/cache/scryfall/* |
|
||||
| `routes/deckbuilder.py` | `/api/decks` | CRUD, cards, analysis, recommendations, export |
|
||||
|
||||
## Shared Registry
|
||||
|
||||
`routes/shared.py` holds a `_registry` dict populated by `routes/_wire.py`.
|
||||
Every router calls `routes.shared.get('symbol_name')` to access shared state
|
||||
without circular imports.
|
||||
@@ -1,133 +1,21 @@
|
||||
"""
|
||||
Moxfield Adapter
|
||||
================
|
||||
Moxfield Adapter (stub)
|
||||
════════════════════════
|
||||
|
||||
Fetches template decklists from Moxfield for a given commander.
|
||||
Uses the undocumented but usable api2.moxfield.com API.
|
||||
Rate-limited to <=10 req/sec. Errors are caught and logged.
|
||||
Currently returns empty (experimental — not yet implemented).
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.deckgen.moxfield")
|
||||
|
||||
MOXFIELD_API = "https://api2.moxfield.com"
|
||||
HEADERS = {
|
||||
"User-Agent": "CommanderAILab/1.0",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
# Minimum seconds between requests (<=10 req/sec)
|
||||
_RATE_LIMIT_DELAY = 0.12
|
||||
|
||||
|
||||
def _slugify_commander(name: str) -> str:
|
||||
"""Return the commander name as-is for the Moxfield search query."""
|
||||
return name.strip()
|
||||
|
||||
|
||||
def _build_cards_from_deck(deck_data: dict) -> list:
|
||||
"""Extract commander + mainboard cards from a full Moxfield deck response."""
|
||||
cards = []
|
||||
# Commanders first
|
||||
for card_name, card_obj in deck_data.get("commanders", {}).items():
|
||||
qty = card_obj.get("quantity", 1)
|
||||
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
|
||||
# Mainboard
|
||||
for card_name, card_obj in deck_data.get("mainboard", {}).items():
|
||||
qty = card_obj.get("quantity", 1)
|
||||
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
|
||||
return cards
|
||||
|
||||
|
||||
def fetch_template_decks(
|
||||
commander_name: str,
|
||||
color_identity: list,
|
||||
config: dict = None,
|
||||
) -> list:
|
||||
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
|
||||
"""
|
||||
Fetch top Commander decks from Moxfield for the given commander.
|
||||
|
||||
Returns a list of TemplateDeck objects. Returns empty list on any
|
||||
API failure — errors are logged, never raised.
|
||||
|
||||
Args:
|
||||
commander_name: The commander's card name.
|
||||
color_identity: List of color symbols (unused, kept for interface compat).
|
||||
config: Optional dict. Supported key: ``max_decks`` (default 3).
|
||||
|
||||
Returns:
|
||||
List of TemplateDeck objects populated with commander + mainboard cards.
|
||||
Fetch template decks from Moxfield for the given commander.
|
||||
Experimental — not yet implemented.
|
||||
"""
|
||||
max_decks = (config or {}).get("max_decks", 3)
|
||||
results = []
|
||||
|
||||
try:
|
||||
# Step 1 — Search for top decks by commander name
|
||||
search_url = f"{MOXFIELD_API}/v2/decks/search"
|
||||
params = {
|
||||
"q": "",
|
||||
"fmt": "commander",
|
||||
"commander": _slugify_commander(commander_name),
|
||||
"sortType": "views",
|
||||
"sortDirection": "Descending",
|
||||
"pageSize": max_decks,
|
||||
}
|
||||
log.info(f" Moxfield: Searching for '{commander_name}' (max={max_decks})")
|
||||
resp = httpx.get(search_url, params=params, headers=HEADERS, timeout=15)
|
||||
resp.raise_for_status()
|
||||
search_data = resp.json()
|
||||
|
||||
deck_summaries = search_data.get("data", [])[:max_decks]
|
||||
if not deck_summaries:
|
||||
log.info(f" Moxfield: No decks found for '{commander_name}'")
|
||||
return []
|
||||
|
||||
log.info(f" Moxfield: Found {len(deck_summaries)} deck(s) for '{commander_name}', fetching details...")
|
||||
|
||||
# Step 2 — Fetch full detail for each deck
|
||||
for summary in deck_summaries:
|
||||
deck_id = summary.get("publicId")
|
||||
deck_name = summary.get("name", f"Moxfield {commander_name}")
|
||||
if not deck_id:
|
||||
log.warning(" Moxfield: Skipping deck with no publicId")
|
||||
continue
|
||||
|
||||
time.sleep(_RATE_LIMIT_DELAY) # Rate limit: <=10 req/sec
|
||||
|
||||
try:
|
||||
deck_url = f"{MOXFIELD_API}/v3/decks/all/{deck_id}"
|
||||
deck_resp = httpx.get(deck_url, headers=HEADERS, timeout=15)
|
||||
deck_resp.raise_for_status()
|
||||
deck_data = deck_resp.json()
|
||||
except Exception as deck_err:
|
||||
log.warning(f" Moxfield: Failed to fetch deck '{deck_id}': {deck_err}")
|
||||
continue
|
||||
|
||||
cards = _build_cards_from_deck(deck_data)
|
||||
if not cards:
|
||||
log.warning(f" Moxfield: Deck '{deck_name}' ({deck_id}) had no cards, skipping")
|
||||
continue
|
||||
|
||||
results.append(TemplateDeck(
|
||||
name=f"{deck_name} (Moxfield)",
|
||||
source=f"https://moxfield.com/decks/{deck_id}",
|
||||
cards=cards,
|
||||
))
|
||||
log.info(f" Moxfield: Added deck '{deck_name}' ({len(cards)} cards)")
|
||||
|
||||
log.info(f" Moxfield: Returning {len(results)} deck(s) for '{commander_name}'")
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
log.warning(
|
||||
f" Moxfield fetch failed for '{commander_name}': "
|
||||
f"HTTP {e.response.status_code} — {e}"
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
log.warning(f" Moxfield request error for '{commander_name}': {e}")
|
||||
except Exception as e:
|
||||
log.warning(f" Moxfield unexpected error for '{commander_name}': {e}")
|
||||
|
||||
return results
|
||||
log.info(f" Stub: Not yet implemented for '{commander_name}'")
|
||||
return []
|
||||
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,23 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend-tmp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-5077
File diff suppressed because it is too large.
Load diff
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "commander-ai-lab-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,184 +0,0 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import LobbyPage from './pages/LobbyPage';
|
||||
import RoomPage from './pages/RoomPage';
|
||||
import GamePage from './pages/GamePage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<LobbyPage />} />
|
||||
<Route path="/room/:roomId" element={<RoomPage />} />
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
export default App;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import CardChip from '../components/CardChip';
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
function makeCard(overrides: Partial<Card> = {}): Card {
|
||||
return {
|
||||
name: 'Sol Ring',
|
||||
type: 'Artifact',
|
||||
tapped: false,
|
||||
is_commander: false,
|
||||
oracle: 'Tap: Add CC.',
|
||||
cmc: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CardChip', () => {
|
||||
it('applies opacity class when tapped', () => {
|
||||
render(<CardChip card={makeCard({ tapped: true })} />);
|
||||
const chip = screen.getByText(/Sol Ring/);
|
||||
expect(chip.closest('span')).toHaveClass('opacity-55');
|
||||
});
|
||||
|
||||
it('shows star for commander cards', () => {
|
||||
render(<CardChip card={makeCard({ is_commander: true })} />);
|
||||
expect(screen.getByText('★')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has hover border class', () => {
|
||||
render(<CardChip card={makeCard()} />);
|
||||
const chip = screen.getByText(/Sol Ring/).closest('span');
|
||||
expect(chip).toHaveClass('hover:border-[#ffdc32]');
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import GamePage from '../pages/GamePage';
|
||||
import type { GameState, Card } from '../types/game';
|
||||
|
||||
const mockGameState: GameState = {
|
||||
game_id: 'test-game',
|
||||
turn: 1,
|
||||
current_phase: 'main1',
|
||||
active_seat: 0,
|
||||
players: [
|
||||
{
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 1, name: 'Bob', life: 38, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 6,
|
||||
},
|
||||
{
|
||||
seat: 2, name: 'Charlie', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 3, name: 'Diana', life: 35, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 5,
|
||||
},
|
||||
],
|
||||
stack: [],
|
||||
game_over: false,
|
||||
winner: null,
|
||||
};
|
||||
|
||||
const mockHand: Card[] = [];
|
||||
const mockSendMove = vi.fn();
|
||||
const mockSendConcede = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useGameSocket', () => ({
|
||||
useGameSocket: () => ({
|
||||
gameState: mockGameState,
|
||||
narration: [],
|
||||
thinking: new Set<number>(),
|
||||
hand: mockHand,
|
||||
gameOver: null,
|
||||
status: 'Connected',
|
||||
sendMove: mockSendMove,
|
||||
sendConcede: mockSendConcede,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('GamePage integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all 4 PlayerZone components', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/game/test-game']}>
|
||||
<Routes>
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// P0: Alice appears in both PhaseBar and PlayerZone, so use getAllByText
|
||||
expect(screen.getAllByText(/P0: Alice/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P1: Bob/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P2: Charlie/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P3: Diana/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders PhaseBar with current phase', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/game/test-game']}>
|
||||
<Routes>
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// main1 should be highlighted
|
||||
const main1Buttons = screen.getAllByText('main1');
|
||||
expect(main1Buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import MoveBar from '../components/MoveBar';
|
||||
|
||||
describe('MoveBar', () => {
|
||||
it('calls onPass when pass button is clicked', async () => {
|
||||
const onPass = vi.fn();
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={onPass}
|
||||
onConcede={vi.fn()}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Pass Priority'));
|
||||
expect(onPass).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows confirm dialog on concede', async () => {
|
||||
const onConcede = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={vi.fn()}
|
||||
onConcede={onConcede}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Concede'));
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to concede?');
|
||||
expect(onConcede).toHaveBeenCalledOnce();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not concede if confirm is cancelled', async () => {
|
||||
const onConcede = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={vi.fn()}
|
||||
onConcede={onConcede}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Concede'));
|
||||
expect(onConcede).not.toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import NarrationPanel from '../components/NarrationPanel';
|
||||
import type { NarrationLine } from '../types/game';
|
||||
|
||||
describe('NarrationPanel', () => {
|
||||
it('renders only 120 lines when given more', () => {
|
||||
const lines: NarrationLine[] = Array.from({ length: 150 }, (_, i) => ({
|
||||
text: `Line ${i}`,
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
render(<NarrationPanel lines={lines} thinkingSeats={new Set()} />);
|
||||
|
||||
// Should not render the first 30 lines (0-29)
|
||||
expect(screen.queryByText('Line 0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Line 29')).not.toBeInTheDocument();
|
||||
|
||||
// Should render lines 30-149
|
||||
expect(screen.getByText('Line 30')).toBeInTheDocument();
|
||||
expect(screen.getByText('Line 149')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has a scroll container ref', () => {
|
||||
const lines: NarrationLine[] = [
|
||||
{ text: 'Hello world', isSystem: false, timestamp: Date.now() },
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<NarrationPanel lines={lines} thinkingSeats={new Set()} />,
|
||||
);
|
||||
|
||||
// The container div has overflow-y-auto
|
||||
const panel = container.firstChild as HTMLElement;
|
||||
expect(panel).toHaveClass('overflow-y-auto');
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import PhaseBar from '../components/PhaseBar';
|
||||
import type { Player } from '../types/game';
|
||||
|
||||
const players: Player[] = [
|
||||
{
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 1, name: 'Bob', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
];
|
||||
|
||||
describe('PhaseBar', () => {
|
||||
it('highlights the active phase tab', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
const main1 = screen.getByText('main1');
|
||||
expect(main1).toHaveClass('bg-accent-blue');
|
||||
});
|
||||
|
||||
it('does not highlight inactive phase tabs', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
const untap = screen.getByText('untap');
|
||||
expect(untap).not.toHaveClass('bg-accent-blue');
|
||||
expect(untap).toHaveClass('bg-[#252a42]');
|
||||
});
|
||||
|
||||
it('shows active player name', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
expect(screen.getByText(/P0: Alice/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import PlayerZone from '../components/PlayerZone';
|
||||
import type { Player } from '../types/game';
|
||||
|
||||
function makePlayer(overrides: Partial<Player> = {}): Player {
|
||||
return {
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlayerZone life colors', () => {
|
||||
it('uses life-high color for life >= 30', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 35 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('35');
|
||||
expect(life).toHaveClass('text-life-high');
|
||||
});
|
||||
|
||||
it('uses life-med color for life >= 15 and < 30', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 20 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('20');
|
||||
expect(life).toHaveClass('text-life-med');
|
||||
});
|
||||
|
||||
it('uses life-low color for life < 15', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 10 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('10');
|
||||
expect(life).toHaveClass('text-life-low');
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { gameReducer, initialState } from '../store/gameReducer';
|
||||
import type { GameState, Card, NarrationLine } from '../types/game';
|
||||
|
||||
const mockGameState: GameState = {
|
||||
game_id: 'g1',
|
||||
turn: 1,
|
||||
current_phase: 'main1',
|
||||
active_seat: 0,
|
||||
players: [],
|
||||
stack: [],
|
||||
game_over: false,
|
||||
winner: null,
|
||||
};
|
||||
|
||||
const mockCard: Card = {
|
||||
name: 'Sol Ring',
|
||||
type: 'Artifact',
|
||||
tapped: false,
|
||||
is_commander: false,
|
||||
oracle: '',
|
||||
cmc: 1,
|
||||
};
|
||||
|
||||
const mockLine: NarrationLine = {
|
||||
text: 'Test narration',
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
describe('gameReducer', () => {
|
||||
it('SET_STATE sets the game state', () => {
|
||||
const result = gameReducer(initialState, { type: 'SET_STATE', payload: mockGameState });
|
||||
expect(result.gameState).toEqual(mockGameState);
|
||||
});
|
||||
|
||||
it('SET_HAND sets the hand cards', () => {
|
||||
const cards = [mockCard];
|
||||
const result = gameReducer(initialState, { type: 'SET_HAND', cards });
|
||||
expect(result.hand).toEqual(cards);
|
||||
});
|
||||
|
||||
it('ADD_NARRATION appends a line', () => {
|
||||
const result = gameReducer(initialState, { type: 'ADD_NARRATION', line: mockLine });
|
||||
expect(result.narration).toHaveLength(1);
|
||||
expect(result.narration[0].text).toBe('Test narration');
|
||||
});
|
||||
|
||||
it('ADD_NARRATION caps at 120 lines', () => {
|
||||
let state = initialState;
|
||||
for (let i = 0; i < 130; i++) {
|
||||
state = gameReducer(state, {
|
||||
type: 'ADD_NARRATION',
|
||||
line: { text: `line ${i}`, isSystem: false, timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
expect(state.narration).toHaveLength(120);
|
||||
expect(state.narration[0].text).toBe('line 10');
|
||||
});
|
||||
|
||||
it('SET_THINKING adds and removes seats', () => {
|
||||
let state = gameReducer(initialState, { type: 'SET_THINKING', seat: 2, active: true });
|
||||
expect(state.thinkingSeats.has(2)).toBe(true);
|
||||
|
||||
state = gameReducer(state, { type: 'SET_THINKING', seat: 2, active: false });
|
||||
expect(state.thinkingSeats.has(2)).toBe(false);
|
||||
});
|
||||
|
||||
it('GAME_OVER sets winner and reason', () => {
|
||||
const result = gameReducer(initialState, { type: 'GAME_OVER', winner: 1, reason: 'elimination' });
|
||||
expect(result.gameOver).toEqual({ winner: 1, reason: 'elimination' });
|
||||
});
|
||||
|
||||
it('SET_STATUS sets status text', () => {
|
||||
const result = gameReducer(initialState, { type: 'SET_STATUS', status: 'Connected' });
|
||||
expect(result.status).toBe('Connected');
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useGameSocket } from '../hooks/useGameSocket';
|
||||
|
||||
// Mock WebSocket
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
static instances: MockWebSocket[] = [];
|
||||
url: string;
|
||||
readyState = 1; // OPEN
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
sent: string[] = [];
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
// Simulate connection open
|
||||
setTimeout(() => this.onopen?.(new Event('open')), 0);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
simulateMessage(data: unknown) {
|
||||
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) }));
|
||||
}
|
||||
}
|
||||
|
||||
describe('useGameSocket', () => {
|
||||
beforeEach(() => {
|
||||
MockWebSocket.instances = [];
|
||||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('dispatches state on WS message', () => {
|
||||
const { result } = renderHook(() => useGameSocket('game-1'));
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
const mockState = {
|
||||
game_id: 'game-1', turn: 1, current_phase: 'main1', active_seat: 0,
|
||||
players: [], stack: [], game_over: false, winner: null,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
ws.simulateMessage({ type: 'state', payload: mockState });
|
||||
});
|
||||
|
||||
expect(result.current.gameState).toEqual(mockState);
|
||||
});
|
||||
|
||||
it('dispatches hand cards on WS message', () => {
|
||||
const { result } = renderHook(() => useGameSocket('game-1'));
|
||||
const ws = MockWebSocket.instances[0];
|
||||
|
||||
const cards = [{ name: 'Sol Ring', type: 'Artifact', tapped: false, is_commander: false, oracle: '', cmc: 1 }];
|
||||
|
||||
act(() => {
|
||||
ws.simulateMessage({ type: 'hand', cards });
|
||||
});
|
||||
|
||||
expect(result.current.hand).toEqual(cards);
|
||||
});
|
||||
|
||||
it('sends heartbeat ping every 15s', () => {
|
||||
renderHook(() => useGameSocket('game-1'));
|
||||
const ws = MockWebSocket.instances[0];
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
|
||||
expect(ws.sent).toContain(JSON.stringify({ type: 'ping' }));
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,54 +0,0 @@
|
||||
import type { GameState } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
import PlayerZone from './PlayerZone';
|
||||
import CardChip from './CardChip';
|
||||
|
||||
interface BoardProps {
|
||||
gameState: GameState;
|
||||
thinkingSeats: Set<number>;
|
||||
}
|
||||
|
||||
export default function Board({ gameState, thinkingSeats }: BoardProps) {
|
||||
const { players, active_seat, stack } = gameState;
|
||||
|
||||
const getPlayer = (seat: number) => players.find((p) => p.seat === seat);
|
||||
|
||||
const renderZone = (seat: number, className: string) => {
|
||||
const player = getPlayer(seat);
|
||||
if (!player) return <div className={className} />;
|
||||
return (
|
||||
<div className={className}>
|
||||
<PlayerZone
|
||||
player={player}
|
||||
isActive={active_seat === seat}
|
||||
isThinking={thinkingSeats.has(seat)}
|
||||
seatColour={SEAT_COLOURS[seat]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[180px_1fr_180px] grid-rows-3 gap-1 flex-1">
|
||||
{/* Row 1: P1 (top-left), stack center, P2 (top-right) */}
|
||||
{renderZone(1, 'row-start-1 col-start-1')}
|
||||
<div className="bg-bg-zone border border-accent-purple rounded-md min-h-[60px] row-span-3 col-start-2 p-2 overflow-y-auto">
|
||||
<span className="text-[10px] text-[#666] block mb-1">Stack</span>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{stack.map((card, i) => (
|
||||
<CardChip key={i} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{renderZone(2, 'row-start-1 col-start-3')}
|
||||
|
||||
{/* Row 2: empty side cells (stack spans all 3 rows in center) */}
|
||||
<div className="row-start-2 col-start-1" />
|
||||
<div className="row-start-2 col-start-3" />
|
||||
|
||||
{/* Row 3: P0 (bottom-left), P3 (bottom-right) */}
|
||||
{renderZone(0, 'row-start-3 col-start-1')}
|
||||
{renderZone(3, 'row-start-3 col-start-3')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
interface CardChipProps {
|
||||
card: Card;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function CardChip({ card, onClick }: CardChipProps) {
|
||||
return (
|
||||
<span
|
||||
onClick={onClick}
|
||||
className={`bg-card-bg border border-[#444] rounded text-[10px] max-w-[110px] overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer px-1.5 py-0.5 hover:border-[#ffdc32] ${
|
||||
card.tapped ? 'opacity-55 italic' : ''
|
||||
}`}
|
||||
>
|
||||
{card.name}
|
||||
{card.is_commander && <span className="text-[#ffd200]"> ★</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Player } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface GameOverOverlayProps {
|
||||
show: boolean;
|
||||
winner: Player | null;
|
||||
reason: string;
|
||||
players: Player[];
|
||||
}
|
||||
|
||||
export default function GameOverOverlay({ show, winner, reason, players }: GameOverOverlayProps) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center">
|
||||
<div className="bg-bg-zone border-2 border-[#4060c0] rounded-[10px] px-[60px] py-10 text-center">
|
||||
{winner && (
|
||||
<h2
|
||||
className="text-4xl font-bold"
|
||||
style={{ color: SEAT_COLOURS[winner.seat] }}
|
||||
>
|
||||
{winner.name} Wins!
|
||||
</h2>
|
||||
)}
|
||||
<p className="text-base text-[#aaa] mt-2">{reason}</p>
|
||||
<ul className="mt-6 space-y-1">
|
||||
{players.map((p) => (
|
||||
<li key={p.seat} className="text-sm">
|
||||
<span style={{ color: SEAT_COLOURS[p.seat] }}>P{p.seat}</span>{' '}
|
||||
{p.name} — {p.life} life
|
||||
{p.eliminated && (
|
||||
<span className="text-red-500 ml-1 font-bold">[ELIM]</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
interface HandCardProps {
|
||||
card: Card;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function HandCard({ card, isSelected, onClick }: HandCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`rounded text-[11px] px-2 py-1 cursor-pointer ${
|
||||
isSelected
|
||||
? 'border border-[#ffdc32] bg-card-selected'
|
||||
: 'bg-card-hand border border-[#5060a0]'
|
||||
}`}
|
||||
>
|
||||
{card.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { Card } from '../types/game';
|
||||
import HandCard from './HandCard';
|
||||
|
||||
interface MoveBarProps {
|
||||
hand: Card[];
|
||||
humanSeat: number;
|
||||
onPass: () => void;
|
||||
onConcede: () => void;
|
||||
onPlayCard: (index: number) => void;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function MoveBar({ hand, onPass, onConcede, onPlayCard, status }: MoveBarProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const handleCardClick = (index: number) => {
|
||||
if (selectedIndex === index) {
|
||||
onPlayCard(index);
|
||||
setSelectedIndex(null);
|
||||
} else {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-bg-bar border-t border-[#2a2e48] px-2.5 py-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPass}
|
||||
className="bg-[#2a5090] border border-[#4080d0] text-white rounded px-3 py-1 text-sm cursor-pointer hover:brightness-110"
|
||||
>
|
||||
Pass Priority
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Are you sure you want to concede?')) onConcede();
|
||||
}}
|
||||
className="bg-[#2a3868] border border-[#4060a0] text-white rounded px-3 py-1 text-sm cursor-pointer"
|
||||
>
|
||||
Concede
|
||||
</button>
|
||||
{hand.map((card, i) => (
|
||||
<HandCard
|
||||
key={i}
|
||||
card={card}
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => handleCardClick(i)}
|
||||
/>
|
||||
))}
|
||||
<span className="text-[11px] text-[#778] ml-auto">{status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import type { NarrationLine } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface NarrationPanelProps {
|
||||
lines: NarrationLine[];
|
||||
thinkingSeats: Set<number>;
|
||||
}
|
||||
|
||||
export default function NarrationPanel({ lines, thinkingSeats }: NarrationPanelProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dots, setDots] = useState('.');
|
||||
|
||||
// Auto-scroll on new lines
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines.length]);
|
||||
|
||||
// Cycling dots for thinking animation
|
||||
useEffect(() => {
|
||||
if (thinkingSeats.size === 0) return;
|
||||
const interval = setInterval(() => {
|
||||
setDots((prev) => (prev.length >= 3 ? '.' : prev + '.'));
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [thinkingSeats.size]);
|
||||
|
||||
const displayed = lines.slice(-120);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-[260px] bg-bg-panel border-l border-[#2a2e48] overflow-y-auto p-2 flex flex-col"
|
||||
>
|
||||
{displayed.map((line, i) => {
|
||||
let colorClass = 'text-[#e8e8e8]';
|
||||
let style: React.CSSProperties | undefined;
|
||||
|
||||
if (line.isSystem) {
|
||||
colorClass = 'text-[#556]';
|
||||
} else if (line.seat !== undefined) {
|
||||
colorClass = '';
|
||||
style = { color: SEAT_COLOURS[line.seat] };
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`text-[11px] leading-[1.4] break-words ${colorClass}`}
|
||||
style={style}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{thinkingSeats.size > 0 &&
|
||||
Array.from(thinkingSeats).map((seat) => (
|
||||
<div
|
||||
key={`thinking-${seat}`}
|
||||
className="text-[11px] leading-[1.4] break-words text-accent-think italic"
|
||||
>
|
||||
P{seat} thinking{dots}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Phase, Player } from '../types/game';
|
||||
import { PHASES, SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface PhaseBarProps {
|
||||
phase: Phase;
|
||||
activeSeat: number;
|
||||
players: Player[];
|
||||
}
|
||||
|
||||
export default function PhaseBar({ phase, activeSeat, players }: PhaseBarProps) {
|
||||
const activePlayer = players.find((p) => p.seat === activeSeat);
|
||||
|
||||
return (
|
||||
<div className="h-[34px] bg-[#1e2338] px-2 py-1 flex items-center gap-1">
|
||||
{PHASES.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`text-[11px] rounded px-2 py-0.5 ${
|
||||
p === phase
|
||||
? 'bg-accent-blue text-white'
|
||||
: 'bg-[#252a42] text-[#888]'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{activePlayer && (
|
||||
<span
|
||||
className="text-[11px] ml-auto font-bold"
|
||||
style={{ color: SEAT_COLOURS[activeSeat] }}
|
||||
>
|
||||
P{activeSeat}: {activePlayer.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Player } from '../types/game';
|
||||
import CardChip from './CardChip';
|
||||
|
||||
interface PlayerZoneProps {
|
||||
player: Player;
|
||||
isActive: boolean;
|
||||
isThinking: boolean;
|
||||
seatColour: string;
|
||||
}
|
||||
|
||||
function lifeColor(life: number): string {
|
||||
if (life >= 30) return 'text-life-high';
|
||||
if (life >= 15) return 'text-life-med';
|
||||
return 'text-life-low';
|
||||
}
|
||||
|
||||
export default function PlayerZone({ player, isActive, isThinking, seatColour }: PlayerZoneProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-bg-zone border border-[#3a4060] rounded-md p-1.5 ${
|
||||
isThinking ? 'animate-pulse' : ''
|
||||
} ${player.eliminated ? 'opacity-40' : ''}`}
|
||||
style={isActive ? { borderColor: seatColour } : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[11px] text-[#666]">
|
||||
P{player.seat}: {player.name}
|
||||
</span>
|
||||
<span className={`text-[28px] font-bold ${lifeColor(player.life)}`}>
|
||||
{player.life}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{player.battlefield.map((card, i) => (
|
||||
<CardChip key={i} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Phase } from './types/game';
|
||||
|
||||
export const SEAT_COLOURS = ['#4696ff', '#ff6450', '#50d278', '#dcaa32'] as const;
|
||||
|
||||
export const PHASES: Phase[] = [
|
||||
'untap', 'upkeep', 'draw', 'main1',
|
||||
'begin_combat', 'declare_attackers', 'declare_blockers', 'combat_damage',
|
||||
'main2', 'end_step', 'cleanup',
|
||||
];
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useReducer, useRef, useEffect, useCallback } from 'react';
|
||||
import { gameReducer, initialState } from '../store/gameReducer';
|
||||
import type { WsIncoming, NarrationLine } from '../types/game';
|
||||
|
||||
export function useGameSocket(gameId: string) {
|
||||
const [state, dispatch] = useReducer(gameReducer, initialState);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const url = `ws://${window.location.host}/ws/game/${gameId}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Connected' });
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg: WsIncoming = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'state':
|
||||
dispatch({ type: 'SET_STATE', payload: msg.payload });
|
||||
break;
|
||||
|
||||
case 'hand':
|
||||
dispatch({ type: 'SET_HAND', cards: msg.cards });
|
||||
break;
|
||||
|
||||
case 'event': {
|
||||
const line: NarrationLine = {
|
||||
text: msg.text,
|
||||
seat: msg.seat,
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch({ type: 'ADD_NARRATION', line });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'phase': {
|
||||
const line: NarrationLine = {
|
||||
text: `Phase: ${msg.phase} (P${msg.active_seat})`,
|
||||
isSystem: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch({ type: 'ADD_NARRATION', line });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'thinking':
|
||||
dispatch({ type: 'SET_THINKING', seat: msg.seat, active: msg.active });
|
||||
break;
|
||||
|
||||
case 'game_over':
|
||||
dispatch({ type: 'GAME_OVER', winner: msg.winner, reason: msg.reason });
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
dispatch({ type: 'SET_STATUS', status: `Error: ${msg.message}` });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!intentionalClose.current) {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Disconnected — reconnecting…' });
|
||||
reconnectTimeout.current = setTimeout(() => {
|
||||
connect();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Connection error' });
|
||||
};
|
||||
}, [gameId]);
|
||||
|
||||
// Connect on mount
|
||||
useEffect(() => {
|
||||
intentionalClose.current = false;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
intentionalClose.current = true;
|
||||
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// Heartbeat ping every 15s
|
||||
useEffect(() => {
|
||||
heartbeatInterval.current = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendMove = useCallback((move: string, data?: Record<string, unknown>) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'move', move, ...data }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendConcede = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'concede' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
gameState: state.gameState,
|
||||
narration: state.narration,
|
||||
thinking: state.thinkingSeats,
|
||||
hand: state.hand,
|
||||
gameOver: state.gameOver,
|
||||
status: state.status,
|
||||
sendMove,
|
||||
sendConcede,
|
||||
};
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { useReducer, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface Seat {
|
||||
seat: number;
|
||||
name: string;
|
||||
ready: boolean;
|
||||
isAi: boolean;
|
||||
}
|
||||
|
||||
export interface RoomState {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
host: string;
|
||||
seats: (Seat | null)[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'PLAYER' | 'AI' | 'SYSTEM';
|
||||
name: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type LobbyAction =
|
||||
| { type: 'SET_ROOM'; payload: RoomState }
|
||||
| { type: 'ADD_CHAT'; message: ChatMessage }
|
||||
| { type: 'SET_CHAT_HISTORY'; messages: ChatMessage[] }
|
||||
| { type: 'LAUNCH'; gameId: string };
|
||||
|
||||
interface LobbyState {
|
||||
roomState: RoomState | null;
|
||||
chat: ChatMessage[];
|
||||
launchedGameId: string | null;
|
||||
}
|
||||
|
||||
const initialState: LobbyState = {
|
||||
roomState: null,
|
||||
chat: [],
|
||||
launchedGameId: null,
|
||||
};
|
||||
|
||||
function lobbyReducer(state: LobbyState, action: LobbyAction): LobbyState {
|
||||
switch (action.type) {
|
||||
case 'SET_ROOM':
|
||||
return { ...state, roomState: action.payload };
|
||||
case 'ADD_CHAT':
|
||||
return { ...state, chat: [...state.chat, action.message].slice(-50) };
|
||||
case 'SET_CHAT_HISTORY':
|
||||
return { ...state, chat: action.messages.slice(-50) };
|
||||
case 'LAUNCH':
|
||||
return { ...state, launchedGameId: action.gameId };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useLobbySock(roomId: string, playerName: string) {
|
||||
const [state, dispatch] = useReducer(lobbyReducer, initialState);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const url = `ws://${window.location.host}/ws/lobby/${roomId}?name=${encodeURIComponent(playerName)}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'lobby_state':
|
||||
dispatch({ type: 'SET_ROOM', payload: msg.payload });
|
||||
break;
|
||||
case 'chat':
|
||||
dispatch({
|
||||
type: 'ADD_CHAT',
|
||||
message: {
|
||||
role: msg.role ?? 'PLAYER',
|
||||
name: msg.name ?? '',
|
||||
text: msg.text,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'chat_history':
|
||||
dispatch({
|
||||
type: 'SET_CHAT_HISTORY',
|
||||
messages: (msg.messages ?? []).map((m: { role?: string; name?: string; text: string }) => ({
|
||||
role: m.role ?? 'SYSTEM',
|
||||
name: m.name ?? '',
|
||||
text: m.text,
|
||||
timestamp: Date.now(),
|
||||
})),
|
||||
});
|
||||
break;
|
||||
case 'launch':
|
||||
dispatch({ type: 'LAUNCH', gameId: msg.game_id });
|
||||
break;
|
||||
case 'pong':
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!intentionalClose.current) {
|
||||
reconnectTimeout.current = setTimeout(() => connect(), 3000);
|
||||
}
|
||||
};
|
||||
}, [roomId, playerName]);
|
||||
|
||||
useEffect(() => {
|
||||
intentionalClose.current = false;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
intentionalClose.current = true;
|
||||
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// Heartbeat ping every 15s
|
||||
useEffect(() => {
|
||||
heartbeatInterval.current = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendReady = useCallback((seat: number) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ready', seat }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendChat = useCallback((text: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'chat', text }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendLaunch = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'launch' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
roomState: state.roomState,
|
||||
chat: state.chat,
|
||||
launchedGameId: state.launchedGameId,
|
||||
sendReady,
|
||||
sendChat,
|
||||
sendLaunch,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGameSocket } from '../hooks/useGameSocket';
|
||||
import PhaseBar from '../components/PhaseBar';
|
||||
import Board from '../components/Board';
|
||||
import NarrationPanel from '../components/NarrationPanel';
|
||||
import MoveBar from '../components/MoveBar';
|
||||
import GameOverOverlay from '../components/GameOverOverlay';
|
||||
|
||||
export default function GamePage() {
|
||||
const { gameId } = useParams<{ gameId: string }>();
|
||||
const { gameState, narration, thinking, hand, gameOver, status, sendMove, sendConcede } =
|
||||
useGameSocket(gameId!);
|
||||
|
||||
if (!gameState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
Connecting…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const winnerPlayer = gameOver
|
||||
? gameState.players.find((p) => p.seat === gameOver.winner) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-bg-base text-white">
|
||||
<PhaseBar
|
||||
phase={gameState.current_phase}
|
||||
activeSeat={gameState.active_seat}
|
||||
players={gameState.players}
|
||||
/>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Board gameState={gameState} thinkingSeats={thinking} />
|
||||
<NarrationPanel lines={narration} thinkingSeats={thinking} />
|
||||
</div>
|
||||
<MoveBar
|
||||
hand={hand}
|
||||
humanSeat={0}
|
||||
onPass={() => sendMove('pass')}
|
||||
onConcede={sendConcede}
|
||||
onPlayCard={(index) => sendMove('play_card', { index })}
|
||||
status={status}
|
||||
/>
|
||||
<GameOverOverlay
|
||||
show={gameOver !== null}
|
||||
winner={winnerPlayer}
|
||||
reason={gameOver?.reason ?? ''}
|
||||
players={gameState.players}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface RoomSummary {
|
||||
roomId: string;
|
||||
name: string;
|
||||
host: string;
|
||||
playerCount: number;
|
||||
}
|
||||
|
||||
export default function LobbyPage() {
|
||||
const navigate = useNavigate();
|
||||
const [rooms, setRooms] = useState<RoomSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Create room form state
|
||||
const [roomName, setRoomName] = useState('');
|
||||
const [hostName, setHostName] = useState('');
|
||||
const [aiSlots, setAiSlots] = useState(0);
|
||||
const [password, setPassword] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/lobby/rooms')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setRooms(data))
|
||||
.catch(() => setRooms([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/lobby/rooms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: roomName,
|
||||
host: hostName,
|
||||
ai_slots: aiSlots,
|
||||
password: password || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
navigate(`/room/${data.roomId}?name=${encodeURIComponent(hostName)}`);
|
||||
} catch {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-base text-white p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">Commander AI Lab — Lobby</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
|
||||
{/* Room List */}
|
||||
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Open Rooms</h2>
|
||||
{loading ? (
|
||||
<p className="text-text-muted text-sm">Loading...</p>
|
||||
) : rooms.length === 0 ? (
|
||||
<p className="text-text-muted text-sm">No rooms available. Create one!</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rooms.map((room) => (
|
||||
<li key={room.roomId}>
|
||||
<button
|
||||
onClick={() => navigate(`/room/${room.roomId}`)}
|
||||
className="w-full text-left bg-bg-panel border border-border-zone rounded px-3 py-2 hover:border-accent-blue transition-colors"
|
||||
>
|
||||
<span className="font-medium">{room.name}</span>
|
||||
<span className="text-text-muted text-sm ml-2">
|
||||
Host: {room.host} · {room.playerCount}/4 players
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Room Form */}
|
||||
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Create Room</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Room Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={roomName}
|
||||
onChange={(e) => setRoomName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Your Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hostName}
|
||||
onChange={(e) => setHostName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">AI Players</label>
|
||||
<select
|
||||
value={aiSlots}
|
||||
onChange={(e) => setAiSlots(Number(e.target.value))}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value={0}>0 AI players</option>
|
||||
<option value={1}>1 AI player</option>
|
||||
<option value={2}>2 AI players</option>
|
||||
<option value={3}>3 AI players</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Password (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Room'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useLobbySock } from '../hooks/useLobbySock';
|
||||
import type { ChatMessage } from '../hooks/useLobbySock';
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
PLAYER: 'text-accent-blue',
|
||||
AI: 'text-accent-purple',
|
||||
SYSTEM: 'text-text-muted',
|
||||
};
|
||||
|
||||
function ChatBadge({ role }: { role: string }) {
|
||||
return (
|
||||
<span className={`text-[10px] font-bold mr-1 ${ROLE_COLORS[role] ?? 'text-text-muted'}`}>
|
||||
[{role}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoomPage() {
|
||||
const { roomId } = useParams<{ roomId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isSpectator = searchParams.get('spectate') === '1';
|
||||
const nameFromUrl = searchParams.get('name') ?? '';
|
||||
const [playerName, setPlayerName] = useState(nameFromUrl);
|
||||
const [nameConfirmed, setNameConfirmed] = useState(!!nameFromUrl);
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { roomState, chat, launchedGameId, sendReady, sendChat, sendLaunch } =
|
||||
useLobbySock(roomId!, nameConfirmed ? playerName : '');
|
||||
|
||||
// Redirect when game launches
|
||||
useEffect(() => {
|
||||
if (launchedGameId) {
|
||||
navigate(`/game/${launchedGameId}`);
|
||||
}
|
||||
}, [launchedGameId, navigate]);
|
||||
|
||||
// Auto-scroll chat
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [chat.length]);
|
||||
|
||||
// Determine own seat
|
||||
const mySeat = roomState?.seats.findIndex(
|
||||
(s) => s && !s.isAi && s.name === playerName,
|
||||
) ?? -1;
|
||||
const isHost = mySeat === 0;
|
||||
|
||||
if (!nameConfirmed) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
<div className="bg-bg-zone border border-border-zone rounded-lg p-6 w-80">
|
||||
<h2 className="text-lg font-semibold mb-3">Enter your name</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && playerName.trim()) setNameConfirmed(true);
|
||||
}}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white mb-3 focus:outline-none focus:border-accent-blue"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => playerName.trim() && setNameConfirmed(true)}
|
||||
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
|
||||
>
|
||||
Join Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!roomState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
Connecting to room...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSendChat = () => {
|
||||
if (chatInput.trim()) {
|
||||
sendChat(chatInput.trim());
|
||||
setChatInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg-base text-white">
|
||||
{/* Header */}
|
||||
<div className="bg-bg-bar border-b border-border-sep px-4 py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">{roomState.roomName}</h1>
|
||||
<span className="text-text-muted text-sm">Host: {roomState.host}</span>
|
||||
</div>
|
||||
{isSpectator && (
|
||||
<span className="text-text-muted text-xs bg-bg-panel px-2 py-1 rounded">
|
||||
SPECTATING
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Seat Grid */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="grid grid-cols-2 gap-3 max-w-lg">
|
||||
{[0, 1, 2, 3].map((seatIdx) => {
|
||||
const seat = roomState.seats[seatIdx];
|
||||
return (
|
||||
<div
|
||||
key={seatIdx}
|
||||
className="bg-bg-zone border border-border-zone rounded-lg p-4 min-h-[100px] flex flex-col items-center justify-center"
|
||||
>
|
||||
{seat ? (
|
||||
<>
|
||||
<span className="text-sm font-medium">
|
||||
{seat.isAi ? '🤖 AI' : seat.name}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted mt-1">
|
||||
Seat {seatIdx}
|
||||
</span>
|
||||
{!seat.isAi && (
|
||||
<span
|
||||
className={`text-xs mt-1 font-bold ${
|
||||
seat.ready ? 'text-life-high' : 'text-life-med'
|
||||
}`}
|
||||
>
|
||||
{seat.ready ? 'READY' : 'Not Ready'}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-text-muted text-sm">Waiting...</span>
|
||||
<span className="text-xs text-text-muted mt-1">
|
||||
Seat {seatIdx}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{!isSpectator && (
|
||||
<div className="mt-4 flex gap-3">
|
||||
{mySeat >= 0 && (
|
||||
<button
|
||||
onClick={() => sendReady(mySeat)}
|
||||
className="bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
|
||||
>
|
||||
Toggle Ready
|
||||
</button>
|
||||
)}
|
||||
{isHost && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/lobby/rooms/${roomId}/launch`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.gameId) {
|
||||
navigate(`/game/${data.gameId}`);
|
||||
}
|
||||
} catch {
|
||||
// launch via WS fallback
|
||||
sendLaunch();
|
||||
}
|
||||
}}
|
||||
className="bg-life-high border border-life-high text-white rounded px-4 py-2 text-sm font-bold hover:brightness-110"
|
||||
>
|
||||
Launch Game
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat Panel */}
|
||||
<div className="w-[280px] bg-bg-panel border-l border-border-sep flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border-sep text-sm font-semibold">
|
||||
Chat
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{chat.map((msg: ChatMessage, i: number) => (
|
||||
<div key={i} className="text-[11px] leading-[1.4] break-words">
|
||||
<ChatBadge role={msg.role} />
|
||||
{msg.name && <span className="font-medium mr-1">{msg.name}:</span>}
|
||||
<span className="text-text-body">{msg.text}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
{!isSpectator && (
|
||||
<div className="border-t border-border-sep p-2">
|
||||
<input
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSendChat();
|
||||
}}
|
||||
placeholder="Type a message..."
|
||||
className="w-full bg-bg-base border border-border-zone rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { GameState, Card, NarrationLine } from '../types/game';
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'SET_STATE'; payload: GameState }
|
||||
| { type: 'SET_HAND'; cards: Card[] }
|
||||
| { type: 'ADD_NARRATION'; line: NarrationLine }
|
||||
| { type: 'SET_THINKING'; seat: number; active: boolean }
|
||||
| { type: 'GAME_OVER'; winner: number; reason: string }
|
||||
| { type: 'SET_STATUS'; status: string };
|
||||
|
||||
export interface ReducerState {
|
||||
gameState: GameState | null;
|
||||
hand: Card[];
|
||||
narration: NarrationLine[];
|
||||
thinkingSeats: Set<number>;
|
||||
gameOver: { winner: number; reason: string } | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const initialState: ReducerState = {
|
||||
gameState: null,
|
||||
hand: [],
|
||||
narration: [],
|
||||
thinkingSeats: new Set(),
|
||||
gameOver: null,
|
||||
status: '',
|
||||
};
|
||||
|
||||
export function gameReducer(state: ReducerState, action: GameAction): ReducerState {
|
||||
switch (action.type) {
|
||||
case 'SET_STATE':
|
||||
return { ...state, gameState: action.payload };
|
||||
|
||||
case 'SET_HAND':
|
||||
return { ...state, hand: action.cards };
|
||||
|
||||
case 'ADD_NARRATION': {
|
||||
const narration = [...state.narration, action.line];
|
||||
return { ...state, narration: narration.slice(-120) };
|
||||
}
|
||||
|
||||
case 'SET_THINKING': {
|
||||
const thinkingSeats = new Set(state.thinkingSeats);
|
||||
if (action.active) {
|
||||
thinkingSeats.add(action.seat);
|
||||
} else {
|
||||
thinkingSeats.delete(action.seat);
|
||||
}
|
||||
return { ...state, thinkingSeats };
|
||||
}
|
||||
|
||||
case 'GAME_OVER':
|
||||
return { ...state, gameOver: { winner: action.winner, reason: action.reason } };
|
||||
|
||||
case 'SET_STATUS':
|
||||
return { ...state, status: action.status };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -1,55 +0,0 @@
|
||||
export type Phase =
|
||||
| 'untap' | 'upkeep' | 'draw' | 'main1'
|
||||
| 'begin_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage'
|
||||
| 'main2' | 'end_step' | 'cleanup';
|
||||
|
||||
export interface Card {
|
||||
name: string;
|
||||
type: string;
|
||||
tapped: boolean;
|
||||
is_commander: boolean;
|
||||
oracle: string;
|
||||
cmc: number;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
seat: number;
|
||||
name: string;
|
||||
life: number;
|
||||
eliminated: boolean;
|
||||
hand: Card[];
|
||||
battlefield: Card[];
|
||||
graveyard: Card[];
|
||||
exile: Card[];
|
||||
command_zone: Card[];
|
||||
hand_count: number;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
game_id: string;
|
||||
turn: number;
|
||||
current_phase: Phase;
|
||||
active_seat: number;
|
||||
players: Player[];
|
||||
stack: Card[];
|
||||
game_over: boolean;
|
||||
winner: Player | null;
|
||||
}
|
||||
|
||||
export type WsIncoming =
|
||||
| { type: 'state'; payload: GameState }
|
||||
| { type: 'event'; text: string; seat?: number }
|
||||
| { type: 'phase'; phase: Phase; active_seat: number }
|
||||
| { type: 'thinking'; seat: number; active: boolean }
|
||||
| { type: 'elimination'; seat: number }
|
||||
| { type: 'game_over'; winner: number; reason: string }
|
||||
| { type: 'hand'; cards: Card[] }
|
||||
| { type: 'pong' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
export interface NarrationLine {
|
||||
text: string;
|
||||
seat?: number;
|
||||
isSystem: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: {
|
||||
base: '#12141e',
|
||||
zone: '#1c2034',
|
||||
panel: '#161820',
|
||||
bar: '#1a1e30',
|
||||
phase: '#1e2338',
|
||||
hand: '#1a2030',
|
||||
},
|
||||
accent: {
|
||||
blue: '#3c82dc',
|
||||
think: '#64b4ff',
|
||||
purple: '#7060c0',
|
||||
},
|
||||
life: {
|
||||
high: '#50c87a',
|
||||
med: '#dcb432',
|
||||
low: '#dc3c3c',
|
||||
},
|
||||
seat: {
|
||||
0: '#4696ff',
|
||||
1: '#ff6450',
|
||||
2: '#50d278',
|
||||
3: '#dcaa32',
|
||||
},
|
||||
card: {
|
||||
bg: '#2a3050',
|
||||
hand: '#2a3868',
|
||||
selected: '#3a4878',
|
||||
},
|
||||
border: {
|
||||
zone: '#3a4060',
|
||||
sep: '#2a2e48',
|
||||
btn: '#4060a0',
|
||||
overlay: '#4060c0',
|
||||
'btn-hover': '#6080d0',
|
||||
'btn-primary': '#4080d0',
|
||||
'hand-card': '#5060a0',
|
||||
chip: '#444444',
|
||||
},
|
||||
phase: {
|
||||
tab: '#252a42',
|
||||
text: '#888888',
|
||||
},
|
||||
btn: {
|
||||
primary: '#2a5090',
|
||||
},
|
||||
text: {
|
||||
body: '#e8e8e8',
|
||||
muted: '#888888',
|
||||
system: '#555566',
|
||||
'zone-label': '#666666',
|
||||
status: '#777788',
|
||||
sub: '#aaaaaa',
|
||||
btn: '#cccccc',
|
||||
},
|
||||
highlight: {
|
||||
gold: '#ffdc32',
|
||||
commander: '#ffd200',
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
pulse: {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.7' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
pulse: 'pulse 1s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
// Dev-only proxy — in production FastAPI serves the built assets
|
||||
// and handles /api and /ws routes directly.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
},
|
||||
})
|
||||
+1944
-163
File diff suppressed because it is too large.
Load diff
+950
-61
File diff suppressed because it is too large.
Load diff
@@ -1,47 +0,0 @@
|
||||
"""Pydantic models for Commander AI Lab API."""
|
||||
|
||||
from models.requests import (
|
||||
StartRequest,
|
||||
ImportUrlRequest,
|
||||
ImportTextRequest,
|
||||
MetaFetchRequest,
|
||||
CreateDeckRequest,
|
||||
UpdateDeckRequest,
|
||||
AddDeckCardRequest,
|
||||
PatchDeckCardRequest,
|
||||
BulkAddRequest,
|
||||
BulkAddRecommendedRequest,
|
||||
DeckGenerationSourceConfig,
|
||||
DeckGenerationRequest,
|
||||
DeckGenV3Request,
|
||||
DeckGenV3SubstituteRequest,
|
||||
)
|
||||
from models.responses import (
|
||||
StartResponse,
|
||||
StatusResponse,
|
||||
DeckInfo,
|
||||
GeneratedDeckCard,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Requests
|
||||
"StartRequest",
|
||||
"ImportUrlRequest",
|
||||
"ImportTextRequest",
|
||||
"MetaFetchRequest",
|
||||
"CreateDeckRequest",
|
||||
"UpdateDeckRequest",
|
||||
"AddDeckCardRequest",
|
||||
"PatchDeckCardRequest",
|
||||
"BulkAddRequest",
|
||||
"BulkAddRecommendedRequest",
|
||||
"DeckGenerationSourceConfig",
|
||||
"DeckGenerationRequest",
|
||||
"DeckGenV3Request",
|
||||
"DeckGenV3SubstituteRequest",
|
||||
# Responses
|
||||
"StartResponse",
|
||||
"StatusResponse",
|
||||
"DeckInfo",
|
||||
"GeneratedDeckCard",
|
||||
]
|
||||
@@ -1,119 +0,0 @@
|
||||
"""Pydantic request models for Commander AI Lab API."""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class StartRequest(BaseModel):
|
||||
decks: list[str]
|
||||
numGames: int = 100
|
||||
threads: int = 4
|
||||
seed: Optional[int] = None
|
||||
clock: int = 120
|
||||
deckSources: Optional[list[Optional[dict]]] = None
|
||||
useLearnedPolicy: bool = False
|
||||
policyStyle: str = "midrange"
|
||||
policyGreedy: bool = False
|
||||
aiSimplified: bool = False
|
||||
aiThinkTimeMs: int = -1
|
||||
maxQueueDepth: int = -1
|
||||
|
||||
|
||||
class ImportUrlRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class ImportTextRequest(BaseModel):
|
||||
text: str
|
||||
commander: Optional[str] = None
|
||||
|
||||
|
||||
class MetaFetchRequest(BaseModel):
|
||||
commander: str
|
||||
|
||||
|
||||
class CreateDeckRequest(BaseModel):
|
||||
name: str
|
||||
commander_scryfall_id: Optional[str] = ""
|
||||
commander_name: Optional[str] = ""
|
||||
color_identity: Optional[list] = []
|
||||
strategy_tag: Optional[str] = ""
|
||||
|
||||
|
||||
class UpdateDeckRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
commander_scryfall_id: Optional[str] = None
|
||||
commander_name: Optional[str] = None
|
||||
color_identity: Optional[list] = None
|
||||
strategy_tag: Optional[str] = None
|
||||
|
||||
|
||||
class AddDeckCardRequest(BaseModel):
|
||||
scryfall_id: str
|
||||
card_name: Optional[str] = ""
|
||||
quantity: Optional[int] = 1
|
||||
is_commander: Optional[int] = 0
|
||||
role_tag: Optional[str] = ""
|
||||
|
||||
|
||||
class PatchDeckCardRequest(BaseModel):
|
||||
quantity: Optional[int] = None
|
||||
role_tag: Optional[str] = None
|
||||
|
||||
|
||||
class BulkAddRequest(BaseModel):
|
||||
cards: list[dict]
|
||||
respect_ratios: Optional[bool] = False
|
||||
|
||||
|
||||
class BulkAddRecommendedRequest(BaseModel):
|
||||
source: str = "collection"
|
||||
only_owned: Optional[bool] = True
|
||||
respect_ratios: Optional[bool] = False
|
||||
types: Optional[list[str]] = None
|
||||
roles: Optional[list[str]] = None
|
||||
|
||||
|
||||
class DeckGenerationSourceConfig(BaseModel):
|
||||
use_archidekt: bool = True
|
||||
use_edhrec: bool = True
|
||||
use_moxfield: bool = False
|
||||
use_mtggoldfish: bool = False
|
||||
archidekt_url: Optional[str] = ""
|
||||
moxfield_url: Optional[str] = ""
|
||||
mtggoldfish_url: Optional[str] = ""
|
||||
|
||||
|
||||
class DeckGenerationRequest(BaseModel):
|
||||
commander_name: Optional[str] = ""
|
||||
commander_scryfall_id: Optional[str] = ""
|
||||
color_identity: Optional[list[str]] = None
|
||||
sources: Optional[DeckGenerationSourceConfig] = None
|
||||
target_land_count: int = 37
|
||||
target_instant_count: int = 10
|
||||
target_sorcery_count: int = 8
|
||||
target_artifact_count: int = 10
|
||||
target_enchantment_count: int = 8
|
||||
target_creature_count: int = 25
|
||||
target_planeswalker_count: int = 2
|
||||
only_cards_in_collection: bool = False
|
||||
allow_proxies: bool = True
|
||||
deck_name: Optional[str] = ""
|
||||
|
||||
|
||||
class DeckGenV3Request(BaseModel):
|
||||
commander_name: str = ""
|
||||
strategy: str = ""
|
||||
target_bracket: int = 3
|
||||
budget_usd: Optional[float] = None
|
||||
budget_mode: str = "total"
|
||||
omit_cards: list[str] = []
|
||||
use_collection: bool = True
|
||||
run_substitution: bool = True
|
||||
model: Optional[str] = None
|
||||
deck_name: Optional[str] = ""
|
||||
|
||||
|
||||
class DeckGenV3SubstituteRequest(BaseModel):
|
||||
card_name: str
|
||||
substitute_name: str
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Pydantic response models for Commander AI Lab API."""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class StartResponse(BaseModel):
|
||||
batchId: str
|
||||
status: str = "started"
|
||||
message: str = ""
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
batchId: str = ""
|
||||
running: bool = False
|
||||
completed: int = 0
|
||||
total: int = 0
|
||||
threads: int = 0
|
||||
elapsedMs: int = 0
|
||||
error: Optional[str] = None
|
||||
simsPerSec: float = 0.0
|
||||
run_id: str = ""
|
||||
games_completed: int = 0
|
||||
total_games: int = 0
|
||||
current_decks: list = []
|
||||
|
||||
|
||||
class DeckInfo(BaseModel):
|
||||
name: str
|
||||
filename: str
|
||||
|
||||
|
||||
class GeneratedDeckCard(BaseModel):
|
||||
scryfall_id: str = ""
|
||||
name: str = ""
|
||||
type_line: str = ""
|
||||
mana_cost: str = ""
|
||||
cmc: float = 0
|
||||
card_type: str = ""
|
||||
roles: list[str] = []
|
||||
source: str = "collection"
|
||||
quantity: int = 1
|
||||
image_url: str = ""
|
||||
owned_qty: int = 0
|
||||
is_proxy: bool = False
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Configuration
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class Config:
|
||||
"""Runtime configuration -- set via CLI args or env vars."""
|
||||
forge_jar: str = ""
|
||||
forge_dir: str = ""
|
||||
forge_decks_dir: str = ""
|
||||
lab_jar: str = ""
|
||||
results_dir: str = "results"
|
||||
port: int = 8080
|
||||
ximilar_api_key: str = ""
|
||||
pplx_api_key: str = ""
|
||||
|
||||
|
||||
CFG = Config()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# In-Memory State
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class BatchState:
|
||||
def __init__(self, batch_id: str, total_games: int, threads: int):
|
||||
self.batch_id = batch_id
|
||||
self.total_games = total_games
|
||||
self.threads = threads
|
||||
self.completed_games = 0
|
||||
self.running = True
|
||||
self.start_time = datetime.now()
|
||||
self.elapsed_ms = 0
|
||||
self.result_path: Optional[str] = None
|
||||
self.error: Optional[str] = None
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self.log_lines: list = []
|
||||
self.sims_per_sec: float = 0.0
|
||||
|
||||
|
||||
active_batches: dict[str, BatchState] = {}
|
||||
|
||||
|
||||
COMMANDER_META: dict = {}
|
||||
|
||||
BUILTIN_COMMANDERS: dict = {
|
||||
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
|
||||
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
|
||||
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
|
||||
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
|
||||
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
|
||||
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
|
||||
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
|
||||
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
|
||||
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
|
||||
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
|
||||
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
|
||||
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
|
||||
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
|
||||
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
|
||||
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
|
||||
}
|
||||
|
||||
|
||||
def load_commander_meta() -> None:
|
||||
"""Load commander meta mapping from file or fall back to builtins."""
|
||||
global COMMANDER_META
|
||||
meta_path = Path(__file__).parent.parent / "commander-meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
COMMANDER_META = json.load(f)
|
||||
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
|
||||
return
|
||||
except Exception as e:
|
||||
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
|
||||
COMMANDER_META = BUILTIN_COMMANDERS
|
||||
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# AI Profiles
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
AI_PROFILES = {
|
||||
"default": {"name": "default", "description": "Balanced \u2014 Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
|
||||
"aggro": {"name": "aggro", "description": "Aggressive \u2014 attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
|
||||
"control": {"name": "control", "description": "Control \u2014 defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
|
||||
"combo": {"name": "combo", "description": "Combo \u2014 ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
|
||||
"midrange": {"name": "midrange", "description": "Midrange \u2014 flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
|
||||
}
|
||||
+504
-328
@@ -16,10 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading as _threading
|
||||
import traceback
|
||||
import uuid as _uuid
|
||||
from pathlib import Path
|
||||
|
||||
@@ -30,175 +27,157 @@ from fastapi.responses import JSONResponse
|
||||
from routes.shared import (
|
||||
CFG, log_sim,
|
||||
_get_db_conn,
|
||||
_get_deepseek_brain,
|
||||
_ml_logging_enabled,
|
||||
_get_deepseek_brain,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["deepseek"])
|
||||
|
||||
|
||||
# In-memory store for simulation runs
|
||||
_sim_runs = {} # sim_id -> { status, result, error }
|
||||
_sim_lock = _threading.Lock()
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Shared Helpers (extracted to deduplicate sim threads — #42)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
_SRC_PATH_ADDED = False
|
||||
|
||||
|
||||
def _ensure_src_path():
|
||||
"""Add project src/ to sys.path once."""
|
||||
global _SRC_PATH_ADDED
|
||||
if not _SRC_PATH_ADDED:
|
||||
src_dir = str(Path(__file__).resolve().parent.parent / 'src')
|
||||
def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int, deck_name: str, record_logs: bool):
|
||||
"""Background thread for simulations with full card data from the DB."""
|
||||
try:
|
||||
import sys, os
|
||||
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src')
|
||||
if src_dir not in sys.path:
|
||||
sys.path.insert(0, src_dir)
|
||||
_SRC_PATH_ADDED = True
|
||||
|
||||
|
||||
def _build_deck_from_card_data(card_data: list[dict]) -> list:
|
||||
"""Build a list of Card objects from DB card dicts. Used by v2 + deepseek threads."""
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.models import Card
|
||||
from commander_ai_lab.sim.rules import enrich_card
|
||||
|
||||
deck = []
|
||||
for cd in card_data:
|
||||
c = Card(name=cd['name'])
|
||||
if cd.get('type_line'):
|
||||
c.type_line = cd['type_line']
|
||||
if cd.get('cmc'):
|
||||
c.cmc = float(cd['cmc'])
|
||||
if cd.get('power') and cd.get('toughness'):
|
||||
c.power = str(cd['power'])
|
||||
c.toughness = str(cd['toughness'])
|
||||
c.pt = c.power + '/' + c.toughness
|
||||
if cd.get('oracle_text'):
|
||||
c.oracle_text = cd['oracle_text']
|
||||
if cd.get('mana_cost'):
|
||||
c.mana_cost = cd['mana_cost']
|
||||
if cd.get('keywords'):
|
||||
kw = cd['keywords']
|
||||
if isinstance(kw, str):
|
||||
try:
|
||||
kw = json.loads(kw)
|
||||
except Exception:
|
||||
kw = []
|
||||
if isinstance(kw, list):
|
||||
c.keywords = kw
|
||||
enrich_card(c)
|
||||
deck.append(c)
|
||||
return deck
|
||||
|
||||
|
||||
def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
|
||||
num_games, sim_id, **run_kwargs):
|
||||
"""Run *num_games* and accumulate stats. Returns (game_results, stats, elapsed)."""
|
||||
start = time.time()
|
||||
wins = losses = total_turns = 0
|
||||
total_damage_dealt = total_damage_received = 0
|
||||
total_spells_cast = total_creatures_played = 0
|
||||
total_removal_used = total_ramp_played = 0
|
||||
total_cards_drawn = total_max_board = 0
|
||||
game_results = []
|
||||
|
||||
for i in range(num_games):
|
||||
kw = dict(run_kwargs)
|
||||
# DeepSeek engine needs per-game IDs
|
||||
if 'game_id_prefix' in kw:
|
||||
prefix = kw.pop('game_id_prefix')
|
||||
kw['game_id'] = f'{prefix}-g{i+1}'
|
||||
result = engine.run(deck_a, deck_b, name_a=deck_name,
|
||||
name_b=opponent_name, **kw)
|
||||
game_data = result.to_dict()
|
||||
game_data['gameNumber'] = i + 1
|
||||
game_results.append(game_data)
|
||||
|
||||
if result.winner == 0:
|
||||
wins += 1
|
||||
else:
|
||||
losses += 1
|
||||
total_turns += result.turns
|
||||
|
||||
if result.player_a_stats:
|
||||
s = result.player_a_stats
|
||||
total_damage_dealt += s.damage_dealt
|
||||
total_damage_received += s.damage_received
|
||||
total_spells_cast += s.spells_cast
|
||||
total_creatures_played += s.creatures_played
|
||||
total_removal_used += s.removal_used
|
||||
total_ramp_played += s.ramp_played
|
||||
total_cards_drawn += s.cards_drawn
|
||||
total_max_board += s.max_board_size
|
||||
from commander_ai_lab.sim.models import Card
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
from commander_ai_lab.sim.rules import enrich_card
|
||||
from commander_ai_lab.lab.experiments import _generate_training_deck
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['completed'] = i + 1
|
||||
_sim_runs[sim_id]['status'] = 'running'
|
||||
|
||||
elapsed = time.time() - start
|
||||
stats = dict(
|
||||
wins=wins, losses=losses, total_turns=total_turns,
|
||||
total_damage_dealt=total_damage_dealt,
|
||||
total_damage_received=total_damage_received,
|
||||
total_spells_cast=total_spells_cast,
|
||||
total_creatures_played=total_creatures_played,
|
||||
total_removal_used=total_removal_used,
|
||||
total_ramp_played=total_ramp_played,
|
||||
total_cards_drawn=total_cards_drawn,
|
||||
total_max_board=total_max_board,
|
||||
)
|
||||
return game_results, stats, elapsed
|
||||
# Build deck with real card data from DB
|
||||
deck_a = []
|
||||
for cd in card_data:
|
||||
c = Card(name=cd['name'])
|
||||
if cd.get('type_line'):
|
||||
c.type_line = cd['type_line']
|
||||
if cd.get('cmc'):
|
||||
c.cmc = float(cd['cmc'])
|
||||
if cd.get('power') and cd.get('toughness'):
|
||||
c.power = str(cd['power'])
|
||||
c.toughness = str(cd['toughness'])
|
||||
c.pt = c.power + '/' + c.toughness
|
||||
if cd.get('oracle_text'):
|
||||
c.oracle_text = cd['oracle_text']
|
||||
if cd.get('mana_cost'):
|
||||
c.mana_cost = cd['mana_cost']
|
||||
if cd.get('keywords'):
|
||||
kw = cd['keywords']
|
||||
if isinstance(kw, str):
|
||||
try:
|
||||
import json as _json
|
||||
kw = _json.loads(kw)
|
||||
except Exception:
|
||||
kw = []
|
||||
if isinstance(kw, list):
|
||||
c.keywords = kw
|
||||
# Enrich fills in flags like is_removal, is_ramp, is_board_wipe
|
||||
enrich_card(c)
|
||||
deck_a.append(c)
|
||||
|
||||
deck_b = _generate_training_deck()
|
||||
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs)
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
wins = 0
|
||||
losses = 0
|
||||
total_turns = 0
|
||||
total_damage_dealt = 0
|
||||
total_damage_received = 0
|
||||
total_spells_cast = 0
|
||||
total_creatures_played = 0
|
||||
total_removal_used = 0
|
||||
total_ramp_played = 0
|
||||
total_cards_drawn = 0
|
||||
total_max_board = 0
|
||||
game_results = []
|
||||
|
||||
for i in range(num_games):
|
||||
result = engine.run(deck_a, deck_b, name_a=deck_name, name_b="Training Deck")
|
||||
|
||||
game_data = result.to_dict()
|
||||
game_data['gameNumber'] = i + 1
|
||||
game_results.append(game_data)
|
||||
|
||||
if result.winner == 0:
|
||||
wins += 1
|
||||
else:
|
||||
losses += 1
|
||||
|
||||
total_turns += result.turns
|
||||
if result.player_a_stats:
|
||||
total_damage_dealt += result.player_a_stats.damage_dealt
|
||||
total_damage_received += result.player_a_stats.damage_received
|
||||
total_spells_cast += result.player_a_stats.spells_cast
|
||||
total_creatures_played += result.player_a_stats.creatures_played
|
||||
total_removal_used += result.player_a_stats.removal_used
|
||||
total_ramp_played += result.player_a_stats.ramp_played
|
||||
total_cards_drawn += result.player_a_stats.cards_drawn
|
||||
total_max_board += result.player_a_stats.max_board_size
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['completed'] = i + 1
|
||||
|
||||
elapsed = time.time() - start
|
||||
n = num_games
|
||||
|
||||
summary = {
|
||||
'deckName': deck_name,
|
||||
'opponentName': 'Training Deck',
|
||||
'totalGames': n,
|
||||
'wins': wins,
|
||||
'losses': losses,
|
||||
'winRate': round(wins / n * 100, 1) if n > 0 else 0.0,
|
||||
'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0,
|
||||
'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0,
|
||||
'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0,
|
||||
'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0,
|
||||
'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0,
|
||||
'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0,
|
||||
'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0,
|
||||
'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0,
|
||||
'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0,
|
||||
'elapsedSeconds': round(elapsed, 3),
|
||||
}
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'complete'
|
||||
_sim_runs[sim_id]['result'] = {
|
||||
'summary': summary,
|
||||
'games': game_results,
|
||||
}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'error'
|
||||
_sim_runs[sim_id]['error'] = str(e)
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def _build_summary(stats: dict, deck_name: str, opponent_name: str,
|
||||
num_games: int, elapsed: float, **extra) -> dict:
|
||||
"""Build the JSON-serialisable summary dict from accumulated stats."""
|
||||
n = num_games
|
||||
summary = {
|
||||
'deckName': deck_name,
|
||||
'opponentName': opponent_name,
|
||||
'totalGames': n,
|
||||
'wins': stats['wins'],
|
||||
'losses': stats['losses'],
|
||||
'winRate': round(stats['wins'] / n * 100, 1) if n > 0 else 0.0,
|
||||
'avgTurns': round(stats['total_turns'] / n, 1) if n > 0 else 0.0,
|
||||
'avgDamageDealt': round(stats['total_damage_dealt'] / n, 1) if n > 0 else 0.0,
|
||||
'avgDamageReceived': round(stats['total_damage_received'] / n, 1) if n > 0 else 0.0,
|
||||
'avgSpellsCast': round(stats['total_spells_cast'] / n, 1) if n > 0 else 0.0,
|
||||
'avgCreaturesPlayed': round(stats['total_creatures_played'] / n, 1) if n > 0 else 0.0,
|
||||
'avgRemovalUsed': round(stats['total_removal_used'] / n, 1) if n > 0 else 0.0,
|
||||
'avgRampPlayed': round(stats['total_ramp_played'] / n, 1) if n > 0 else 0.0,
|
||||
'avgCardsDrawn': round(stats['total_cards_drawn'] / n, 1) if n > 0 else 0.0,
|
||||
'avgMaxBoardSize': round(stats['total_max_board'] / n, 1) if n > 0 else 0.0,
|
||||
'elapsedSeconds': round(elapsed, 3),
|
||||
}
|
||||
summary.update(extra)
|
||||
return summary
|
||||
|
||||
|
||||
def _finish_sim(sim_id, summary, game_results):
|
||||
"""Mark a sim run as complete."""
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'complete'
|
||||
_sim_runs[sim_id]['result'] = {'summary': summary, 'games': game_results}
|
||||
|
||||
|
||||
def _fail_sim(sim_id, error):
|
||||
"""Mark a sim run as errored."""
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'error'
|
||||
_sim_runs[sim_id]['error'] = str(error)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Background Sim Threads (now thin wrappers around shared helpers)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _run_sim_thread(sim_id: str, decklist: list, num_games: int,
|
||||
deck_name: str, record_logs: bool):
|
||||
"""Background thread: basic Monte Carlo sim from a raw decklist."""
|
||||
def _run_sim_thread(sim_id: str, decklist: list, num_games: int, deck_name: str, record_logs: bool):
|
||||
"""Background thread for running Monte Carlo simulations."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
import sys, os
|
||||
# Add src/ to path if needed for the simulator package
|
||||
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src')
|
||||
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.engine import GameEngine
|
||||
from commander_ai_lab.sim.rules import enrich_card, parse_decklist
|
||||
from commander_ai_lab.lab.experiments import build_deck, _generate_training_deck
|
||||
|
||||
with _sim_lock:
|
||||
@@ -206,143 +185,85 @@ def _run_sim_thread(sim_id: str, decklist: list, num_games: int,
|
||||
|
||||
deck_a = build_deck(decklist)
|
||||
deck_b = _generate_training_deck()
|
||||
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs)
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
game_results, stats, elapsed = _run_game_loop(
|
||||
engine, deck_a, deck_b, deck_name, 'Training Deck',
|
||||
num_games, sim_id)
|
||||
wins = 0
|
||||
losses = 0
|
||||
total_turns = 0
|
||||
total_damage_dealt = 0
|
||||
total_damage_received = 0
|
||||
total_spells_cast = 0
|
||||
total_creatures_played = 0
|
||||
total_removal_used = 0
|
||||
total_ramp_played = 0
|
||||
total_cards_drawn = 0
|
||||
total_max_board = 0
|
||||
game_results = []
|
||||
|
||||
summary = _build_summary(stats, deck_name, 'Training Deck',
|
||||
num_games, elapsed)
|
||||
_finish_sim(sim_id, summary, game_results)
|
||||
except Exception as e:
|
||||
_fail_sim(sim_id, e)
|
||||
traceback.print_exc()
|
||||
for i in range(num_games):
|
||||
result = engine.run(deck_a, deck_b, name_a=deck_name, name_b="Training Deck")
|
||||
|
||||
game_data = result.to_dict()
|
||||
game_data['gameNumber'] = i + 1
|
||||
game_results.append(game_data)
|
||||
|
||||
def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int,
|
||||
deck_name: str, record_logs: bool):
|
||||
"""Background thread: sim with full card data from the DB."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
from commander_ai_lab.lab.experiments import _generate_training_deck
|
||||
if result.winner == 0:
|
||||
wins += 1
|
||||
else:
|
||||
losses += 1
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'running'
|
||||
total_turns += result.turns
|
||||
if result.player_a_stats:
|
||||
total_damage_dealt += result.player_a_stats.damage_dealt
|
||||
total_damage_received += result.player_a_stats.damage_received
|
||||
total_spells_cast += result.player_a_stats.spells_cast
|
||||
total_creatures_played += result.player_a_stats.creatures_played
|
||||
total_removal_used += result.player_a_stats.removal_used
|
||||
total_ramp_played += result.player_a_stats.ramp_played
|
||||
total_cards_drawn += result.player_a_stats.cards_drawn
|
||||
total_max_board += result.player_a_stats.max_board_size
|
||||
|
||||
deck_a = _build_deck_from_card_data(card_data)
|
||||
deck_b = _generate_training_deck()
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs)
|
||||
# Update progress
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['completed'] = i + 1
|
||||
|
||||
game_results, stats, elapsed = _run_game_loop(
|
||||
engine, deck_a, deck_b, deck_name, 'Training Deck',
|
||||
num_games, sim_id)
|
||||
elapsed = time.time() - start
|
||||
n = num_games
|
||||
|
||||
summary = _build_summary(stats, deck_name, 'Training Deck',
|
||||
num_games, elapsed)
|
||||
_finish_sim(sim_id, summary, game_results)
|
||||
except Exception as e:
|
||||
_fail_sim(sim_id, e)
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
|
||||
num_games: int, deck_name: str,
|
||||
record_logs: bool):
|
||||
"""Background thread: sim using DeepSeek AI opponent."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
|
||||
from commander_ai_lab.lab.experiments import _generate_training_deck
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'running'
|
||||
|
||||
deck_a = _build_deck_from_card_data(card_data)
|
||||
deck_b = _generate_training_deck()
|
||||
|
||||
brain = _get_deepseek_brain()
|
||||
if brain and not brain._connected:
|
||||
brain.check_connection()
|
||||
|
||||
engine = DeepSeekGameEngine(
|
||||
brain=brain, ai_player_index=1,
|
||||
max_turns=25, record_log=record_logs)
|
||||
|
||||
game_results, stats, elapsed = _run_game_loop(
|
||||
engine, deck_a, deck_b, deck_name, 'DeepSeek AI',
|
||||
num_games, sim_id,
|
||||
game_id_prefix=f'ds-sim-{sim_id[:8]}',
|
||||
archetype='midrange')
|
||||
|
||||
# Write ML decision JSONL for training pipeline
|
||||
ml_decisions = engine.flush_ml_decisions()
|
||||
if ml_decisions:
|
||||
ml_path = os.path.join('results',
|
||||
f'ml-decisions-sim-{sim_id[:8]}.jsonl')
|
||||
os.makedirs('results', exist_ok=True)
|
||||
with open(ml_path, 'w', encoding='utf-8') as mf:
|
||||
for dec in ml_decisions:
|
||||
mf.write(json.dumps(dec) + '\n')
|
||||
|
||||
ds_stats = brain.get_stats() if brain else {}
|
||||
summary = _build_summary(
|
||||
stats, deck_name, 'DeepSeek AI', num_games, elapsed,
|
||||
opponentType='deepseek', deepseekStats=ds_stats)
|
||||
_finish_sim(sim_id, summary, game_results)
|
||||
except Exception as e:
|
||||
_fail_sim(sim_id, e)
|
||||
traceback.print_exc()
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Simulation API Endpoints
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _create_sim_run(num_games, deck_name):
|
||||
"""Create a queued sim run entry and return its ID."""
|
||||
sim_id = str(_uuid.uuid4())[:8]
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id] = {
|
||||
'status': 'queued', 'completed': 0,
|
||||
'total': num_games, 'deckName': deck_name,
|
||||
'result': None, 'error': None,
|
||||
summary = {
|
||||
'deckName': deck_name,
|
||||
'opponentName': 'Training Deck',
|
||||
'totalGames': n,
|
||||
'wins': wins,
|
||||
'losses': losses,
|
||||
'winRate': round(wins / n * 100, 1) if n > 0 else 0.0,
|
||||
'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0,
|
||||
'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0,
|
||||
'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0,
|
||||
'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0,
|
||||
'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0,
|
||||
'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0,
|
||||
'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0,
|
||||
'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0,
|
||||
'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0,
|
||||
'elapsedSeconds': round(elapsed, 3),
|
||||
}
|
||||
return sim_id
|
||||
|
||||
|
||||
def _fetch_deck_card_data(deck_id):
|
||||
"""Load card data from the DB for a given deck ID. Returns (deck_name, card_data) or raises."""
|
||||
conn = _get_db_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None, None
|
||||
deck_name = row[0]
|
||||
cur.execute("""
|
||||
SELECT dc.card_name, dc.quantity,
|
||||
ce.type_line, ce.cmc, ce.power, ce.toughness,
|
||||
ce.oracle_text, ce.keywords, ce.mana_cost
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, cmc, power, toughness,
|
||||
oracle_text, keywords, mana_cost
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,))
|
||||
card_data = []
|
||||
for r in cur.fetchall():
|
||||
for _ in range(r[1] or 1):
|
||||
card_data.append({
|
||||
'name': r[0], 'type_line': r[2] or '',
|
||||
'cmc': r[3] or 0, 'power': r[4] or '',
|
||||
'toughness': r[5] or '', 'oracle_text': r[6] or '',
|
||||
'keywords': r[7] or '', 'mana_cost': r[8] or '',
|
||||
})
|
||||
return deck_name, card_data
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'complete'
|
||||
_sim_runs[sim_id]['result'] = {
|
||||
'summary': summary,
|
||||
'games': game_results,
|
||||
}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'error'
|
||||
_sim_runs[sim_id]['error'] = str(e)
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@router.post('/api/sim/run')
|
||||
@@ -359,10 +280,20 @@ async def sim_run(request: FastAPIRequest):
|
||||
if num_games < 1 or num_games > 1000:
|
||||
return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400)
|
||||
|
||||
sim_id = _create_sim_run(num_games, deck_name)
|
||||
_threading.Thread(target=_run_sim_thread,
|
||||
args=(sim_id, decklist, num_games, deck_name, record_logs),
|
||||
daemon=True).start()
|
||||
sim_id = str(_uuid.uuid4())[:8]
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id] = {
|
||||
'status': 'queued',
|
||||
'completed': 0,
|
||||
'total': num_games,
|
||||
'deckName': deck_name,
|
||||
'result': None,
|
||||
'error': None,
|
||||
}
|
||||
|
||||
t = _threading.Thread(target=_run_sim_thread, args=(sim_id, decklist, num_games, deck_name, record_logs), daemon=True)
|
||||
t.start()
|
||||
|
||||
return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games})
|
||||
|
||||
|
||||
@@ -374,9 +305,12 @@ async def sim_status(simId: str):
|
||||
if not run:
|
||||
return JSONResponse({'error': 'sim not found'}, status_code=404)
|
||||
return JSONResponse({
|
||||
'simId': simId, 'status': run['status'],
|
||||
'completed': run['completed'], 'total': run['total'],
|
||||
'deckName': run.get('deckName', ''), 'error': run.get('error'),
|
||||
'simId': simId,
|
||||
'status': run['status'],
|
||||
'completed': run['completed'],
|
||||
'total': run['total'],
|
||||
'deckName': run.get('deckName', ''),
|
||||
'error': run.get('error'),
|
||||
})
|
||||
|
||||
|
||||
@@ -388,8 +322,7 @@ async def sim_result(simId: str):
|
||||
if not run:
|
||||
return JSONResponse({'error': 'sim not found'}, status_code=404)
|
||||
if run['status'] != 'complete':
|
||||
return JSONResponse({'error': 'sim not complete', 'status': run['status']},
|
||||
status_code=400)
|
||||
return JSONResponse({'error': 'sim not complete', 'status': run['status']}, status_code=400)
|
||||
return JSONResponse(run['result'])
|
||||
|
||||
|
||||
@@ -404,53 +337,64 @@ async def sim_run_from_deck(request: FastAPIRequest):
|
||||
if not deck_id:
|
||||
return JSONResponse({'error': 'deckId required'}, status_code=400)
|
||||
|
||||
deck_name, card_data = _fetch_deck_card_data(deck_id)
|
||||
if deck_name is None:
|
||||
conn = _get_db_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return JSONResponse({'error': 'deck not found'}, status_code=404)
|
||||
deck_name = row[0]
|
||||
|
||||
# Pull full card data from collection join so the sim engine has real types/stats
|
||||
cur.execute("""
|
||||
SELECT dc.card_name, dc.quantity,
|
||||
ce.type_line, ce.cmc, ce.power, ce.toughness,
|
||||
ce.oracle_text, ce.keywords, ce.mana_cost
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, cmc, power, toughness,
|
||||
oracle_text, keywords, mana_cost
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,))
|
||||
card_data = []
|
||||
for r in cur.fetchall():
|
||||
for _ in range(r[1] or 1):
|
||||
card_data.append({
|
||||
'name': r[0],
|
||||
'type_line': r[2] or '',
|
||||
'cmc': r[3] or 0,
|
||||
'power': r[4] or '',
|
||||
'toughness': r[5] or '',
|
||||
'oracle_text': r[6] or '',
|
||||
'keywords': r[7] or '',
|
||||
'mana_cost': r[8] or '',
|
||||
})
|
||||
if not card_data:
|
||||
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
|
||||
|
||||
sim_id = _create_sim_run(num_games, deck_name)
|
||||
_threading.Thread(target=_run_sim_thread_v2,
|
||||
args=(sim_id, card_data, num_games, deck_name, record_logs),
|
||||
daemon=True).start()
|
||||
return JSONResponse({'simId': sim_id, 'status': 'queued',
|
||||
'total': num_games, 'deckName': deck_name})
|
||||
sim_id = str(_uuid.uuid4())[:8]
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id] = {
|
||||
'status': 'queued',
|
||||
'completed': 0,
|
||||
'total': num_games,
|
||||
'deckName': deck_name,
|
||||
'result': None,
|
||||
'error': None,
|
||||
}
|
||||
|
||||
t = _threading.Thread(target=_run_sim_thread_v2, args=(sim_id, card_data, num_games, deck_name, record_logs), daemon=True)
|
||||
t.start()
|
||||
|
||||
@router.post('/api/sim/run-deepseek')
|
||||
async def sim_run_deepseek(request: FastAPIRequest):
|
||||
"""Start simulation using DeepSeek AI as the opponent brain."""
|
||||
body = await request.json()
|
||||
deck_id = body.get('deckId')
|
||||
num_games = body.get('numGames', 5)
|
||||
record_logs = body.get('recordLogs', True)
|
||||
return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games, 'deckName': deck_name})
|
||||
|
||||
if not deck_id:
|
||||
return JSONResponse({'error': 'deckId required'}, status_code=400)
|
||||
if num_games < 1 or num_games > 50:
|
||||
return JSONResponse({'error': 'numGames must be 1-50 for DeepSeek mode'},
|
||||
status_code=400)
|
||||
|
||||
deck_name, card_data = _fetch_deck_card_data(deck_id)
|
||||
if deck_name is None:
|
||||
return JSONResponse({'error': 'deck not found'}, status_code=404)
|
||||
if not card_data:
|
||||
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
|
||||
|
||||
sim_id = _create_sim_run(num_games, deck_name)
|
||||
_threading.Thread(target=_run_sim_thread_deepseek,
|
||||
args=(sim_id, card_data, num_games, deck_name, record_logs),
|
||||
daemon=True).start()
|
||||
return JSONResponse({'simId': sim_id, 'status': 'queued',
|
||||
'total': num_games, 'deckName': deck_name,
|
||||
'opponentType': 'deepseek'})
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# DeepSeek AI Brain Endpoints
|
||||
# DeepSeek AI Opponent Brain
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post('/api/deepseek/connect')
|
||||
async def deepseek_connect(request: FastAPIRequest):
|
||||
"""Test connection to the DeepSeek LLM endpoint and auto-detect model."""
|
||||
@@ -460,9 +404,9 @@ async def deepseek_connect(request: FastAPIRequest):
|
||||
|
||||
brain = _get_deepseek_brain()
|
||||
if brain is None:
|
||||
return JSONResponse({'error': 'DeepSeek brain failed to initialize'},
|
||||
status_code=500)
|
||||
return JSONResponse({'error': 'DeepSeek brain failed to initialize'}, status_code=500)
|
||||
|
||||
# Allow runtime reconfiguration
|
||||
if api_base:
|
||||
brain.config.api_base = api_base
|
||||
if model:
|
||||
@@ -511,7 +455,9 @@ async def deepseek_configure(request: FastAPIRequest):
|
||||
if 'fallbackOnTimeout' in body:
|
||||
brain.config.fallback_on_timeout = bool(body['fallbackOnTimeout'])
|
||||
|
||||
# Re-test connection with new settings
|
||||
connected = brain.check_connection()
|
||||
|
||||
return JSONResponse({
|
||||
'connected': connected,
|
||||
'config': {
|
||||
@@ -540,9 +486,9 @@ async def deepseek_logs():
|
||||
try:
|
||||
flushed_path = brain.flush_log()
|
||||
except Exception as e:
|
||||
return JSONResponse({'error': f'Failed to flush: {e}'},
|
||||
status_code=500)
|
||||
return JSONResponse({'error': f'Failed to flush: {e}'}, status_code=500)
|
||||
|
||||
# List existing log files
|
||||
import glob as _glob
|
||||
log_dir = brain.config.log_dir
|
||||
log_files = []
|
||||
@@ -558,5 +504,235 @@ async def deepseek_logs():
|
||||
return JSONResponse({
|
||||
'flushed': pending,
|
||||
'flushedPath': flushed_path,
|
||||
'logFiles': log_files[-20:],
|
||||
'logFiles': log_files[-20:], # last 20
|
||||
})
|
||||
|
||||
|
||||
def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict], num_games: int, deck_name: str, record_logs: bool):
|
||||
"""Background thread for simulations using DeepSeek AI opponent."""
|
||||
try:
|
||||
import sys, os
|
||||
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src')
|
||||
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
|
||||
from commander_ai_lab.lab.experiments import _generate_training_deck
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'running'
|
||||
|
||||
# Build player's deck with real card data
|
||||
deck_a = []
|
||||
for cd in card_data:
|
||||
c = Card(name=cd['name'])
|
||||
if cd.get('type_line'):
|
||||
c.type_line = cd['type_line']
|
||||
if cd.get('cmc'):
|
||||
c.cmc = float(cd['cmc'])
|
||||
if cd.get('power') and cd.get('toughness'):
|
||||
c.power = str(cd['power'])
|
||||
c.toughness = str(cd['toughness'])
|
||||
c.pt = c.power + '/' + c.toughness
|
||||
if cd.get('oracle_text'):
|
||||
c.oracle_text = cd['oracle_text']
|
||||
if cd.get('mana_cost'):
|
||||
c.mana_cost = cd['mana_cost']
|
||||
if cd.get('keywords'):
|
||||
kw = cd['keywords']
|
||||
if isinstance(kw, str):
|
||||
try:
|
||||
import json as _json
|
||||
kw = _json.loads(kw)
|
||||
except Exception:
|
||||
kw = []
|
||||
if isinstance(kw, list):
|
||||
c.keywords = kw
|
||||
enrich_card(c)
|
||||
deck_a.append(c)
|
||||
|
||||
deck_b = _generate_training_deck()
|
||||
|
||||
# Get DeepSeek brain from shared canonical source
|
||||
brain = _get_deepseek_brain()
|
||||
if brain and not brain._connected:
|
||||
brain.check_connection()
|
||||
|
||||
engine = DeepSeekGameEngine(
|
||||
brain=brain,
|
||||
ai_player_index=1, # Training deck is player B (index 1)
|
||||
max_turns=25,
|
||||
record_log=record_logs,
|
||||
)
|
||||
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
wins = 0
|
||||
losses = 0
|
||||
total_turns = 0
|
||||
total_damage_dealt = 0
|
||||
total_damage_received = 0
|
||||
total_spells_cast = 0
|
||||
total_creatures_played = 0
|
||||
total_removal_used = 0
|
||||
total_ramp_played = 0
|
||||
total_cards_drawn = 0
|
||||
total_max_board = 0
|
||||
game_results = []
|
||||
|
||||
for i in range(num_games):
|
||||
game_id = f'ds-sim-{sim_id[:8]}-g{i+1}'
|
||||
result = engine.run(deck_a, deck_b, name_a=deck_name, name_b='DeepSeek AI',
|
||||
game_id=game_id, archetype='midrange')
|
||||
|
||||
game_data = result.to_dict()
|
||||
game_data['gameNumber'] = i + 1
|
||||
game_results.append(game_data)
|
||||
|
||||
if result.winner == 0:
|
||||
wins += 1
|
||||
else:
|
||||
losses += 1
|
||||
|
||||
total_turns += result.turns
|
||||
if result.player_a_stats:
|
||||
total_damage_dealt += result.player_a_stats.damage_dealt
|
||||
total_damage_received += result.player_a_stats.damage_received
|
||||
total_spells_cast += result.player_a_stats.spells_cast
|
||||
total_creatures_played += result.player_a_stats.creatures_played
|
||||
total_removal_used += result.player_a_stats.removal_used
|
||||
total_ramp_played += result.player_a_stats.ramp_played
|
||||
total_cards_drawn += result.player_a_stats.cards_drawn
|
||||
total_max_board += result.player_a_stats.max_board_size
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['completed'] = i + 1
|
||||
|
||||
elapsed = time.time() - start
|
||||
n = num_games
|
||||
|
||||
# Write ML decision JSONL for training pipeline
|
||||
ml_decisions = engine.flush_ml_decisions()
|
||||
if ml_decisions:
|
||||
ml_jsonl_path = os.path.join('results', f'ml-decisions-sim-{sim_id[:8]}.jsonl')
|
||||
os.makedirs('results', exist_ok=True)
|
||||
with open(ml_jsonl_path, 'w', encoding='utf-8') as mf:
|
||||
import json as _mljson
|
||||
for dec in ml_decisions:
|
||||
mf.write(_mljson.dumps(dec) + '\n')
|
||||
|
||||
# Get DeepSeek stats for the summary
|
||||
ds_stats = brain.get_stats() if brain else {}
|
||||
|
||||
summary = {
|
||||
'deckName': deck_name,
|
||||
'opponentName': 'DeepSeek AI',
|
||||
'opponentType': 'deepseek',
|
||||
'totalGames': n,
|
||||
'wins': wins,
|
||||
'losses': losses,
|
||||
'winRate': round(wins / n * 100, 1) if n > 0 else 0.0,
|
||||
'avgTurns': round(total_turns / n, 1) if n > 0 else 0.0,
|
||||
'avgDamageDealt': round(total_damage_dealt / n, 1) if n > 0 else 0.0,
|
||||
'avgDamageReceived': round(total_damage_received / n, 1) if n > 0 else 0.0,
|
||||
'avgSpellsCast': round(total_spells_cast / n, 1) if n > 0 else 0.0,
|
||||
'avgCreaturesPlayed': round(total_creatures_played / n, 1) if n > 0 else 0.0,
|
||||
'avgRemovalUsed': round(total_removal_used / n, 1) if n > 0 else 0.0,
|
||||
'avgRampPlayed': round(total_ramp_played / n, 1) if n > 0 else 0.0,
|
||||
'avgCardsDrawn': round(total_cards_drawn / n, 1) if n > 0 else 0.0,
|
||||
'avgMaxBoardSize': round(total_max_board / n, 1) if n > 0 else 0.0,
|
||||
'elapsedSeconds': round(elapsed, 3),
|
||||
'deepseekStats': ds_stats,
|
||||
}
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'complete'
|
||||
_sim_runs[sim_id]['result'] = {
|
||||
'summary': summary,
|
||||
'games': game_results,
|
||||
}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'error'
|
||||
_sim_runs[sim_id]['error'] = str(e)
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@router.post('/api/sim/run-deepseek')
|
||||
async def sim_run_deepseek(request: FastAPIRequest):
|
||||
"""Start simulation using DeepSeek AI as the opponent brain."""
|
||||
body = await request.json()
|
||||
deck_id = body.get('deckId')
|
||||
num_games = body.get('numGames', 5) # Default 5 (LLM is slower)
|
||||
record_logs = body.get('recordLogs', True)
|
||||
|
||||
if not deck_id:
|
||||
return JSONResponse({'error': 'deckId required'}, status_code=400)
|
||||
if num_games < 1 or num_games > 50:
|
||||
return JSONResponse({'error': 'numGames must be 1-50 for DeepSeek mode'}, status_code=400)
|
||||
|
||||
conn = _get_db_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return JSONResponse({'error': 'deck not found'}, status_code=404)
|
||||
deck_name = row[0]
|
||||
|
||||
cur.execute("""
|
||||
SELECT dc.card_name, dc.quantity,
|
||||
ce.type_line, ce.cmc, ce.power, ce.toughness,
|
||||
ce.oracle_text, ce.keywords, ce.mana_cost
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, cmc, power, toughness,
|
||||
oracle_text, keywords, mana_cost
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,))
|
||||
card_data = []
|
||||
for r in cur.fetchall():
|
||||
for _ in range(r[1] or 1):
|
||||
card_data.append({
|
||||
'name': r[0],
|
||||
'type_line': r[2] or '',
|
||||
'cmc': r[3] or 0,
|
||||
'power': r[4] or '',
|
||||
'toughness': r[5] or '',
|
||||
'oracle_text': r[6] or '',
|
||||
'keywords': r[7] or '',
|
||||
'mana_cost': r[8] or '',
|
||||
})
|
||||
if not card_data:
|
||||
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
|
||||
|
||||
sim_id = str(_uuid.uuid4())[:8]
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id] = {
|
||||
'status': 'queued',
|
||||
'completed': 0,
|
||||
'total': num_games,
|
||||
'deckName': deck_name,
|
||||
'result': None,
|
||||
'error': None,
|
||||
}
|
||||
|
||||
t = _threading.Thread(
|
||||
target=_run_sim_thread_deepseek,
|
||||
args=(sim_id, card_data, num_games, deck_name, record_logs),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
return JSONResponse({
|
||||
'simId': sim_id,
|
||||
'status': 'queued',
|
||||
'total': num_games,
|
||||
'deckName': deck_name,
|
||||
'opponentType': 'deepseek',
|
||||
})
|
||||
-626
@@ -1,626 +0,0 @@
|
||||
"""
|
||||
routes/ml.py
|
||||
============
|
||||
ML training data, policy inference, supervised training,
|
||||
PPO training, and tournament endpoints.
|
||||
|
||||
GET /api/ml/status
|
||||
GET /api/ml/decisions/{filename}
|
||||
GET /api/ml/data/status
|
||||
POST /api/ml/toggle
|
||||
POST /api/ml/predict
|
||||
POST /api/ml/predict/batch
|
||||
GET /api/ml/model
|
||||
POST /api/ml/reload
|
||||
POST /api/ml/train
|
||||
GET /api/ml/train/status
|
||||
POST /api/ml/train/ppo
|
||||
GET /api/ml/train/ppo/status
|
||||
POST /api/ml/tournament
|
||||
GET /api/ml/tournament/status
|
||||
GET /api/ml/tournament/results
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
|
||||
|
||||
from routes.shared import CFG, _ml_logging_enabled
|
||||
|
||||
log_ml = logging.getLogger("commander_ai_lab.ml")
|
||||
|
||||
router = APIRouter(tags=["ml"])
|
||||
|
||||
# ---- module-level state (moved from lab_api.py) ----
|
||||
|
||||
_policy_service = None
|
||||
_policy_service_init_attempted = False
|
||||
|
||||
_training_state = {
|
||||
"running": False,
|
||||
"progress": 0,
|
||||
"total_epochs": 0,
|
||||
"current_epoch": 0,
|
||||
"phase": "idle",
|
||||
"message": "",
|
||||
"metrics": None,
|
||||
"result": None,
|
||||
"error": None,
|
||||
"started_at": None,
|
||||
}
|
||||
|
||||
_ppo_state = {
|
||||
"running": False,
|
||||
"iteration": 0,
|
||||
"total_iterations": 0,
|
||||
"phase": "idle",
|
||||
"message": "",
|
||||
"metrics": None,
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
_tournament_state = {
|
||||
"running": False,
|
||||
"phase": "idle",
|
||||
"message": "",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
# module-level toggle (local copy; the shared one is for batch runs)
|
||||
_ml_logging_enabled_local = _ml_logging_enabled
|
||||
|
||||
|
||||
def _get_policy_service():
|
||||
"""Get or initialize the policy inference service (lazy loading)."""
|
||||
global _policy_service, _policy_service_init_attempted
|
||||
if _policy_service is None and not _policy_service_init_attempted:
|
||||
_policy_service_init_attempted = True
|
||||
try:
|
||||
from ml.serving.policy_server import PolicyInferenceService
|
||||
_policy_service = PolicyInferenceService()
|
||||
if _policy_service.load():
|
||||
log_ml.info(f"Policy model loaded on {_policy_service.device}")
|
||||
else:
|
||||
log_ml.error(f"Policy model not available: {_policy_service._load_error}")
|
||||
except Exception as e:
|
||||
log_ml.error(f"Policy service init failed: {e}")
|
||||
return _policy_service
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# ML Training Data Endpoints
|
||||
# ==============================================================
|
||||
|
||||
@router.get("/api/ml/status")
|
||||
async def ml_status():
|
||||
"""Get ML decision logging status and available training data."""
|
||||
import routes.shared as _shared
|
||||
lab_root = Path(__file__).resolve().parent.parent
|
||||
results_dir = lab_root / CFG.results_dir
|
||||
ml_files = []
|
||||
if results_dir.exists():
|
||||
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
|
||||
lines = 0
|
||||
try:
|
||||
with open(f) as fh:
|
||||
lines = sum(1 for _ in fh)
|
||||
except Exception:
|
||||
pass
|
||||
ml_files.append({
|
||||
"file": f.name,
|
||||
"decisions": lines,
|
||||
"size_kb": round(f.stat().st_size / 1024, 1),
|
||||
})
|
||||
total_decisions = sum(f["decisions"] for f in ml_files)
|
||||
return {
|
||||
"ml_logging_enabled": _shared._ml_logging_enabled,
|
||||
"training_files": ml_files,
|
||||
"total_decisions": total_decisions,
|
||||
"total_files": len(ml_files),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/ml/toggle")
|
||||
async def ml_toggle(enable: bool = True):
|
||||
"""Enable or disable ML decision logging for future batch runs."""
|
||||
import routes.shared as _shared
|
||||
_shared._ml_logging_enabled = enable
|
||||
return {
|
||||
"ml_logging_enabled": _shared._ml_logging_enabled,
|
||||
"message": f"ML decision logging {'enabled' if enable else 'disabled'} for future batches",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/ml/decisions/{filename}")
|
||||
async def ml_get_decisions(filename: str, limit: int = 100, offset: int = 0):
|
||||
"""Read decision snapshots from a training data file."""
|
||||
lab_root = Path(__file__).resolve().parent.parent
|
||||
filepath = lab_root / CFG.results_dir / filename
|
||||
if not filepath.exists() or not filename.startswith("ml-decisions-"):
|
||||
raise HTTPException(404, f"ML decisions file not found: {filename}")
|
||||
decisions = []
|
||||
try:
|
||||
with open(filepath) as f:
|
||||
for i, line in enumerate(f):
|
||||
if i < offset:
|
||||
continue
|
||||
if len(decisions) >= limit:
|
||||
break
|
||||
try:
|
||||
decisions.append(json.loads(line.strip()))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to read decisions: {e}")
|
||||
return {
|
||||
"file": filename,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"count": len(decisions),
|
||||
"decisions": decisions,
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# ML Policy Inference Endpoints
|
||||
# ==============================================================
|
||||
|
||||
@router.post("/api/ml/predict")
|
||||
async def ml_predict(request: FastAPIRequest):
|
||||
"""Predict a macro-action from a game state snapshot."""
|
||||
svc = _get_policy_service()
|
||||
if svc is None or not svc._loaded:
|
||||
detail = "Policy model not loaded. "
|
||||
if svc:
|
||||
detail += svc._load_error or "Train a model first."
|
||||
else:
|
||||
detail += "PyTorch may not be installed."
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
body = await request.json()
|
||||
playstyle = body.pop("archetype", "midrange")
|
||||
temperature = body.pop("temperature", 1.0)
|
||||
greedy = body.pop("greedy", False)
|
||||
result = svc.predict(
|
||||
decision_snapshot=body,
|
||||
playstyle=playstyle,
|
||||
temperature=temperature,
|
||||
greedy=greedy,
|
||||
)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/ml/predict/batch")
|
||||
async def ml_predict_batch(request: FastAPIRequest):
|
||||
"""Predict actions for multiple snapshots at once."""
|
||||
svc = _get_policy_service()
|
||||
if svc is None or not svc._loaded:
|
||||
raise HTTPException(status_code=503, detail="Policy model not loaded")
|
||||
body = await request.json()
|
||||
snapshots = body.get("snapshots", [])
|
||||
greedy = body.get("greedy", True)
|
||||
if not snapshots:
|
||||
return {"results": []}
|
||||
results = svc.predict_batch(snapshots, greedy=greedy)
|
||||
return {"results": results, "count": len(results)}
|
||||
|
||||
|
||||
@router.get("/api/ml/model")
|
||||
async def ml_model_info():
|
||||
"""Get information about the loaded policy model."""
|
||||
svc = _get_policy_service()
|
||||
if svc is None:
|
||||
return {
|
||||
"loaded": False,
|
||||
"error": "Policy service not initialized",
|
||||
"torch_available": False,
|
||||
}
|
||||
return svc.get_status()
|
||||
|
||||
|
||||
@router.post("/api/ml/reload")
|
||||
async def ml_reload_model(checkpoint: str = None):
|
||||
"""Hot-reload a policy model checkpoint."""
|
||||
svc = _get_policy_service()
|
||||
if svc is None:
|
||||
raise HTTPException(status_code=503, detail="Policy service not available")
|
||||
if not svc._loaded:
|
||||
ok = svc.load()
|
||||
else:
|
||||
ok = svc.reload(checkpoint)
|
||||
return {
|
||||
"success": ok,
|
||||
"status": svc.get_status(),
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# ML Training Management Endpoints
|
||||
# ==============================================================
|
||||
|
||||
def _run_training_pipeline(
|
||||
results_dir: str, epochs: int, lr: float,
|
||||
batch_size: int, patience: int, rebuild_dataset: bool,
|
||||
):
|
||||
"""Run the full ML training pipeline in a background thread."""
|
||||
global _training_state, _policy_service, _policy_service_init_attempted
|
||||
try:
|
||||
import sys
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
_training_state["running"] = True
|
||||
_training_state["started_at"] = datetime.now().isoformat()
|
||||
_training_state["error"] = None
|
||||
_training_state["result"] = None
|
||||
data_dir = os.path.join(project_root, "ml", "models")
|
||||
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
os.makedirs(ckpt_dir, exist_ok=True)
|
||||
# Phase 1: Build Dataset
|
||||
train_path = os.path.join(data_dir, "train.npz")
|
||||
if rebuild_dataset or not os.path.exists(train_path):
|
||||
_training_state["phase"] = "building"
|
||||
_training_state["message"] = "Loading card embeddings & building dataset..."
|
||||
log_ml.info("Building dataset (loading embeddings, may auto-download)...")
|
||||
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
|
||||
dataset = build_dataset(results_dir=results_dir)
|
||||
if not dataset:
|
||||
raise RuntimeError("No training data produced. Check server log for details.")
|
||||
train_ds, val_ds, test_ds = split_dataset(dataset)
|
||||
save_dataset(train_ds, os.path.join(data_dir, "train.npz"))
|
||||
save_dataset(val_ds, os.path.join(data_dir, "val.npz"))
|
||||
save_dataset(test_ds, os.path.join(data_dir, "test.npz"))
|
||||
total_samples = len(dataset["states"])
|
||||
_training_state["message"] = f"Dataset built: {total_samples} samples"
|
||||
log_ml.info(f"Dataset built: {total_samples} samples")
|
||||
# Phase 2: Train
|
||||
_training_state["phase"] = "training"
|
||||
_training_state["total_epochs"] = epochs
|
||||
_training_state["current_epoch"] = 0
|
||||
_training_state["message"] = f"Training policy network ({epochs} epochs)..."
|
||||
log_ml.info(f"Starting training: {epochs} epochs, lr={lr}, bs={batch_size}")
|
||||
import numpy as np
|
||||
import torch
|
||||
from ml.training.policy_network import PolicyNetwork
|
||||
from ml.training.trainer import SupervisedTrainer
|
||||
def load_npz_split(path):
|
||||
data = np.load(path)
|
||||
return data["states"].astype(np.float32), data["labels"].astype(np.int64)
|
||||
train_states, train_labels = load_npz_split(train_path)
|
||||
val_path = os.path.join(data_dir, "val.npz")
|
||||
val_states, val_labels = load_npz_split(val_path)
|
||||
actual_dim = train_states.shape[1]
|
||||
model = PolicyNetwork(input_dim=actual_dim)
|
||||
device = "cpu"
|
||||
if torch.cuda.is_available():
|
||||
device = "cuda"
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
device = "mps"
|
||||
trainer = SupervisedTrainer(
|
||||
model=model, device=device, learning_rate=lr,
|
||||
batch_size=batch_size, epochs=epochs, patience=patience,
|
||||
checkpoint_dir=ckpt_dir,
|
||||
)
|
||||
summary = trainer.train(train_states, train_labels, val_states, val_labels)
|
||||
_training_state["current_epoch"] = summary.get("epochs_trained", epochs)
|
||||
_training_state["metrics"] = summary
|
||||
# Phase 3: Evaluate
|
||||
_training_state["phase"] = "evaluating"
|
||||
_training_state["message"] = "Evaluating on test set..."
|
||||
test_path = os.path.join(data_dir, "test.npz")
|
||||
eval_results = None
|
||||
if os.path.exists(test_path):
|
||||
from ml.training.trainer import load_checkpoint, evaluate_model
|
||||
test_states, test_labels = load_npz_split(test_path)
|
||||
best_model, _ = load_checkpoint(summary["checkpoint_path"], device)
|
||||
eval_results = evaluate_model(best_model, test_states, test_labels, device)
|
||||
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
||||
with open(eval_path, "w") as f:
|
||||
json.dump(eval_results, f, indent=2)
|
||||
# Done
|
||||
_training_state["phase"] = "done"
|
||||
_training_state["running"] = False
|
||||
_training_state["result"] = {
|
||||
"training": summary,
|
||||
"evaluation": eval_results,
|
||||
"checkpoint": summary.get("checkpoint_path", ""),
|
||||
"device": device,
|
||||
}
|
||||
_training_state["message"] = f"Training complete! Best val acc: {summary.get('best_val_accuracy', 0):.1%}"
|
||||
log_ml.info(f"Complete: {summary.get('best_val_accuracy', 0):.1%} val accuracy")
|
||||
# Auto-reload policy server
|
||||
if _policy_service and _policy_service._loaded:
|
||||
_policy_service.reload(summary.get("checkpoint_path"))
|
||||
log_ml.info("Policy server reloaded with new checkpoint")
|
||||
elif not _policy_service_init_attempted:
|
||||
_policy_service_init_attempted = False
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_training_state["phase"] = "error"
|
||||
_training_state["running"] = False
|
||||
_training_state["error"] = str(e)
|
||||
_training_state["message"] = f"Training failed: {e}"
|
||||
log_ml.error(f"ERROR: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@router.post("/api/ml/train")
|
||||
async def ml_start_training(request: FastAPIRequest):
|
||||
"""Trigger ML training pipeline from the web UI."""
|
||||
if _training_state["running"]:
|
||||
raise HTTPException(409, "Training already in progress")
|
||||
body = await request.json() if await request.body() else {}
|
||||
epochs = body.get("epochs", 50)
|
||||
lr = body.get("lr", 0.001)
|
||||
batch_size = body.get("batchSize", 256)
|
||||
patience = body.get("patience", 10)
|
||||
rebuild = body.get("rebuildDataset", True)
|
||||
results_dir = os.path.join(str(Path(__file__).resolve().parent.parent), "results")
|
||||
_training_state.update({
|
||||
"running": True, "progress": 0, "total_epochs": epochs,
|
||||
"current_epoch": 0, "phase": "starting",
|
||||
"message": "Initializing training pipeline...",
|
||||
"metrics": None, "result": None, "error": None,
|
||||
})
|
||||
thread = threading.Thread(
|
||||
target=_run_training_pipeline,
|
||||
args=(results_dir, epochs, lr, batch_size, patience, rebuild),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return {
|
||||
"status": "started",
|
||||
"config": {
|
||||
"epochs": epochs, "lr": lr, "batchSize": batch_size,
|
||||
"patience": patience, "rebuildDataset": rebuild,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/ml/train/status")
|
||||
async def ml_training_status():
|
||||
"""Get current training pipeline status."""
|
||||
return dict(_training_state)
|
||||
|
||||
|
||||
@router.get("/api/ml/data/status")
|
||||
async def ml_data_status():
|
||||
"""Get status of available training data and model checkpoints."""
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
results_dir = project_root / "results"
|
||||
data_dir = project_root / "ml" / "models"
|
||||
ckpt_dir = data_dir / "checkpoints"
|
||||
decision_files = []
|
||||
if results_dir.exists():
|
||||
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
|
||||
line_count = 0
|
||||
try:
|
||||
with open(f, "r") as fh:
|
||||
line_count = sum(1 for _ in fh)
|
||||
except Exception:
|
||||
pass
|
||||
decision_files.append({"name": f.name, "size": f.stat().st_size, "decisions": line_count})
|
||||
datasets = {}
|
||||
for split in ["train", "val", "test"]:
|
||||
path = data_dir / f"{split}.npz"
|
||||
if path.exists():
|
||||
try:
|
||||
import numpy as np
|
||||
data = np.load(str(path))
|
||||
datasets[split] = {
|
||||
"samples": int(data["states"].shape[0]),
|
||||
"features": int(data["states"].shape[1]),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
except Exception:
|
||||
datasets[split] = {"size": path.stat().st_size}
|
||||
checkpoints = []
|
||||
if ckpt_dir.exists():
|
||||
for f in sorted(ckpt_dir.glob("*.pt")):
|
||||
checkpoints.append({
|
||||
"name": f.name,
|
||||
"size": f.stat().st_size,
|
||||
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
||||
})
|
||||
eval_path = ckpt_dir / "eval_results.json"
|
||||
eval_results = None
|
||||
if eval_path.exists():
|
||||
try:
|
||||
with open(eval_path, "r") as f:
|
||||
eval_results = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"decisionFiles": decision_files,
|
||||
"totalDecisions": sum(d["decisions"] for d in decision_files),
|
||||
"datasets": datasets,
|
||||
"checkpoints": checkpoints,
|
||||
"evalResults": eval_results,
|
||||
"policyLoaded": _policy_service is not None and _policy_service._loaded if _policy_service else False,
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# PPO Training + Tournament Endpoints
|
||||
# ==============================================================
|
||||
|
||||
def _run_ppo_pipeline(
|
||||
iterations: int, episodes_per_iter: int, ppo_epochs: int,
|
||||
batch_size: int, lr: float, clip_epsilon: float,
|
||||
entropy_coeff: float, opponent: str, playstyle: str,
|
||||
load_supervised: str,
|
||||
):
|
||||
"""Run PPO training in a background thread."""
|
||||
global _ppo_state
|
||||
try:
|
||||
import sys
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
_ppo_state["running"] = True
|
||||
_ppo_state["error"] = None
|
||||
_ppo_state["result"] = None
|
||||
from ml.training.ppo_trainer import PPOTrainer, PPOConfig
|
||||
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
||||
config = PPOConfig(
|
||||
iterations=iterations, episodes_per_iter=episodes_per_iter,
|
||||
ppo_epochs=ppo_epochs, batch_size=batch_size,
|
||||
learning_rate=lr, clip_epsilon=clip_epsilon,
|
||||
entropy_coeff=entropy_coeff, opponent=opponent,
|
||||
playstyle=playstyle, checkpoint_dir=ckpt_dir,
|
||||
load_supervised=load_supervised if load_supervised else None,
|
||||
)
|
||||
trainer = PPOTrainer(config)
|
||||
def progress_cb(iteration, metrics):
|
||||
_ppo_state["iteration"] = iteration
|
||||
_ppo_state["phase"] = "training"
|
||||
_ppo_state["message"] = f"Iteration {iteration}/{iterations} | WR: {metrics.get('win_rate', 0):.0%}"
|
||||
_ppo_state["metrics"] = metrics
|
||||
summary = trainer.train(progress_callback=progress_cb)
|
||||
_ppo_state["phase"] = "done"
|
||||
_ppo_state["running"] = False
|
||||
_ppo_state["result"] = summary
|
||||
_ppo_state["message"] = f"PPO complete! Best win rate: {summary.get('best_win_rate', 0):.0%}"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_ppo_state["phase"] = "error"
|
||||
_ppo_state["running"] = False
|
||||
_ppo_state["error"] = str(e)
|
||||
_ppo_state["message"] = f"PPO failed: {e}"
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@router.post("/api/ml/train/ppo")
|
||||
async def ml_start_ppo(request: FastAPIRequest):
|
||||
"""Start PPO training pipeline."""
|
||||
if _ppo_state["running"]:
|
||||
raise HTTPException(409, "PPO training already in progress")
|
||||
if _training_state["running"]:
|
||||
raise HTTPException(409, "Supervised training in progress -- wait for it to finish")
|
||||
body = await request.json() if await request.body() else {}
|
||||
iterations = body.get("iterations", 100)
|
||||
episodes = body.get("episodesPerIter", 64)
|
||||
ppo_epochs = body.get("ppoEpochs", 4)
|
||||
batch_size = body.get("batchSize", 256)
|
||||
lr = body.get("lr", 3e-4)
|
||||
clip_eps = body.get("clipEpsilon", 0.2)
|
||||
entropy = body.get("entropyCoeff", 0.01)
|
||||
opponent = body.get("opponent", "heuristic")
|
||||
playstyle = body.get("playstyle", "midrange")
|
||||
load_sup = body.get("loadSupervised", "")
|
||||
_ppo_state.update({
|
||||
"running": True, "iteration": 0, "total_iterations": iterations,
|
||||
"phase": "starting", "message": "Initializing PPO...",
|
||||
"metrics": None, "result": None, "error": None,
|
||||
})
|
||||
thread = threading.Thread(
|
||||
target=_run_ppo_pipeline,
|
||||
args=(iterations, episodes, ppo_epochs, batch_size, lr,
|
||||
clip_eps, entropy, opponent, playstyle, load_sup),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return {"status": "started", "iterations": iterations}
|
||||
|
||||
|
||||
@router.get("/api/ml/train/ppo/status")
|
||||
async def ml_ppo_status():
|
||||
"""Get PPO training status."""
|
||||
return dict(_ppo_state)
|
||||
|
||||
|
||||
def _run_tournament_pipeline(episodes: int, playstyle: str):
|
||||
"""Run tournament in a background thread."""
|
||||
global _tournament_state
|
||||
try:
|
||||
import sys
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
_tournament_state["running"] = True
|
||||
_tournament_state["error"] = None
|
||||
_tournament_state["result"] = None
|
||||
_tournament_state["phase"] = "running"
|
||||
_tournament_state["message"] = "Running tournament..."
|
||||
from ml.eval.tournament import (
|
||||
run_tournament, HeuristicPolicy, RandomPolicy, LearnedPolicy,
|
||||
)
|
||||
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
||||
policies = {
|
||||
"heuristic": HeuristicPolicy(),
|
||||
"random": RandomPolicy(),
|
||||
}
|
||||
sup_path = os.path.join(ckpt_dir, "best_policy.pt")
|
||||
if os.path.exists(sup_path):
|
||||
policies["supervised"] = LearnedPolicy(sup_path)
|
||||
ppo_path = os.path.join(ckpt_dir, "best_ppo.pt")
|
||||
if os.path.exists(ppo_path):
|
||||
policies["ppo"] = LearnedPolicy(ppo_path)
|
||||
result = run_tournament(
|
||||
policies=policies,
|
||||
episodes_per_matchup=episodes,
|
||||
playstyle=playstyle,
|
||||
)
|
||||
output_path = os.path.join(ckpt_dir, "tournament_results.json")
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(result.to_dict(), f, indent=2)
|
||||
_tournament_state["phase"] = "done"
|
||||
_tournament_state["running"] = False
|
||||
_tournament_state["result"] = result.to_dict()
|
||||
_tournament_state["message"] = f"Tournament complete! {result.total_matches} matches"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_tournament_state["phase"] = "error"
|
||||
_tournament_state["running"] = False
|
||||
_tournament_state["error"] = str(e)
|
||||
_tournament_state["message"] = f"Tournament failed: {e}"
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@router.post("/api/ml/tournament")
|
||||
async def ml_start_tournament(request: FastAPIRequest):
|
||||
"""Start a tournament evaluation."""
|
||||
if _tournament_state["running"]:
|
||||
raise HTTPException(409, "Tournament already in progress")
|
||||
body = await request.json() if await request.body() else {}
|
||||
episodes = body.get("episodes", 50)
|
||||
playstyle = body.get("playstyle", "midrange")
|
||||
_tournament_state.update({
|
||||
"running": True, "phase": "starting",
|
||||
"message": "Initializing tournament...",
|
||||
"result": None, "error": None,
|
||||
})
|
||||
thread = threading.Thread(
|
||||
target=_run_tournament_pipeline,
|
||||
args=(episodes, playstyle),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return {"status": "started", "episodes": episodes}
|
||||
|
||||
|
||||
@router.get("/api/ml/tournament/status")
|
||||
async def ml_tournament_status():
|
||||
"""Get tournament status."""
|
||||
return dict(_tournament_state)
|
||||
|
||||
|
||||
@router.get("/api/ml/tournament/results")
|
||||
async def ml_tournament_results():
|
||||
"""Get latest tournament results."""
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
results_path = project_root / "ml" / "models" / "checkpoints" / "tournament_results.json"
|
||||
if results_path.exists():
|
||||
with open(results_path, "r") as f:
|
||||
return json.load(f)
|
||||
return {"error": "No tournament results found. Run a tournament first."}
|
||||
+1764
-62
File diff suppressed because it is too large.
Load diff
@@ -1 +0,0 @@
|
||||
"""Services for Commander AI Lab."""
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Card role detection, type classification, and deck composition analysis."""
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
|
||||
roles = []
|
||||
ot = (oracle_text or "").lower()
|
||||
tl = (type_line or "").lower()
|
||||
kw_raw = keywords or []
|
||||
if isinstance(kw_raw, str):
|
||||
try:
|
||||
kw_raw = json.loads(kw_raw)
|
||||
except Exception:
|
||||
kw_raw = []
|
||||
kw = " ".join(str(k).lower() for k in kw_raw)
|
||||
|
||||
if ("add {" in ot or "add one mana" in ot
|
||||
or ("search your library for a" in ot and "land" in ot)
|
||||
or "treasure token" in ot or "add mana" in ot):
|
||||
roles.append("Ramp")
|
||||
if ("draw a card" in ot or "draw cards" in ot or "draws a card" in ot
|
||||
or "draw two cards" in ot or "draw three cards" in ot
|
||||
or re.search(r"draw \d+ cards?", ot)
|
||||
or ("whenever" in ot and "draw" in ot)):
|
||||
roles.append("Draw")
|
||||
if ("destroy target" in ot or "exile target" in ot
|
||||
or re.search(r"deals? \d+ damage to (target|any target)", ot)
|
||||
or re.search(r"deals? x damage to (target|any target)", ot)
|
||||
or "target creature gets -" in ot or "fights target" in ot):
|
||||
roles.append("Removal")
|
||||
if ("destroy all" in ot or "exile all" in ot
|
||||
or re.search(r"all creatures get -\d+/-\d+", ot)
|
||||
or re.search(r"each creature gets -\d+/-\d+", ot)
|
||||
or ("deals" in ot and "to each creature" in ot)):
|
||||
roles.append("Board Wipe")
|
||||
if "search your library" in ot and "land" not in ot:
|
||||
roles.append("Tutor")
|
||||
if ("counter target" in ot or "counter that" in ot
|
||||
or "counters" in kw):
|
||||
roles.append("Counter")
|
||||
if ("create" in ot and ("token" in ot or "tokens" in ot)):
|
||||
roles.append("Token")
|
||||
if ("each creature you control gets" in ot or "creatures you control get" in ot
|
||||
or "all creatures you control" in ot or "anthem" in tl.lower()):
|
||||
roles.append("Anthem")
|
||||
if ("target creature gains" in ot and ("hexproof" in ot or "indestructible" in ot or "shroud" in ot))\
|
||||
or ("protection" in kw):
|
||||
roles.append("Protection")
|
||||
if ("sacrifice" in ot and ("whenever" in ot or "you may" in ot)):
|
||||
roles.append("Sacrifice")
|
||||
if ("return" in ot and ("from your graveyard" in ot and "onto the battlefield" in ot)
|
||||
or ("put" in ot and "from a graveyard" in ot and "onto the battlefield" in ot)):
|
||||
roles.append("Recursion")
|
||||
if (("graveyard" in ot and ("mill" in ot or "put" in ot and "into" in ot and "graveyard" in ot))
|
||||
or "mill" in kw):
|
||||
roles.append("Graveyard")
|
||||
if ("gain" in ot and "life" in ot) or "lifelink" in ot or "lifelink" in kw:
|
||||
roles.append("Lifegain")
|
||||
if (re.search(r"deals? \d+ damage to (each|any|target) (opponent|player)", ot)
|
||||
or "each opponent loses" in ot and "life" in ot
|
||||
or "deals damage to each opponent" in ot):
|
||||
roles.append("Burn")
|
||||
if ("can't cast" in ot or "can't attack" in ot or "can't activate" in ot
|
||||
or ("enters the battlefield tapped" in ot and "opponents" in ot)
|
||||
or "each player can't" in ot or "players can't" in ot
|
||||
or ("cost {" in ot and "more to cast" in ot)):
|
||||
roles.append("Stax")
|
||||
if ("flying" in kw or "trample" in kw or "menace" in kw or "shadow" in kw
|
||||
or "fear" in kw or "intimidate" in kw
|
||||
or "can't be blocked" in ot or "unblockable" in ot):
|
||||
roles.append("Evasion")
|
||||
if ("you win the game" in ot or "extra turn" in ot or "infinite" in ot
|
||||
or "loses the game" in ot
|
||||
or ("damage equal to" in ot and ("number" in ot or "total" in ot))):
|
||||
roles.append("Finisher")
|
||||
if ("untap all" in ot or ("copy" in ot and "spell" in ot)
|
||||
or "take an extra" in ot
|
||||
or ("double" in ot and ("damage" in ot or "counters" in ot or "tokens" in ot))):
|
||||
roles.append("Combo")
|
||||
return roles
|
||||
@@ -1,267 +0,0 @@
|
||||
"""SQLite database management and collection utility helpers."""
|
||||
from __future__ import annotations
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
log_collect = logging.getLogger("commander_ai_lab.collection")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Database
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
COLLECTION_DB_PATH = Path(__file__).parent.parent / "collection.db"
|
||||
_db_local = threading.local()
|
||||
|
||||
|
||||
def _get_db_conn() -> sqlite3.Connection:
|
||||
if not hasattr(_db_local, "conn") or _db_local.conn is None:
|
||||
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
_db_local.conn = conn
|
||||
return _db_local.conn
|
||||
|
||||
|
||||
def init_collection_db():
|
||||
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS card_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type_line TEXT DEFAULT '',
|
||||
subtypes TEXT DEFAULT '',
|
||||
is_legendary INTEGER DEFAULT 0,
|
||||
is_basic INTEGER DEFAULT 0,
|
||||
color_identity TEXT DEFAULT '',
|
||||
cmc REAL DEFAULT 0,
|
||||
oracle_text TEXT DEFAULT '',
|
||||
keywords TEXT DEFAULT '',
|
||||
tcg_price REAL DEFAULT 0,
|
||||
salt_score REAL DEFAULT 0,
|
||||
is_game_changer INTEGER DEFAULT 0,
|
||||
category TEXT DEFAULT '',
|
||||
scryfall_id TEXT DEFAULT '',
|
||||
tcgplayer_id TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type_line TEXT DEFAULT '',
|
||||
subtypes TEXT DEFAULT '',
|
||||
is_legendary INTEGER DEFAULT 0,
|
||||
is_basic INTEGER DEFAULT 0,
|
||||
color_identity TEXT DEFAULT '',
|
||||
cmc REAL DEFAULT 0,
|
||||
mana_cost TEXT DEFAULT '',
|
||||
oracle_text TEXT DEFAULT '',
|
||||
keywords TEXT DEFAULT '',
|
||||
power TEXT DEFAULT '',
|
||||
toughness TEXT DEFAULT '',
|
||||
rarity TEXT DEFAULT '',
|
||||
set_name TEXT DEFAULT '',
|
||||
edhrec_rank INTEGER DEFAULT 0,
|
||||
tcg_price REAL DEFAULT 0,
|
||||
salt_score REAL DEFAULT 0,
|
||||
is_game_changer INTEGER DEFAULT 0,
|
||||
category TEXT DEFAULT '',
|
||||
scryfall_id TEXT DEFAULT '',
|
||||
tcgplayer_id TEXT DEFAULT '',
|
||||
quantity INTEGER DEFAULT 1,
|
||||
finish TEXT DEFAULT 'NORMAL',
|
||||
condition TEXT DEFAULT '',
|
||||
language TEXT DEFAULT '',
|
||||
notes TEXT DEFAULT '',
|
||||
tags TEXT DEFAULT '',
|
||||
set_code TEXT DEFAULT '',
|
||||
collector_number TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_name ON collection_entries(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_color_identity ON collection_entries(color_identity);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_type_line ON collection_entries(type_line);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_is_legendary ON collection_entries(is_legendary);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_cmc ON collection_entries(cmc);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_category ON collection_entries(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_set_code ON collection_entries(set_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_collector_number ON collection_entries(collector_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_finish ON collection_entries(finish);
|
||||
CREATE INDEX IF NOT EXISTS idx_cr_name ON card_records(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
commander_scryfall_id TEXT DEFAULT '',
|
||||
commander_name TEXT DEFAULT '',
|
||||
color_identity TEXT DEFAULT '[]',
|
||||
strategy_tag TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deck_cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
deck_id INTEGER NOT NULL,
|
||||
scryfall_id TEXT NOT NULL,
|
||||
card_name TEXT DEFAULT '',
|
||||
quantity INTEGER DEFAULT 1,
|
||||
is_commander INTEGER DEFAULT 0,
|
||||
role_tag TEXT DEFAULT '',
|
||||
FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id);
|
||||
""")
|
||||
conn.commit()
|
||||
_migrate_columns = [
|
||||
("mana_cost", "TEXT DEFAULT ''"),
|
||||
("power", "TEXT DEFAULT ''"),
|
||||
("toughness", "TEXT DEFAULT ''"),
|
||||
("rarity", "TEXT DEFAULT ''"),
|
||||
("set_name", "TEXT DEFAULT ''"),
|
||||
("edhrec_rank", "INTEGER DEFAULT 0"),
|
||||
]
|
||||
existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(collection_entries)").fetchall()}
|
||||
for col_name, col_def in _migrate_columns:
|
||||
if col_name not in existing_cols:
|
||||
conn.execute(f"ALTER TABLE collection_entries ADD COLUMN {col_name} {col_def}")
|
||||
log_collect.info(f" Migration: added column '{col_name}' to collection_entries")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
log_collect.info(f" Collection DB: {COLLECTION_DB_PATH}")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Utility Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
|
||||
|
||||
VALID_SORT_FIELDS = {
|
||||
"name", "cmc", "tcg_price", "salt_score", "category", "color_identity",
|
||||
"quantity", "type_line", "finish", "rarity", "set_code", "power",
|
||||
"toughness", "edhrec_rank",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def _row_to_dict(row) -> dict:
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
for f in _JSON_FIELDS:
|
||||
if f in d and isinstance(d[f], str):
|
||||
try:
|
||||
d[f] = json.loads(d[f])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
d[f] = []
|
||||
return d
|
||||
|
||||
|
||||
def _snake_to_camel(s: str) -> str:
|
||||
parts = s.split("_")
|
||||
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
||||
|
||||
|
||||
def _add_image_url(card: dict) -> dict:
|
||||
scryfall_id = card.get("scryfall_id", "")
|
||||
if scryfall_id:
|
||||
card["imageUrl"] = f"https://api.scryfall.com/cards/{scryfall_id}?format=image&version=normal"
|
||||
else:
|
||||
card["imageUrl"] = None
|
||||
for key in list(card.keys()):
|
||||
if "_" in key:
|
||||
camel = _snake_to_camel(key)
|
||||
if camel not in card:
|
||||
card[camel] = card[key]
|
||||
return card
|
||||
|
||||
|
||||
def _build_collection_filters(
|
||||
q=None, colors=None, types=None, isLegendary=None, isBasic=None,
|
||||
isGameChanger=None, highSalt=None, finish=None, cmcMin=None, cmcMax=None,
|
||||
priceMin=None, priceMax=None, category=None, rarity=None, setCode=None,
|
||||
powerMin=None, powerMax=None, toughMin=None, toughMax=None, keyword=None,
|
||||
edhrecMin=None, edhrecMax=None, qtyMin=None, qtyMax=None,
|
||||
) -> tuple:
|
||||
conditions = []
|
||||
params = []
|
||||
if q:
|
||||
like_q = f"%{q}%"
|
||||
conditions.append("(name LIKE ? OR type_line LIKE ? OR oracle_text LIKE ?)")
|
||||
params.extend([like_q, like_q, like_q])
|
||||
if colors:
|
||||
for color in [c.strip().upper() for c in colors.split(",") if c.strip()]:
|
||||
conditions.append("color_identity LIKE ?")
|
||||
params.append(f"%{color}%")
|
||||
if types:
|
||||
tc = []
|
||||
for t in [t.strip() for t in types.split(",") if t.strip()]:
|
||||
tc.append("type_line LIKE ?")
|
||||
params.append(f"%{t}%")
|
||||
if tc:
|
||||
conditions.append(f"({' OR '.join(tc)})")
|
||||
if isLegendary is not None:
|
||||
conditions.append("is_legendary = ?"); params.append(1 if isLegendary else 0)
|
||||
if isBasic is not None:
|
||||
conditions.append("is_basic = ?"); params.append(1 if isBasic else 0)
|
||||
if isGameChanger is not None:
|
||||
conditions.append("is_game_changer = ?"); params.append(1 if isGameChanger else 0)
|
||||
if highSalt:
|
||||
conditions.append("salt_score > 2.0")
|
||||
if finish:
|
||||
conditions.append("finish = ?"); params.append(finish.upper())
|
||||
if cmcMin is not None:
|
||||
conditions.append("cmc >= ?"); params.append(cmcMin)
|
||||
if cmcMax is not None:
|
||||
conditions.append("cmc <= ?"); params.append(cmcMax)
|
||||
if priceMin is not None:
|
||||
conditions.append("tcg_price >= ?"); params.append(priceMin)
|
||||
if priceMax is not None:
|
||||
conditions.append("tcg_price <= ?"); params.append(priceMax)
|
||||
if category:
|
||||
cc = []
|
||||
for c in [c.strip() for c in category.split(",") if c.strip()]:
|
||||
cc.append("category LIKE ?"); params.append(f"%{c}%")
|
||||
if cc:
|
||||
conditions.append(f"({' OR '.join(cc)})")
|
||||
if rarity:
|
||||
rl = [r.strip().lower() for r in rarity.split(",") if r.strip()]
|
||||
if rl:
|
||||
conditions.append(f"LOWER(rarity) IN ({','.join(['?']*len(rl))})"); params.extend(rl)
|
||||
if setCode:
|
||||
sl = [s.strip().upper() for s in setCode.split(",") if s.strip()]
|
||||
if sl:
|
||||
conditions.append(f"UPPER(set_code) IN ({','.join(['?']*len(sl))})"); params.extend(sl)
|
||||
if powerMin is not None:
|
||||
conditions.append("CAST(power AS REAL) >= ?"); params.append(float(powerMin))
|
||||
if powerMax is not None:
|
||||
conditions.append("CAST(power AS REAL) <= ?"); params.append(float(powerMax))
|
||||
if toughMin is not None:
|
||||
conditions.append("CAST(toughness AS REAL) >= ?"); params.append(float(toughMin))
|
||||
if toughMax is not None:
|
||||
conditions.append("CAST(toughness AS REAL) <= ?"); params.append(float(toughMax))
|
||||
if keyword:
|
||||
for kw in [k.strip() for k in keyword.split(",") if k.strip()]:
|
||||
conditions.append("keywords LIKE ?"); params.append(f"%{kw}%")
|
||||
if edhrecMin is not None:
|
||||
conditions.append("edhrec_rank >= ?"); params.append(edhrecMin)
|
||||
if edhrecMax is not None:
|
||||
conditions.append("edhrec_rank <= ?"); params.append(edhrecMax)
|
||||
if qtyMin is not None:
|
||||
conditions.append("quantity >= ?"); params.append(qtyMin)
|
||||
if qtyMax is not None:
|
||||
conditions.append("quantity <= ?"); params.append(qtyMax)
|
||||
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
return where_str, params
|
||||
@@ -1,194 +0,0 @@
|
||||
"""Deck service: .dck parsing, deck profile saving, deck loading."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from models.state import CFG
|
||||
from services.database import _get_db_conn, _row_to_dict
|
||||
|
||||
log_collect = logging.getLogger("lab.collection")
|
||||
|
||||
|
||||
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
|
||||
_TYPE_TARGETS = {
|
||||
"Land": [36, 38],
|
||||
"Instant": [9, 11],
|
||||
"Sorcery": [7, 9],
|
||||
"Artifact": [9, 11],
|
||||
"Creature": [20, 30],
|
||||
"Enchantment": [5, 10],
|
||||
"Planeswalker": [0, 5],
|
||||
}
|
||||
|
||||
|
||||
def _classify_card_type(type_line: str) -> str:
|
||||
tl = type_line or ""
|
||||
for t in _TYPE_PRIORITY:
|
||||
if t in tl:
|
||||
return t
|
||||
return "Other"
|
||||
|
||||
|
||||
def _get_deck_or_404(deck_id: int):
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute("SELECT * FROM decks WHERE id = ?", (deck_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Deck {deck_id} not found")
|
||||
d = dict(row)
|
||||
try:
|
||||
d["color_identity"] = json.loads(d.get("color_identity", "[]"))
|
||||
except Exception:
|
||||
d["color_identity"] = []
|
||||
return d
|
||||
|
||||
|
||||
def _compute_deck_analysis(deck_id: int) -> dict:
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute("""
|
||||
SELECT dc.id, dc.scryfall_id, dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
|
||||
ce.type_line, ce.oracle_text, ce.keywords, ce.cmc, ce.color_identity
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, oracle_text, keywords, cmc, color_identity
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,)).fetchall()
|
||||
counts_by_type = {t: 0 for t in _TYPE_PRIORITY}
|
||||
counts_by_type["Other"] = 0
|
||||
mana_curve = {"0": 0, "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6+": 0}
|
||||
color_pips = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
|
||||
total_cards = 0
|
||||
roles_count = {}
|
||||
for row in rows:
|
||||
r = dict(row)
|
||||
qty = r.get("quantity", 1)
|
||||
total_cards += qty
|
||||
card_type = _classify_card_type(r.get("type_line", ""))
|
||||
counts_by_type[card_type] = counts_by_type.get(card_type, 0) + qty
|
||||
cmc = int(r.get("cmc", 0) or 0)
|
||||
if card_type != "Land":
|
||||
key = str(min(cmc, 6)) if cmc < 6 else "6+"
|
||||
mana_curve[key] = mana_curve.get(key, 0) + qty
|
||||
ci_raw = r.get("color_identity", "[]")
|
||||
if isinstance(ci_raw, str):
|
||||
try:
|
||||
ci_raw = json.loads(ci_raw)
|
||||
except Exception:
|
||||
ci_raw = []
|
||||
for c in (ci_raw or []):
|
||||
if c in color_pips:
|
||||
color_pips[c] += qty
|
||||
role = r.get("role_tag") or "Unknown"
|
||||
roles_count[role] = roles_count.get(role, 0) + qty
|
||||
return {
|
||||
"total_cards": total_cards,
|
||||
"counts_by_type": counts_by_type,
|
||||
"mana_curve": mana_curve,
|
||||
"color_pips": color_pips,
|
||||
"total_cards": total_cards,
|
||||
"roles": roles_count,
|
||||
}
|
||||
|
||||
|
||||
def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool:
|
||||
analysis = _compute_deck_analysis(deck_id)
|
||||
current = analysis["counts_by_type"].get(card_type, 0)
|
||||
target_max = _TYPE_TARGETS.get(card_type, [0, 9999])[1]
|
||||
return (current + count_to_add) <= target_max
|
||||
|
||||
|
||||
def _to_edhrec_slug(name: str) -> str:
|
||||
slug = name.lower()
|
||||
slug = re.sub(r"[',.]" , "", slug)
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
||||
return slug.strip("-")
|
||||
|
||||
|
||||
def parse_dck_file(deck_path: str) -> dict:
|
||||
"""Parse a Forge .dck file into {name, commanders, mainboard, colorIdentity, totalCards}."""
|
||||
path = Path(deck_path)
|
||||
if not path.exists():
|
||||
return {"error": f"File not found: {deck_path}"}
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
name = path.stem
|
||||
section = "main"
|
||||
commanders: dict = {}
|
||||
mainboard: dict = {}
|
||||
commander_name = None
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line == "[metadata]":
|
||||
section = "metadata"; continue
|
||||
if line.startswith("Name="):
|
||||
name = line.split("=", 1)[1].strip(); continue
|
||||
if line == "[Commander]":
|
||||
section = "commander"; continue
|
||||
if line == "[Main]":
|
||||
section = "main"; continue
|
||||
if line.lower().startswith("sideboard") or line == "[Sideboard]":
|
||||
section = "sideboard"; continue
|
||||
clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip()
|
||||
clean = re.sub(r"\s*\*\.\*$", "", clean).strip()
|
||||
m = re.match(r"^(\d+)x?\s+(.+)$", clean)
|
||||
qty, card_name = (int(m.group(1)), m.group(2).strip()) if m else (1, clean)
|
||||
if not card_name:
|
||||
continue
|
||||
if section == "commander":
|
||||
commanders[card_name] = qty
|
||||
if not commander_name:
|
||||
commander_name = card_name
|
||||
elif section != "sideboard":
|
||||
mainboard[card_name] = qty
|
||||
if commander_name:
|
||||
name = f"{commander_name} - Text Import"
|
||||
total = sum(commanders.values()) + sum(mainboard.values())
|
||||
return {"name": name, "commander": commander_name, "commanders": commanders, "mainboard": mainboard, "colorIdentity": [], "totalCards": total}
|
||||
|
||||
|
||||
def _save_profile_to_dck(profile: dict) -> Path:
|
||||
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
|
||||
for name, qty in profile.get("commanders", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
lines += ["", "[Main]"]
|
||||
for name, qty in profile.get("mainboard", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
content = "\n".join(lines)
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", profile.get("name", "imported")).replace(" ", "_").strip()
|
||||
if not safe_name:
|
||||
safe_name = "imported_deck"
|
||||
save_dir = CFG.forge_decks_dir
|
||||
if not save_dir or not os.path.isdir(save_dir):
|
||||
save_dir = os.path.join(Path(__file__).parent.parent, "imported-decks")
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
out_path = Path(save_dir) / f"{safe_name}.dck"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
log_collect.info(f" Saved .dck: {out_path}")
|
||||
return out_path
|
||||
|
||||
|
||||
def _load_deck_cards_by_name(deck_name: str) -> list:
|
||||
"""Load cards from a deck by name, returning list of card dicts."""
|
||||
conn = _get_db_conn()
|
||||
deck_row = conn.execute("SELECT id FROM decks WHERE name = ?", (deck_name,)).fetchone()
|
||||
if not deck_row:
|
||||
return []
|
||||
deck_id = deck_row[0]
|
||||
rows = conn.execute("""
|
||||
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
|
||||
ce.type_line, ce.oracle_text, ce.cmc, ce.color_identity, ce.mana_cost
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,)).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
@@ -1,141 +0,0 @@
|
||||
"""Forge batch simulation runner: Java discovery, command building, process management."""
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from models.state import CFG, BatchState
|
||||
from services.deck_service import parse_dck_file
|
||||
|
||||
log = logging.getLogger("lab.batch")
|
||||
|
||||
|
||||
def _find_java17() -> str:
|
||||
search_dirs = [r'C:\Program Files\Eclipse Adoptium', r'C:\Program Files\Java']
|
||||
for d in search_dirs:
|
||||
if os.path.isdir(d):
|
||||
for child in os.listdir(d):
|
||||
if child.startswith('jdk-17'):
|
||||
candidate = os.path.join(d, child, 'bin', 'java.exe')
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return 'java'
|
||||
|
||||
|
||||
_JAVA17_PATH = None
|
||||
|
||||
|
||||
def get_java17() -> str:
|
||||
global _JAVA17_PATH
|
||||
if _JAVA17_PATH is None:
|
||||
_JAVA17_PATH = _find_java17()
|
||||
return _JAVA17_PATH
|
||||
|
||||
|
||||
def build_java_command(
|
||||
decks: list, num_games: int, threads: int, seed: Optional[int],
|
||||
clock: int, output_path: str, use_learned_policy: bool = False,
|
||||
policy_server: str = "http://localhost:8080", policy_style: str = "midrange",
|
||||
policy_greedy: bool = False, ai_simplified: bool = False,
|
||||
ai_think_time_ms: int = -1, max_queue_depth: int = -1,
|
||||
) -> list:
|
||||
java17 = get_java17()
|
||||
forge_jar = CFG.forge_jar
|
||||
cmd = [
|
||||
java17, '-jar', forge_jar, '-sim',
|
||||
'-decks', ','.join(str(d) for d in decks),
|
||||
'-games', str(num_games),
|
||||
'-threads', str(threads),
|
||||
'-clock', str(clock),
|
||||
'-output', output_path,
|
||||
]
|
||||
if seed is not None:
|
||||
cmd += ['-seed', str(seed)]
|
||||
if use_learned_policy:
|
||||
cmd += ['-policy', policy_style, '-policyServer', policy_server]
|
||||
if policy_greedy:
|
||||
cmd += ['-policyGreedy']
|
||||
if ai_simplified:
|
||||
cmd += ['-aiSimplified']
|
||||
if ai_think_time_ms > 0:
|
||||
cmd += ['-aiThinkTimeMs', str(ai_think_time_ms)]
|
||||
if max_queue_depth > 0:
|
||||
cmd += ['-maxQueueDepth', str(max_queue_depth)]
|
||||
return cmd
|
||||
|
||||
|
||||
def _run_process_blocking(state: BatchState, cmd: list):
|
||||
"""Run Forge subprocess, parse progress, generate deck reports."""
|
||||
log.info(f"Running: {' '.join(cmd)}")
|
||||
state.started_at = datetime.datetime.now().isoformat()
|
||||
state.status = "running"
|
||||
env = os.environ.copy()
|
||||
java17 = get_java17()
|
||||
java_bin = os.path.dirname(java17)
|
||||
if java_bin:
|
||||
env["JAVA_HOME"] = os.path.dirname(java_bin)
|
||||
env["PATH"] = java_bin + os.pathsep + env.get("PATH", "")
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1, env=env,
|
||||
)
|
||||
except Exception as e:
|
||||
state.status = "error"
|
||||
state.error = str(e)
|
||||
log.error(f"Failed to start subprocess: {e}")
|
||||
return
|
||||
last_activity = [time.monotonic()]
|
||||
stall_limit = 300 # 5 min
|
||||
def watchdog():
|
||||
while proc.poll() is None:
|
||||
if (time.monotonic() - last_activity[0]) > stall_limit:
|
||||
log.warning("Stall detected, killing process")
|
||||
proc.kill()
|
||||
state.status = "error"
|
||||
state.error = "Stall detected (5 min no output)"
|
||||
return
|
||||
time.sleep(5)
|
||||
wd = threading.Thread(target=watchdog, daemon=True)
|
||||
wd.start()
|
||||
output_lines = []
|
||||
for line in proc.stdout:
|
||||
last_activity[0] = time.monotonic()
|
||||
line = line.rstrip()
|
||||
output_lines.append(line)
|
||||
gm = re.match(r'\[Game (\d+)/(\d+)\]', line)
|
||||
if gm:
|
||||
state.games_done = int(gm.group(1))
|
||||
state.games_total = int(gm.group(2))
|
||||
pm = re.match(r'\[PROGRESS\].*?(\d+\.?\d*)\s*sims/sec', line)
|
||||
if pm:
|
||||
state.throughput = float(pm.group(1))
|
||||
proc.wait()
|
||||
state.log = '\n'.join(output_lines)
|
||||
if proc.returncode != 0:
|
||||
state.status = "error"
|
||||
state.error = f"Exit code {proc.returncode}"
|
||||
else:
|
||||
state.status = "done"
|
||||
state.games_done = state.games_total
|
||||
state.ended_at = datetime.datetime.now().isoformat()
|
||||
|
||||
|
||||
async def run_batch_subprocess(
|
||||
state: BatchState, decks: list, num_games: int, threads: int,
|
||||
seed: Optional[int] = None, clock: int = 6000, output_path: str = "results",
|
||||
use_learned_policy: bool = False, **kwargs,
|
||||
):
|
||||
"""Async wrapper that runs Forge subprocess in executor."""
|
||||
cmd = build_java_command(
|
||||
decks, num_games, threads, seed, clock, output_path,
|
||||
use_learned_policy=use_learned_policy, **kwargs,
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _run_process_blocking, state, cmd)
|
||||
@@ -1,193 +0,0 @@
|
||||
"""URL-based deck import helpers: Archidekt, EDHREC, text decklist parsing."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
from models.state import CFG
|
||||
from services.deck_service import _to_edhrec_slug
|
||||
from services.scryfall import _API_HEADERS
|
||||
|
||||
log_collect = logging.getLogger("commander_ai_lab.collection")
|
||||
|
||||
|
||||
def _http_get(url: str) -> str:
|
||||
with urlopen(Request(url, headers=_API_HEADERS), timeout=30) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
|
||||
|
||||
def _fetch_archidekt_deck(deck_id: str) -> dict:
|
||||
url = f"https://archidekt.com/api/decks/{deck_id}/"
|
||||
data = json.loads(_http_get(url))
|
||||
profile = {
|
||||
"name": data.get("name", f"Archidekt {deck_id}"),
|
||||
"commander": None,
|
||||
"source": "Archidekt",
|
||||
"sourceUrl": f"https://archidekt.com/decks/{deck_id}",
|
||||
"commanders": {},
|
||||
"mainboard": {},
|
||||
"colorIdentity": [],
|
||||
"totalCards": 0,
|
||||
}
|
||||
for card_entry in data.get("cards", []):
|
||||
qty = card_entry.get("quantity", 1)
|
||||
oracle = card_entry.get("card", {}).get("oracleCard", {})
|
||||
card_name = oracle.get("name", "Unknown")
|
||||
is_commander = any(c.lower() == "commander" for c in card_entry.get("categories", []))
|
||||
if is_commander:
|
||||
profile["commanders"][card_name] = qty
|
||||
if not profile["commander"]:
|
||||
profile["commander"] = card_name
|
||||
if oracle.get("colorIdentity"):
|
||||
profile["colorIdentity"] = oracle["colorIdentity"]
|
||||
else:
|
||||
profile["mainboard"][card_name] = qty
|
||||
profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values())
|
||||
if not profile["name"] or profile["name"] == f"Archidekt {deck_id}":
|
||||
profile["name"] = f"{profile['commander']} \u2014 Archidekt"
|
||||
return profile
|
||||
|
||||
|
||||
def _fetch_edhrec_average(commander_name: str) -> dict:
|
||||
slug = _to_edhrec_slug(commander_name)
|
||||
url = f"https://json.edhrec.com/pages/average-decks/{slug}.json"
|
||||
data = json.loads(_http_get(url))
|
||||
container = data.get("container", {})
|
||||
json_dict = container.get("json_dict", {})
|
||||
card_info = json_dict.get("card", {})
|
||||
real_name = card_info.get("name", commander_name.title())
|
||||
profile = {
|
||||
"name": f"{real_name} \u2014 EDHREC Average",
|
||||
"commander": real_name,
|
||||
"source": "EDHREC Average",
|
||||
"sourceUrl": f"https://edhrec.com/average-decks/{slug}",
|
||||
"commanders": {real_name: 1},
|
||||
"mainboard": {},
|
||||
"colorIdentity": card_info.get("color_identity", []),
|
||||
"sampleSize": data.get("num_decks_avg"),
|
||||
"totalCards": 0,
|
||||
}
|
||||
for cl in json_dict.get("cardlists", []):
|
||||
for cv in cl.get("cardviews", []):
|
||||
card_name = cv.get("name", "")
|
||||
if card_name:
|
||||
profile["mainboard"][card_name] = 1
|
||||
archidekt_data = data.get("archidekt", [])
|
||||
if archidekt_data:
|
||||
basic_names = []
|
||||
for cl in json_dict.get("cardlists", []):
|
||||
if cl.get("tag") == "basics":
|
||||
for cv in cl.get("cardviews", []):
|
||||
basic_names.append(cv.get("name", ""))
|
||||
basic_quantities = [e["q"] for e in archidekt_data if e.get("q", 1) > 1]
|
||||
for i, name in enumerate(basic_names):
|
||||
if i < len(basic_quantities):
|
||||
profile["mainboard"][name] = basic_quantities[i]
|
||||
profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values())
|
||||
return profile
|
||||
|
||||
|
||||
def _import_from_url(url: str) -> dict:
|
||||
url = url.strip()
|
||||
if "archidekt.com/decks/" in url:
|
||||
m = re.search(r"/decks/(\d+)", url)
|
||||
if not m:
|
||||
raise ValueError("Could not parse Archidekt deck ID from URL")
|
||||
return _fetch_archidekt_deck(m.group(1))
|
||||
if "edhrec.com/average-decks/" in url:
|
||||
slug = re.search(r"/average-decks/([^/?#]+)", url).group(1)
|
||||
return _fetch_edhrec_average(slug.replace("-", " "))
|
||||
if "edhrec.com/commanders/" in url:
|
||||
slug = re.search(r"/commanders/([^/?#]+)", url).group(1)
|
||||
return _fetch_edhrec_average(slug.replace("-", " "))
|
||||
raise ValueError(f"Unsupported URL: {url}. Supported: archidekt.com/decks/..., edhrec.com/average-decks/..., edhrec.com/commanders/...")
|
||||
|
||||
|
||||
def _parse_text_decklist(text: str, commander_override: str = None) -> dict:
|
||||
profile = {
|
||||
"name": "Text Import",
|
||||
"commander": None,
|
||||
"source": "Text Import",
|
||||
"sourceUrl": None,
|
||||
"commanders": {},
|
||||
"mainboard": {},
|
||||
"totalCards": 0,
|
||||
}
|
||||
section = "main"
|
||||
for line in text.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("//") or line.startswith("#"):
|
||||
continue
|
||||
if line.lower().startswith("commander") or line == "[Commander]":
|
||||
section = "commander"; continue
|
||||
if line.lower().startswith("main") or line.lower().startswith("deck") or line == "[Main]":
|
||||
section = "main"; continue
|
||||
if line.lower().startswith("sideboard") or line == "[Sideboard]":
|
||||
section = "sideboard"; continue
|
||||
clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip()
|
||||
clean = re.sub(r"\s*\*.*$", "", clean).strip()
|
||||
m = re.match(r"^(\d+)x?\s+(.+)$", clean)
|
||||
qty, card_name = (int(m.group(1)), m.group(2).strip()) if m else (1, clean)
|
||||
if not card_name:
|
||||
continue
|
||||
if section == "commander":
|
||||
profile["commanders"][card_name] = qty
|
||||
if not profile["commander"]:
|
||||
profile["commander"] = card_name
|
||||
elif section != "sideboard":
|
||||
profile["mainboard"][card_name] = qty
|
||||
if commander_override:
|
||||
profile["commander"] = commander_override
|
||||
if not profile["commanders"]:
|
||||
if commander_override in profile["mainboard"]:
|
||||
qty = profile["mainboard"].pop(commander_override)
|
||||
profile["commanders"][commander_override] = qty
|
||||
else:
|
||||
profile["commanders"][commander_override] = 1
|
||||
if profile["commander"]:
|
||||
profile["name"] = f"{profile['commander']} \u2014 Text Import"
|
||||
profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values())
|
||||
return profile
|
||||
|
||||
|
||||
def _save_profile_to_dck(profile: dict) -> Path:
|
||||
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
|
||||
for name, qty in profile.get("commanders", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
lines += ["", "[Main]"]
|
||||
for name, qty in profile.get("mainboard", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
content = "\n".join(lines)
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", profile.get("name", "imported")).replace(" ", "_").strip()
|
||||
if not safe_name:
|
||||
safe_name = "imported_deck"
|
||||
save_dir = CFG.forge_decks_dir
|
||||
if not save_dir or not os.path.isdir(save_dir):
|
||||
save_dir = os.path.join(Path(__file__).parent.parent, "imported-decks")
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
out_path = Path(save_dir) / f"{safe_name}.dck"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
log_collect.info(f" Saved .dck: {out_path}")
|
||||
return out_path
|
||||
|
||||
|
||||
# EDHREC in-memory cache
|
||||
_edhrec_cache: dict = {}
|
||||
_EDHREC_CACHE_TTL = 3600
|
||||
|
||||
|
||||
def _edhrec_cache_get(key: str):
|
||||
entry = _edhrec_cache.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
if time.time() - entry["ts"] > _EDHREC_CACHE_TTL:
|
||||
del _edhrec_cache[key]
|
||||
return None
|
||||
return entry["data"]
|
||||
|
||||
|
||||
def _edhrec_cache_set(key: str, data):
|
||||
_edhrec_cache[key] = {"data": data, "ts": time.time()}
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Collection import service: CSV/text parsing helpers."""
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _parse_finish(raw: str) -> str:
|
||||
if not raw:
|
||||
return "NORMAL"
|
||||
r = raw.strip().lower()
|
||||
if r in ("etched", "foil etched", "foil_etched", "etched foil"):
|
||||
return "ETCHED"
|
||||
if r in ("yes", "foil", "true", "1"):
|
||||
return "FOIL"
|
||||
return "NORMAL"
|
||||
|
||||
|
||||
def _parse_text_line(line: str) -> dict:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("//"):
|
||||
return {}
|
||||
result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""}
|
||||
qty_match = re.match(r"^(\d+)x?\s+", line)
|
||||
if qty_match:
|
||||
result["quantity"] = int(qty_match.group(1))
|
||||
line = line[qty_match.end():]
|
||||
bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line)
|
||||
if bracket_match:
|
||||
result["set_code"] = bracket_match.group(1).lower()
|
||||
result["collector_number"] = bracket_match.group(2)
|
||||
result["name"] = line[:bracket_match.start()].strip()
|
||||
return result
|
||||
paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line)
|
||||
if paren_coll_match:
|
||||
result["set_code"] = paren_coll_match.group(1).lower()
|
||||
result["collector_number"] = paren_coll_match.group(2)
|
||||
result["name"] = line[:paren_coll_match.start()].strip()
|
||||
return result
|
||||
paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line)
|
||||
if paren_match:
|
||||
result["set_code"] = paren_match.group(1).lower()
|
||||
result["name"] = line[:paren_match.start()].strip()
|
||||
return result
|
||||
result["name"] = line.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _auto_infer_mapping(headers: list) -> dict:
|
||||
mapping = {}
|
||||
header_lower = {h: h.lower().strip() for h in headers}
|
||||
field_patterns = {
|
||||
"name": ["name", "card name", "card_name", "cardname", "title"],
|
||||
"quantity": ["quantity", "qty", "count", "amount", "number", "#"],
|
||||
"set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"],
|
||||
"collector_number": ["collector number", "collector_number", "collectornumber", "col #", "col#", "number", "card number"],
|
||||
"finish": ["finish", "foil", "printing", "treatment"],
|
||||
"condition": ["condition", "cond", "grade"],
|
||||
"language": ["language", "lang", "locale"],
|
||||
"notes": ["notes", "note", "comments", "comment"],
|
||||
"tags": ["tags", "tag", "labels", "label"],
|
||||
}
|
||||
for header, lower_h in header_lower.items():
|
||||
for field, patterns in field_patterns.items():
|
||||
if lower_h in patterns:
|
||||
mapping[header] = field
|
||||
break
|
||||
return mapping
|
||||
|
||||
|
||||
def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list:
|
||||
rows = []
|
||||
if (source or "").upper() == "TEXT":
|
||||
for line in content.splitlines():
|
||||
parsed = _parse_text_line(line)
|
||||
if parsed.get("name"):
|
||||
rows.append(parsed)
|
||||
return rows
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
headers = reader.fieldnames or []
|
||||
col_map = {h: (mapping.get(h, "") if mapping else "") for h in headers}
|
||||
if not mapping:
|
||||
col_map = _auto_infer_mapping(headers)
|
||||
col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()}
|
||||
for csv_row in reader:
|
||||
row = {"name": "", "quantity": 1, "set_code": "", "collector_number": "",
|
||||
"finish": "NORMAL", "condition": "", "language": "", "notes": "", "tags": ""}
|
||||
for header, field in col_map.items():
|
||||
val = csv_row.get(header, "").strip()
|
||||
if not val or field == "ignore" or not field:
|
||||
continue
|
||||
if field == "name":
|
||||
val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip()
|
||||
row["name"] = val
|
||||
elif field == "quantity":
|
||||
try:
|
||||
row["quantity"] = int(float(val))
|
||||
except ValueError:
|
||||
row["quantity"] = 1
|
||||
elif field in ("set_code", "set_code_secondary"):
|
||||
row["set_code"] = val.lower()
|
||||
elif field in ("collector_number", "collector_number_secondary"):
|
||||
row["collector_number"] = val
|
||||
elif field == "finish":
|
||||
row["finish"] = _parse_finish(val)
|
||||
elif field in ("condition", "language", "notes", "tags"):
|
||||
row[field] = val
|
||||
if row["name"]:
|
||||
rows.append(row)
|
||||
return rows
|
||||
@@ -1,125 +0,0 @@
|
||||
"""Precon deck download, caching, and conversion logic."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
PRECON_DIR = Path(__file__).parent.parent / "precon-decks"
|
||||
PRECON_INDEX: list[dict] = []
|
||||
GITHUB_PRECON_URL = (
|
||||
"https://raw.githubusercontent.com/taw/magic-preconstructed-decks-data/"
|
||||
"master/decks_v2.json"
|
||||
)
|
||||
PRECON_CACHE_HOURS = 168
|
||||
|
||||
|
||||
def load_precon_index():
|
||||
global PRECON_INDEX
|
||||
idx_path = PRECON_DIR / "precon-index.json"
|
||||
if idx_path.exists():
|
||||
with open(idx_path, "r", encoding="utf-8") as f:
|
||||
PRECON_INDEX = json.load(f)
|
||||
log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
|
||||
else:
|
||||
PRECON_INDEX = []
|
||||
log.info(f" Precons: index not found at {idx_path}")
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
safe = re.sub(r'[<>:"/\\|?*]', '', name)
|
||||
safe = safe.replace(' ', '_').replace("'", '').replace('!', '')
|
||||
return re.sub(r'_+', '_', safe).strip('_')
|
||||
|
||||
|
||||
def _deck_to_dck(deck_data: dict) -> str:
|
||||
lines = ["[metadata]", f"Name={deck_data['name']}"]
|
||||
commanders = deck_data.get('commander', [])
|
||||
if commanders:
|
||||
lines.append("[Commander]")
|
||||
for card in commanders:
|
||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||
cards = deck_data.get('cards', [])
|
||||
if cards:
|
||||
lines.append("[Main]")
|
||||
for card in cards:
|
||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||
sideboard = deck_data.get('sideboard', [])
|
||||
if sideboard:
|
||||
lines.append("[Sideboard]")
|
||||
for card in sideboard:
|
||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def download_precon_database(force: bool = False) -> dict:
|
||||
global PRECON_INDEX
|
||||
idx_path = PRECON_DIR / "precon-index.json"
|
||||
if not force and idx_path.exists():
|
||||
try:
|
||||
with open(idx_path, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
if len(existing) > 50:
|
||||
age_hours = (time.time() - idx_path.stat().st_mtime) / 3600
|
||||
if age_hours < PRECON_CACHE_HOURS:
|
||||
log.info(f" Precons: {len(existing)} decks cached ({age_hours:.0f}h old)")
|
||||
PRECON_INDEX = existing
|
||||
return {"downloaded": 0, "skipped": True, "total": len(existing), "error": None}
|
||||
except Exception:
|
||||
pass
|
||||
log.info(" Precons: Downloading full precon database from GitHub...")
|
||||
try:
|
||||
req = Request(GITHUB_PRECON_URL, headers={"User-Agent": "CommanderAILab/3.0"})
|
||||
with urlopen(req, timeout=120) as resp:
|
||||
all_decks = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
msg = f"Failed to download precon database: {e}"
|
||||
log.error(f" Precons: ERROR - {msg}")
|
||||
if idx_path.exists():
|
||||
load_precon_index()
|
||||
return {"downloaded": 0, "skipped": False, "error": msg}
|
||||
commander_decks = [
|
||||
d for d in all_decks
|
||||
if d.get('type') == 'Commander Deck'
|
||||
and (d.get('format') or '').lower() == 'commander'
|
||||
]
|
||||
log.info(f" Precons: Found {len(commander_decks)} Commander precon decks")
|
||||
PRECON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name_counts = Counter(_sanitize_filename(d['name']) for d in commander_decks)
|
||||
dup_names = {n for n, c in name_counts.items() if c > 1}
|
||||
index = []
|
||||
written = 0
|
||||
for deck in sorted(commander_decks, key=lambda d: (d.get('release_date', ''), d.get('name', ''))):
|
||||
safe_name = _sanitize_filename(deck['name'])
|
||||
if safe_name in dup_names:
|
||||
sc = (deck.get('set_code') or 'unk').upper()
|
||||
safe_name = f"{safe_name}_{sc}"
|
||||
file_name = f"{safe_name}.dck"
|
||||
dck_path = PRECON_DIR / file_name
|
||||
commanders = deck.get('commander', [])
|
||||
cmdr_names = [c['name'] for c in commanders] if commanders else []
|
||||
total_cards = sum(c.get('count', 1) for c in deck.get('cards', [])) + sum(c.get('count', 1) for c in commanders)
|
||||
with open(dck_path, "w", encoding="utf-8") as f:
|
||||
f.write(_deck_to_dck(deck))
|
||||
written += 1
|
||||
release = deck.get('release_date', '')
|
||||
year = int(release[:4]) if release and len(release) >= 4 else 0
|
||||
index.append({
|
||||
"name": deck['name'], "commander": cmdr_names[0] if cmdr_names else "Unknown",
|
||||
"commanders": cmdr_names, "colors": [],
|
||||
"set": deck.get('set_name', ''), "setCode": deck.get('set_code', ''),
|
||||
"year": year, "releaseDate": release, "theme": "",
|
||||
"fileName": file_name, "cardCount": total_cards,
|
||||
})
|
||||
with open(idx_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=2, ensure_ascii=False)
|
||||
PRECON_INDEX = index
|
||||
log.info(f" Precons: {written} .dck files written, index saved")
|
||||
return {"downloaded": written, "skipped": False, "total": written, "error": None}
|
||||
@@ -1,216 +0,0 @@
|
||||
"""Scryfall API client and cache service."""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
# Lazy import to avoid circular dependency
|
||||
def _detect_card_roles(oracle_text, type_line, keywords):
|
||||
from services.card_analysis import _detect_card_roles as _dcr
|
||||
return _dcr(oracle_text, type_line, keywords)
|
||||
|
||||
|
||||
SCRYFALL_CACHE_DB_PATH = Path(__file__).parent.parent / "scryfall_cache.db"
|
||||
SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600))
|
||||
|
||||
_API_HEADERS = {"User-Agent": "CommanderAILab/3.0", "Accept": "application/json"}
|
||||
|
||||
|
||||
class _ScryfallCache:
|
||||
def __init__(self, db_path: Path = None, ttl_seconds: int = None):
|
||||
self._db_path = db_path or SCRYFALL_CACHE_DB_PATH
|
||||
self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS
|
||||
self._lock = threading.Lock()
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scryfall_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
json_blob TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _make_key(name: str, set_code: str, collector_number: str) -> str:
|
||||
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
|
||||
|
||||
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT json_blob FROM scryfall_cache WHERE cache_key = ?", (key,)
|
||||
).fetchone()
|
||||
if row:
|
||||
self._hits += 1
|
||||
return json.loads(row[0])
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
def put(self, name: str, set_code: str, collector_number: str, data: dict):
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
blob = json.dumps(data)
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob) VALUES (?, ?)",
|
||||
(key, blob),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def stats(self) -> dict:
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
return {"cached": count, "hits": self._hits, "misses": self._misses}
|
||||
|
||||
def clear(self) -> int:
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
self._conn.execute("DELETE FROM scryfall_cache")
|
||||
self._conn.commit()
|
||||
self._conn.execute("VACUUM")
|
||||
self._hits = 0; self._misses = 0
|
||||
return count
|
||||
|
||||
def evict_expired(self) -> int:
|
||||
cutoff = datetime.datetime.utcnow().isoformat()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"DELETE FROM scryfall_cache WHERE (julianday(?) - julianday(fetched_at)) * 86400 > ?",
|
||||
(cutoff, self._ttl),
|
||||
)
|
||||
deleted = self._conn.total_changes
|
||||
self._conn.commit()
|
||||
return deleted
|
||||
|
||||
|
||||
_scryfall_cache = _ScryfallCache()
|
||||
_scryfall_lock = threading.Lock()
|
||||
_scryfall_last_call = 0.0
|
||||
|
||||
|
||||
def _scryfall_rate_limit():
|
||||
global _scryfall_last_call
|
||||
with _scryfall_lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - _scryfall_last_call
|
||||
if elapsed < 0.1:
|
||||
time.sleep(0.1 - elapsed)
|
||||
_scryfall_last_call = time.monotonic()
|
||||
|
||||
|
||||
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
from urllib.parse import quote
|
||||
cached = _scryfall_cache.get(name, set_code, collector_number)
|
||||
if cached is not None:
|
||||
return cached
|
||||
card_data = None
|
||||
last_error = ""
|
||||
if set_code and collector_number:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}"
|
||||
with urlopen(Request(url, headers=_API_HEADERS), timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e); card_data = None
|
||||
if not card_data:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
url = f"https://api.scryfall.com/cards/named?exact={quote(name)}"
|
||||
with urlopen(Request(url, headers=_API_HEADERS), timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
if not card_data or card_data.get("object") == "error":
|
||||
err_detail = card_data.get("details", "unknown error") if card_data else last_error
|
||||
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
|
||||
_scryfall_cache.put(name, set_code, collector_number, card_data)
|
||||
resolved_name = card_data.get("name", "")
|
||||
if resolved_name and resolved_name.lower() != name.strip().lower():
|
||||
_scryfall_cache.put(resolved_name, set_code, collector_number, card_data)
|
||||
return card_data
|
||||
|
||||
|
||||
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
card_data = _fetch_scryfall_api(name, set_code, collector_number)
|
||||
if not card_data or "_error" in card_data:
|
||||
return card_data or {}
|
||||
type_line = card_data.get("type_line", "")
|
||||
color_identity = card_data.get("color_identity", [])
|
||||
keywords = card_data.get("keywords", [])
|
||||
subtypes = []
|
||||
for sep in [" \u2014 ", " - "]:
|
||||
if sep in type_line:
|
||||
subtypes = [s.strip() for s in type_line.split(sep, 1)[1].split() if s.strip()]
|
||||
break
|
||||
is_legendary = 1 if "Legendary" in type_line else 0
|
||||
is_basic = 1 if "Basic" in type_line else 0
|
||||
prices = card_data.get("prices", {})
|
||||
try:
|
||||
tcg_price = float(prices.get("usd") or prices.get("usd_foil") or "0") or 0.0
|
||||
except (ValueError, TypeError):
|
||||
tcg_price = 0.0
|
||||
oracle_text = card_data.get("oracle_text", "")
|
||||
if not oracle_text and card_data.get("card_faces"):
|
||||
oracle_text = "\n//\n".join(f.get("oracle_text", "") for f in card_data["card_faces"] if f.get("oracle_text"))
|
||||
power = card_data.get("power", "")
|
||||
toughness = card_data.get("toughness", "")
|
||||
if not power and card_data.get("card_faces"):
|
||||
power = card_data["card_faces"][0].get("power", "")
|
||||
toughness = card_data["card_faces"][0].get("toughness", "")
|
||||
mana_cost = card_data.get("mana_cost", "")
|
||||
if not mana_cost and card_data.get("card_faces"):
|
||||
mana_cost = card_data["card_faces"][0].get("mana_cost", "")
|
||||
auto_roles = _detect_card_roles(oracle_text, type_line, keywords)
|
||||
return {
|
||||
"name": card_data.get("name", name),
|
||||
"type_line": type_line,
|
||||
"subtypes": json.dumps(subtypes),
|
||||
"is_legendary": is_legendary,
|
||||
"is_basic": is_basic,
|
||||
"color_identity": json.dumps(color_identity),
|
||||
"cmc": card_data.get("cmc", 0.0),
|
||||
"mana_cost": mana_cost,
|
||||
"oracle_text": oracle_text,
|
||||
"keywords": json.dumps(keywords),
|
||||
"power": power or "",
|
||||
"toughness": toughness or "",
|
||||
"rarity": card_data.get("rarity", ""),
|
||||
"set_name": card_data.get("set_name", ""),
|
||||
"edhrec_rank": card_data.get("edhrec_rank", 0) or 0,
|
||||
"tcg_price": tcg_price,
|
||||
"salt_score": 0.0,
|
||||
"is_game_changer": 0,
|
||||
"category": json.dumps(auto_roles),
|
||||
"scryfall_id": card_data.get("id", ""),
|
||||
"tcgplayer_id": str(card_data.get("tcgplayer_id", "")),
|
||||
}
|
||||
|
||||
|
||||
def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
|
||||
"""Fuzzy-search Scryfall for a card name. Returns raw Scryfall JSON dict, or None on failure."""
|
||||
from urllib.parse import quote
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
encoded = quote(name)
|
||||
url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}"
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
if data.get("object") == "error":
|
||||
return None
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
@@ -79,26 +79,23 @@ class GameEngine:
|
||||
deck_b: list[Card],
|
||||
name_a: str = "Player A",
|
||||
name_b: str = "Player B",
|
||||
commander_names: list[str] | None = None,
|
||||
) -> GameResult:
|
||||
"""Backward-compatible 2-player entry point."""
|
||||
return self.run_n(
|
||||
decks=[deck_a, deck_b],
|
||||
names=[name_a, name_b],
|
||||
commander_names=commander_names,
|
||||
)
|
||||
|
||||
def run_n(
|
||||
self,
|
||||
decks: list[list[Card]],
|
||||
names: list[str] | None = None,
|
||||
commander_names: list[str] | None = None,
|
||||
) -> GameResult:
|
||||
"""Run a single headless N-player game and return the result."""
|
||||
n = len(decks)
|
||||
if names is None:
|
||||
names = [f"Player {chr(65 + i)}" for i in range(n)]
|
||||
sim = self._create_state(decks, names, commander_names=commander_names)
|
||||
sim = self._create_state(decks, names)
|
||||
game_log: list[dict] = []
|
||||
elimination_order: list[int] = [] # seats in order of elimination
|
||||
|
||||
@@ -197,34 +194,16 @@ class GameEngine:
|
||||
self,
|
||||
decks: list[list[Card]],
|
||||
names: list[str],
|
||||
commander_names: list[str] | None = None,
|
||||
) -> SimState:
|
||||
"""Create initial simulation state with shuffled decks and opening hands (N players).
|
||||
|
||||
If commander_names is provided, the matching card is flagged
|
||||
is_commander=True and placed in the player's command_zone
|
||||
instead of the library/hand.
|
||||
"""
|
||||
commander_names = commander_names or []
|
||||
"""Create initial simulation state with shuffled decks and opening hands (N players)."""
|
||||
sim = SimState(max_turns=self.max_turns)
|
||||
|
||||
for idx, (deck, name) in enumerate(zip(decks, names)):
|
||||
cards = [enrich_card(c.clone()) for c in deck]
|
||||
random.shuffle(cards)
|
||||
|
||||
# Identify and separate commander card
|
||||
cmd_name = commander_names[idx].lower() if idx < len(commander_names) else ""
|
||||
commander_card = None
|
||||
remaining = []
|
||||
for c in cards:
|
||||
if cmd_name and c.name.lower() == cmd_name and commander_card is None:
|
||||
c.is_commander = True
|
||||
commander_card = c
|
||||
else:
|
||||
remaining.append(c)
|
||||
|
||||
random.shuffle(remaining)
|
||||
hand = remaining[:7]
|
||||
library = remaining[7:]
|
||||
hand = cards[:7]
|
||||
library = cards[7:]
|
||||
|
||||
player = Player(
|
||||
name=name,
|
||||
@@ -234,8 +213,6 @@ class GameEngine:
|
||||
hand=hand,
|
||||
stats=PlayerStats(cards_drawn=7),
|
||||
)
|
||||
if commander_card:
|
||||
player.command_zone.append(commander_card)
|
||||
sim.players.append(player)
|
||||
|
||||
sim.init_battlefields(len(sim.players))
|
||||
@@ -260,17 +237,6 @@ class GameEngine:
|
||||
sim.add_to_battlefield(pi, land_card)
|
||||
p.stats.lands_played += 1
|
||||
|
||||
@staticmethod
|
||||
def _send_to_graveyard(sim: SimState, card, owner_idx: int) -> None:
|
||||
"""Route card to command zone if it's a commander, else graveyard."""
|
||||
owner = sim.players[owner_idx]
|
||||
if card.is_commander:
|
||||
card.tapped = False
|
||||
card.turn_played = -1
|
||||
owner.command_zone.append(card)
|
||||
else:
|
||||
owner.graveyard.append(card)
|
||||
|
||||
def _count_untapped_lands(self, sim: SimState, pi: int) -> int:
|
||||
"""Count untapped lands for a player."""
|
||||
return sum(
|
||||
@@ -336,7 +302,7 @@ class GameEngine:
|
||||
all_opp_creatures.sort(key=lambda c: -score_card(c, w))
|
||||
killed = all_opp_creatures[0]
|
||||
sim.remove_from_battlefield(killed.id)
|
||||
self._send_to_graveyard(sim, killed, killed.owner_id)
|
||||
sim.players[killed.owner_id].graveyard.append(killed)
|
||||
if events is not None:
|
||||
events.append(f"Cast {card.name} (removal) — destroyed {killed.name}")
|
||||
else:
|
||||
@@ -353,7 +319,7 @@ class GameEngine:
|
||||
for c in bf:
|
||||
if c.is_creature():
|
||||
wiped_names.append(c.name)
|
||||
self._send_to_graveyard(sim, c, c.owner_id)
|
||||
sim.players[c.owner_id].graveyard.append(c)
|
||||
else:
|
||||
keep.append(c)
|
||||
sim.battlefields[seat_idx] = keep
|
||||
@@ -378,47 +344,6 @@ class GameEngine:
|
||||
played += 1
|
||||
available_mana = self._count_untapped_lands(sim, pi)
|
||||
|
||||
# --- Commander casting from command zone (second pass) ---
|
||||
available_mana = self._count_untapped_lands(sim, pi)
|
||||
for cmd_card in list(p.command_zone):
|
||||
tax = p.commander_tax.get(cmd_card.name, 0)
|
||||
total_cost = (cmd_card.cmc or 0) + tax
|
||||
if total_cost <= available_mana:
|
||||
# Remove from command zone
|
||||
p.command_zone.remove(cmd_card)
|
||||
|
||||
# Assign metadata
|
||||
cmd_card.owner_id = pi
|
||||
cmd_card.tapped = False
|
||||
cmd_card.id = sim.next_card_id
|
||||
sim.next_card_id += 1
|
||||
cmd_card.turn_played = sim.turn
|
||||
|
||||
# Tap lands for mana
|
||||
mana_needed = total_cost
|
||||
for bf_card in sim.get_battlefield(pi):
|
||||
if mana_needed <= 0:
|
||||
break
|
||||
if not bf_card.tapped and bf_card.is_land():
|
||||
bf_card.tapped = True
|
||||
mana_needed -= 1
|
||||
|
||||
# Place on battlefield
|
||||
sim.add_to_battlefield(pi, cmd_card)
|
||||
p.stats.spells_cast += 1
|
||||
p.stats.mana_spent += total_cost
|
||||
|
||||
# Increment commander tax for next cast
|
||||
p.commander_tax[cmd_card.name] = tax + 2
|
||||
|
||||
if events is not None:
|
||||
events.append(
|
||||
f"Cast commander {cmd_card.name} from command zone "
|
||||
f"for {total_cost} mana (tax={tax})"
|
||||
)
|
||||
|
||||
available_mana = self._count_untapped_lands(sim, pi)
|
||||
|
||||
@staticmethod
|
||||
def _select_attack_target(sim: SimState, pi: int) -> int:
|
||||
"""Select the best opponent to attack.
|
||||
@@ -543,13 +468,13 @@ class GameEngine:
|
||||
|
||||
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
|
||||
sim.remove_from_battlefield(blocker.id)
|
||||
self._send_to_graveyard(sim, blocker, opp_idx)
|
||||
sim.players[opp_idx].graveyard.append(blocker)
|
||||
if events is not None:
|
||||
combat_details.append(f" {blocker.name} dies")
|
||||
|
||||
if b_pow >= a_tou or blocker.has_keyword("deathtouch"):
|
||||
sim.remove_from_battlefield(atk.id)
|
||||
self._send_to_graveyard(sim, atk, pi)
|
||||
sim.players[pi].graveyard.append(atk)
|
||||
if events is not None:
|
||||
combat_details.append(f" {atk.name} dies")
|
||||
|
||||
|
||||
@@ -142,14 +142,6 @@ class Player:
|
||||
hand: list[Card] = field(default_factory=list)
|
||||
graveyard: list[Card] = field(default_factory=list)
|
||||
exile: list[Card] = field(default_factory=list)
|
||||
command_zone: list[Card] = field(default_factory=list)
|
||||
|
||||
# commander_tax tracks the cumulative tax per commander name.
|
||||
# Keyed by card name so N-player pods with multiple commanders
|
||||
# each track their own tax independently.
|
||||
# engine._play_spells uses: tax = p.commander_tax.get(name, 0)
|
||||
# p.commander_tax[name] = tax + 2
|
||||
commander_tax: dict = field(default_factory=dict)
|
||||
|
||||
stats: PlayerStats = field(default_factory=PlayerStats)
|
||||
|
||||
|
||||
@@ -68,8 +68,6 @@ AI_DEFAULT_WEIGHTS: dict[str, float] = {
|
||||
"spell_enchantment": 3.0,
|
||||
"spell_flash": 2.0,
|
||||
"spell_cascade": 3.0,
|
||||
# Commander bonus
|
||||
"commander_bonus": 5.0,
|
||||
}
|
||||
|
||||
|
||||
@@ -322,10 +320,6 @@ def score_card(
|
||||
if "search your library" in text:
|
||||
score += w.get("spell_search", 2.0)
|
||||
|
||||
# ── Commander bonus ──
|
||||
if card.is_commander:
|
||||
score += w.get("commander_bonus", 5.0)
|
||||
|
||||
return score
|
||||
|
||||
|
||||
|
||||
@@ -23,15 +23,12 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from commander_ai_lab.web.routers.api import router as api_router
|
||||
from commander_ai_lab.web.routers.ws import router as ws_router
|
||||
from commander_ai_lab.web.routers.art import router as art_router
|
||||
|
||||
# Prefer the React build output; fall back to legacy static dir
|
||||
_FRONTEND_DIST = Path(__file__).parents[3] / "frontend" / "dist"
|
||||
_STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
@@ -67,8 +64,8 @@ def create_app(
|
||||
app.include_router(ws_router) # /ws/game/{game_id}
|
||||
app.include_router(art_router, prefix="/api/v1")
|
||||
|
||||
# Static files (web client) — prefer React build, fall back to legacy
|
||||
sd = static_dir or (_FRONTEND_DIST if _FRONTEND_DIST.exists() else _STATIC_DIR)
|
||||
# Static files (web client)
|
||||
sd = static_dir or _STATIC_DIR
|
||||
if sd.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(sd), html=True), name="static")
|
||||
|
||||
@@ -76,16 +73,6 @@ def create_app(
|
||||
async def health() -> dict:
|
||||
return {"status": "ok", "version": "0.5.0"}
|
||||
|
||||
# SPA catch-all: serve index.html for non-API, non-WS paths
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def spa_fallback(full_path: str) -> FileResponse:
|
||||
index = sd / "index.html"
|
||||
if index.exists():
|
||||
return FileResponse(str(index))
|
||||
# Fall back to legacy static
|
||||
legacy = _STATIC_DIR / "index.html"
|
||||
return FileResponse(str(legacy))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- DEPRECATED: This file is replaced by the React frontend in frontend/. Kept as fallback. -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
Reference in new issue
Block a user