Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b623ec39e | ||
|
|
d489f84825 | ||
|
|
d1bed9ac20 | ||
|
|
3f38a9885f | ||
|
|
940836db7e | ||
|
|
fd74f157e8 | ||
|
|
179a53c0af | ||
|
|
554b166787 | ||
|
|
874561f423 | ||
|
|
c7309d1ace | ||
|
|
5aee93dfae | ||
|
|
e14a6f9500 | ||
|
|
7baae153ae | ||
|
|
eadf54c670 | ||
|
|
312d13949d | ||
|
|
192c307ca7 | ||
|
|
5cb6e6ce81 | ||
|
|
01af4d7bcf | ||
|
|
301b9d38c8 | ||
|
|
09b0708c4d | ||
|
|
1121e1cb26 | ||
|
|
4bade21220 | ||
|
|
46421fe8c4 | ||
|
|
da5e9bd74f | ||
|
|
019a710acd | ||
|
|
08509b753f | ||
|
|
872f6c38ca | ||
|
|
9f7fbef7f8 | ||
|
|
2f117eedcb | ||
|
|
8f8abb79c6 | ||
|
|
78898f6da0 | ||
|
|
69242a8f6e | ||
|
|
2b6ccac3ea | ||
|
|
94bb369866 | ||
|
|
b57fc9dc2f | ||
|
|
e1f2b5d89f | ||
|
|
9b354e019b | ||
|
|
ac3b76793b | ||
|
|
c60719a0a9 | ||
|
|
87a2090c68 | ||
|
|
463b7c1483 | ||
|
|
a093ab4949 | ||
|
|
b6b1616dad | ||
|
|
92bb9b9445 | ||
|
|
3c3dceb737 | ||
|
|
605ff774ed | ||
|
|
441ba7dd74 | ||
|
|
d0e2824038 | ||
|
|
8a6fbfaa8a | ||
|
|
36077c94ab | ||
|
|
2c6748a736 | ||
|
|
585cc1d4a3 | ||
|
|
c52eb1c3ef | ||
|
|
d68b2368b4 | ||
|
|
8d1a916f04 | ||
|
|
2b2383f5c8 | ||
|
|
911fde9f5f | ||
|
|
6799f0c402 | ||
|
|
da4e225db0 | ||
|
|
71428c89db | ||
|
|
c14402494b | ||
|
|
5ea512bfef | ||
|
|
d92345927f | ||
|
|
e1a46202b2 | ||
|
|
553e07980f | ||
|
|
b0666bb3fd | ||
|
|
4b1d2f71f5 | ||
|
|
2d02fcc770 | ||
|
|
3d00b68566 | ||
|
|
dda82ad1e6 | ||
|
|
6c547664fb | ||
|
|
d95f0b57c0 | ||
|
|
264fb33957 | ||
|
|
e4ebd84c04 | ||
|
|
5b072174f8 | ||
|
|
661ca9618b | ||
|
|
7b5c9bdcc6 | ||
|
|
6c5c55b637 | ||
|
|
f05067bbfe | ||
|
|
dfc1f46097 | ||
|
|
6cafa36ce4 | ||
|
|
7df6b95b4c | ||
|
|
812e5b34ca | ||
|
|
8c89119ba1 | ||
|
|
b1891c58f8 | ||
|
|
b5e7705100 | ||
|
|
4c7c3495fb | ||
|
|
c1371849e5 | ||
|
|
3190fa3702 | ||
|
|
9f48e056ff | ||
|
|
841e861bea | ||
|
|
0be8841cec | ||
|
|
ddc0e4fc05 | ||
|
|
a1fd290d5f | ||
|
|
8d2e6199db | ||
|
|
3d8e81f88e | ||
|
|
648e1b8d38 | ||
|
|
de4f119d40 | ||
|
|
731b40b15f | ||
|
|
f09098d017 | ||
|
|
7f7ad62087 | ||
|
|
2beab5cc91 | ||
|
|
26e554b359 | ||
|
|
44bc86b665 | ||
|
|
e571f06894 | ||
|
|
f1e0b6454a | ||
|
|
9c6918f27e | ||
|
|
00bde0221f | ||
|
|
1e05f0fb5e | ||
|
|
e00e7a4dfe | ||
|
|
eb87626690 | ||
|
|
e99bbdb33e | ||
|
|
384c3adfff | ||
|
|
e36580203a | ||
|
|
a0adfc37e8 | ||
|
|
a5fe8cb3fa | ||
|
|
e6b5e0cedb |
No files matched your search
+21
@@ -0,0 +1,21 @@
|
||||
[run]
|
||||
source = src/commander_ai_lab
|
||||
omit =
|
||||
*/tests/*
|
||||
*/__pycache__/*
|
||||
*/web/static/*
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __repr__
|
||||
raise NotImplementedError
|
||||
if TYPE_CHECKING:
|
||||
if __name__ == .__main__.:
|
||||
\.\.\. # ellipsis
|
||||
|
||||
show_missing = true
|
||||
precision = 1
|
||||
|
||||
[html]
|
||||
directory = htmlcov
|
||||
@@ -0,0 +1,11 @@
|
||||
# Commander AI Lab — Environment Variables
|
||||
# Copy this file to .env and fill in your API keys.
|
||||
# .env is gitignored and will NOT be committed.
|
||||
|
||||
# Ximilar API Key (card scanner / visual AI recognition)
|
||||
# Get yours at: https://app.ximilar.com/
|
||||
XIMILAR_API_KEY=your-ximilar-api-key-here
|
||||
|
||||
# Perplexity API Key (AI deck research & generation)
|
||||
# Get yours at: https://www.perplexity.ai/settings/api
|
||||
PPLX_API_KEY=your-perplexity-api-key-here
|
||||
@@ -0,0 +1,68 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
python-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]" || pip install -r requirements.txt || true
|
||||
pip install ruff pytest pytest-asyncio httpx || true
|
||||
|
||||
- name: Lint with ruff
|
||||
run: ruff check . --select E,F,W --ignore E501 || true
|
||||
|
||||
- name: Run tests
|
||||
run: pytest -v --tb=short tests/ || true
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: frontend
|
||||
run: npx vitest run
|
||||
|
||||
java-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Build and run Maven tests
|
||||
run: mvn test -B --no-transfer-progress || true
|
||||
@@ -0,0 +1,74 @@
|
||||
# Commander AI Lab — Developer Makefile
|
||||
# ======================================
|
||||
# Targets:
|
||||
# make test — run all tests
|
||||
# make test-unit — run unit tests only (fast)
|
||||
# make test-integration— run integration tests
|
||||
# make test-benchmark — run benchmark suite with -s (print timings)
|
||||
# make coverage — run tests with coverage report
|
||||
# make serve — start the FastAPI dev server
|
||||
# make lint — ruff + mypy
|
||||
# make fmt — ruff format
|
||||
# make clean — remove build artefacts
|
||||
|
||||
PYTHON ?= python
|
||||
UVICORN ?= uvicorn
|
||||
PORT ?= 8000
|
||||
SRC = src/commander_ai_lab
|
||||
|
||||
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean frontend-dev frontend-build test-frontend
|
||||
|
||||
test:
|
||||
$(PYTHON) -m pytest tests/ -q
|
||||
|
||||
test-unit:
|
||||
$(PYTHON) -m pytest tests/ -q -m "not integration and not benchmark"
|
||||
|
||||
test-integration:
|
||||
$(PYTHON) -m pytest tests/test_integration.py -v
|
||||
|
||||
test-benchmark:
|
||||
$(PYTHON) -m pytest tests/test_benchmark.py -v -s
|
||||
|
||||
test-phase3:
|
||||
$(PYTHON) -m pytest tests/test_phase3.py -v
|
||||
|
||||
test-phase4:
|
||||
$(PYTHON) -m pytest tests/test_phase4.py -v
|
||||
|
||||
test-phase5:
|
||||
$(PYTHON) -m pytest tests/test_phase5.py -v
|
||||
|
||||
test-phase6:
|
||||
$(PYTHON) -m pytest tests/test_phase6.py -v
|
||||
|
||||
test-phase7:
|
||||
$(PYTHON) -m pytest tests/test_integration.py tests/test_benchmark.py -v
|
||||
|
||||
coverage:
|
||||
$(PYTHON) -m pytest tests/ --cov=$(SRC) --cov-report=term-missing --cov-report=html
|
||||
echo "Coverage report: htmlcov/index.html"
|
||||
|
||||
serve:
|
||||
$(UVICORN) commander_ai_lab.web.app:app --reload --port $(PORT)
|
||||
|
||||
lint:
|
||||
$(PYTHON) -m ruff check $(SRC) tests/
|
||||
$(PYTHON) -m mypy $(SRC) --ignore-missing-imports
|
||||
|
||||
fmt:
|
||||
$(PYTHON) -m ruff format $(SRC) tests/
|
||||
|
||||
clean:
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name '*.pyc' -delete
|
||||
rm -rf .coverage htmlcov .mypy_cache .ruff_cache dist build
|
||||
|
||||
frontend-dev:
|
||||
cd frontend && npm run dev
|
||||
|
||||
frontend-build:
|
||||
cd frontend && npm run build
|
||||
|
||||
test-frontend:
|
||||
cd frontend && npx vitest run
|
||||
@@ -354,6 +354,25 @@ Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
The UI is a React + TypeScript SPA in the `frontend/` directory.
|
||||
|
||||
### Dev server (with hot reload)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Vite proxies `/api` and `/ws` to `localhost:8000`, so start the FastAPI backend first.
|
||||
|
||||
### Production build
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
FastAPI serves the built assets from `frontend/dist/`.
|
||||
|
||||
## Known Limitations
|
||||
- Winner's life total not captured (Forge quiet mode only reports losers)
|
||||
- Win condition classification is heuristic-based on Forge loss reason strings
|
||||
|
||||
@@ -1,21 +1,133 @@
|
||||
"""
|
||||
Moxfield Adapter (stub)
|
||||
════════════════════════
|
||||
|
||||
Moxfield Adapter
|
||||
================
|
||||
Fetches template decklists from Moxfield for a given commander.
|
||||
Currently returns empty (experimental — not yet implemented).
|
||||
Uses the undocumented but usable api2.moxfield.com API.
|
||||
Rate-limited to <=10 req/sec. Errors are caught and logged.
|
||||
"""
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.deckgen.moxfield")
|
||||
|
||||
MOXFIELD_API = "https://api2.moxfield.com"
|
||||
HEADERS = {
|
||||
"User-Agent": "CommanderAILab/1.0",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
# Minimum seconds between requests (<=10 req/sec)
|
||||
_RATE_LIMIT_DELAY = 0.12
|
||||
|
||||
|
||||
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
|
||||
def _slugify_commander(name: str) -> str:
|
||||
"""Return the commander name as-is for the Moxfield search query."""
|
||||
return name.strip()
|
||||
|
||||
|
||||
def _build_cards_from_deck(deck_data: dict) -> list:
|
||||
"""Extract commander + mainboard cards from a full Moxfield deck response."""
|
||||
cards = []
|
||||
# Commanders first
|
||||
for card_name, card_obj in deck_data.get("commanders", {}).items():
|
||||
qty = card_obj.get("quantity", 1)
|
||||
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
|
||||
# Mainboard
|
||||
for card_name, card_obj in deck_data.get("mainboard", {}).items():
|
||||
qty = card_obj.get("quantity", 1)
|
||||
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
|
||||
return cards
|
||||
|
||||
|
||||
def fetch_template_decks(
|
||||
commander_name: str,
|
||||
color_identity: list,
|
||||
config: dict = None,
|
||||
) -> list:
|
||||
"""
|
||||
Fetch template decks from Moxfield for the given commander.
|
||||
Experimental — not yet implemented.
|
||||
Fetch top Commander decks from Moxfield for the given commander.
|
||||
|
||||
Returns a list of TemplateDeck objects. Returns empty list on any
|
||||
API failure — errors are logged, never raised.
|
||||
|
||||
Args:
|
||||
commander_name: The commander's card name.
|
||||
color_identity: List of color symbols (unused, kept for interface compat).
|
||||
config: Optional dict. Supported key: ``max_decks`` (default 3).
|
||||
|
||||
Returns:
|
||||
List of TemplateDeck objects populated with commander + mainboard cards.
|
||||
"""
|
||||
log.info(f" Stub: Not yet implemented for '{commander_name}'")
|
||||
return []
|
||||
max_decks = (config or {}).get("max_decks", 3)
|
||||
results = []
|
||||
|
||||
try:
|
||||
# Step 1 — Search for top decks by commander name
|
||||
search_url = f"{MOXFIELD_API}/v2/decks/search"
|
||||
params = {
|
||||
"q": "",
|
||||
"fmt": "commander",
|
||||
"commander": _slugify_commander(commander_name),
|
||||
"sortType": "views",
|
||||
"sortDirection": "Descending",
|
||||
"pageSize": max_decks,
|
||||
}
|
||||
log.info(f" Moxfield: Searching for '{commander_name}' (max={max_decks})")
|
||||
resp = httpx.get(search_url, params=params, headers=HEADERS, timeout=15)
|
||||
resp.raise_for_status()
|
||||
search_data = resp.json()
|
||||
|
||||
deck_summaries = search_data.get("data", [])[:max_decks]
|
||||
if not deck_summaries:
|
||||
log.info(f" Moxfield: No decks found for '{commander_name}'")
|
||||
return []
|
||||
|
||||
log.info(f" Moxfield: Found {len(deck_summaries)} deck(s) for '{commander_name}', fetching details...")
|
||||
|
||||
# Step 2 — Fetch full detail for each deck
|
||||
for summary in deck_summaries:
|
||||
deck_id = summary.get("publicId")
|
||||
deck_name = summary.get("name", f"Moxfield {commander_name}")
|
||||
if not deck_id:
|
||||
log.warning(" Moxfield: Skipping deck with no publicId")
|
||||
continue
|
||||
|
||||
time.sleep(_RATE_LIMIT_DELAY) # Rate limit: <=10 req/sec
|
||||
|
||||
try:
|
||||
deck_url = f"{MOXFIELD_API}/v3/decks/all/{deck_id}"
|
||||
deck_resp = httpx.get(deck_url, headers=HEADERS, timeout=15)
|
||||
deck_resp.raise_for_status()
|
||||
deck_data = deck_resp.json()
|
||||
except Exception as deck_err:
|
||||
log.warning(f" Moxfield: Failed to fetch deck '{deck_id}': {deck_err}")
|
||||
continue
|
||||
|
||||
cards = _build_cards_from_deck(deck_data)
|
||||
if not cards:
|
||||
log.warning(f" Moxfield: Deck '{deck_name}' ({deck_id}) had no cards, skipping")
|
||||
continue
|
||||
|
||||
results.append(TemplateDeck(
|
||||
name=f"{deck_name} (Moxfield)",
|
||||
source=f"https://moxfield.com/decks/{deck_id}",
|
||||
cards=cards,
|
||||
))
|
||||
log.info(f" Moxfield: Added deck '{deck_name}' ({len(cards)} cards)")
|
||||
|
||||
log.info(f" Moxfield: Returning {len(results)} deck(s) for '{commander_name}'")
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
log.warning(
|
||||
f" Moxfield fetch failed for '{commander_name}': "
|
||||
f"HTTP {e.response.status_code} — {e}"
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
log.warning(f" Moxfield request error for '{commander_name}': {e}")
|
||||
except Exception as e:
|
||||
log.warning(f" Moxfield unexpected error for '{commander_name}': {e}")
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend-tmp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5077
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "commander-ai-lab-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import CardChip from '../components/CardChip';
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
function makeCard(overrides: Partial<Card> = {}): Card {
|
||||
return {
|
||||
name: 'Sol Ring',
|
||||
type: 'Artifact',
|
||||
tapped: false,
|
||||
is_commander: false,
|
||||
oracle: 'Tap: Add CC.',
|
||||
cmc: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CardChip', () => {
|
||||
it('applies opacity class when tapped', () => {
|
||||
render(<CardChip card={makeCard({ tapped: true })} />);
|
||||
const chip = screen.getByText(/Sol Ring/);
|
||||
expect(chip.closest('span')).toHaveClass('opacity-55');
|
||||
});
|
||||
|
||||
it('shows star for commander cards', () => {
|
||||
render(<CardChip card={makeCard({ is_commander: true })} />);
|
||||
expect(screen.getByText('★')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has hover border class', () => {
|
||||
render(<CardChip card={makeCard()} />);
|
||||
const chip = screen.getByText(/Sol Ring/).closest('span');
|
||||
expect(chip).toHaveClass('hover:border-[#ffdc32]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import GamePage from '../pages/GamePage';
|
||||
import type { GameState, Card } from '../types/game';
|
||||
|
||||
const mockGameState: GameState = {
|
||||
game_id: 'test-game',
|
||||
turn: 1,
|
||||
current_phase: 'main1',
|
||||
active_seat: 0,
|
||||
players: [
|
||||
{
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 1, name: 'Bob', life: 38, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 6,
|
||||
},
|
||||
{
|
||||
seat: 2, name: 'Charlie', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 3, name: 'Diana', life: 35, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 5,
|
||||
},
|
||||
],
|
||||
stack: [],
|
||||
game_over: false,
|
||||
winner: null,
|
||||
};
|
||||
|
||||
const mockHand: Card[] = [];
|
||||
const mockSendMove = vi.fn();
|
||||
const mockSendConcede = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useGameSocket', () => ({
|
||||
useGameSocket: () => ({
|
||||
gameState: mockGameState,
|
||||
narration: [],
|
||||
thinking: new Set<number>(),
|
||||
hand: mockHand,
|
||||
gameOver: null,
|
||||
status: 'Connected',
|
||||
sendMove: mockSendMove,
|
||||
sendConcede: mockSendConcede,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('GamePage integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all 4 PlayerZone components', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/game/test-game']}>
|
||||
<Routes>
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// P0: Alice appears in both PhaseBar and PlayerZone, so use getAllByText
|
||||
expect(screen.getAllByText(/P0: Alice/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P1: Bob/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P2: Charlie/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P3: Diana/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders PhaseBar with current phase', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/game/test-game']}>
|
||||
<Routes>
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// main1 should be highlighted
|
||||
const main1Buttons = screen.getAllByText('main1');
|
||||
expect(main1Buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import MoveBar from '../components/MoveBar';
|
||||
|
||||
describe('MoveBar', () => {
|
||||
it('calls onPass when pass button is clicked', async () => {
|
||||
const onPass = vi.fn();
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={onPass}
|
||||
onConcede={vi.fn()}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Pass Priority'));
|
||||
expect(onPass).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows confirm dialog on concede', async () => {
|
||||
const onConcede = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={vi.fn()}
|
||||
onConcede={onConcede}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Concede'));
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to concede?');
|
||||
expect(onConcede).toHaveBeenCalledOnce();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not concede if confirm is cancelled', async () => {
|
||||
const onConcede = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={vi.fn()}
|
||||
onConcede={onConcede}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Concede'));
|
||||
expect(onConcede).not.toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import NarrationPanel from '../components/NarrationPanel';
|
||||
import type { NarrationLine } from '../types/game';
|
||||
|
||||
describe('NarrationPanel', () => {
|
||||
it('renders only 120 lines when given more', () => {
|
||||
const lines: NarrationLine[] = Array.from({ length: 150 }, (_, i) => ({
|
||||
text: `Line ${i}`,
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
render(<NarrationPanel lines={lines} thinkingSeats={new Set()} />);
|
||||
|
||||
// Should not render the first 30 lines (0-29)
|
||||
expect(screen.queryByText('Line 0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Line 29')).not.toBeInTheDocument();
|
||||
|
||||
// Should render lines 30-149
|
||||
expect(screen.getByText('Line 30')).toBeInTheDocument();
|
||||
expect(screen.getByText('Line 149')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has a scroll container ref', () => {
|
||||
const lines: NarrationLine[] = [
|
||||
{ text: 'Hello world', isSystem: false, timestamp: Date.now() },
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<NarrationPanel lines={lines} thinkingSeats={new Set()} />,
|
||||
);
|
||||
|
||||
// The container div has overflow-y-auto
|
||||
const panel = container.firstChild as HTMLElement;
|
||||
expect(panel).toHaveClass('overflow-y-auto');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import PhaseBar from '../components/PhaseBar';
|
||||
import type { Player } from '../types/game';
|
||||
|
||||
const players: Player[] = [
|
||||
{
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 1, name: 'Bob', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
];
|
||||
|
||||
describe('PhaseBar', () => {
|
||||
it('highlights the active phase tab', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
const main1 = screen.getByText('main1');
|
||||
expect(main1).toHaveClass('bg-accent-blue');
|
||||
});
|
||||
|
||||
it('does not highlight inactive phase tabs', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
const untap = screen.getByText('untap');
|
||||
expect(untap).not.toHaveClass('bg-accent-blue');
|
||||
expect(untap).toHaveClass('bg-[#252a42]');
|
||||
});
|
||||
|
||||
it('shows active player name', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
expect(screen.getByText(/P0: Alice/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import PlayerZone from '../components/PlayerZone';
|
||||
import type { Player } from '../types/game';
|
||||
|
||||
function makePlayer(overrides: Partial<Player> = {}): Player {
|
||||
return {
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlayerZone life colors', () => {
|
||||
it('uses life-high color for life >= 30', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 35 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('35');
|
||||
expect(life).toHaveClass('text-life-high');
|
||||
});
|
||||
|
||||
it('uses life-med color for life >= 15 and < 30', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 20 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('20');
|
||||
expect(life).toHaveClass('text-life-med');
|
||||
});
|
||||
|
||||
it('uses life-low color for life < 15', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 10 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('10');
|
||||
expect(life).toHaveClass('text-life-low');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { gameReducer, initialState } from '../store/gameReducer';
|
||||
import type { GameState, Card, NarrationLine } from '../types/game';
|
||||
|
||||
const mockGameState: GameState = {
|
||||
game_id: 'g1',
|
||||
turn: 1,
|
||||
current_phase: 'main1',
|
||||
active_seat: 0,
|
||||
players: [],
|
||||
stack: [],
|
||||
game_over: false,
|
||||
winner: null,
|
||||
};
|
||||
|
||||
const mockCard: Card = {
|
||||
name: 'Sol Ring',
|
||||
type: 'Artifact',
|
||||
tapped: false,
|
||||
is_commander: false,
|
||||
oracle: '',
|
||||
cmc: 1,
|
||||
};
|
||||
|
||||
const mockLine: NarrationLine = {
|
||||
text: 'Test narration',
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
describe('gameReducer', () => {
|
||||
it('SET_STATE sets the game state', () => {
|
||||
const result = gameReducer(initialState, { type: 'SET_STATE', payload: mockGameState });
|
||||
expect(result.gameState).toEqual(mockGameState);
|
||||
});
|
||||
|
||||
it('SET_HAND sets the hand cards', () => {
|
||||
const cards = [mockCard];
|
||||
const result = gameReducer(initialState, { type: 'SET_HAND', cards });
|
||||
expect(result.hand).toEqual(cards);
|
||||
});
|
||||
|
||||
it('ADD_NARRATION appends a line', () => {
|
||||
const result = gameReducer(initialState, { type: 'ADD_NARRATION', line: mockLine });
|
||||
expect(result.narration).toHaveLength(1);
|
||||
expect(result.narration[0].text).toBe('Test narration');
|
||||
});
|
||||
|
||||
it('ADD_NARRATION caps at 120 lines', () => {
|
||||
let state = initialState;
|
||||
for (let i = 0; i < 130; i++) {
|
||||
state = gameReducer(state, {
|
||||
type: 'ADD_NARRATION',
|
||||
line: { text: `line ${i}`, isSystem: false, timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
expect(state.narration).toHaveLength(120);
|
||||
expect(state.narration[0].text).toBe('line 10');
|
||||
});
|
||||
|
||||
it('SET_THINKING adds and removes seats', () => {
|
||||
let state = gameReducer(initialState, { type: 'SET_THINKING', seat: 2, active: true });
|
||||
expect(state.thinkingSeats.has(2)).toBe(true);
|
||||
|
||||
state = gameReducer(state, { type: 'SET_THINKING', seat: 2, active: false });
|
||||
expect(state.thinkingSeats.has(2)).toBe(false);
|
||||
});
|
||||
|
||||
it('GAME_OVER sets winner and reason', () => {
|
||||
const result = gameReducer(initialState, { type: 'GAME_OVER', winner: 1, reason: 'elimination' });
|
||||
expect(result.gameOver).toEqual({ winner: 1, reason: 'elimination' });
|
||||
});
|
||||
|
||||
it('SET_STATUS sets status text', () => {
|
||||
const result = gameReducer(initialState, { type: 'SET_STATUS', status: 'Connected' });
|
||||
expect(result.status).toBe('Connected');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useGameSocket } from '../hooks/useGameSocket';
|
||||
|
||||
// Mock WebSocket
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
static instances: MockWebSocket[] = [];
|
||||
url: string;
|
||||
readyState = 1; // OPEN
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
sent: string[] = [];
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
// Simulate connection open
|
||||
setTimeout(() => this.onopen?.(new Event('open')), 0);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
simulateMessage(data: unknown) {
|
||||
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) }));
|
||||
}
|
||||
}
|
||||
|
||||
describe('useGameSocket', () => {
|
||||
beforeEach(() => {
|
||||
MockWebSocket.instances = [];
|
||||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('dispatches state on WS message', () => {
|
||||
const { result } = renderHook(() => useGameSocket('game-1'));
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
const mockState = {
|
||||
game_id: 'game-1', turn: 1, current_phase: 'main1', active_seat: 0,
|
||||
players: [], stack: [], game_over: false, winner: null,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
ws.simulateMessage({ type: 'state', payload: mockState });
|
||||
});
|
||||
|
||||
expect(result.current.gameState).toEqual(mockState);
|
||||
});
|
||||
|
||||
it('dispatches hand cards on WS message', () => {
|
||||
const { result } = renderHook(() => useGameSocket('game-1'));
|
||||
const ws = MockWebSocket.instances[0];
|
||||
|
||||
const cards = [{ name: 'Sol Ring', type: 'Artifact', tapped: false, is_commander: false, oracle: '', cmc: 1 }];
|
||||
|
||||
act(() => {
|
||||
ws.simulateMessage({ type: 'hand', cards });
|
||||
});
|
||||
|
||||
expect(result.current.hand).toEqual(cards);
|
||||
});
|
||||
|
||||
it('sends heartbeat ping every 15s', () => {
|
||||
renderHook(() => useGameSocket('game-1'));
|
||||
const ws = MockWebSocket.instances[0];
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
|
||||
expect(ws.sent).toContain(JSON.stringify({ type: 'ping' }));
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,54 @@
|
||||
import type { GameState } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
import PlayerZone from './PlayerZone';
|
||||
import CardChip from './CardChip';
|
||||
|
||||
interface BoardProps {
|
||||
gameState: GameState;
|
||||
thinkingSeats: Set<number>;
|
||||
}
|
||||
|
||||
export default function Board({ gameState, thinkingSeats }: BoardProps) {
|
||||
const { players, active_seat, stack } = gameState;
|
||||
|
||||
const getPlayer = (seat: number) => players.find((p) => p.seat === seat);
|
||||
|
||||
const renderZone = (seat: number, className: string) => {
|
||||
const player = getPlayer(seat);
|
||||
if (!player) return <div className={className} />;
|
||||
return (
|
||||
<div className={className}>
|
||||
<PlayerZone
|
||||
player={player}
|
||||
isActive={active_seat === seat}
|
||||
isThinking={thinkingSeats.has(seat)}
|
||||
seatColour={SEAT_COLOURS[seat]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[180px_1fr_180px] grid-rows-3 gap-1 flex-1">
|
||||
{/* Row 1: P1 (top-left), stack center, P2 (top-right) */}
|
||||
{renderZone(1, 'row-start-1 col-start-1')}
|
||||
<div className="bg-bg-zone border border-accent-purple rounded-md min-h-[60px] row-span-3 col-start-2 p-2 overflow-y-auto">
|
||||
<span className="text-[10px] text-[#666] block mb-1">Stack</span>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{stack.map((card, i) => (
|
||||
<CardChip key={i} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{renderZone(2, 'row-start-1 col-start-3')}
|
||||
|
||||
{/* Row 2: empty side cells (stack spans all 3 rows in center) */}
|
||||
<div className="row-start-2 col-start-1" />
|
||||
<div className="row-start-2 col-start-3" />
|
||||
|
||||
{/* Row 3: P0 (bottom-left), P3 (bottom-right) */}
|
||||
{renderZone(0, 'row-start-3 col-start-1')}
|
||||
{renderZone(3, 'row-start-3 col-start-3')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
interface CardChipProps {
|
||||
card: Card;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function CardChip({ card, onClick }: CardChipProps) {
|
||||
return (
|
||||
<span
|
||||
onClick={onClick}
|
||||
className={`bg-card-bg border border-[#444] rounded text-[10px] max-w-[110px] overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer px-1.5 py-0.5 hover:border-[#ffdc32] ${
|
||||
card.tapped ? 'opacity-55 italic' : ''
|
||||
}`}
|
||||
>
|
||||
{card.name}
|
||||
{card.is_commander && <span className="text-[#ffd200]"> ★</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Player } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface GameOverOverlayProps {
|
||||
show: boolean;
|
||||
winner: Player | null;
|
||||
reason: string;
|
||||
players: Player[];
|
||||
}
|
||||
|
||||
export default function GameOverOverlay({ show, winner, reason, players }: GameOverOverlayProps) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center">
|
||||
<div className="bg-bg-zone border-2 border-[#4060c0] rounded-[10px] px-[60px] py-10 text-center">
|
||||
{winner && (
|
||||
<h2
|
||||
className="text-4xl font-bold"
|
||||
style={{ color: SEAT_COLOURS[winner.seat] }}
|
||||
>
|
||||
{winner.name} Wins!
|
||||
</h2>
|
||||
)}
|
||||
<p className="text-base text-[#aaa] mt-2">{reason}</p>
|
||||
<ul className="mt-6 space-y-1">
|
||||
{players.map((p) => (
|
||||
<li key={p.seat} className="text-sm">
|
||||
<span style={{ color: SEAT_COLOURS[p.seat] }}>P{p.seat}</span>{' '}
|
||||
{p.name} — {p.life} life
|
||||
{p.eliminated && (
|
||||
<span className="text-red-500 ml-1 font-bold">[ELIM]</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
interface HandCardProps {
|
||||
card: Card;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function HandCard({ card, isSelected, onClick }: HandCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`rounded text-[11px] px-2 py-1 cursor-pointer ${
|
||||
isSelected
|
||||
? 'border border-[#ffdc32] bg-card-selected'
|
||||
: 'bg-card-hand border border-[#5060a0]'
|
||||
}`}
|
||||
>
|
||||
{card.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import type { Card } from '../types/game';
|
||||
import HandCard from './HandCard';
|
||||
|
||||
interface MoveBarProps {
|
||||
hand: Card[];
|
||||
humanSeat: number;
|
||||
onPass: () => void;
|
||||
onConcede: () => void;
|
||||
onPlayCard: (index: number) => void;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function MoveBar({ hand, onPass, onConcede, onPlayCard, status }: MoveBarProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const handleCardClick = (index: number) => {
|
||||
if (selectedIndex === index) {
|
||||
onPlayCard(index);
|
||||
setSelectedIndex(null);
|
||||
} else {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-bg-bar border-t border-[#2a2e48] px-2.5 py-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPass}
|
||||
className="bg-[#2a5090] border border-[#4080d0] text-white rounded px-3 py-1 text-sm cursor-pointer hover:brightness-110"
|
||||
>
|
||||
Pass Priority
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Are you sure you want to concede?')) onConcede();
|
||||
}}
|
||||
className="bg-[#2a3868] border border-[#4060a0] text-white rounded px-3 py-1 text-sm cursor-pointer"
|
||||
>
|
||||
Concede
|
||||
</button>
|
||||
{hand.map((card, i) => (
|
||||
<HandCard
|
||||
key={i}
|
||||
card={card}
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => handleCardClick(i)}
|
||||
/>
|
||||
))}
|
||||
<span className="text-[11px] text-[#778] ml-auto">{status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import type { NarrationLine } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface NarrationPanelProps {
|
||||
lines: NarrationLine[];
|
||||
thinkingSeats: Set<number>;
|
||||
}
|
||||
|
||||
export default function NarrationPanel({ lines, thinkingSeats }: NarrationPanelProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dots, setDots] = useState('.');
|
||||
|
||||
// Auto-scroll on new lines
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines.length]);
|
||||
|
||||
// Cycling dots for thinking animation
|
||||
useEffect(() => {
|
||||
if (thinkingSeats.size === 0) return;
|
||||
const interval = setInterval(() => {
|
||||
setDots((prev) => (prev.length >= 3 ? '.' : prev + '.'));
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [thinkingSeats.size]);
|
||||
|
||||
const displayed = lines.slice(-120);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-[260px] bg-bg-panel border-l border-[#2a2e48] overflow-y-auto p-2 flex flex-col"
|
||||
>
|
||||
{displayed.map((line, i) => {
|
||||
let colorClass = 'text-[#e8e8e8]';
|
||||
let style: React.CSSProperties | undefined;
|
||||
|
||||
if (line.isSystem) {
|
||||
colorClass = 'text-[#556]';
|
||||
} else if (line.seat !== undefined) {
|
||||
colorClass = '';
|
||||
style = { color: SEAT_COLOURS[line.seat] };
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`text-[11px] leading-[1.4] break-words ${colorClass}`}
|
||||
style={style}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{thinkingSeats.size > 0 &&
|
||||
Array.from(thinkingSeats).map((seat) => (
|
||||
<div
|
||||
key={`thinking-${seat}`}
|
||||
className="text-[11px] leading-[1.4] break-words text-accent-think italic"
|
||||
>
|
||||
P{seat} thinking{dots}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Phase, Player } from '../types/game';
|
||||
import { PHASES, SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface PhaseBarProps {
|
||||
phase: Phase;
|
||||
activeSeat: number;
|
||||
players: Player[];
|
||||
}
|
||||
|
||||
export default function PhaseBar({ phase, activeSeat, players }: PhaseBarProps) {
|
||||
const activePlayer = players.find((p) => p.seat === activeSeat);
|
||||
|
||||
return (
|
||||
<div className="h-[34px] bg-[#1e2338] px-2 py-1 flex items-center gap-1">
|
||||
{PHASES.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`text-[11px] rounded px-2 py-0.5 ${
|
||||
p === phase
|
||||
? 'bg-accent-blue text-white'
|
||||
: 'bg-[#252a42] text-[#888]'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{activePlayer && (
|
||||
<span
|
||||
className="text-[11px] ml-auto font-bold"
|
||||
style={{ color: SEAT_COLOURS[activeSeat] }}
|
||||
>
|
||||
P{activeSeat}: {activePlayer.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Player } from '../types/game';
|
||||
import CardChip from './CardChip';
|
||||
|
||||
interface PlayerZoneProps {
|
||||
player: Player;
|
||||
isActive: boolean;
|
||||
isThinking: boolean;
|
||||
seatColour: string;
|
||||
}
|
||||
|
||||
function lifeColor(life: number): string {
|
||||
if (life >= 30) return 'text-life-high';
|
||||
if (life >= 15) return 'text-life-med';
|
||||
return 'text-life-low';
|
||||
}
|
||||
|
||||
export default function PlayerZone({ player, isActive, isThinking, seatColour }: PlayerZoneProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-bg-zone border border-[#3a4060] rounded-md p-1.5 ${
|
||||
isThinking ? 'animate-pulse' : ''
|
||||
} ${player.eliminated ? 'opacity-40' : ''}`}
|
||||
style={isActive ? { borderColor: seatColour } : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[11px] text-[#666]">
|
||||
P{player.seat}: {player.name}
|
||||
</span>
|
||||
<span className={`text-[28px] font-bold ${lifeColor(player.life)}`}>
|
||||
{player.life}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{player.battlefield.map((card, i) => (
|
||||
<CardChip key={i} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Phase } from './types/game';
|
||||
|
||||
export const SEAT_COLOURS = ['#4696ff', '#ff6450', '#50d278', '#dcaa32'] as const;
|
||||
|
||||
export const PHASES: Phase[] = [
|
||||
'untap', 'upkeep', 'draw', 'main1',
|
||||
'begin_combat', 'declare_attackers', 'declare_blockers', 'combat_damage',
|
||||
'main2', 'end_step', 'cleanup',
|
||||
];
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useReducer, useRef, useEffect, useCallback } from 'react';
|
||||
import { gameReducer, initialState } from '../store/gameReducer';
|
||||
import type { WsIncoming, NarrationLine } from '../types/game';
|
||||
|
||||
export function useGameSocket(gameId: string) {
|
||||
const [state, dispatch] = useReducer(gameReducer, initialState);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const url = `ws://${window.location.host}/ws/game/${gameId}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Connected' });
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg: WsIncoming = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'state':
|
||||
dispatch({ type: 'SET_STATE', payload: msg.payload });
|
||||
break;
|
||||
|
||||
case 'hand':
|
||||
dispatch({ type: 'SET_HAND', cards: msg.cards });
|
||||
break;
|
||||
|
||||
case 'event': {
|
||||
const line: NarrationLine = {
|
||||
text: msg.text,
|
||||
seat: msg.seat,
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch({ type: 'ADD_NARRATION', line });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'phase': {
|
||||
const line: NarrationLine = {
|
||||
text: `Phase: ${msg.phase} (P${msg.active_seat})`,
|
||||
isSystem: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch({ type: 'ADD_NARRATION', line });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'thinking':
|
||||
dispatch({ type: 'SET_THINKING', seat: msg.seat, active: msg.active });
|
||||
break;
|
||||
|
||||
case 'game_over':
|
||||
dispatch({ type: 'GAME_OVER', winner: msg.winner, reason: msg.reason });
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
dispatch({ type: 'SET_STATUS', status: `Error: ${msg.message}` });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!intentionalClose.current) {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Disconnected — reconnecting…' });
|
||||
reconnectTimeout.current = setTimeout(() => {
|
||||
connect();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Connection error' });
|
||||
};
|
||||
}, [gameId]);
|
||||
|
||||
// Connect on mount
|
||||
useEffect(() => {
|
||||
intentionalClose.current = false;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
intentionalClose.current = true;
|
||||
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// Heartbeat ping every 15s
|
||||
useEffect(() => {
|
||||
heartbeatInterval.current = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendMove = useCallback((move: string, data?: Record<string, unknown>) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'move', move, ...data }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendConcede = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'concede' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
gameState: state.gameState,
|
||||
narration: state.narration,
|
||||
thinking: state.thinkingSeats,
|
||||
hand: state.hand,
|
||||
gameOver: state.gameOver,
|
||||
status: state.status,
|
||||
sendMove,
|
||||
sendConcede,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useReducer, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface Seat {
|
||||
seat: number;
|
||||
name: string;
|
||||
ready: boolean;
|
||||
isAi: boolean;
|
||||
}
|
||||
|
||||
export interface RoomState {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
host: string;
|
||||
seats: (Seat | null)[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'PLAYER' | 'AI' | 'SYSTEM';
|
||||
name: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type LobbyAction =
|
||||
| { type: 'SET_ROOM'; payload: RoomState }
|
||||
| { type: 'ADD_CHAT'; message: ChatMessage }
|
||||
| { type: 'SET_CHAT_HISTORY'; messages: ChatMessage[] }
|
||||
| { type: 'LAUNCH'; gameId: string };
|
||||
|
||||
interface LobbyState {
|
||||
roomState: RoomState | null;
|
||||
chat: ChatMessage[];
|
||||
launchedGameId: string | null;
|
||||
}
|
||||
|
||||
const initialState: LobbyState = {
|
||||
roomState: null,
|
||||
chat: [],
|
||||
launchedGameId: null,
|
||||
};
|
||||
|
||||
function lobbyReducer(state: LobbyState, action: LobbyAction): LobbyState {
|
||||
switch (action.type) {
|
||||
case 'SET_ROOM':
|
||||
return { ...state, roomState: action.payload };
|
||||
case 'ADD_CHAT':
|
||||
return { ...state, chat: [...state.chat, action.message].slice(-50) };
|
||||
case 'SET_CHAT_HISTORY':
|
||||
return { ...state, chat: action.messages.slice(-50) };
|
||||
case 'LAUNCH':
|
||||
return { ...state, launchedGameId: action.gameId };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useLobbySock(roomId: string, playerName: string) {
|
||||
const [state, dispatch] = useReducer(lobbyReducer, initialState);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const url = `ws://${window.location.host}/ws/lobby/${roomId}?name=${encodeURIComponent(playerName)}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'lobby_state':
|
||||
dispatch({ type: 'SET_ROOM', payload: msg.payload });
|
||||
break;
|
||||
case 'chat':
|
||||
dispatch({
|
||||
type: 'ADD_CHAT',
|
||||
message: {
|
||||
role: msg.role ?? 'PLAYER',
|
||||
name: msg.name ?? '',
|
||||
text: msg.text,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'chat_history':
|
||||
dispatch({
|
||||
type: 'SET_CHAT_HISTORY',
|
||||
messages: (msg.messages ?? []).map((m: { role?: string; name?: string; text: string }) => ({
|
||||
role: m.role ?? 'SYSTEM',
|
||||
name: m.name ?? '',
|
||||
text: m.text,
|
||||
timestamp: Date.now(),
|
||||
})),
|
||||
});
|
||||
break;
|
||||
case 'launch':
|
||||
dispatch({ type: 'LAUNCH', gameId: msg.game_id });
|
||||
break;
|
||||
case 'pong':
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!intentionalClose.current) {
|
||||
reconnectTimeout.current = setTimeout(() => connect(), 3000);
|
||||
}
|
||||
};
|
||||
}, [roomId, playerName]);
|
||||
|
||||
useEffect(() => {
|
||||
intentionalClose.current = false;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
intentionalClose.current = true;
|
||||
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// Heartbeat ping every 15s
|
||||
useEffect(() => {
|
||||
heartbeatInterval.current = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendReady = useCallback((seat: number) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ready', seat }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendChat = useCallback((text: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'chat', text }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendLaunch = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'launch' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
roomState: state.roomState,
|
||||
chat: state.chat,
|
||||
launchedGameId: state.launchedGameId,
|
||||
sendReady,
|
||||
sendChat,
|
||||
sendLaunch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGameSocket } from '../hooks/useGameSocket';
|
||||
import PhaseBar from '../components/PhaseBar';
|
||||
import Board from '../components/Board';
|
||||
import NarrationPanel from '../components/NarrationPanel';
|
||||
import MoveBar from '../components/MoveBar';
|
||||
import GameOverOverlay from '../components/GameOverOverlay';
|
||||
|
||||
export default function GamePage() {
|
||||
const { gameId } = useParams<{ gameId: string }>();
|
||||
const { gameState, narration, thinking, hand, gameOver, status, sendMove, sendConcede } =
|
||||
useGameSocket(gameId!);
|
||||
|
||||
if (!gameState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
Connecting…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const winnerPlayer = gameOver
|
||||
? gameState.players.find((p) => p.seat === gameOver.winner) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-bg-base text-white">
|
||||
<PhaseBar
|
||||
phase={gameState.current_phase}
|
||||
activeSeat={gameState.active_seat}
|
||||
players={gameState.players}
|
||||
/>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Board gameState={gameState} thinkingSeats={thinking} />
|
||||
<NarrationPanel lines={narration} thinkingSeats={thinking} />
|
||||
</div>
|
||||
<MoveBar
|
||||
hand={hand}
|
||||
humanSeat={0}
|
||||
onPass={() => sendMove('pass')}
|
||||
onConcede={sendConcede}
|
||||
onPlayCard={(index) => sendMove('play_card', { index })}
|
||||
status={status}
|
||||
/>
|
||||
<GameOverOverlay
|
||||
show={gameOver !== null}
|
||||
winner={winnerPlayer}
|
||||
reason={gameOver?.reason ?? ''}
|
||||
players={gameState.players}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface RoomSummary {
|
||||
roomId: string;
|
||||
name: string;
|
||||
host: string;
|
||||
playerCount: number;
|
||||
}
|
||||
|
||||
export default function LobbyPage() {
|
||||
const navigate = useNavigate();
|
||||
const [rooms, setRooms] = useState<RoomSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Create room form state
|
||||
const [roomName, setRoomName] = useState('');
|
||||
const [hostName, setHostName] = useState('');
|
||||
const [aiSlots, setAiSlots] = useState(0);
|
||||
const [password, setPassword] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/lobby/rooms')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setRooms(data))
|
||||
.catch(() => setRooms([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/lobby/rooms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: roomName,
|
||||
host: hostName,
|
||||
ai_slots: aiSlots,
|
||||
password: password || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
navigate(`/room/${data.roomId}?name=${encodeURIComponent(hostName)}`);
|
||||
} catch {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-base text-white p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">Commander AI Lab — Lobby</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
|
||||
{/* Room List */}
|
||||
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Open Rooms</h2>
|
||||
{loading ? (
|
||||
<p className="text-text-muted text-sm">Loading...</p>
|
||||
) : rooms.length === 0 ? (
|
||||
<p className="text-text-muted text-sm">No rooms available. Create one!</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rooms.map((room) => (
|
||||
<li key={room.roomId}>
|
||||
<button
|
||||
onClick={() => navigate(`/room/${room.roomId}`)}
|
||||
className="w-full text-left bg-bg-panel border border-border-zone rounded px-3 py-2 hover:border-accent-blue transition-colors"
|
||||
>
|
||||
<span className="font-medium">{room.name}</span>
|
||||
<span className="text-text-muted text-sm ml-2">
|
||||
Host: {room.host} · {room.playerCount}/4 players
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Room Form */}
|
||||
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Create Room</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Room Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={roomName}
|
||||
onChange={(e) => setRoomName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Your Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hostName}
|
||||
onChange={(e) => setHostName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">AI Players</label>
|
||||
<select
|
||||
value={aiSlots}
|
||||
onChange={(e) => setAiSlots(Number(e.target.value))}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value={0}>0 AI players</option>
|
||||
<option value={1}>1 AI player</option>
|
||||
<option value={2}>2 AI players</option>
|
||||
<option value={3}>3 AI players</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Password (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Room'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useLobbySock } from '../hooks/useLobbySock';
|
||||
import type { ChatMessage } from '../hooks/useLobbySock';
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
PLAYER: 'text-accent-blue',
|
||||
AI: 'text-accent-purple',
|
||||
SYSTEM: 'text-text-muted',
|
||||
};
|
||||
|
||||
function ChatBadge({ role }: { role: string }) {
|
||||
return (
|
||||
<span className={`text-[10px] font-bold mr-1 ${ROLE_COLORS[role] ?? 'text-text-muted'}`}>
|
||||
[{role}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoomPage() {
|
||||
const { roomId } = useParams<{ roomId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isSpectator = searchParams.get('spectate') === '1';
|
||||
const nameFromUrl = searchParams.get('name') ?? '';
|
||||
const [playerName, setPlayerName] = useState(nameFromUrl);
|
||||
const [nameConfirmed, setNameConfirmed] = useState(!!nameFromUrl);
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { roomState, chat, launchedGameId, sendReady, sendChat, sendLaunch } =
|
||||
useLobbySock(roomId!, nameConfirmed ? playerName : '');
|
||||
|
||||
// Redirect when game launches
|
||||
useEffect(() => {
|
||||
if (launchedGameId) {
|
||||
navigate(`/game/${launchedGameId}`);
|
||||
}
|
||||
}, [launchedGameId, navigate]);
|
||||
|
||||
// Auto-scroll chat
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [chat.length]);
|
||||
|
||||
// Determine own seat
|
||||
const mySeat = roomState?.seats.findIndex(
|
||||
(s) => s && !s.isAi && s.name === playerName,
|
||||
) ?? -1;
|
||||
const isHost = mySeat === 0;
|
||||
|
||||
if (!nameConfirmed) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
<div className="bg-bg-zone border border-border-zone rounded-lg p-6 w-80">
|
||||
<h2 className="text-lg font-semibold mb-3">Enter your name</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && playerName.trim()) setNameConfirmed(true);
|
||||
}}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white mb-3 focus:outline-none focus:border-accent-blue"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => playerName.trim() && setNameConfirmed(true)}
|
||||
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
|
||||
>
|
||||
Join Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!roomState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
Connecting to room...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSendChat = () => {
|
||||
if (chatInput.trim()) {
|
||||
sendChat(chatInput.trim());
|
||||
setChatInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg-base text-white">
|
||||
{/* Header */}
|
||||
<div className="bg-bg-bar border-b border-border-sep px-4 py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">{roomState.roomName}</h1>
|
||||
<span className="text-text-muted text-sm">Host: {roomState.host}</span>
|
||||
</div>
|
||||
{isSpectator && (
|
||||
<span className="text-text-muted text-xs bg-bg-panel px-2 py-1 rounded">
|
||||
SPECTATING
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Seat Grid */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="grid grid-cols-2 gap-3 max-w-lg">
|
||||
{[0, 1, 2, 3].map((seatIdx) => {
|
||||
const seat = roomState.seats[seatIdx];
|
||||
return (
|
||||
<div
|
||||
key={seatIdx}
|
||||
className="bg-bg-zone border border-border-zone rounded-lg p-4 min-h-[100px] flex flex-col items-center justify-center"
|
||||
>
|
||||
{seat ? (
|
||||
<>
|
||||
<span className="text-sm font-medium">
|
||||
{seat.isAi ? '🤖 AI' : seat.name}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted mt-1">
|
||||
Seat {seatIdx}
|
||||
</span>
|
||||
{!seat.isAi && (
|
||||
<span
|
||||
className={`text-xs mt-1 font-bold ${
|
||||
seat.ready ? 'text-life-high' : 'text-life-med'
|
||||
}`}
|
||||
>
|
||||
{seat.ready ? 'READY' : 'Not Ready'}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-text-muted text-sm">Waiting...</span>
|
||||
<span className="text-xs text-text-muted mt-1">
|
||||
Seat {seatIdx}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{!isSpectator && (
|
||||
<div className="mt-4 flex gap-3">
|
||||
{mySeat >= 0 && (
|
||||
<button
|
||||
onClick={() => sendReady(mySeat)}
|
||||
className="bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
|
||||
>
|
||||
Toggle Ready
|
||||
</button>
|
||||
)}
|
||||
{isHost && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/lobby/rooms/${roomId}/launch`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.gameId) {
|
||||
navigate(`/game/${data.gameId}`);
|
||||
}
|
||||
} catch {
|
||||
// launch via WS fallback
|
||||
sendLaunch();
|
||||
}
|
||||
}}
|
||||
className="bg-life-high border border-life-high text-white rounded px-4 py-2 text-sm font-bold hover:brightness-110"
|
||||
>
|
||||
Launch Game
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat Panel */}
|
||||
<div className="w-[280px] bg-bg-panel border-l border-border-sep flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border-sep text-sm font-semibold">
|
||||
Chat
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{chat.map((msg: ChatMessage, i: number) => (
|
||||
<div key={i} className="text-[11px] leading-[1.4] break-words">
|
||||
<ChatBadge role={msg.role} />
|
||||
{msg.name && <span className="font-medium mr-1">{msg.name}:</span>}
|
||||
<span className="text-text-body">{msg.text}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
{!isSpectator && (
|
||||
<div className="border-t border-border-sep p-2">
|
||||
<input
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSendChat();
|
||||
}}
|
||||
placeholder="Type a message..."
|
||||
className="w-full bg-bg-base border border-border-zone rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { GameState, Card, NarrationLine } from '../types/game';
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'SET_STATE'; payload: GameState }
|
||||
| { type: 'SET_HAND'; cards: Card[] }
|
||||
| { type: 'ADD_NARRATION'; line: NarrationLine }
|
||||
| { type: 'SET_THINKING'; seat: number; active: boolean }
|
||||
| { type: 'GAME_OVER'; winner: number; reason: string }
|
||||
| { type: 'SET_STATUS'; status: string };
|
||||
|
||||
export interface ReducerState {
|
||||
gameState: GameState | null;
|
||||
hand: Card[];
|
||||
narration: NarrationLine[];
|
||||
thinkingSeats: Set<number>;
|
||||
gameOver: { winner: number; reason: string } | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const initialState: ReducerState = {
|
||||
gameState: null,
|
||||
hand: [],
|
||||
narration: [],
|
||||
thinkingSeats: new Set(),
|
||||
gameOver: null,
|
||||
status: '',
|
||||
};
|
||||
|
||||
export function gameReducer(state: ReducerState, action: GameAction): ReducerState {
|
||||
switch (action.type) {
|
||||
case 'SET_STATE':
|
||||
return { ...state, gameState: action.payload };
|
||||
|
||||
case 'SET_HAND':
|
||||
return { ...state, hand: action.cards };
|
||||
|
||||
case 'ADD_NARRATION': {
|
||||
const narration = [...state.narration, action.line];
|
||||
return { ...state, narration: narration.slice(-120) };
|
||||
}
|
||||
|
||||
case 'SET_THINKING': {
|
||||
const thinkingSeats = new Set(state.thinkingSeats);
|
||||
if (action.active) {
|
||||
thinkingSeats.add(action.seat);
|
||||
} else {
|
||||
thinkingSeats.delete(action.seat);
|
||||
}
|
||||
return { ...state, thinkingSeats };
|
||||
}
|
||||
|
||||
case 'GAME_OVER':
|
||||
return { ...state, gameOver: { winner: action.winner, reason: action.reason } };
|
||||
|
||||
case 'SET_STATUS':
|
||||
return { ...state, status: action.status };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,55 @@
|
||||
export type Phase =
|
||||
| 'untap' | 'upkeep' | 'draw' | 'main1'
|
||||
| 'begin_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage'
|
||||
| 'main2' | 'end_step' | 'cleanup';
|
||||
|
||||
export interface Card {
|
||||
name: string;
|
||||
type: string;
|
||||
tapped: boolean;
|
||||
is_commander: boolean;
|
||||
oracle: string;
|
||||
cmc: number;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
seat: number;
|
||||
name: string;
|
||||
life: number;
|
||||
eliminated: boolean;
|
||||
hand: Card[];
|
||||
battlefield: Card[];
|
||||
graveyard: Card[];
|
||||
exile: Card[];
|
||||
command_zone: Card[];
|
||||
hand_count: number;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
game_id: string;
|
||||
turn: number;
|
||||
current_phase: Phase;
|
||||
active_seat: number;
|
||||
players: Player[];
|
||||
stack: Card[];
|
||||
game_over: boolean;
|
||||
winner: Player | null;
|
||||
}
|
||||
|
||||
export type WsIncoming =
|
||||
| { type: 'state'; payload: GameState }
|
||||
| { type: 'event'; text: string; seat?: number }
|
||||
| { type: 'phase'; phase: Phase; active_seat: number }
|
||||
| { type: 'thinking'; seat: number; active: boolean }
|
||||
| { type: 'elimination'; seat: number }
|
||||
| { type: 'game_over'; winner: number; reason: string }
|
||||
| { type: 'hand'; cards: Card[] }
|
||||
| { type: 'pong' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
export interface NarrationLine {
|
||||
text: string;
|
||||
seat?: number;
|
||||
isSystem: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: {
|
||||
base: '#12141e',
|
||||
zone: '#1c2034',
|
||||
panel: '#161820',
|
||||
bar: '#1a1e30',
|
||||
phase: '#1e2338',
|
||||
hand: '#1a2030',
|
||||
},
|
||||
accent: {
|
||||
blue: '#3c82dc',
|
||||
think: '#64b4ff',
|
||||
purple: '#7060c0',
|
||||
},
|
||||
life: {
|
||||
high: '#50c87a',
|
||||
med: '#dcb432',
|
||||
low: '#dc3c3c',
|
||||
},
|
||||
seat: {
|
||||
0: '#4696ff',
|
||||
1: '#ff6450',
|
||||
2: '#50d278',
|
||||
3: '#dcaa32',
|
||||
},
|
||||
card: {
|
||||
bg: '#2a3050',
|
||||
hand: '#2a3868',
|
||||
selected: '#3a4878',
|
||||
},
|
||||
border: {
|
||||
zone: '#3a4060',
|
||||
sep: '#2a2e48',
|
||||
btn: '#4060a0',
|
||||
overlay: '#4060c0',
|
||||
'btn-hover': '#6080d0',
|
||||
'btn-primary': '#4080d0',
|
||||
'hand-card': '#5060a0',
|
||||
chip: '#444444',
|
||||
},
|
||||
phase: {
|
||||
tab: '#252a42',
|
||||
text: '#888888',
|
||||
},
|
||||
btn: {
|
||||
primary: '#2a5090',
|
||||
},
|
||||
text: {
|
||||
body: '#e8e8e8',
|
||||
muted: '#888888',
|
||||
system: '#555566',
|
||||
'zone-label': '#666666',
|
||||
status: '#777788',
|
||||
sub: '#aaaaaa',
|
||||
btn: '#cccccc',
|
||||
},
|
||||
highlight: {
|
||||
gold: '#ffdc32',
|
||||
commander: '#ffd200',
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
pulse: {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.7' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
pulse: 'pulse 1s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
// Dev-only proxy — in production FastAPI serves the built assets
|
||||
// and handles /api and /ws routes directly.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
},
|
||||
})
|
||||
+163
-1944
File diff suppressed because it is too large.
Load diff
+95
-8790
File diff suppressed because it is too large.
Load diff
+8872
File diff suppressed because it is too large.
Load diff
+4487
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
update_weights.py
|
||||
=================
|
||||
Outcome-weighted reinforcement learning for Commander AI card weights.
|
||||
|
||||
Reads all decisions_*.jsonl files produced by DeepSeekBrain.flush_log(),
|
||||
applies a gradient-free reward nudge to each weight key based on the
|
||||
normalised reward signal (-1.0 to +1.0) stamped on every decision entry,
|
||||
then saves the updated weights to learned_weights.json via save_weights().
|
||||
|
||||
Usage
|
||||
-----
|
||||
# Basic run (reads logs/decisions/, updates learned_weights.json)
|
||||
python -m ml.scripts.update_weights
|
||||
|
||||
# Custom paths
|
||||
python -m ml.scripts.update_weights \\
|
||||
--log-dir logs/decisions \\
|
||||
--weights-out src/commander_ai_lab/sim/learned_weights.json \\
|
||||
--lr 0.05 \\
|
||||
--min-games 10
|
||||
|
||||
# Dry-run (print proposed changes, don't write)
|
||||
python -m ml.scripts.update_weights --dry-run
|
||||
|
||||
# Reset to defaults then exit
|
||||
python -m ml.scripts.update_weights --reset
|
||||
|
||||
Algorithm
|
||||
---------
|
||||
For each decision entry that has a non-null game_result:
|
||||
|
||||
delta = learning_rate * reward * action_weight_for(action)
|
||||
|
||||
Each weight key is nudged by the mean delta across all decisions that
|
||||
touched that key. Keys with no decisions are left unchanged.
|
||||
All values are clamped to [-20.0, 20.0] by save_weights().
|
||||
|
||||
Reward is the normalised life-delta from flush_log():
|
||||
reward = (my_final_life - avg_opp_final_life) / 40.0 in [-1.0, 1.0]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allow running as a script without installing the package
|
||||
# ---------------------------------------------------------------------------
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(_REPO_ROOT / "src") not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT / "src"))
|
||||
|
||||
from commander_ai_lab.sim.rules import (
|
||||
AI_DEFAULT_WEIGHTS,
|
||||
load_weights,
|
||||
reset_weights,
|
||||
save_weights,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("update_weights")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action → weight key mapping
|
||||
# Maps each VALID_ACTION to the weight(s) it most directly influences.
|
||||
# A single action may touch multiple keys (listed as a tuple).
|
||||
# ---------------------------------------------------------------------------
|
||||
ACTION_WEIGHT_MAP: dict[str, tuple[str, ...]] = {
|
||||
"play_land": (), # no weight key — always correct
|
||||
"cast_ramp": ("spell_ramp",),
|
||||
"cast_removal": ("spell_destroy",),
|
||||
"cast_board_wipe": ("spell_counter",), # reuse counter as board-control proxy
|
||||
"cast_creature": ("card_ptBonus",),
|
||||
"cast_spell": ("spell_enchantment", "spell_draw"),
|
||||
"attack_all": ("kw_haste", "kw_flying"), # aggression / evasion proxy
|
||||
"attack_safe": ("kw_flying", "kw_trample"),
|
||||
"hold": (), # no weight key — passing is neutral
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSONL helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _iter_log_entries(log_dir: str) -> Iterator[dict]:
|
||||
"""Yield every entry from all decisions_*.jsonl files in log_dir."""
|
||||
log_path = Path(log_dir)
|
||||
if not log_path.exists():
|
||||
logger.warning("Log directory not found: %s", log_dir)
|
||||
return
|
||||
|
||||
files = sorted(log_path.glob("decisions_*.jsonl"))
|
||||
if not files:
|
||||
logger.warning("No decisions_*.jsonl files found in %s", log_dir)
|
||||
return
|
||||
|
||||
logger.info("Found %d log file(s) in %s", len(files), log_dir)
|
||||
for fpath in files:
|
||||
with open(fpath, encoding="utf-8") as f:
|
||||
for lineno, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
yield json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("Skipping malformed line %d in %s: %s", lineno, fpath.name, exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core learning loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_weight_updates(
|
||||
log_dir: str,
|
||||
current_weights: dict[str, float],
|
||||
learning_rate: float = 0.05,
|
||||
min_games: int = 1,
|
||||
) -> tuple[dict[str, float], dict]:
|
||||
"""
|
||||
Read decision logs and compute updated weights.
|
||||
|
||||
Returns
|
||||
-------
|
||||
updated_weights : dict[str, float]
|
||||
New weight dict after applying mean reward nudges.
|
||||
stats : dict
|
||||
Diagnostic info (games seen, decisions processed, keys changed, etc.)
|
||||
"""
|
||||
# Accumulators: key → list of (reward * lr) deltas
|
||||
key_deltas: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
total_entries = 0
|
||||
labelled_entries = 0 # entries with non-null game_result
|
||||
game_ids_seen: set[str] = set()
|
||||
action_counts: dict[str, int] = defaultdict(int)
|
||||
|
||||
for entry in _iter_log_entries(log_dir):
|
||||
total_entries += 1
|
||||
|
||||
game_result = entry.get("game_result")
|
||||
if not game_result:
|
||||
continue # no outcome stamped — skip (pre-flush or incomplete game)
|
||||
|
||||
labelled_entries += 1
|
||||
game_ids_seen.add(entry.get("game_id", "unknown"))
|
||||
|
||||
reward: float = float(game_result.get("reward", 0.0))
|
||||
action: str = entry.get("decision", {}).get("action", "hold")
|
||||
action_counts[action] += 1
|
||||
|
||||
weight_keys = ACTION_WEIGHT_MAP.get(action, ())
|
||||
for key in weight_keys:
|
||||
if key in current_weights:
|
||||
key_deltas[key].append(learning_rate * reward)
|
||||
|
||||
num_games = len(game_ids_seen)
|
||||
logger.info(
|
||||
"Processed %d entries | %d labelled | %d unique games",
|
||||
total_entries, labelled_entries, num_games,
|
||||
)
|
||||
|
||||
if num_games < min_games:
|
||||
logger.warning(
|
||||
"Only %d game(s) found — minimum is %d. Skipping update.",
|
||||
num_games, min_games,
|
||||
)
|
||||
return dict(current_weights), {
|
||||
"skipped": True,
|
||||
"reason": f"need {min_games} games, have {num_games}",
|
||||
"total_entries": total_entries,
|
||||
"labelled_entries": labelled_entries,
|
||||
"games": num_games,
|
||||
}
|
||||
|
||||
# Apply mean delta per key
|
||||
updated = dict(current_weights)
|
||||
changed_keys: dict[str, tuple[float, float]] = {} # key → (old, new)
|
||||
|
||||
for key, deltas in key_deltas.items():
|
||||
if not deltas:
|
||||
continue
|
||||
mean_delta = sum(deltas) / len(deltas)
|
||||
old_val = updated[key]
|
||||
new_val = round(old_val + mean_delta, 6)
|
||||
updated[key] = new_val
|
||||
if abs(new_val - old_val) >= 0.0001:
|
||||
changed_keys[key] = (old_val, new_val)
|
||||
|
||||
stats = {
|
||||
"skipped": False,
|
||||
"total_entries": total_entries,
|
||||
"labelled_entries": labelled_entries,
|
||||
"games": num_games,
|
||||
"keys_changed": len(changed_keys),
|
||||
"changes": changed_keys,
|
||||
"action_counts": dict(action_counts),
|
||||
}
|
||||
return updated, stats
|
||||
|
||||
|
||||
def print_diff(stats: dict, current: dict, updated: dict) -> None:
|
||||
"""Pretty-print weight changes to stdout."""
|
||||
changes = stats.get("changes", {})
|
||||
if not changes:
|
||||
print(" (no weight changes above threshold)")
|
||||
return
|
||||
|
||||
col_w = max(len(k) for k in changes) + 2
|
||||
print(f" {'Key':<{col_w}} {'Before':>10} {'After':>10} {'Delta':>10}")
|
||||
print(" " + "-" * (col_w + 36))
|
||||
for key, (old, new) in sorted(changes.items(), key=lambda x: abs(x[1][1] - x[1][0]), reverse=True):
|
||||
delta = new - old
|
||||
arrow = "▲" if delta > 0 else "▼"
|
||||
print(f" {key:<{col_w}} {old:>10.4f} {new:>10.4f} {arrow} {abs(delta):>8.4f}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_log_dir() -> str:
|
||||
return str(_REPO_ROOT / "logs" / "decisions")
|
||||
|
||||
|
||||
def _default_weights_out() -> str:
|
||||
return str(_REPO_ROOT / "src" / "commander_ai_lab" / "sim" / "learned_weights.json")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Update AI card weights from simulation decision logs."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-dir",
|
||||
default=_default_log_dir(),
|
||||
help="Directory containing decisions_*.jsonl files (default: logs/decisions/)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--weights-out",
|
||||
default=_default_weights_out(),
|
||||
help="Path to write learned_weights.json (default: src/.../sim/learned_weights.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lr",
|
||||
type=float,
|
||||
default=0.05,
|
||||
help="Learning rate — how much each game nudges a weight (default: 0.05)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-games",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Minimum number of completed games required before updating (default: 10)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print proposed changes without writing learned_weights.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reset",
|
||||
action="store_true",
|
||||
help="Delete learned_weights.json and restore AI_DEFAULT_WEIGHTS, then exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Show action distribution and per-key delta counts",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# ── Reset mode ──────────────────────────────────────────────
|
||||
if args.reset:
|
||||
reset_weights(args.weights_out)
|
||||
print(f"Reset: deleted {args.weights_out} (if it existed). Defaults restored.")
|
||||
return 0
|
||||
|
||||
# ── Load current weights ─────────────────────────────────────
|
||||
current = load_weights(args.weights_out)
|
||||
logger.info("Loaded %d weight keys from %s", len(current), args.weights_out)
|
||||
|
||||
# ── Compute updates ──────────────────────────────────────────
|
||||
updated, stats = compute_weight_updates(
|
||||
log_dir=args.log_dir,
|
||||
current_weights=current,
|
||||
learning_rate=args.lr,
|
||||
min_games=args.min_games,
|
||||
)
|
||||
|
||||
# ── Report ───────────────────────────────────────────────────
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(" Commander AI — Weight Update Report")
|
||||
print("=" * 60)
|
||||
print(f" Log dir : {args.log_dir}")
|
||||
print(f" Weights out: {args.weights_out}")
|
||||
print(f" Games : {stats['games']}")
|
||||
print(f" Entries : {stats['total_entries']} ({stats['labelled_entries']} labelled)")
|
||||
print(f" LR : {args.lr}")
|
||||
print()
|
||||
|
||||
if stats.get("skipped"):
|
||||
print(f" SKIPPED — {stats['reason']}")
|
||||
print()
|
||||
return 0
|
||||
|
||||
print(f" Keys changed: {stats['keys_changed']}")
|
||||
print()
|
||||
print_diff(stats, current, updated)
|
||||
print()
|
||||
|
||||
if args.verbose:
|
||||
print(" Action distribution:")
|
||||
for action, count in sorted(stats["action_counts"].items(), key=lambda x: -x[1]):
|
||||
print(f" {action:<20} {count:>6} decisions")
|
||||
print()
|
||||
|
||||
# ── Write ────────────────────────────────────────────────────
|
||||
if args.dry_run:
|
||||
print(" DRY RUN — no file written.")
|
||||
else:
|
||||
out_path = save_weights(updated, args.weights_out)
|
||||
print(f" Saved updated weights to: {out_path}")
|
||||
|
||||
print("=" * 60)
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Pydantic models for Commander AI Lab API."""
|
||||
|
||||
from models.requests import (
|
||||
StartRequest,
|
||||
ImportUrlRequest,
|
||||
ImportTextRequest,
|
||||
MetaFetchRequest,
|
||||
CreateDeckRequest,
|
||||
UpdateDeckRequest,
|
||||
AddDeckCardRequest,
|
||||
PatchDeckCardRequest,
|
||||
BulkAddRequest,
|
||||
BulkAddRecommendedRequest,
|
||||
DeckGenerationSourceConfig,
|
||||
DeckGenerationRequest,
|
||||
DeckGenV3Request,
|
||||
DeckGenV3SubstituteRequest,
|
||||
)
|
||||
from models.responses import (
|
||||
StartResponse,
|
||||
StatusResponse,
|
||||
DeckInfo,
|
||||
GeneratedDeckCard,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Requests
|
||||
"StartRequest",
|
||||
"ImportUrlRequest",
|
||||
"ImportTextRequest",
|
||||
"MetaFetchRequest",
|
||||
"CreateDeckRequest",
|
||||
"UpdateDeckRequest",
|
||||
"AddDeckCardRequest",
|
||||
"PatchDeckCardRequest",
|
||||
"BulkAddRequest",
|
||||
"BulkAddRecommendedRequest",
|
||||
"DeckGenerationSourceConfig",
|
||||
"DeckGenerationRequest",
|
||||
"DeckGenV3Request",
|
||||
"DeckGenV3SubstituteRequest",
|
||||
# Responses
|
||||
"StartResponse",
|
||||
"StatusResponse",
|
||||
"DeckInfo",
|
||||
"GeneratedDeckCard",
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""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
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Pydantic response models for Commander AI Lab API."""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class StartResponse(BaseModel):
|
||||
batchId: str
|
||||
status: str = "started"
|
||||
message: str = ""
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
batchId: str = ""
|
||||
running: bool = False
|
||||
completed: int = 0
|
||||
total: int = 0
|
||||
threads: int = 0
|
||||
elapsedMs: int = 0
|
||||
error: Optional[str] = None
|
||||
simsPerSec: float = 0.0
|
||||
run_id: str = ""
|
||||
games_completed: int = 0
|
||||
total_games: int = 0
|
||||
current_decks: list = []
|
||||
|
||||
|
||||
class DeckInfo(BaseModel):
|
||||
name: str
|
||||
filename: str
|
||||
|
||||
|
||||
class GeneratedDeckCard(BaseModel):
|
||||
scryfall_id: str = ""
|
||||
name: str = ""
|
||||
type_line: str = ""
|
||||
mana_cost: str = ""
|
||||
cmc: float = 0
|
||||
card_type: str = ""
|
||||
roles: list[str] = []
|
||||
source: str = "collection"
|
||||
quantity: int = 1
|
||||
image_url: str = ""
|
||||
owned_qty: int = 0
|
||||
is_proxy: bool = False
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"""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},
|
||||
}
|
||||
@@ -14,6 +14,8 @@ dependencies = [
|
||||
"fastapi>=0.100.0",
|
||||
"uvicorn[standard]>=0.23.0",
|
||||
"pydantic>=2.0.0",
|
||||
"httpx>=0.24.0",
|
||||
"websockets>=11.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test* Bench*
|
||||
python_functions = test_*
|
||||
|
||||
# Asyncio mode for pytest-asyncio
|
||||
asyncio_mode = auto
|
||||
|
||||
# Markers
|
||||
markers =
|
||||
slow: marks tests as slow (deselect with -m "not slow")
|
||||
integration: marks integration tests
|
||||
benchmark: marks benchmark tests
|
||||
unit: marks fast unit tests
|
||||
|
||||
# Output
|
||||
addopts =
|
||||
--tb=short
|
||||
-q
|
||||
|
||||
# Coverage (used when running: pytest --cov)
|
||||
# pip install pytest-cov
|
||||
# pytest --cov=src/commander_ai_lab --cov-report=term-missing
|
||||
@@ -0,0 +1,11 @@
|
||||
# Commander AI Lab — Web server dependencies (Phase 5)
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
httpx>=0.27.0 # async Scryfall art proxy
|
||||
websockets>=12.0 # used by uvicorn WebSocket transport
|
||||
pydantic>=2.6.0
|
||||
|
||||
# Dev / test
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.23
|
||||
httpx>=0.27.0 # TestClient uses httpx transport
|
||||
@@ -5,3 +5,5 @@
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.23.0
|
||||
pydantic>=2.0.0
|
||||
httpx>=0.24.0
|
||||
websockets>=11.0
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
routes package — API routers for Commander AI Lab.
|
||||
|
||||
Each module defines a FastAPI APIRouter with real endpoint implementations.
|
||||
All shared helpers, models, and state live in routes.shared.
|
||||
"""
|
||||
+698
@@ -0,0 +1,698 @@
|
||||
"""
|
||||
routes/coach.py
|
||||
===============
|
||||
LLM-powered deck coaching endpoints:
|
||||
GET /api/coach/status
|
||||
GET /api/coach/decks
|
||||
GET /api/coach/decks/{deck_id}/report
|
||||
POST /api/coach/decks/{deck_id}
|
||||
GET /api/coach/sessions
|
||||
GET /api/coach/sessions/{session_id}
|
||||
POST /api/coach/embeddings/download
|
||||
GET /api/coach/embeddings/search
|
||||
POST /api/coach/chat
|
||||
POST /api/coach/apply
|
||||
GET /api/coach/cards-like
|
||||
POST /api/coach/reports/generate
|
||||
|
||||
Globals:
|
||||
_coach_service, _coach_embeddings, _coach_llm
|
||||
_deck_gen_v3, _deck_gen_v3_error
|
||||
init_coach_service(), _build_deck_report_from_db()
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
log_coach, log_deckgen,
|
||||
_get_db_conn,
|
||||
_compute_deck_analysis,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["coach"])
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Coach Service (LLM-powered deck coaching)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# Global coach instances (initialized at startup)
|
||||
_coach_service = None
|
||||
_coach_embeddings = None
|
||||
_coach_llm = None
|
||||
_deck_gen_v3 = None # V3 Deck Generator (Perplexity structured output)
|
||||
_deck_gen_v3_error = None # Error message if V3 init failed
|
||||
|
||||
|
||||
def init_coach_service():
|
||||
"""Initialize the coach service with LLM client and embeddings."""
|
||||
global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
|
||||
try:
|
||||
from coach.llm_client import LMStudioClient
|
||||
from coach.embeddings import MTGEmbeddingIndex
|
||||
from coach.coach_service import CoachService
|
||||
from coach.config import ensure_dirs
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
_coach_llm = LMStudioClient()
|
||||
_coach_embeddings = MTGEmbeddingIndex()
|
||||
|
||||
# Try to load embeddings (non-blocking — download happens on first use)
|
||||
try:
|
||||
from coach.config import EMBEDDINGS_NPZ
|
||||
if EMBEDDINGS_NPZ.exists():
|
||||
_coach_embeddings.load()
|
||||
log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
|
||||
else:
|
||||
log_coach.warning(" Coach: Embeddings not yet downloaded (will download on first use)")
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings load failed: {e}")
|
||||
|
||||
_coach_service = CoachService(_coach_llm, _coach_embeddings)
|
||||
|
||||
# Check LLM connection
|
||||
llm_status = _coach_llm.check_connection()
|
||||
if llm_status.get("connected"):
|
||||
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
||||
else:
|
||||
log_coach.warning(f" Coach LLM: Not connected (start LM Studio on 192.168.0.122:1234)")
|
||||
|
||||
log_coach.info(" Coach: Service initialized")
|
||||
|
||||
# Initialize V3 Deck Generator (Perplexity)
|
||||
_deck_gen_v3_error = None
|
||||
if CFG.pplx_api_key:
|
||||
try:
|
||||
from coach.clients.perplexity_client import PerplexityClient
|
||||
from coach.services.deck_generator import DeckGeneratorV3
|
||||
from coach.config import DECK_GEN_MODEL
|
||||
|
||||
pplx_client = PerplexityClient(
|
||||
api_key=CFG.pplx_api_key,
|
||||
model=DECK_GEN_MODEL,
|
||||
)
|
||||
_deck_gen_v3 = DeckGeneratorV3(
|
||||
pplx_client=pplx_client,
|
||||
db_conn_factory=_get_db_conn,
|
||||
embedding_index=_coach_embeddings,
|
||||
)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})")
|
||||
except ImportError as e:
|
||||
_deck_gen_v3_error = f"Missing dependency: {e}. Run: pip install openai"
|
||||
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||
_deck_gen_v3 = None
|
||||
except Exception as e:
|
||||
_deck_gen_v3_error = str(e)
|
||||
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
||||
_deck_gen_v3 = None
|
||||
else:
|
||||
_deck_gen_v3_error = "PPLX_API_KEY not set"
|
||||
log_deckgen.info(" Deck Gen V3: Skipped (no PPLX_API_KEY)")
|
||||
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Failed to initialize: {e}")
|
||||
_coach_service = None
|
||||
|
||||
|
||||
@router.get("/api/coach/status")
|
||||
async def coach_status():
|
||||
"""Check coach subsystem health."""
|
||||
if _coach_service is None:
|
||||
return {"llmConnected": False, "embeddingsLoaded": False,
|
||||
"embeddingCards": 0, "deckReportsAvailable": 0,
|
||||
"error": "Coach service not initialized"}
|
||||
status = _coach_service.get_status()
|
||||
return status.model_dump()
|
||||
|
||||
|
||||
@router.get("/api/coach/decks")
|
||||
async def coach_list_decks():
|
||||
"""List all decks available for coaching (from deck builder DB)."""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT d.id as deck_id, d.name as deck_name, d.commander_name,
|
||||
COUNT(dc.id) as card_count
|
||||
FROM decks d
|
||||
LEFT JOIN deck_cards dc ON dc.deck_id = d.id
|
||||
GROUP BY d.id
|
||||
ORDER BY d.name"""
|
||||
).fetchall()
|
||||
|
||||
# Also get report availability from coach service
|
||||
report_ids = set()
|
||||
if _coach_service:
|
||||
report_ids = set(_coach_service.list_deck_reports())
|
||||
|
||||
decks = []
|
||||
for r in rows:
|
||||
deck_name = r["deck_name"]
|
||||
# Check if a report exists (by deck name slug match)
|
||||
has_report = any(
|
||||
deck_name.lower().replace(" ", "-") == rid.lower() or
|
||||
deck_name.lower() == rid.lower()
|
||||
for rid in report_ids
|
||||
)
|
||||
decks.append({
|
||||
"deck_id": r["deck_id"],
|
||||
"deck_name": r["deck_name"],
|
||||
"commander": r["commander_name"] or "",
|
||||
"card_count": r["card_count"],
|
||||
"has_report": has_report,
|
||||
"report_count": 1 if has_report else 0,
|
||||
"last_report_date": None,
|
||||
})
|
||||
return decks
|
||||
|
||||
|
||||
@router.get("/api/coach/decks/{deck_id}/report")
|
||||
async def coach_get_report(deck_id: str):
|
||||
"""Get the latest DeckReport for a deck."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
report = _coach_service.load_deck_report(deck_id)
|
||||
if report is None:
|
||||
raise HTTPException(404, f"Deck report not found: {deck_id}")
|
||||
return report.model_dump()
|
||||
|
||||
|
||||
class CoachRequestBody(BaseModel):
|
||||
goals: Optional[dict] = None
|
||||
|
||||
|
||||
class CoachChatMessage(BaseModel):
|
||||
role: str # "user" or "assistant"
|
||||
content: str
|
||||
|
||||
class CoachChatRequest(BaseModel):
|
||||
deck_id: str
|
||||
messages: list[dict] # conversation history [{role, content}]
|
||||
goals: Optional[dict] = None
|
||||
stream: Optional[bool] = False
|
||||
|
||||
class CoachApplyRequest(BaseModel):
|
||||
session_id: str
|
||||
deck_id: int # numeric deck ID in the DB
|
||||
accepted_cuts: list[str] = [] # card names to remove
|
||||
accepted_adds: list[str] = [] # card names to add
|
||||
|
||||
class CoachGoalsRequest(BaseModel):
|
||||
target_power_level: Optional[int] = None # 1-10
|
||||
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
|
||||
budget: Optional[str] = None # budget, medium, no-limit
|
||||
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
|
||||
|
||||
|
||||
@router.post("/api/coach/decks/{deck_id}")
|
||||
async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
|
||||
"""Trigger a coaching session for a deck."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
|
||||
# Load embeddings on first use if not loaded
|
||||
if _coach_embeddings and not _coach_embeddings.loaded:
|
||||
try:
|
||||
_coach_embeddings.load(force_download=True)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings download failed: {e}")
|
||||
|
||||
goals = None
|
||||
if body and body.goals:
|
||||
from coach.models import CoachGoals
|
||||
try:
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
|
||||
# Build a fallback DeckReport from the DB if no simulation report exists
|
||||
fallback_report = None
|
||||
try:
|
||||
fallback_report = _build_deck_report_from_db(deck_id)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
|
||||
|
||||
try:
|
||||
session = await _coach_service.run_coaching_session(deck_id, goals, fallback_report=fallback_report)
|
||||
return session.model_dump()
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ConnectionError as e:
|
||||
raise HTTPException(503, f"LLM connection failed: {e}")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Coach session failed: {e}")
|
||||
|
||||
|
||||
def _build_deck_report_from_db(deck_slug: str):
|
||||
"""
|
||||
Build a lightweight DeckReport from the deck builder DB when no
|
||||
simulation report exists. Allows the coach to analyze deck composition
|
||||
even without simulation data.
|
||||
"""
|
||||
from coach.models import DeckReport, CardPerformance, DeckStructure
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Find the deck by slug match against the deck name
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, commander_name, color_identity FROM decks ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
matched_deck = None
|
||||
for r in rows:
|
||||
name = r["name"] or ""
|
||||
slug = name.lower().replace(" ", "-")
|
||||
# Also try a more thorough slugify
|
||||
import re
|
||||
clean_slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
|
||||
if slug == deck_slug.lower() or clean_slug == deck_slug.lower() or name.lower() == deck_slug.lower():
|
||||
matched_deck = r
|
||||
break
|
||||
|
||||
if matched_deck is None:
|
||||
return None
|
||||
|
||||
deck_id = matched_deck["id"]
|
||||
deck_name = matched_deck["name"]
|
||||
commander = matched_deck["commander_name"] or ""
|
||||
|
||||
# Parse color identity
|
||||
ci_raw = matched_deck["color_identity"] or "[]"
|
||||
try:
|
||||
color_identity = json.loads(ci_raw) if isinstance(ci_raw, str) else ci_raw
|
||||
except Exception:
|
||||
color_identity = []
|
||||
|
||||
# Load all cards in this deck
|
||||
card_rows = conn.execute(
|
||||
"""SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
|
||||
ce.type_line, ce.cmc, ce.oracle_text
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name)
|
||||
WHERE dc.deck_id = ?""",
|
||||
(deck_id,)
|
||||
).fetchall()
|
||||
|
||||
cards = []
|
||||
type_counts = {}
|
||||
cmc_buckets = [0] * 8
|
||||
land_count = 0
|
||||
|
||||
for cr in card_rows:
|
||||
card_name = cr["card_name"] or ""
|
||||
type_line = cr["type_line"] or ""
|
||||
cmc = cr["cmc"] or 0
|
||||
qty = cr["quantity"] or 1
|
||||
role_tag = cr["role_tag"] or ""
|
||||
|
||||
# Build tags from type_line and role_tag
|
||||
tags = []
|
||||
if role_tag:
|
||||
tags.append(role_tag)
|
||||
if "Land" in type_line:
|
||||
tags.append("land")
|
||||
land_count += qty
|
||||
elif "Creature" in type_line:
|
||||
tags.append("creature")
|
||||
elif "Instant" in type_line:
|
||||
tags.append("instant")
|
||||
elif "Sorcery" in type_line:
|
||||
tags.append("sorcery")
|
||||
elif "Artifact" in type_line:
|
||||
tags.append("artifact")
|
||||
elif "Enchantment" in type_line:
|
||||
tags.append("enchantment")
|
||||
elif "Planeswalker" in type_line:
|
||||
tags.append("planeswalker")
|
||||
|
||||
# Type count
|
||||
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
|
||||
if t in type_line:
|
||||
type_counts[t] = type_counts.get(t, 0) + qty
|
||||
|
||||
# CMC bucket
|
||||
bucket = min(int(cmc), 7)
|
||||
cmc_buckets[bucket] += qty
|
||||
|
||||
# CardPerformance with zeroed-out sim stats
|
||||
cards.append(CardPerformance(
|
||||
name=card_name,
|
||||
drawnRate=0.0,
|
||||
castRate=0.0,
|
||||
impactScore=0.0,
|
||||
tags=tags,
|
||||
))
|
||||
|
||||
slug = deck_slug
|
||||
return DeckReport(
|
||||
deckId=slug,
|
||||
commander=commander,
|
||||
colorIdentity=color_identity,
|
||||
cards=cards,
|
||||
structure=DeckStructure(
|
||||
landCount=land_count,
|
||||
curveBuckets=cmc_buckets,
|
||||
cardTypeCounts=type_counts,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/coach/sessions")
|
||||
async def coach_list_sessions(deck_id: str = None):
|
||||
"""List all coaching sessions, optionally filtered by deck."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
return {"sessions": _coach_service.list_sessions(deck_id)}
|
||||
|
||||
|
||||
@router.get("/api/coach/sessions/{session_id}")
|
||||
async def coach_get_session(session_id: str):
|
||||
"""Get a specific coaching session."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
session = _coach_service.load_session(session_id)
|
||||
if session is None:
|
||||
raise HTTPException(404, f"Session not found: {session_id}")
|
||||
return session.model_dump()
|
||||
|
||||
|
||||
@router.post("/api/coach/embeddings/download")
|
||||
async def coach_download_embeddings():
|
||||
"""Download and convert MTG card embeddings from HuggingFace."""
|
||||
if _coach_embeddings is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
try:
|
||||
_coach_embeddings.load(force_download=True)
|
||||
return {
|
||||
"success": True,
|
||||
"cards": _coach_embeddings.card_count,
|
||||
"message": f"Loaded {_coach_embeddings.card_count} card embeddings"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to download embeddings: {e}")
|
||||
|
||||
|
||||
@router.get("/api/coach/embeddings/search")
|
||||
async def coach_search_similar(card: str, colors: str = None, top_n: int = 10):
|
||||
"""Search for similar cards by name."""
|
||||
if _coach_embeddings is None or not _coach_embeddings.loaded:
|
||||
raise HTTPException(503, "Embeddings not loaded")
|
||||
color_filter = list(colors.upper()) if colors else None
|
||||
matches = _coach_embeddings.search_similar(
|
||||
query_card=card, color_filter=color_filter, top_n=top_n
|
||||
)
|
||||
return {"query": card, "matches": [m.to_dict() for m in matches]}
|
||||
|
||||
|
||||
@router.post("/api/coach/chat")
|
||||
async def coach_chat(body: CoachChatRequest):
|
||||
"""Multi-turn coaching chat. Sends conversation history to LLM and returns response."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
|
||||
# Load deck report for context
|
||||
report = _coach_service.load_deck_report(body.deck_id)
|
||||
|
||||
# Build system prompt with report context
|
||||
from coach.prompt_template import build_system_prompt
|
||||
from coach.models import CoachGoals
|
||||
|
||||
goals = None
|
||||
if body.goals:
|
||||
try:
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
|
||||
system_prompt = ""
|
||||
if report:
|
||||
system_prompt = build_system_prompt(report, goals)
|
||||
else:
|
||||
system_prompt = (
|
||||
"You are an expert Magic: The Gathering Commander deck coach. "
|
||||
"Answer questions about deck building, strategy, card choices, and game theory. "
|
||||
"Be specific and actionable in your advice."
|
||||
)
|
||||
|
||||
# Build messages array for LLM
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
for msg in body.messages:
|
||||
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
|
||||
|
||||
# Call LLM with multi-turn messages
|
||||
try:
|
||||
import json
|
||||
from urllib.request import urlopen, Request as UrlRequest
|
||||
from coach.config import LM_STUDIO_URL, LM_STUDIO_TIMEOUT
|
||||
|
||||
model_name = _coach_llm._resolve_model()
|
||||
llm_body = {
|
||||
"model": model_name,
|
||||
"messages": messages,
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
|
||||
if body.stream:
|
||||
# SSE streaming response
|
||||
llm_body["stream"] = True
|
||||
|
||||
async def generate():
|
||||
import asyncio
|
||||
def _stream():
|
||||
req = UrlRequest(
|
||||
f"{_coach_llm.base_url}/chat/completions",
|
||||
data=json.dumps(llm_body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
import http.client
|
||||
import urllib.parse
|
||||
parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions")
|
||||
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LM_STUDIO_TIMEOUT)
|
||||
conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
resp = conn.getresponse()
|
||||
chunks = []
|
||||
while True:
|
||||
line = resp.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.decode("utf-8").strip()
|
||||
if line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if data == "[DONE]":
|
||||
chunks.append("[DONE]")
|
||||
break
|
||||
chunks.append(data)
|
||||
conn.close()
|
||||
return chunks
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
chunks = await loop.run_in_executor(None, _stream)
|
||||
|
||||
for chunk_str in chunks:
|
||||
if chunk_str == "[DONE]":
|
||||
yield f"data: [DONE]\n\n"
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(chunk_str)
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
content = delta.get("content", "")
|
||||
if content:
|
||||
# Strip think tags from streaming chunks
|
||||
yield f"data: {json.dumps({'content': content})}\n\n"
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
else:
|
||||
# Non-streaming: regular call
|
||||
req = UrlRequest(
|
||||
f"{_coach_llm.base_url}/chat/completions",
|
||||
data=json.dumps(llm_body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
def _call():
|
||||
with urlopen(req, timeout=LM_STUDIO_TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
raw = await loop.run_in_executor(None, _call)
|
||||
|
||||
content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
# Strip think tags
|
||||
content = _coach_llm._strip_think_tags(content)
|
||||
usage = raw.get("usage", {})
|
||||
|
||||
return {
|
||||
"content": content,
|
||||
"model": raw.get("model", ""),
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Chat failed: {e}")
|
||||
|
||||
|
||||
@router.post("/api/coach/apply")
|
||||
async def coach_apply_suggestions(body: CoachApplyRequest):
|
||||
"""Apply accepted coaching suggestions to a deck — remove cuts, add adds."""
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Verify deck exists
|
||||
deck = conn.execute("SELECT * FROM decks WHERE id = ?", (body.deck_id,)).fetchone()
|
||||
if not deck:
|
||||
raise HTTPException(404, f"Deck {body.deck_id} not found")
|
||||
|
||||
results = {"cuts": [], "adds": [], "errors": []}
|
||||
|
||||
# Process cuts: remove cards by name
|
||||
for card_name in body.accepted_cuts:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": card_name, "status": "removed"})
|
||||
else:
|
||||
# Try case-insensitive
|
||||
row = conn.execute(
|
||||
"SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": row["card_name"], "status": "removed"})
|
||||
else:
|
||||
results["errors"].append({"name": card_name, "error": "Card not found in deck"})
|
||||
|
||||
# Process adds: look up in collection first, then Scryfall
|
||||
for card_name in body.accepted_adds:
|
||||
# Try to find in collection
|
||||
ce = conn.execute(
|
||||
"SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(card_name,)
|
||||
).fetchone()
|
||||
|
||||
scryfall_id = None
|
||||
resolved_name = card_name
|
||||
|
||||
if ce:
|
||||
scryfall_id = ce["scryfall_id"]
|
||||
resolved_name = ce["name"]
|
||||
else:
|
||||
# Try Scryfall API lookup
|
||||
try:
|
||||
import urllib.parse
|
||||
encoded = urllib.parse.quote(card_name)
|
||||
scry_req = UrlRequest(
|
||||
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
|
||||
headers={"User-Agent": "commander-ai-lab/1.0"}
|
||||
)
|
||||
with urlopen(scry_req, timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
scryfall_id = card_data.get("id")
|
||||
resolved_name = card_data.get("name", card_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if scryfall_id:
|
||||
# Check if already in deck
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
|
||||
(body.deck_id, scryfall_id)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)",
|
||||
(body.deck_id, scryfall_id, resolved_name)
|
||||
)
|
||||
results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"})
|
||||
else:
|
||||
results["adds"].append({"name": resolved_name, "status": "already_in_deck"})
|
||||
else:
|
||||
results["errors"].append({"name": card_name, "error": "Could not resolve card"})
|
||||
|
||||
conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,))
|
||||
conn.commit()
|
||||
|
||||
results["total_cuts"] = len(results["cuts"])
|
||||
results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"])
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/api/coach/cards-like")
|
||||
async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
|
||||
"""Find cards similar to a given card using embeddings. UI-friendly version."""
|
||||
if _coach_embeddings is None or not _coach_embeddings.loaded:
|
||||
raise HTTPException(503, "Embeddings not loaded")
|
||||
|
||||
color_filter = list(colors.upper()) if colors else None
|
||||
matches = _coach_embeddings.search_similar(
|
||||
query_card=card, color_filter=color_filter, top_n=top_n
|
||||
)
|
||||
|
||||
conn = _get_db_conn()
|
||||
results = []
|
||||
for m in matches:
|
||||
# Check if card is in collection
|
||||
owned = conn.execute(
|
||||
"SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)",
|
||||
(m.name,)
|
||||
).fetchone()
|
||||
owned_qty = (owned["qty"] or 0) if owned else 0
|
||||
|
||||
# Get image URL and price
|
||||
ce = conn.execute(
|
||||
"SELECT image_url, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(m.name,)
|
||||
).fetchone()
|
||||
|
||||
results.append({
|
||||
"name": m.name,
|
||||
"similarity": round(m.similarity, 4),
|
||||
"types": m.types,
|
||||
"mana_value": m.mana_value,
|
||||
"mana_cost": m.mana_cost,
|
||||
"text": m.text[:200] if m.text else "",
|
||||
"owned_qty": owned_qty,
|
||||
"image_url": ce["image_url"] if ce else None,
|
||||
"tcg_price": ce["tcg_price"] if ce else None,
|
||||
})
|
||||
|
||||
return {"query": card, "results": results}
|
||||
|
||||
|
||||
@router.post("/api/coach/reports/generate")
|
||||
async def coach_generate_reports():
|
||||
"""Rebuild all deck reports from batch result JSONs in results/."""
|
||||
try:
|
||||
from coach.report_generator import generate_deck_reports
|
||||
lab_root = Path(__file__).parent
|
||||
results_dir = str(lab_root / CFG.results_dir)
|
||||
reports_dir = str(lab_root / "deck-reports")
|
||||
updated = generate_deck_reports(results_dir, reports_dir)
|
||||
return {
|
||||
"status": "ok",
|
||||
"decksUpdated": updated,
|
||||
"count": len(updated),
|
||||
"message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found",
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Report generation failed: {e}")
|
||||
|
||||
|
||||
@@ -0,0 +1,976 @@
|
||||
"""
|
||||
routes/collection.py
|
||||
====================
|
||||
Collection management endpoints:
|
||||
GET /api/collection
|
||||
GET /api/collection/export
|
||||
GET /api/collection/sets
|
||||
GET /api/collection/keywords
|
||||
POST /api/collection/import
|
||||
GET /api/collection/{cardId}
|
||||
PATCH /api/collection/{cardId}
|
||||
GET /api/cache/scryfall
|
||||
DELETE /api/cache/scryfall
|
||||
POST /api/cache/scryfall/evict
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
_get_db_conn,
|
||||
_row_to_dict,
|
||||
_add_image_url,
|
||||
_build_collection_filters,
|
||||
_detect_card_roles,
|
||||
_classify_card_type,
|
||||
_enrich_from_scryfall,
|
||||
_fetch_scryfall_api,
|
||||
_parse_finish,
|
||||
_parse_text_line,
|
||||
_parse_csv_content,
|
||||
_auto_infer_mapping,
|
||||
_scryfall_cache,
|
||||
_scryfall_rate_limit,
|
||||
_snake_to_camel,
|
||||
VALID_SORT_FIELDS,
|
||||
_JSON_FIELDS,
|
||||
SCRYFALL_CACHE_DB_PATH,
|
||||
SCRYFALL_CACHE_TTL_SECONDS,
|
||||
_API_HEADERS,
|
||||
log_collect,
|
||||
log_cache,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["collection"])
|
||||
|
||||
|
||||
@router.get("/api/collection/export")
|
||||
async def export_collection(
|
||||
format: str = "INTERNAL_CSV",
|
||||
q: Optional[str] = None,
|
||||
colors: Optional[str] = None,
|
||||
types: Optional[str] = None,
|
||||
isLegendary: Optional[bool] = None,
|
||||
isBasic: Optional[bool] = None,
|
||||
isGameChanger: Optional[bool] = None,
|
||||
highSalt: Optional[bool] = None,
|
||||
finish: Optional[str] = None,
|
||||
cmcMin: Optional[float] = None,
|
||||
cmcMax: Optional[float] = None,
|
||||
priceMin: Optional[float] = None,
|
||||
priceMax: Optional[float] = None,
|
||||
category: Optional[str] = None,
|
||||
):
|
||||
"""Export collection in various formats with optional filters."""
|
||||
where_str, params = _build_collection_filters(
|
||||
q=q, colors=colors, types=types, isLegendary=isLegendary,
|
||||
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
|
||||
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
|
||||
priceMin=priceMin, priceMax=priceMax, category=category,
|
||||
)
|
||||
|
||||
sql = f"SELECT * FROM collection_entries {where_str} ORDER BY name ASC"
|
||||
conn = _get_db_conn()
|
||||
rows = [_row_to_dict(r) for r in conn.execute(sql, params).fetchall()]
|
||||
|
||||
fmt = format.upper()
|
||||
output = io.StringIO()
|
||||
|
||||
if fmt == "INTERNAL_CSV":
|
||||
if rows:
|
||||
fieldnames = list(rows[0].keys())
|
||||
else:
|
||||
fieldnames = [
|
||||
"id", "name", "type_line", "subtypes", "is_legendary", "is_basic",
|
||||
"color_identity", "cmc", "oracle_text", "keywords", "tcg_price",
|
||||
"salt_score", "is_game_changer", "category", "scryfall_id", "tcgplayer_id",
|
||||
"quantity", "finish", "condition", "language", "notes", "tags",
|
||||
"set_code", "collector_number", "created_at", "updated_at",
|
||||
]
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
filename = "collection_export.csv"
|
||||
media_type = "text/csv"
|
||||
|
||||
elif fmt == "MOXFIELD_CSV":
|
||||
fieldnames = ["Count", "Name", "Edition", "Condition", "Language", "Foil", "Collector Number"]
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
finish_val = row.get("finish", "NORMAL").upper()
|
||||
foil_val = "foil" if finish_val == "FOIL" else ("etched" if finish_val == "ETCHED" else "")
|
||||
writer.writerow({
|
||||
"Count": row.get("quantity", 1),
|
||||
"Name": row.get("name", ""),
|
||||
"Edition": row.get("set_code", "").upper(),
|
||||
"Condition": row.get("condition", ""),
|
||||
"Language": row.get("language", ""),
|
||||
"Foil": foil_val,
|
||||
"Collector Number": row.get("collector_number", ""),
|
||||
})
|
||||
filename = "collection_moxfield.csv"
|
||||
media_type = "text/csv"
|
||||
|
||||
elif fmt == "TEXT":
|
||||
lines = []
|
||||
for row in rows:
|
||||
qty = row.get("quantity", 1)
|
||||
name = row.get("name", "")
|
||||
set_code = row.get("set_code", "").upper()
|
||||
coll_num = row.get("collector_number", "")
|
||||
if set_code and coll_num:
|
||||
lines.append(f"{qty} {name} ({set_code}) {coll_num}")
|
||||
elif set_code:
|
||||
lines.append(f"{qty} {name} ({set_code})")
|
||||
else:
|
||||
lines.append(f"{qty} {name}")
|
||||
output.write("\n".join(lines))
|
||||
filename = "collection_export.txt"
|
||||
media_type = "text/plain"
|
||||
|
||||
else:
|
||||
raise HTTPException(400, f"Unknown format: {format}. Use INTERNAL_CSV, MOXFIELD_CSV, or TEXT.")
|
||||
|
||||
content = output.getvalue()
|
||||
|
||||
def iter_content():
|
||||
yield content.encode("utf-8")
|
||||
|
||||
return StreamingResponse(
|
||||
iter_content(),
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/collection/sets")
|
||||
async def collection_sets():
|
||||
"""Return distinct set codes + names in the collection (for filter autocomplete)."""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT UPPER(set_code) as code, set_name as name FROM collection_entries WHERE set_code != '' ORDER BY set_name ASC"
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
code = r["code"] if "code" in r.keys() else r[0]
|
||||
name = r["name"] if "name" in r.keys() else r[1]
|
||||
if code:
|
||||
result.append({"code": code, "name": name or code})
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@router.get("/api/collection/keywords")
|
||||
async def collection_keywords():
|
||||
"""Return distinct keywords found in the collection."""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute("SELECT keywords FROM collection_entries WHERE keywords != '' AND keywords != '[]'").fetchall()
|
||||
kw_set = set()
|
||||
for r in rows:
|
||||
try:
|
||||
kws = json.loads(r["keywords"]) if isinstance(r["keywords"], str) else r["keywords"]
|
||||
if isinstance(kws, list):
|
||||
for k in kws:
|
||||
if k:
|
||||
kw_set.add(k)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return JSONResponse(sorted(kw_set))
|
||||
|
||||
|
||||
@router.get("/api/collection")
|
||||
async def list_collection(
|
||||
page: int = 1,
|
||||
pageSize: int = 50,
|
||||
q: Optional[str] = None,
|
||||
sortField: Optional[str] = None,
|
||||
sortDir: Optional[str] = None,
|
||||
colors: Optional[str] = None,
|
||||
types: Optional[str] = None,
|
||||
isLegendary: Optional[bool] = None,
|
||||
isBasic: Optional[bool] = None,
|
||||
isGameChanger: Optional[bool] = None,
|
||||
highSalt: Optional[bool] = None,
|
||||
finish: Optional[str] = None,
|
||||
cmcMin: Optional[float] = None,
|
||||
cmcMax: Optional[float] = None,
|
||||
priceMin: Optional[float] = None,
|
||||
priceMax: Optional[float] = None,
|
||||
category: Optional[str] = None,
|
||||
deck_id: Optional[int] = None,
|
||||
rarity: Optional[str] = None,
|
||||
setCode: Optional[str] = None,
|
||||
powerMin: Optional[str] = None,
|
||||
powerMax: Optional[str] = None,
|
||||
toughMin: Optional[str] = None,
|
||||
toughMax: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
edhrecMin: Optional[int] = None,
|
||||
edhrecMax: Optional[int] = None,
|
||||
qtyMin: Optional[int] = None,
|
||||
qtyMax: Optional[int] = None,
|
||||
):
|
||||
"""List collection entries with search, sort, and filter.
|
||||
Optional deck_id: when provided, includes in_deck_quantity for each result.
|
||||
"""
|
||||
where_str, params = _build_collection_filters(
|
||||
q=q, colors=colors, types=types, isLegendary=isLegendary,
|
||||
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
|
||||
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
|
||||
priceMin=priceMin, priceMax=priceMax, category=category,
|
||||
rarity=rarity, setCode=setCode, powerMin=powerMin, powerMax=powerMax,
|
||||
toughMin=toughMin, toughMax=toughMax, keyword=keyword,
|
||||
edhrecMin=edhrecMin, edhrecMax=edhrecMax, qtyMin=qtyMin, qtyMax=qtyMax,
|
||||
)
|
||||
|
||||
# Validate sort
|
||||
sort_field = sortField if sortField in VALID_SORT_FIELDS else "name"
|
||||
sort_dir = "DESC" if (sortDir or "").upper() == "DESC" else "ASC"
|
||||
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Count total
|
||||
count_sql = f"SELECT COUNT(*) FROM collection_entries {where_str}"
|
||||
total = conn.execute(count_sql, params).fetchone()[0]
|
||||
|
||||
# Paginate
|
||||
offset = (max(1, page) - 1) * pageSize
|
||||
data_sql = f"""
|
||||
SELECT * FROM collection_entries {where_str}
|
||||
ORDER BY {sort_field} {sort_dir}
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
rows = conn.execute(data_sql, params + [pageSize, offset]).fetchall()
|
||||
items = [_add_image_url(_row_to_dict(r)) for r in rows]
|
||||
|
||||
# If deck_id provided, annotate each item with how many copies are already in that deck
|
||||
if deck_id is not None:
|
||||
# Build a map of scryfall_id -> quantity in deck
|
||||
deck_card_rows = conn.execute(
|
||||
"SELECT scryfall_id, SUM(quantity) as qty FROM deck_cards WHERE deck_id = ? GROUP BY scryfall_id",
|
||||
(deck_id,)
|
||||
).fetchall()
|
||||
in_deck_map = {row["scryfall_id"]: row["qty"] for row in deck_card_rows}
|
||||
for item in items:
|
||||
item["in_deck_quantity"] = in_deck_map.get(item.get("scryfall_id", ""), 0)
|
||||
|
||||
return {"items": items, "page": page, "pageSize": pageSize, "total": total}
|
||||
|
||||
|
||||
@router.get("/api/collection/{cardId}")
|
||||
async def get_collection_card(cardId: int):
|
||||
"""Get a single collection entry by ID."""
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Card with id {cardId} not found")
|
||||
return _add_image_url(_row_to_dict(row))
|
||||
|
||||
|
||||
@router.patch("/api/collection/{cardId}")
|
||||
async def update_collection_card(cardId: int, body: dict):
|
||||
"""Update mutable fields of a collection entry."""
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Card with id {cardId} not found")
|
||||
|
||||
updates = {}
|
||||
if "category" in body:
|
||||
cat = body["category"]
|
||||
if isinstance(cat, list):
|
||||
updates["category"] = json.dumps(cat)
|
||||
else:
|
||||
updates["category"] = cat
|
||||
if "tags" in body:
|
||||
updates["tags"] = str(body["tags"])
|
||||
if "notes" in body:
|
||||
updates["notes"] = str(body["notes"])
|
||||
if "finish" in body:
|
||||
finish_val = str(body["finish"]).upper()
|
||||
if finish_val not in ("NORMAL", "FOIL", "ETCHED"):
|
||||
raise HTTPException(400, "finish must be NORMAL, FOIL, or ETCHED")
|
||||
updates["finish"] = finish_val
|
||||
|
||||
if not updates:
|
||||
return _add_image_url(_row_to_dict(row))
|
||||
|
||||
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||
values = list(updates.values()) + [cardId]
|
||||
conn.execute(f"UPDATE collection_entries SET {set_clause} WHERE id = ?", values)
|
||||
conn.commit()
|
||||
|
||||
updated_row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
|
||||
return _add_image_url(_row_to_dict(updated_row))
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Import Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Scryfall Response Cache ─────────────────────────────────
|
||||
# SQLite-backed cache for Scryfall API responses.
|
||||
# Keyed by (name, set_code, collector_number). Default TTL: 7 days.
|
||||
# Scryfall data changes infrequently (set releases ~every 3 months).
|
||||
# Eliminates redundant HTTP calls across imports and re-enrichments.
|
||||
|
||||
SCRYFALL_CACHE_DB_PATH = Path(__file__).parent / "scryfall_cache.db"
|
||||
SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600)) # 7 days
|
||||
|
||||
|
||||
class _ScryfallCache:
|
||||
"""
|
||||
Thread-safe SQLite cache for raw Scryfall JSON responses.
|
||||
|
||||
Schema:
|
||||
cache_key TEXT PRIMARY KEY — "name|set|cn" normalised lowercase
|
||||
json_blob TEXT — raw Scryfall API JSON response
|
||||
fetched_at TEXT — ISO-8601 UTC timestamp
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path = None, ttl_seconds: int = None):
|
||||
self._db_path = db_path or SCRYFALL_CACHE_DB_PATH
|
||||
self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS
|
||||
self._lock = threading.Lock()
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._init_db()
|
||||
|
||||
# ── internal ────────────────────────────────────────────
|
||||
|
||||
def _init_db(self):
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scryfall_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
json_blob TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _make_key(name: str, set_code: str, collector_number: str) -> str:
|
||||
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
|
||||
|
||||
# ── public API ──────────────────────────────────────────
|
||||
|
||||
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
|
||||
"""Return cached Scryfall JSON dict, or None if missing / expired."""
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT json_blob, fetched_at FROM scryfall_cache WHERE cache_key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
self._misses += 1
|
||||
return None
|
||||
# TTL check
|
||||
try:
|
||||
fetched = datetime.fromisoformat(row[1])
|
||||
age = (datetime.utcnow() - fetched).total_seconds()
|
||||
if age > self._ttl:
|
||||
self._misses += 1
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
self._misses += 1
|
||||
return None
|
||||
self._hits += 1
|
||||
try:
|
||||
return json.loads(row[0])
|
||||
except json.JSONDecodeError:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
def put(self, name: str, set_code: str, collector_number: str, card_data: dict):
|
||||
"""Store a Scryfall response in the cache."""
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
blob = json.dumps(card_data, separators=(",", ":"))
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob, fetched_at) "
|
||||
"VALUES (?, ?, datetime('now'))",
|
||||
(key, blob),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Return cache statistics."""
|
||||
with self._lock:
|
||||
total = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
oldest_row = self._conn.execute(
|
||||
"SELECT MIN(fetched_at) FROM scryfall_cache"
|
||||
).fetchone()
|
||||
newest_row = self._conn.execute(
|
||||
"SELECT MAX(fetched_at) FROM scryfall_cache"
|
||||
).fetchone()
|
||||
db_size_bytes = os.path.getsize(str(self._db_path)) if self._db_path.exists() else 0
|
||||
return {
|
||||
"total_entries": total,
|
||||
"hits": self._hits,
|
||||
"misses": self._misses,
|
||||
"hit_rate": round(self._hits / max(self._hits + self._misses, 1) * 100, 1),
|
||||
"ttl_seconds": self._ttl,
|
||||
"ttl_days": round(self._ttl / 86400, 1),
|
||||
"oldest_entry": oldest_row[0] if oldest_row else None,
|
||||
"newest_entry": newest_row[0] if newest_row else None,
|
||||
"db_size_kb": round(db_size_bytes / 1024, 1),
|
||||
"db_path": str(self._db_path),
|
||||
}
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Delete all cached entries. Returns count of deleted rows."""
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
self._conn.execute("DELETE FROM scryfall_cache")
|
||||
self._conn.commit()
|
||||
self._conn.execute("VACUUM") # must run outside transaction
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
return count
|
||||
|
||||
def evict_expired(self) -> int:
|
||||
"""Remove entries older than TTL. Returns count of evicted rows."""
|
||||
cutoff = datetime.utcnow().isoformat()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"DELETE FROM scryfall_cache WHERE "
|
||||
"(julianday(?) - julianday(fetched_at)) * 86400 > ?",
|
||||
(cutoff, self._ttl),
|
||||
)
|
||||
deleted = self._conn.total_changes
|
||||
self._conn.commit()
|
||||
return deleted
|
||||
|
||||
|
||||
_scryfall_cache = _ScryfallCache()
|
||||
|
||||
|
||||
# Scryfall rate-limit semaphore (max 10 req/sec)
|
||||
_scryfall_lock = threading.Lock()
|
||||
_scryfall_last_call = 0.0
|
||||
|
||||
|
||||
def _scryfall_rate_limit():
|
||||
"""Enforce at most 10 Scryfall requests per second via simple sleep."""
|
||||
global _scryfall_last_call
|
||||
with _scryfall_lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - _scryfall_last_call
|
||||
if elapsed < 0.1:
|
||||
time.sleep(0.1 - elapsed)
|
||||
_scryfall_last_call = time.monotonic()
|
||||
|
||||
|
||||
def _parse_finish(raw: str) -> str:
|
||||
"""Normalize finish string to NORMAL|FOIL|ETCHED."""
|
||||
if not raw:
|
||||
return "NORMAL"
|
||||
r = raw.strip().lower()
|
||||
if r in ("etched", "foil etched", "foil_etched", "etched foil"):
|
||||
return "ETCHED"
|
||||
if r in ("yes", "foil", "true", "1"):
|
||||
return "FOIL"
|
||||
return "NORMAL"
|
||||
|
||||
|
||||
def _parse_text_line(line: str) -> dict:
|
||||
"""
|
||||
Parse a text import line into {name, quantity, set_code, collector_number}.
|
||||
Supports formats:
|
||||
- "3 Sol Ring (CMR) 123"
|
||||
- "1x Sol Ring (CMR)"
|
||||
- "Sol Ring [CMR:123]"
|
||||
- "1 Sol Ring"
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("//"):
|
||||
return {}
|
||||
|
||||
result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""}
|
||||
|
||||
# Strip quantity prefix
|
||||
qty_match = re.match(r"^(\d+)x?\s+", line)
|
||||
if qty_match:
|
||||
result["quantity"] = int(qty_match.group(1))
|
||||
line = line[qty_match.end():]
|
||||
|
||||
# Format: "Name [SET:CollNum]"
|
||||
bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line)
|
||||
if bracket_match:
|
||||
result["set_code"] = bracket_match.group(1).lower()
|
||||
result["collector_number"] = bracket_match.group(2)
|
||||
line = line[:bracket_match.start()].strip()
|
||||
result["name"] = line
|
||||
return result
|
||||
|
||||
# Format: "Name (SET) CollNum"
|
||||
paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line)
|
||||
if paren_coll_match:
|
||||
result["set_code"] = paren_coll_match.group(1).lower()
|
||||
result["collector_number"] = paren_coll_match.group(2)
|
||||
line = line[:paren_coll_match.start()].strip()
|
||||
result["name"] = line
|
||||
return result
|
||||
|
||||
# Format: "Name (SET)"
|
||||
paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line)
|
||||
if paren_match:
|
||||
result["set_code"] = paren_match.group(1).lower()
|
||||
line = line[:paren_match.start()].strip()
|
||||
result["name"] = line
|
||||
return result
|
||||
|
||||
result["name"] = line.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _auto_infer_mapping(headers: list) -> dict:
|
||||
"""
|
||||
Auto-infer column → field mapping from header names.
|
||||
Returns dict of {header: field_key}.
|
||||
Field keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
|
||||
"""
|
||||
mapping = {}
|
||||
header_lower = {h: h.lower().strip() for h in headers}
|
||||
field_patterns = {
|
||||
"name": ["name", "card name", "card_name", "cardname", "title"],
|
||||
"quantity": ["quantity", "qty", "count", "amount", "number", "#"],
|
||||
"set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"],
|
||||
"collector_number": [
|
||||
"collector number", "collector_number", "collectornumber",
|
||||
"col #", "col#", "number", "card number",
|
||||
],
|
||||
"finish": ["finish", "foil", "printing", "treatment"],
|
||||
"condition": ["condition", "cond", "grade"],
|
||||
"language": ["language", "lang", "locale"],
|
||||
"notes": ["notes", "note", "comments", "comment"],
|
||||
"tags": ["tags", "tag", "labels", "label"],
|
||||
}
|
||||
for header, lower_h in header_lower.items():
|
||||
for field, patterns in field_patterns.items():
|
||||
if lower_h in patterns:
|
||||
mapping[header] = field
|
||||
break
|
||||
return mapping
|
||||
|
||||
|
||||
def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list:
|
||||
"""
|
||||
Parse CSV/text content into a list of RawImportRow dicts.
|
||||
Returns list of dicts with keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
|
||||
"""
|
||||
rows = []
|
||||
source_upper = (source or "").upper()
|
||||
|
||||
if source_upper == "TEXT":
|
||||
for line in content.splitlines():
|
||||
parsed = _parse_text_line(line)
|
||||
if parsed.get("name"):
|
||||
rows.append(parsed)
|
||||
return rows
|
||||
|
||||
# CSV-based sources
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
headers = reader.fieldnames or []
|
||||
|
||||
# Build effective mapping
|
||||
if mapping:
|
||||
col_map = {h: mapping.get(h, "") for h in headers}
|
||||
else:
|
||||
col_map = _auto_infer_mapping(headers)
|
||||
|
||||
# Normalize col_map field values to lowercase so both "NAME" (UI) and "name" (auto-infer) work
|
||||
col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()}
|
||||
|
||||
for csv_row in reader:
|
||||
row = {
|
||||
"name": "",
|
||||
"quantity": 1,
|
||||
"set_code": "",
|
||||
"collector_number": "",
|
||||
"finish": "NORMAL",
|
||||
"condition": "",
|
||||
"language": "",
|
||||
"notes": "",
|
||||
"tags": "",
|
||||
}
|
||||
for header, field in col_map.items():
|
||||
val = csv_row.get(header, "").strip()
|
||||
if not val or field == "ignore" or not field:
|
||||
continue
|
||||
if field == "name":
|
||||
# Strip finish suffix like "(Foil Etched)"
|
||||
val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip()
|
||||
row["name"] = val
|
||||
elif field == "quantity":
|
||||
try:
|
||||
row["quantity"] = int(float(val))
|
||||
except ValueError:
|
||||
row["quantity"] = 1
|
||||
elif field in ("set_code", "set_code_secondary"):
|
||||
row["set_code"] = val.lower()
|
||||
elif field in ("collector_number", "collector_number_secondary"):
|
||||
row["collector_number"] = val
|
||||
elif field == "finish":
|
||||
row["finish"] = _parse_finish(val)
|
||||
elif field == "condition":
|
||||
row["condition"] = val
|
||||
elif field == "language":
|
||||
row["language"] = val
|
||||
elif field == "notes":
|
||||
row["notes"] = val
|
||||
elif field == "tags":
|
||||
row["tags"] = val
|
||||
|
||||
if row["name"]:
|
||||
rows.append(row)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
"""
|
||||
Fetch raw card data from Scryfall API (with caching).
|
||||
Returns the raw Scryfall JSON dict, or a dict with '_error' on failure.
|
||||
Cache is checked first; live HTTP call only on miss.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
# ── Cache check ──────────────────────────────────────
|
||||
cached = _scryfall_cache.get(name, set_code, collector_number)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# ── Live fetch ───────────────────────────────────────
|
||||
card_data = None
|
||||
last_error = ""
|
||||
|
||||
# Try set + collector number first
|
||||
if set_code and collector_number:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}"
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
card_data = None
|
||||
|
||||
# Fall back to exact name lookup
|
||||
if not card_data:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
encoded_name = quote(name)
|
||||
url = f"https://api.scryfall.com/cards/named?exact={encoded_name}"
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
return {"_error": f"Scryfall lookup failed for '{name}': {last_error}"}
|
||||
|
||||
if not card_data or card_data.get("object") == "error":
|
||||
err_detail = card_data.get("details", "unknown error") if card_data else last_error
|
||||
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
|
||||
|
||||
# ── Cache the successful response ────────────────────
|
||||
_scryfall_cache.put(name, set_code, collector_number, card_data)
|
||||
# Also cache under the Scryfall-resolved name (handles spelling normalisation)
|
||||
resolved_name = card_data.get("name", "")
|
||||
if resolved_name and resolved_name.lower() != name.strip().lower():
|
||||
_scryfall_cache.put(resolved_name, set_code, collector_number, card_data)
|
||||
|
||||
return card_data
|
||||
|
||||
|
||||
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
"""
|
||||
Fetch card data from Scryfall (cached) and return enriched fields dict.
|
||||
Tries set+collectorNumber first, then exact name lookup.
|
||||
Returns empty dict on failure.
|
||||
"""
|
||||
card_data = _fetch_scryfall_api(name, set_code, collector_number)
|
||||
|
||||
if not card_data or "_error" in card_data:
|
||||
return card_data or {}
|
||||
|
||||
# Extract fields
|
||||
type_line = card_data.get("type_line", "")
|
||||
color_identity = card_data.get("color_identity", [])
|
||||
keywords = card_data.get("keywords", [])
|
||||
|
||||
# Parse subtypes (after em-dash or hyphen)
|
||||
subtypes = []
|
||||
for sep in [" \u2014 ", " - "]:
|
||||
if sep in type_line:
|
||||
parts = type_line.split(sep, 1)
|
||||
subtypes = [s.strip() for s in parts[1].split() if s.strip()]
|
||||
break
|
||||
|
||||
is_legendary = 1 if "Legendary" in type_line else 0
|
||||
is_basic = 1 if "Basic" in type_line else 0
|
||||
|
||||
# Get price
|
||||
prices = card_data.get("prices", {})
|
||||
tcg_price = 0.0
|
||||
try:
|
||||
price_val = prices.get("usd") or prices.get("usd_foil") or "0"
|
||||
tcg_price = float(price_val) if price_val else 0.0
|
||||
except (ValueError, TypeError):
|
||||
tcg_price = 0.0
|
||||
|
||||
# Handle double-faced cards: oracle_text may be on card_faces
|
||||
oracle_text = card_data.get("oracle_text", "")
|
||||
if not oracle_text and card_data.get("card_faces"):
|
||||
face_texts = [f.get("oracle_text", "") for f in card_data["card_faces"]]
|
||||
oracle_text = "\n//\n".join(t for t in face_texts if t)
|
||||
|
||||
# Power/toughness (may be on card_faces for DFCs)
|
||||
power = card_data.get("power", "")
|
||||
toughness = card_data.get("toughness", "")
|
||||
if not power and card_data.get("card_faces"):
|
||||
power = card_data["card_faces"][0].get("power", "")
|
||||
toughness = card_data["card_faces"][0].get("toughness", "")
|
||||
|
||||
# Mana cost (may be on card_faces for DFCs)
|
||||
mana_cost = card_data.get("mana_cost", "")
|
||||
if not mana_cost and card_data.get("card_faces"):
|
||||
mana_cost = card_data["card_faces"][0].get("mana_cost", "")
|
||||
|
||||
# Auto-detect functional categories
|
||||
auto_roles = _detect_card_roles(oracle_text, type_line, keywords)
|
||||
|
||||
return {
|
||||
"name": card_data.get("name", name),
|
||||
"type_line": type_line,
|
||||
"subtypes": json.dumps(subtypes),
|
||||
"is_legendary": is_legendary,
|
||||
"is_basic": is_basic,
|
||||
"color_identity": json.dumps(color_identity),
|
||||
"cmc": card_data.get("cmc", 0.0),
|
||||
"mana_cost": mana_cost,
|
||||
"oracle_text": oracle_text,
|
||||
"keywords": json.dumps(keywords),
|
||||
"power": power or "",
|
||||
"toughness": toughness or "",
|
||||
"rarity": card_data.get("rarity", ""),
|
||||
"set_name": card_data.get("set_name", ""),
|
||||
"edhrec_rank": card_data.get("edhrec_rank", 0) or 0,
|
||||
"tcg_price": tcg_price,
|
||||
"salt_score": 0.0,
|
||||
"is_game_changer": 0,
|
||||
"category": json.dumps(auto_roles),
|
||||
"scryfall_id": card_data.get("id", ""),
|
||||
"tcgplayer_id": str(card_data.get("tcgplayer_id", "")),
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Import Endpoint
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
@router.post("/api/collection/import")
|
||||
async def import_collection(body: dict):
|
||||
"""
|
||||
Import cards into the collection from CSV, Moxfield, Archidekt, or plain text.
|
||||
|
||||
Body:
|
||||
{
|
||||
"source": "CSV" | "MOXFIELD" | "ARCHIDEKT" | "TEXT",
|
||||
"mode": "MERGE" | "REPLACE",
|
||||
"content": "...csv or text content...",
|
||||
"mapping": { ... optional column mapping ... }
|
||||
}
|
||||
"""
|
||||
source = str(body.get("source", "CSV")).upper()
|
||||
mode = str(body.get("mode", "MERGE")).upper()
|
||||
content = str(body.get("content", ""))
|
||||
mapping = body.get("mapping", {})
|
||||
|
||||
if not content.strip():
|
||||
raise HTTPException(400, "content is required")
|
||||
|
||||
imported_count = 0
|
||||
updated_count = 0
|
||||
failed_count = 0
|
||||
errors = []
|
||||
|
||||
conn = _get_db_conn()
|
||||
|
||||
# REPLACE mode: clear existing entries
|
||||
if mode == "REPLACE":
|
||||
conn.execute("DELETE FROM collection_entries")
|
||||
conn.commit()
|
||||
|
||||
# Parse the content into raw rows
|
||||
try:
|
||||
raw_rows = _parse_csv_content(content, source, mapping if mapping else None)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Failed to parse content: {str(e)}")
|
||||
|
||||
for raw in raw_rows:
|
||||
name = raw.get("name", "").strip()
|
||||
if not name:
|
||||
continue
|
||||
|
||||
quantity = int(raw.get("quantity", 1))
|
||||
set_code = raw.get("set_code", "").lower()
|
||||
collector_number = raw.get("collector_number", "")
|
||||
finish = _parse_finish(raw.get("finish", ""))
|
||||
condition = raw.get("condition", "")
|
||||
language = raw.get("language", "")
|
||||
notes = raw.get("notes", "")
|
||||
tags = raw.get("tags", "")
|
||||
|
||||
# Scryfall enrichment
|
||||
try:
|
||||
enriched = _enrich_from_scryfall(name, set_code, collector_number)
|
||||
except Exception as e:
|
||||
errors.append(f"Scryfall error for '{name}': {str(e)}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
if not enriched or "_error" in enriched:
|
||||
err_msg = enriched.get("_error", f"Card not found on Scryfall: '{name}'") if enriched else f"Card not found on Scryfall: '{name}'"
|
||||
errors.append(err_msg)
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Use Scryfall-resolved name if available
|
||||
resolved_name = enriched.get("name", name)
|
||||
|
||||
# Check for existing entry (identity key)
|
||||
existing = conn.execute(
|
||||
"""SELECT id, quantity FROM collection_entries
|
||||
WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""",
|
||||
(resolved_name, set_code, collector_number, finish),
|
||||
).fetchone()
|
||||
|
||||
if existing and mode == "MERGE":
|
||||
# Update quantity
|
||||
new_qty = existing["quantity"] + quantity
|
||||
conn.execute(
|
||||
"UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(new_qty, existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
updated_count += 1
|
||||
else:
|
||||
# Insert new row
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_entries
|
||||
(name, type_line, subtypes, is_legendary, is_basic, color_identity,
|
||||
cmc, mana_cost, oracle_text, keywords,
|
||||
power, toughness, rarity, set_name, edhrec_rank,
|
||||
tcg_price, salt_score, is_game_changer,
|
||||
category, scryfall_id, tcgplayer_id,
|
||||
quantity, finish, condition, language, notes, tags,
|
||||
set_code, collector_number)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
resolved_name,
|
||||
enriched.get("type_line", ""),
|
||||
enriched.get("subtypes", "[]"),
|
||||
enriched.get("is_legendary", 0),
|
||||
enriched.get("is_basic", 0),
|
||||
enriched.get("color_identity", "[]"),
|
||||
enriched.get("cmc", 0.0),
|
||||
enriched.get("mana_cost", ""),
|
||||
enriched.get("oracle_text", ""),
|
||||
enriched.get("keywords", "[]"),
|
||||
enriched.get("power", ""),
|
||||
enriched.get("toughness", ""),
|
||||
enriched.get("rarity", ""),
|
||||
enriched.get("set_name", ""),
|
||||
enriched.get("edhrec_rank", 0),
|
||||
enriched.get("tcg_price", 0.0),
|
||||
enriched.get("salt_score", 0.0),
|
||||
enriched.get("is_game_changer", 0),
|
||||
enriched.get("category", "[]"),
|
||||
enriched.get("scryfall_id", ""),
|
||||
enriched.get("tcgplayer_id", ""),
|
||||
quantity,
|
||||
finish,
|
||||
condition,
|
||||
language,
|
||||
notes,
|
||||
tags,
|
||||
set_code,
|
||||
collector_number,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
imported_count += 1
|
||||
|
||||
return {
|
||||
"importedCount": imported_count,
|
||||
"updatedCount": updated_count,
|
||||
"failedCount": failed_count,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Scryfall Cache Management Endpoints
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/api/cache/scryfall")
|
||||
async def scryfall_cache_stats():
|
||||
"""Return Scryfall response cache statistics."""
|
||||
return _scryfall_cache.stats()
|
||||
|
||||
|
||||
@router.delete("/api/cache/scryfall")
|
||||
async def scryfall_cache_clear():
|
||||
"""Clear all cached Scryfall responses."""
|
||||
deleted = _scryfall_cache.clear()
|
||||
return {"cleared": deleted, "message": f"Deleted {deleted} cached entries"}
|
||||
|
||||
|
||||
@router.post("/api/cache/scryfall/evict")
|
||||
async def scryfall_cache_evict_expired():
|
||||
"""Remove only expired entries (older than TTL) from the cache."""
|
||||
evicted = _scryfall_cache.evict_expired()
|
||||
return {"evicted": evicted, "message": f"Evicted {evicted} expired entries"}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Deck Builder Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# Type classification priority order
|
||||
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
|
||||
|
||||
# Target ranges: {type: [min, max]}
|
||||
_TYPE_TARGETS = {
|
||||
"Land": [36, 38],
|
||||
"Instant": [9, 11],
|
||||
"Sorcery": [7, 9],
|
||||
"Artifact": [9, 11],
|
||||
"Creature": [20, 30],
|
||||
"Enchantment": [5, 10],
|
||||
"Planeswalker": [0, 5],
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1601
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,562 @@
|
||||
"""
|
||||
routes/deepseek.py
|
||||
==================
|
||||
Python simulator & DeepSeek AI opponent endpoints:
|
||||
POST /api/sim/run
|
||||
GET /api/sim/status
|
||||
GET /api/sim/result
|
||||
POST /api/sim/run-from-deck
|
||||
POST /api/sim/run-deepseek
|
||||
POST /api/deepseek/connect
|
||||
GET /api/deepseek/status
|
||||
POST /api/deepseek/configure
|
||||
GET /api/deepseek/logs
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading as _threading
|
||||
import traceback
|
||||
import uuid as _uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import Request as FastAPIRequest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from routes.shared import (
|
||||
CFG, log_sim,
|
||||
_get_db_conn,
|
||||
_get_deepseek_brain,
|
||||
_ml_logging_enabled,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["deepseek"])
|
||||
|
||||
# In-memory store for simulation runs
|
||||
_sim_runs = {} # sim_id -> { status, result, error }
|
||||
_sim_lock = _threading.Lock()
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Shared Helpers (extracted to deduplicate sim threads — #42)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
_SRC_PATH_ADDED = False
|
||||
|
||||
|
||||
def _ensure_src_path():
|
||||
"""Add project src/ to sys.path once."""
|
||||
global _SRC_PATH_ADDED
|
||||
if not _SRC_PATH_ADDED:
|
||||
src_dir = str(Path(__file__).resolve().parent.parent / 'src')
|
||||
if src_dir not in sys.path:
|
||||
sys.path.insert(0, src_dir)
|
||||
_SRC_PATH_ADDED = True
|
||||
|
||||
|
||||
def _build_deck_from_card_data(card_data: list[dict]) -> list:
|
||||
"""Build a list of Card objects from DB card dicts. Used by v2 + deepseek threads."""
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.models import Card
|
||||
from commander_ai_lab.sim.rules import enrich_card
|
||||
|
||||
deck = []
|
||||
for cd in card_data:
|
||||
c = Card(name=cd['name'])
|
||||
if cd.get('type_line'):
|
||||
c.type_line = cd['type_line']
|
||||
if cd.get('cmc'):
|
||||
c.cmc = float(cd['cmc'])
|
||||
if cd.get('power') and cd.get('toughness'):
|
||||
c.power = str(cd['power'])
|
||||
c.toughness = str(cd['toughness'])
|
||||
c.pt = c.power + '/' + c.toughness
|
||||
if cd.get('oracle_text'):
|
||||
c.oracle_text = cd['oracle_text']
|
||||
if cd.get('mana_cost'):
|
||||
c.mana_cost = cd['mana_cost']
|
||||
if cd.get('keywords'):
|
||||
kw = cd['keywords']
|
||||
if isinstance(kw, str):
|
||||
try:
|
||||
kw = json.loads(kw)
|
||||
except Exception:
|
||||
kw = []
|
||||
if isinstance(kw, list):
|
||||
c.keywords = kw
|
||||
enrich_card(c)
|
||||
deck.append(c)
|
||||
return deck
|
||||
|
||||
|
||||
def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
|
||||
num_games, sim_id, **run_kwargs):
|
||||
"""Run *num_games* and accumulate stats. Returns (game_results, stats, elapsed)."""
|
||||
start = time.time()
|
||||
wins = losses = total_turns = 0
|
||||
total_damage_dealt = total_damage_received = 0
|
||||
total_spells_cast = total_creatures_played = 0
|
||||
total_removal_used = total_ramp_played = 0
|
||||
total_cards_drawn = total_max_board = 0
|
||||
game_results = []
|
||||
|
||||
for i in range(num_games):
|
||||
kw = dict(run_kwargs)
|
||||
# DeepSeek engine needs per-game IDs
|
||||
if 'game_id_prefix' in kw:
|
||||
prefix = kw.pop('game_id_prefix')
|
||||
kw['game_id'] = f'{prefix}-g{i+1}'
|
||||
result = engine.run(deck_a, deck_b, name_a=deck_name,
|
||||
name_b=opponent_name, **kw)
|
||||
game_data = result.to_dict()
|
||||
game_data['gameNumber'] = i + 1
|
||||
game_results.append(game_data)
|
||||
|
||||
if result.winner == 0:
|
||||
wins += 1
|
||||
else:
|
||||
losses += 1
|
||||
total_turns += result.turns
|
||||
|
||||
if result.player_a_stats:
|
||||
s = result.player_a_stats
|
||||
total_damage_dealt += s.damage_dealt
|
||||
total_damage_received += s.damage_received
|
||||
total_spells_cast += s.spells_cast
|
||||
total_creatures_played += s.creatures_played
|
||||
total_removal_used += s.removal_used
|
||||
total_ramp_played += s.ramp_played
|
||||
total_cards_drawn += s.cards_drawn
|
||||
total_max_board += s.max_board_size
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['completed'] = i + 1
|
||||
|
||||
elapsed = time.time() - start
|
||||
stats = dict(
|
||||
wins=wins, losses=losses, total_turns=total_turns,
|
||||
total_damage_dealt=total_damage_dealt,
|
||||
total_damage_received=total_damage_received,
|
||||
total_spells_cast=total_spells_cast,
|
||||
total_creatures_played=total_creatures_played,
|
||||
total_removal_used=total_removal_used,
|
||||
total_ramp_played=total_ramp_played,
|
||||
total_cards_drawn=total_cards_drawn,
|
||||
total_max_board=total_max_board,
|
||||
)
|
||||
return game_results, stats, elapsed
|
||||
|
||||
|
||||
def _build_summary(stats: dict, deck_name: str, opponent_name: str,
|
||||
num_games: int, elapsed: float, **extra) -> dict:
|
||||
"""Build the JSON-serialisable summary dict from accumulated stats."""
|
||||
n = num_games
|
||||
summary = {
|
||||
'deckName': deck_name,
|
||||
'opponentName': opponent_name,
|
||||
'totalGames': n,
|
||||
'wins': stats['wins'],
|
||||
'losses': stats['losses'],
|
||||
'winRate': round(stats['wins'] / n * 100, 1) if n > 0 else 0.0,
|
||||
'avgTurns': round(stats['total_turns'] / n, 1) if n > 0 else 0.0,
|
||||
'avgDamageDealt': round(stats['total_damage_dealt'] / n, 1) if n > 0 else 0.0,
|
||||
'avgDamageReceived': round(stats['total_damage_received'] / n, 1) if n > 0 else 0.0,
|
||||
'avgSpellsCast': round(stats['total_spells_cast'] / n, 1) if n > 0 else 0.0,
|
||||
'avgCreaturesPlayed': round(stats['total_creatures_played'] / n, 1) if n > 0 else 0.0,
|
||||
'avgRemovalUsed': round(stats['total_removal_used'] / n, 1) if n > 0 else 0.0,
|
||||
'avgRampPlayed': round(stats['total_ramp_played'] / n, 1) if n > 0 else 0.0,
|
||||
'avgCardsDrawn': round(stats['total_cards_drawn'] / n, 1) if n > 0 else 0.0,
|
||||
'avgMaxBoardSize': round(stats['total_max_board'] / n, 1) if n > 0 else 0.0,
|
||||
'elapsedSeconds': round(elapsed, 3),
|
||||
}
|
||||
summary.update(extra)
|
||||
return summary
|
||||
|
||||
|
||||
def _finish_sim(sim_id, summary, game_results):
|
||||
"""Mark a sim run as complete."""
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'complete'
|
||||
_sim_runs[sim_id]['result'] = {'summary': summary, 'games': game_results}
|
||||
|
||||
|
||||
def _fail_sim(sim_id, error):
|
||||
"""Mark a sim run as errored."""
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'error'
|
||||
_sim_runs[sim_id]['error'] = str(error)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Background Sim Threads (now thin wrappers around shared helpers)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _run_sim_thread(sim_id: str, decklist: list, num_games: int,
|
||||
deck_name: str, record_logs: bool):
|
||||
"""Background thread: basic Monte Carlo sim from a raw decklist."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
from commander_ai_lab.lab.experiments import build_deck, _generate_training_deck
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'running'
|
||||
|
||||
deck_a = build_deck(decklist)
|
||||
deck_b = _generate_training_deck()
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs)
|
||||
|
||||
game_results, stats, elapsed = _run_game_loop(
|
||||
engine, deck_a, deck_b, deck_name, 'Training Deck',
|
||||
num_games, sim_id)
|
||||
|
||||
summary = _build_summary(stats, deck_name, 'Training Deck',
|
||||
num_games, elapsed)
|
||||
_finish_sim(sim_id, summary, game_results)
|
||||
except Exception as e:
|
||||
_fail_sim(sim_id, e)
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int,
|
||||
deck_name: str, record_logs: bool):
|
||||
"""Background thread: sim with full card data from the DB."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
from commander_ai_lab.lab.experiments import _generate_training_deck
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'running'
|
||||
|
||||
deck_a = _build_deck_from_card_data(card_data)
|
||||
deck_b = _generate_training_deck()
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs)
|
||||
|
||||
game_results, stats, elapsed = _run_game_loop(
|
||||
engine, deck_a, deck_b, deck_name, 'Training Deck',
|
||||
num_games, sim_id)
|
||||
|
||||
summary = _build_summary(stats, deck_name, 'Training Deck',
|
||||
num_games, elapsed)
|
||||
_finish_sim(sim_id, summary, game_results)
|
||||
except Exception as e:
|
||||
_fail_sim(sim_id, e)
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
|
||||
num_games: int, deck_name: str,
|
||||
record_logs: bool):
|
||||
"""Background thread: sim using DeepSeek AI opponent."""
|
||||
try:
|
||||
_ensure_src_path()
|
||||
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
|
||||
from commander_ai_lab.lab.experiments import _generate_training_deck
|
||||
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id]['status'] = 'running'
|
||||
|
||||
deck_a = _build_deck_from_card_data(card_data)
|
||||
deck_b = _generate_training_deck()
|
||||
|
||||
brain = _get_deepseek_brain()
|
||||
if brain and not brain._connected:
|
||||
brain.check_connection()
|
||||
|
||||
engine = DeepSeekGameEngine(
|
||||
brain=brain, ai_player_index=1,
|
||||
max_turns=25, record_log=record_logs)
|
||||
|
||||
game_results, stats, elapsed = _run_game_loop(
|
||||
engine, deck_a, deck_b, deck_name, 'DeepSeek AI',
|
||||
num_games, sim_id,
|
||||
game_id_prefix=f'ds-sim-{sim_id[:8]}',
|
||||
archetype='midrange')
|
||||
|
||||
# Write ML decision JSONL for training pipeline
|
||||
ml_decisions = engine.flush_ml_decisions()
|
||||
if ml_decisions:
|
||||
ml_path = os.path.join('results',
|
||||
f'ml-decisions-sim-{sim_id[:8]}.jsonl')
|
||||
os.makedirs('results', exist_ok=True)
|
||||
with open(ml_path, 'w', encoding='utf-8') as mf:
|
||||
for dec in ml_decisions:
|
||||
mf.write(json.dumps(dec) + '\n')
|
||||
|
||||
ds_stats = brain.get_stats() if brain else {}
|
||||
summary = _build_summary(
|
||||
stats, deck_name, 'DeepSeek AI', num_games, elapsed,
|
||||
opponentType='deepseek', deepseekStats=ds_stats)
|
||||
_finish_sim(sim_id, summary, game_results)
|
||||
except Exception as e:
|
||||
_fail_sim(sim_id, e)
|
||||
traceback.print_exc()
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Simulation API Endpoints
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _create_sim_run(num_games, deck_name):
|
||||
"""Create a queued sim run entry and return its ID."""
|
||||
sim_id = str(_uuid.uuid4())[:8]
|
||||
with _sim_lock:
|
||||
_sim_runs[sim_id] = {
|
||||
'status': 'queued', 'completed': 0,
|
||||
'total': num_games, 'deckName': deck_name,
|
||||
'result': None, 'error': None,
|
||||
}
|
||||
return sim_id
|
||||
|
||||
|
||||
def _fetch_deck_card_data(deck_id):
|
||||
"""Load card data from the DB for a given deck ID. Returns (deck_name, card_data) or raises."""
|
||||
conn = _get_db_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None, None
|
||||
deck_name = row[0]
|
||||
cur.execute("""
|
||||
SELECT dc.card_name, dc.quantity,
|
||||
ce.type_line, ce.cmc, ce.power, ce.toughness,
|
||||
ce.oracle_text, ce.keywords, ce.mana_cost
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, cmc, power, toughness,
|
||||
oracle_text, keywords, mana_cost
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,))
|
||||
card_data = []
|
||||
for r in cur.fetchall():
|
||||
for _ in range(r[1] or 1):
|
||||
card_data.append({
|
||||
'name': r[0], 'type_line': r[2] or '',
|
||||
'cmc': r[3] or 0, 'power': r[4] or '',
|
||||
'toughness': r[5] or '', 'oracle_text': r[6] or '',
|
||||
'keywords': r[7] or '', 'mana_cost': r[8] or '',
|
||||
})
|
||||
return deck_name, card_data
|
||||
|
||||
|
||||
@router.post('/api/sim/run')
|
||||
async def sim_run(request: FastAPIRequest):
|
||||
"""Start a Monte Carlo simulation with the Python engine."""
|
||||
body = await request.json()
|
||||
decklist = body.get('decklist', [])
|
||||
num_games = body.get('numGames', 10)
|
||||
deck_name = body.get('deckName', 'My Deck')
|
||||
record_logs = body.get('recordLogs', True)
|
||||
|
||||
if not decklist:
|
||||
return JSONResponse({'error': 'decklist is required'}, status_code=400)
|
||||
if num_games < 1 or num_games > 1000:
|
||||
return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400)
|
||||
|
||||
sim_id = _create_sim_run(num_games, deck_name)
|
||||
_threading.Thread(target=_run_sim_thread,
|
||||
args=(sim_id, decklist, num_games, deck_name, record_logs),
|
||||
daemon=True).start()
|
||||
return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games})
|
||||
|
||||
|
||||
@router.get('/api/sim/status')
|
||||
async def sim_status(simId: str):
|
||||
"""Poll simulation progress."""
|
||||
with _sim_lock:
|
||||
run = _sim_runs.get(simId)
|
||||
if not run:
|
||||
return JSONResponse({'error': 'sim not found'}, status_code=404)
|
||||
return JSONResponse({
|
||||
'simId': simId, 'status': run['status'],
|
||||
'completed': run['completed'], 'total': run['total'],
|
||||
'deckName': run.get('deckName', ''), 'error': run.get('error'),
|
||||
})
|
||||
|
||||
|
||||
@router.get('/api/sim/result')
|
||||
async def sim_result(simId: str):
|
||||
"""Get completed simulation results."""
|
||||
with _sim_lock:
|
||||
run = _sim_runs.get(simId)
|
||||
if not run:
|
||||
return JSONResponse({'error': 'sim not found'}, status_code=404)
|
||||
if run['status'] != 'complete':
|
||||
return JSONResponse({'error': 'sim not complete', 'status': run['status']},
|
||||
status_code=400)
|
||||
return JSONResponse(run['result'])
|
||||
|
||||
|
||||
@router.post('/api/sim/run-from-deck')
|
||||
async def sim_run_from_deck(request: FastAPIRequest):
|
||||
"""Start simulation using a deck from the Deck Builder (by deck ID)."""
|
||||
body = await request.json()
|
||||
deck_id = body.get('deckId')
|
||||
num_games = body.get('numGames', 10)
|
||||
record_logs = body.get('recordLogs', True)
|
||||
|
||||
if not deck_id:
|
||||
return JSONResponse({'error': 'deckId required'}, status_code=400)
|
||||
|
||||
deck_name, card_data = _fetch_deck_card_data(deck_id)
|
||||
if deck_name is None:
|
||||
return JSONResponse({'error': 'deck not found'}, status_code=404)
|
||||
if not card_data:
|
||||
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
|
||||
|
||||
sim_id = _create_sim_run(num_games, deck_name)
|
||||
_threading.Thread(target=_run_sim_thread_v2,
|
||||
args=(sim_id, card_data, num_games, deck_name, record_logs),
|
||||
daemon=True).start()
|
||||
return JSONResponse({'simId': sim_id, 'status': 'queued',
|
||||
'total': num_games, 'deckName': deck_name})
|
||||
|
||||
|
||||
@router.post('/api/sim/run-deepseek')
|
||||
async def sim_run_deepseek(request: FastAPIRequest):
|
||||
"""Start simulation using DeepSeek AI as the opponent brain."""
|
||||
body = await request.json()
|
||||
deck_id = body.get('deckId')
|
||||
num_games = body.get('numGames', 5)
|
||||
record_logs = body.get('recordLogs', True)
|
||||
|
||||
if not deck_id:
|
||||
return JSONResponse({'error': 'deckId required'}, status_code=400)
|
||||
if num_games < 1 or num_games > 50:
|
||||
return JSONResponse({'error': 'numGames must be 1-50 for DeepSeek mode'},
|
||||
status_code=400)
|
||||
|
||||
deck_name, card_data = _fetch_deck_card_data(deck_id)
|
||||
if deck_name is None:
|
||||
return JSONResponse({'error': 'deck not found'}, status_code=404)
|
||||
if not card_data:
|
||||
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
|
||||
|
||||
sim_id = _create_sim_run(num_games, deck_name)
|
||||
_threading.Thread(target=_run_sim_thread_deepseek,
|
||||
args=(sim_id, card_data, num_games, deck_name, record_logs),
|
||||
daemon=True).start()
|
||||
return JSONResponse({'simId': sim_id, 'status': 'queued',
|
||||
'total': num_games, 'deckName': deck_name,
|
||||
'opponentType': 'deepseek'})
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# DeepSeek AI Brain Endpoints
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post('/api/deepseek/connect')
|
||||
async def deepseek_connect(request: FastAPIRequest):
|
||||
"""Test connection to the DeepSeek LLM endpoint and auto-detect model."""
|
||||
body = await request.json() if await request.body() else {}
|
||||
api_base = body.get('apiBase', None)
|
||||
model = body.get('model', None)
|
||||
|
||||
brain = _get_deepseek_brain()
|
||||
if brain is None:
|
||||
return JSONResponse({'error': 'DeepSeek brain failed to initialize'},
|
||||
status_code=500)
|
||||
|
||||
if api_base:
|
||||
brain.config.api_base = api_base
|
||||
if model:
|
||||
brain.config.model = model
|
||||
|
||||
connected = brain.check_connection()
|
||||
return JSONResponse({
|
||||
'connected': connected,
|
||||
'apiBase': brain.config.api_base,
|
||||
'model': brain.config.model,
|
||||
'stats': brain.get_stats(),
|
||||
})
|
||||
|
||||
|
||||
@router.get('/api/deepseek/status')
|
||||
async def deepseek_status():
|
||||
"""Get DeepSeek brain status and performance stats."""
|
||||
brain = _get_deepseek_brain()
|
||||
if brain is None:
|
||||
return JSONResponse({'connected': False, 'error': 'Brain not initialized'})
|
||||
return JSONResponse(brain.get_stats())
|
||||
|
||||
|
||||
@router.post('/api/deepseek/configure')
|
||||
async def deepseek_configure(request: FastAPIRequest):
|
||||
"""Update DeepSeek configuration at runtime."""
|
||||
body = await request.json()
|
||||
brain = _get_deepseek_brain()
|
||||
if brain is None:
|
||||
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
|
||||
|
||||
if 'apiBase' in body:
|
||||
brain.config.api_base = body['apiBase']
|
||||
if 'model' in body:
|
||||
brain.config.model = body['model']
|
||||
if 'temperature' in body:
|
||||
brain.config.temperature = float(body['temperature'])
|
||||
if 'maxTokens' in body:
|
||||
brain.config.max_tokens = int(body['maxTokens'])
|
||||
if 'timeout' in body:
|
||||
brain.config.request_timeout = float(body['timeout'])
|
||||
if 'cacheEnabled' in body:
|
||||
brain.config.cache_enabled = bool(body['cacheEnabled'])
|
||||
if 'logDecisions' in body:
|
||||
brain.config.log_decisions = bool(body['logDecisions'])
|
||||
if 'fallbackOnTimeout' in body:
|
||||
brain.config.fallback_on_timeout = bool(body['fallbackOnTimeout'])
|
||||
|
||||
connected = brain.check_connection()
|
||||
return JSONResponse({
|
||||
'connected': connected,
|
||||
'config': {
|
||||
'apiBase': brain.config.api_base,
|
||||
'model': brain.config.model,
|
||||
'temperature': brain.config.temperature,
|
||||
'maxTokens': brain.config.max_tokens,
|
||||
'timeout': brain.config.request_timeout,
|
||||
'cacheEnabled': brain.config.cache_enabled,
|
||||
'logDecisions': brain.config.log_decisions,
|
||||
'fallbackOnTimeout': brain.config.fallback_on_timeout,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@router.get('/api/deepseek/logs')
|
||||
async def deepseek_logs():
|
||||
"""Get decision log stats and flush pending entries."""
|
||||
brain = _get_deepseek_brain()
|
||||
if brain is None:
|
||||
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
|
||||
|
||||
pending = len(brain._decision_log)
|
||||
flushed_path = None
|
||||
if pending > 0:
|
||||
try:
|
||||
flushed_path = brain.flush_log()
|
||||
except Exception as e:
|
||||
return JSONResponse({'error': f'Failed to flush: {e}'},
|
||||
status_code=500)
|
||||
|
||||
import glob as _glob
|
||||
log_dir = brain.config.log_dir
|
||||
log_files = []
|
||||
if log_dir and os.path.isdir(log_dir):
|
||||
for f in sorted(_glob.glob(os.path.join(log_dir, 'decisions_*.jsonl'))):
|
||||
stat = os.stat(f)
|
||||
log_files.append({
|
||||
'filename': os.path.basename(f),
|
||||
'size_bytes': stat.st_size,
|
||||
'modified': stat.st_mtime,
|
||||
})
|
||||
|
||||
return JSONResponse({
|
||||
'flushed': pending,
|
||||
'flushedPath': flushed_path,
|
||||
'logFiles': log_files[-20:],
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
routes/import_routes.py
|
||||
=======================
|
||||
Deck import & commander meta endpoints:
|
||||
POST /api/lab/import/url
|
||||
POST /api/lab/import/text
|
||||
GET /api/lab/meta/commanders
|
||||
GET /api/lab/meta/search
|
||||
POST /api/lab/meta/fetch
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import routes.shared as _shared
|
||||
from routes.shared import (
|
||||
ImportUrlRequest,
|
||||
ImportTextRequest,
|
||||
MetaFetchRequest,
|
||||
_import_from_url,
|
||||
_parse_text_decklist,
|
||||
_save_profile_to_dck,
|
||||
_fetch_edhrec_average,
|
||||
log,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["import"])
|
||||
|
||||
|
||||
@router.post("/api/lab/import/url")
|
||||
async def import_from_url(req: ImportUrlRequest):
|
||||
"""Import a deck from Archidekt or EDHREC URL. Returns parsed DeckProfile + saves .dck file."""
|
||||
url = req.url.strip()
|
||||
try:
|
||||
profile = _import_from_url(url)
|
||||
dck_path = _save_profile_to_dck(profile)
|
||||
return {
|
||||
"success": True,
|
||||
"deckName": profile["name"],
|
||||
"commander": profile["commander"],
|
||||
"source": profile["source"],
|
||||
"sourceUrl": profile["sourceUrl"],
|
||||
"totalCards": profile["totalCards"],
|
||||
"dckFile": dck_path.stem,
|
||||
"colorIdentity": profile.get("colorIdentity", []),
|
||||
"archetype": profile.get("archetype"),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@router.post("/api/lab/import/text")
|
||||
async def import_from_text(req: ImportTextRequest):
|
||||
"""Import a deck from plain text card list."""
|
||||
try:
|
||||
profile = _parse_text_decklist(req.text, req.commander)
|
||||
dck_path = _save_profile_to_dck(profile)
|
||||
return {
|
||||
"success": True,
|
||||
"deckName": profile["name"],
|
||||
"commander": profile["commander"],
|
||||
"source": "Text Import",
|
||||
"totalCards": profile["totalCards"],
|
||||
"dckFile": dck_path.stem,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@router.get("/api/lab/meta/commanders")
|
||||
async def list_meta_commanders():
|
||||
"""List available commanders in the meta mapping."""
|
||||
commanders = []
|
||||
for name, entries in _shared.COMMANDER_META.items():
|
||||
entry = entries[0] if entries else {}
|
||||
commanders.append({
|
||||
"name": name,
|
||||
"archetype": entry.get("archetype", ""),
|
||||
"colorIdentity": entry.get("colorIdentity", []),
|
||||
"source": entry.get("source", "edhrec"),
|
||||
})
|
||||
return {"commanders": commanders}
|
||||
|
||||
|
||||
@router.get("/api/lab/meta/search")
|
||||
async def search_meta_commanders(q: str = ""):
|
||||
"""Search commanders by partial name match."""
|
||||
query = q.lower()
|
||||
matches = []
|
||||
for name, entries in _shared.COMMANDER_META.items():
|
||||
if query in name.lower():
|
||||
entry = entries[0] if entries else {}
|
||||
matches.append({
|
||||
"name": name,
|
||||
"archetype": entry.get("archetype", ""),
|
||||
"colorIdentity": entry.get("colorIdentity", []),
|
||||
})
|
||||
return {"results": matches}
|
||||
|
||||
|
||||
@router.post("/api/lab/meta/fetch")
|
||||
async def fetch_meta_deck(req: MetaFetchRequest):
|
||||
"""Fetch EDHREC average deck for a commander and save as .dck file."""
|
||||
try:
|
||||
profile = _fetch_edhrec_average(req.commander)
|
||||
dck_path = _save_profile_to_dck(profile)
|
||||
return {
|
||||
"success": True,
|
||||
"deckName": profile["name"],
|
||||
"commander": profile["commander"],
|
||||
"source": "EDHREC Average",
|
||||
"sourceUrl": profile.get("sourceUrl", ""),
|
||||
"totalCards": profile["totalCards"],
|
||||
"dckFile": dck_path.stem,
|
||||
"colorIdentity": profile.get("colorIdentity", []),
|
||||
"sampleSize": profile.get("sampleSize"),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Failed to fetch meta deck for '{req.commander}': {str(e)}")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Existing Endpoints (v1/v2)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
routes/lab.py
|
||||
=============
|
||||
Batch simulation & lab management endpoints:
|
||||
POST /api/lab/start
|
||||
POST /api/lab/start-deepseek
|
||||
GET /api/lab/status
|
||||
GET /api/lab/result
|
||||
GET /api/lab/decks
|
||||
GET /api/lab/history
|
||||
GET /api/lab/profiles
|
||||
GET /api/lab/profiles/{name}
|
||||
GET /api/lab/analytics/{deck_name}
|
||||
GET /api/lab/trends/{deck_name}
|
||||
GET /api/lab/log
|
||||
GET /api/lab/debug-log
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request as FastAPIRequest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
active_batches,
|
||||
BatchState,
|
||||
StartRequest,
|
||||
StartResponse,
|
||||
StatusResponse,
|
||||
AI_PROFILES,
|
||||
PRECON_DIR,
|
||||
PRECON_INDEX,
|
||||
build_java_command,
|
||||
run_batch_subprocess,
|
||||
_run_deepseek_batch_thread,
|
||||
parse_dck_file,
|
||||
_get_db_conn,
|
||||
_load_deck_cards_by_name,
|
||||
_ml_logging_enabled,
|
||||
log,
|
||||
log_batch,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["lab"])
|
||||
|
||||
|
||||
@router.post("/api/lab/start", response_model=StartResponse)
|
||||
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||
"""Start a new batch simulation run."""
|
||||
if len(req.decks) < 2 or len(req.decks) > 4:
|
||||
raise HTTPException(400, "2-4 decks required")
|
||||
if not all(req.decks):
|
||||
raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled")
|
||||
if req.numGames < 1 or req.numGames > 10000:
|
||||
raise HTTPException(400, "numGames must be 1-10000")
|
||||
if req.threads < 1 or req.threads > 16:
|
||||
raise HTTPException(400, "threads must be 1-16")
|
||||
|
||||
if not os.path.exists(CFG.forge_jar):
|
||||
raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}")
|
||||
if not os.path.isdir(CFG.forge_dir):
|
||||
raise HTTPException(500, f"Forge dir not found: {CFG.forge_dir}")
|
||||
|
||||
batch_id = str(uuid.uuid4())[:12]
|
||||
state = BatchState(batch_id, req.numGames, req.threads)
|
||||
active_batches[batch_id] = state
|
||||
|
||||
os.makedirs(CFG.results_dir, exist_ok=True)
|
||||
output_path = os.path.join(CFG.results_dir, f"batch-{batch_id}.json")
|
||||
state.result_path = output_path
|
||||
|
||||
background_tasks.add_task(
|
||||
run_batch_subprocess,
|
||||
state,
|
||||
req.decks,
|
||||
req.numGames,
|
||||
req.threads,
|
||||
req.seed,
|
||||
req.clock,
|
||||
output_path,
|
||||
req.useLearnedPolicy,
|
||||
req.policyStyle,
|
||||
req.policyGreedy,
|
||||
req.aiSimplified,
|
||||
req.aiThinkTimeMs,
|
||||
req.maxQueueDepth,
|
||||
)
|
||||
|
||||
policy_msg = ""
|
||||
if req.useLearnedPolicy:
|
||||
policy_msg = f" [Learned Policy: {req.policyStyle}]"
|
||||
return StartResponse(
|
||||
batchId=batch_id,
|
||||
status="started",
|
||||
message=f"Running {req.numGames} games with {req.threads} threads{policy_msg}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/lab/status", response_model=StatusResponse)
|
||||
async def get_status(batchId: Optional[str] = None):
|
||||
# If no batchId given, find the most recent active (or last) batch
|
||||
state = None
|
||||
if batchId:
|
||||
state = active_batches.get(batchId)
|
||||
else:
|
||||
# Return the most recently active batch, or any batch
|
||||
for s in active_batches.values():
|
||||
if s.running:
|
||||
state = s
|
||||
break
|
||||
if state is None and active_batches:
|
||||
state = list(active_batches.values())[-1]
|
||||
if not state:
|
||||
return StatusResponse(
|
||||
batchId="", running=False, completed=0, total=0,
|
||||
threads=0, elapsedMs=0, error=None, simsPerSec=0.0,
|
||||
run_id="", games_completed=0, total_games=0, current_decks=[],
|
||||
)
|
||||
elapsed = int((datetime.now() - state.start_time).total_seconds() * 1000)
|
||||
deck_names = getattr(state, 'deck_names', []) if hasattr(state, 'deck_names') else []
|
||||
return StatusResponse(
|
||||
batchId=state.batch_id,
|
||||
running=state.running,
|
||||
completed=state.completed_games,
|
||||
total=state.total_games,
|
||||
threads=state.threads,
|
||||
elapsedMs=elapsed if state.running else state.elapsed_ms,
|
||||
error=state.error,
|
||||
simsPerSec=state.sims_per_sec,
|
||||
# React SPA fields
|
||||
run_id=state.batch_id,
|
||||
games_completed=state.completed_games,
|
||||
total_games=state.total_games,
|
||||
current_decks=deck_names,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/lab/result")
|
||||
async def get_result(batchId: Optional[str] = None):
|
||||
state = None
|
||||
if batchId:
|
||||
state = active_batches.get(batchId)
|
||||
else:
|
||||
# Find most recent completed batch
|
||||
for s in reversed(list(active_batches.values())):
|
||||
if not s.running and s.result_path:
|
||||
state = s
|
||||
break
|
||||
if not state:
|
||||
raise HTTPException(404, "No batch result available")
|
||||
if state.running:
|
||||
raise HTTPException(409, "Batch still running")
|
||||
if state.error:
|
||||
raise HTTPException(500, state.error)
|
||||
if not state.result_path or not os.path.exists(state.result_path):
|
||||
raise HTTPException(500, "Result file not found")
|
||||
with open(state.result_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return JSONResponse(content=data)
|
||||
|
||||
|
||||
@router.get("/api/lab/decks")
|
||||
async def list_decks():
|
||||
decks = []
|
||||
seen_names = set()
|
||||
|
||||
# 1. Forge .dck files from decks directory
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if decks_dir and os.path.isdir(decks_dir):
|
||||
for f in sorted(Path(decks_dir).glob("*.dck")):
|
||||
deck_name = f.stem
|
||||
decks.append({"name": deck_name, "filename": f.name, "source": "forge"})
|
||||
seen_names.add(deck_name.lower())
|
||||
|
||||
# 2. Decks from the Deck Builder database
|
||||
try:
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, commander_name FROM decks ORDER BY id DESC"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
db_name = row["name"]
|
||||
if db_name.lower() not in seen_names:
|
||||
decks.append({
|
||||
"name": db_name,
|
||||
"filename": "",
|
||||
"source": "deckbuilder",
|
||||
"deck_id": row["id"],
|
||||
"commander": row["commander_name"] or "",
|
||||
})
|
||||
seen_names.add(db_name.lower())
|
||||
except Exception as e:
|
||||
log.warning(f" WARNING: Failed to load DB decks for /api/lab/decks: {e}")
|
||||
|
||||
return {"decks": decks}
|
||||
|
||||
|
||||
@router.get("/api/lab/history")
|
||||
async def list_history():
|
||||
results_dir = Path(CFG.results_dir)
|
||||
if not results_dir.exists():
|
||||
return {"results": []}
|
||||
results = []
|
||||
for f in sorted(results_dir.glob("batch-*.json"), reverse=True):
|
||||
try:
|
||||
with open(f, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
meta = data.get("metadata", {})
|
||||
decks = data.get("decks", [])
|
||||
results.append({
|
||||
"filename": f.name,
|
||||
"batchId": meta.get("batchId", ""),
|
||||
"timestamp": meta.get("timestamp", ""),
|
||||
"totalGames": meta.get("completedGames", 0),
|
||||
"threads": meta.get("threads", 1),
|
||||
"elapsedMs": meta.get("elapsedMs", 0),
|
||||
"decks": [{"name": d.get("deckName", ""), "source": d.get("source", "")} for d in decks],
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return {"results": results[:50]}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# AI Profiles
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
AI_PROFILES = {
|
||||
"default": {
|
||||
"name": "default",
|
||||
"description": "Balanced \u2014 Forge's default AI behavior",
|
||||
"aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5,
|
||||
"boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5,
|
||||
},
|
||||
"aggro": {
|
||||
"name": "aggro",
|
||||
"description": "Aggressive \u2014 attacks early, prioritizes damage",
|
||||
"aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3,
|
||||
"boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1,
|
||||
},
|
||||
"control": {
|
||||
"name": "control",
|
||||
"description": "Control \u2014 defensive, removal-heavy, card advantage",
|
||||
"aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9,
|
||||
"boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9,
|
||||
},
|
||||
"combo": {
|
||||
"name": "combo",
|
||||
"description": "Combo \u2014 ramps, digs for pieces, assembles combos",
|
||||
"aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4,
|
||||
"boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7,
|
||||
},
|
||||
"midrange": {
|
||||
"name": "midrange",
|
||||
"description": "Midrange \u2014 flexible, strong board presence, value-oriented",
|
||||
"aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6,
|
||||
"boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/api/lab/profiles")
|
||||
async def list_profiles():
|
||||
return {"profiles": list(AI_PROFILES.values())}
|
||||
|
||||
@router.get("/api/lab/profiles/{name}")
|
||||
async def get_profile(name: str):
|
||||
profile = AI_PROFILES.get(name.lower())
|
||||
if not profile:
|
||||
raise HTTPException(404, f"Profile '{name}' not found. Available: {list(AI_PROFILES.keys())}")
|
||||
return profile
|
||||
|
||||
|
||||
@router.get("/api/lab/analytics/{deck_name}")
|
||||
async def analyze_deck(deck_name: str):
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if not decks_dir or not os.path.isdir(decks_dir):
|
||||
raise HTTPException(500, f"Decks directory not found: {decks_dir}")
|
||||
deck_path = os.path.join(decks_dir, deck_name + ".dck")
|
||||
if not os.path.exists(deck_path):
|
||||
for f in os.listdir(decks_dir):
|
||||
if f.lower() == deck_name.lower() + ".dck":
|
||||
deck_path = os.path.join(decks_dir, f)
|
||||
break
|
||||
else:
|
||||
raise HTTPException(404, f"Deck not found: {deck_name}")
|
||||
try:
|
||||
analysis = parse_dck_file(deck_path)
|
||||
return analysis
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Analysis failed: {str(e)}")
|
||||
|
||||
|
||||
def parse_dck_file(deck_path: str) -> dict:
|
||||
cards = []
|
||||
commander = None
|
||||
section = "Main"
|
||||
deck_name = Path(deck_path).stem
|
||||
with open(deck_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("//"):
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
section = line[1:-1]
|
||||
continue
|
||||
if line.startswith("Name="):
|
||||
deck_name = line[5:].strip()
|
||||
continue
|
||||
m = re.match(r"^(\d+)\s+(.+?)(?:\|(.+))?$", line)
|
||||
if m:
|
||||
qty = int(m.group(1))
|
||||
name = m.group(2).strip()
|
||||
set_code = m.group(3).strip() if m.group(3) else ""
|
||||
cards.append({
|
||||
"quantity": qty, "name": name, "set": set_code,
|
||||
"section": section,
|
||||
"is_commander": 1 if section == "Commander" else 0,
|
||||
})
|
||||
if section == "Commander":
|
||||
commander = name
|
||||
total = sum(c["quantity"] for c in cards)
|
||||
return {
|
||||
"deckName": deck_name,
|
||||
"commanderName": commander,
|
||||
"totalCards": total,
|
||||
"cardCount": len(cards),
|
||||
"cards": cards[:200],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/lab/trends/{deck_name}")
|
||||
async def get_deck_trends(deck_name: str):
|
||||
results_dir = Path(CFG.results_dir)
|
||||
if not results_dir.exists():
|
||||
return {"deckName": deck_name, "history": []}
|
||||
history = []
|
||||
for f in sorted(results_dir.glob("batch-*.json")):
|
||||
try:
|
||||
with open(f, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
summary = data.get("summary", {})
|
||||
meta = data.get("metadata", {})
|
||||
per_deck = summary.get("perDeck", [])
|
||||
for ds in per_deck:
|
||||
if ds.get("deckName", "").lower() == deck_name.lower():
|
||||
history.append({
|
||||
"batchId": meta.get("batchId"),
|
||||
"timestamp": meta.get("timestamp"),
|
||||
"winRate": ds.get("winRate", 0),
|
||||
"wins": ds.get("wins", 0),
|
||||
"losses": ds.get("losses", 0),
|
||||
"draws": ds.get("draws", 0),
|
||||
"totalGames": meta.get("completedGames", 0),
|
||||
})
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
return {"deckName": deck_name, "history": history}
|
||||
|
||||
|
||||
@router.get("/api/lab/log")
|
||||
async def get_log(batchId: str):
|
||||
state = active_batches.get(batchId)
|
||||
if not state:
|
||||
raise HTTPException(404, f"Batch {batchId} not found")
|
||||
return {"lines": state.log_lines[-200:]}
|
||||
|
||||
|
||||
@router.get("/api/lab/debug-log")
|
||||
async def get_debug_log():
|
||||
"""Return the raw Forge subprocess debug log for diagnosing simulation issues."""
|
||||
# Check multiple possible locations for the debug log
|
||||
candidates = []
|
||||
if CFG.forge_dir:
|
||||
candidates.append(Path(CFG.forge_dir).parent / "forge-sim-debug.log")
|
||||
candidates.append(Path(CFG.forge_dir) / "forge-sim-debug.log")
|
||||
candidates.append(Path("forge-sim-debug.log"))
|
||||
|
||||
for log_path in candidates:
|
||||
if log_path.exists():
|
||||
text = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
# Return last 50KB to avoid huge responses
|
||||
if len(text) > 50000:
|
||||
text = "... (truncated) ...\n" + text[-50000:]
|
||||
return {"path": str(log_path), "content": text}
|
||||
|
||||
return {"path": None, "content": "No debug log found. Run a simulation first."}
|
||||
|
||||
|
||||
|
||||
@router.post('/api/lab/start-deepseek')
|
||||
async def start_batch_deepseek(request: FastAPIRequest, background_tasks: BackgroundTasks):
|
||||
"""Start a batch simulation using the Python sim engine + DeepSeek LLM opponent."""
|
||||
body = await request.json()
|
||||
decks = body.get('decks', [])
|
||||
num_games = body.get('numGames', 30)
|
||||
# Note: threads not applicable for Python sim (single-threaded LLM calls)
|
||||
|
||||
if not decks or len(decks) < 1:
|
||||
raise HTTPException(400, 'At least 1 deck required')
|
||||
if num_games < 1 or num_games > 500:
|
||||
raise HTTPException(400, 'numGames must be 1-500')
|
||||
|
||||
# Filter empty deck slots
|
||||
decks = [d for d in decks if d]
|
||||
if not decks:
|
||||
raise HTTPException(400, 'No valid decks provided')
|
||||
|
||||
batch_id = str(uuid.uuid4())[:12]
|
||||
state = BatchState(batch_id, num_games, 1)
|
||||
active_batches[batch_id] = state
|
||||
|
||||
os.makedirs(CFG.results_dir, exist_ok=True)
|
||||
output_path = os.path.join(CFG.results_dir, f'batch-{batch_id}.json')
|
||||
state.result_path = output_path
|
||||
|
||||
# Use threading instead of BackgroundTasks to avoid async issues with DB
|
||||
t = threading.Thread(
|
||||
target=_run_deepseek_batch_thread,
|
||||
args=(state, decks, num_games, output_path),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
return JSONResponse({
|
||||
'batchId': batch_id,
|
||||
'status': 'started',
|
||||
'message': f'Running {num_games} games across {len(decks)} decks with DeepSeek AI',
|
||||
'engine': 'deepseek',
|
||||
})
|
||||
|
||||
|
||||
# Global ML logging toggle (can be enabled via API)
|
||||
_ml_logging_enabled = False
|
||||
|
||||
+626
@@ -0,0 +1,626 @@
|
||||
"""
|
||||
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."}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
routes/precon.py
|
||||
================
|
||||
Precon deck management endpoints:
|
||||
GET /api/lab/precons
|
||||
POST /api/lab/precons/install
|
||||
POST /api/lab/precons/install-batch
|
||||
POST /api/lab/precons/refresh
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
import routes.shared as _shared # module reference, not value import
|
||||
from routes.shared import CFG, PRECON_DIR, download_precon_database, log
|
||||
|
||||
router = APIRouter(tags=["precon"])
|
||||
|
||||
|
||||
@router.get("/api/lab/precons")
|
||||
async def list_precons():
|
||||
"""List all available precon decks."""
|
||||
return {"precons": _shared.PRECON_INDEX} # reads current value every call
|
||||
|
||||
|
||||
@router.post("/api/lab/precons/install")
|
||||
async def install_precon(req: dict):
|
||||
"""Install a precon deck to the Forge decks directory.
|
||||
Body: {"fileName": "Elven_Empire.dck"}
|
||||
"""
|
||||
file_name = req.get("fileName", "")
|
||||
if not file_name:
|
||||
raise HTTPException(400, "fileName is required")
|
||||
|
||||
src = PRECON_DIR / file_name
|
||||
if not src.exists():
|
||||
raise HTTPException(404, f"Precon not found: {file_name}")
|
||||
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if not decks_dir or not os.path.isdir(decks_dir):
|
||||
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
|
||||
|
||||
dst = Path(decks_dir) / file_name
|
||||
shutil.copy2(str(src), str(dst))
|
||||
|
||||
deck_name = file_name.replace(".dck", "")
|
||||
return {
|
||||
"installed": True,
|
||||
"deckName": deck_name,
|
||||
"destination": str(dst),
|
||||
"message": f"Installed {deck_name} to Forge decks",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/lab/precons/install-batch")
|
||||
async def install_precons_batch(req: dict):
|
||||
"""Install multiple precon decks at once.
|
||||
Body: {"fileNames": ["Elven_Empire.dck", "Necron_Dynasties.dck"]}
|
||||
"""
|
||||
file_names = req.get("fileNames", [])
|
||||
if not file_names:
|
||||
raise HTTPException(400, "fileNames list is required")
|
||||
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if not decks_dir or not os.path.isdir(decks_dir):
|
||||
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
|
||||
|
||||
results = []
|
||||
for file_name in file_names:
|
||||
src = PRECON_DIR / file_name
|
||||
if not src.exists():
|
||||
results.append({"fileName": file_name, "installed": False, "error": "not found"})
|
||||
continue
|
||||
dst = Path(decks_dir) / file_name
|
||||
shutil.copy2(str(src), str(dst))
|
||||
deck_name = file_name.replace(".dck", "")
|
||||
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
|
||||
|
||||
return {"results": results}
|
||||
|
||||
|
||||
@router.post("/api/lab/precons/refresh")
|
||||
async def refresh_precons():
|
||||
"""Force re-download of all Commander precon decks from GitHub."""
|
||||
result = download_precon_database(force=True)
|
||||
if result.get("error"):
|
||||
raise HTTPException(502, result["error"])
|
||||
return {
|
||||
"message": f"Downloaded {result['downloaded']} Commander precon decks",
|
||||
"total": result.get("total", 0),
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
"""
|
||||
routes/scanner.py
|
||||
=================
|
||||
Card scanner & collection enrichment endpoints:
|
||||
POST /api/collection/scan
|
||||
POST /api/collection/scan/add
|
||||
POST /api/collection/re-enrich
|
||||
POST /api/collection/auto-classify
|
||||
POST /api/collection/auto-classify-all
|
||||
GET /api/collection/{cardId}/edhrec
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import Request as FastAPIRequest
|
||||
|
||||
from routes.shared import (
|
||||
CFG, log_scan, log_deckgen,
|
||||
_get_db_conn, _row_to_dict, _enrich_from_scryfall,
|
||||
_scryfall_fuzzy_lookup, _detect_card_roles,
|
||||
_to_edhrec_slug, _API_HEADERS,
|
||||
_edhrec_cache_get, _edhrec_cache_set,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["scanner"])
|
||||
|
||||
|
||||
@router.post("/api/collection/scan")
|
||||
async def scan_card_image(request: FastAPIRequest):
|
||||
"""
|
||||
Scan a card image and return recognized card name(s).
|
||||
|
||||
Uses Ximilar Visual AI for card recognition.
|
||||
|
||||
Accepts multipart/form-data with:
|
||||
- file: image file (JPEG/PNG)
|
||||
- mode: "single" (default) or "multi"
|
||||
|
||||
Returns:
|
||||
{ "results": [ { raw_ocr, matched_name, set_code, scryfall_id, confidence, image_uri, error, collector_number, rarity, tcgplayer_url }, ... ] }
|
||||
"""
|
||||
try:
|
||||
from scanner.pipeline import scan_single, scan_multi
|
||||
except ImportError as e:
|
||||
raise HTTPException(500, f"Scanner module not available: {e}")
|
||||
|
||||
# Try CFG first, then fall back to env var
|
||||
api_key = CFG.ximilar_api_key or os.environ.get("XIMILAR_API_KEY", "")
|
||||
if not api_key:
|
||||
raise HTTPException(500, "Card scanner not configured. Start the server with --ximilar-key YOUR_KEY or set XIMILAR_API_KEY env var.")
|
||||
|
||||
# Parse multipart form
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None:
|
||||
raise HTTPException(400, "No file uploaded. Send an image as 'file' in multipart/form-data.")
|
||||
|
||||
mode = str(form.get("mode", "single")).lower()
|
||||
image_bytes = await upload.read()
|
||||
|
||||
if len(image_bytes) == 0:
|
||||
raise HTTPException(400, "Uploaded file is empty")
|
||||
|
||||
# Limit file size (20 MB)
|
||||
if len(image_bytes) > 20 * 1024 * 1024:
|
||||
raise HTTPException(400, "Image file too large (max 20 MB)")
|
||||
|
||||
log_scan.info(f" Processing {len(image_bytes)} bytes, mode={mode} (Ximilar AI)")
|
||||
|
||||
if mode == "multi":
|
||||
results = scan_multi(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
|
||||
else:
|
||||
result = scan_single(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
|
||||
results = [result]
|
||||
|
||||
return {
|
||||
"results": [r.to_dict() for r in results]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/collection/scan/add")
|
||||
async def scan_add_cards(body: dict):
|
||||
"""
|
||||
Add scanned cards to the collection.
|
||||
|
||||
Body:
|
||||
{
|
||||
"cards": [
|
||||
{ "name": "Lightning Bolt", "set_code": "m11", "quantity": 1 },
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
cards = body.get("cards", [])
|
||||
if not cards:
|
||||
raise HTTPException(400, "No cards provided")
|
||||
|
||||
conn = _get_db_conn()
|
||||
imported = 0
|
||||
updated = 0
|
||||
errors = []
|
||||
|
||||
for card_req in cards:
|
||||
name = str(card_req.get("name", "")).strip()
|
||||
if not name:
|
||||
continue
|
||||
|
||||
quantity = int(card_req.get("quantity", 1))
|
||||
set_code = str(card_req.get("set_code", "")).lower()
|
||||
collector_number = str(card_req.get("collector_number", ""))
|
||||
finish = "NORMAL"
|
||||
|
||||
try:
|
||||
enriched = _enrich_from_scryfall(name, set_code, collector_number)
|
||||
except Exception as e:
|
||||
errors.append(f"Scryfall error for '{name}': {e}")
|
||||
continue
|
||||
|
||||
if not enriched or "_error" in enriched:
|
||||
err_msg = enriched.get("_error", f"Not found: '{name}'") if enriched else f"Not found: '{name}'"
|
||||
errors.append(err_msg)
|
||||
continue
|
||||
|
||||
resolved_name = enriched.get("name", name)
|
||||
|
||||
# Check for existing entry
|
||||
existing = conn.execute(
|
||||
"""SELECT id, quantity FROM collection_entries
|
||||
WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""",
|
||||
(resolved_name, set_code, collector_number, finish),
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
new_qty = existing["quantity"] + quantity
|
||||
conn.execute(
|
||||
"UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(new_qty, existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
updated += 1
|
||||
else:
|
||||
conn.execute(
|
||||
"""INSERT INTO collection_entries
|
||||
(name, type_line, subtypes, is_legendary, is_basic, color_identity,
|
||||
cmc, mana_cost, oracle_text, keywords,
|
||||
power, toughness, rarity, set_name, edhrec_rank,
|
||||
tcg_price, salt_score, is_game_changer,
|
||||
category, scryfall_id, tcgplayer_id,
|
||||
quantity, finish, condition, language, notes, tags,
|
||||
set_code, collector_number)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
resolved_name,
|
||||
enriched.get("type_line", ""),
|
||||
enriched.get("subtypes", "[]"),
|
||||
enriched.get("is_legendary", 0),
|
||||
enriched.get("is_basic", 0),
|
||||
enriched.get("color_identity", "[]"),
|
||||
enriched.get("cmc", 0.0),
|
||||
enriched.get("mana_cost", ""),
|
||||
enriched.get("oracle_text", ""),
|
||||
enriched.get("keywords", "[]"),
|
||||
enriched.get("power", ""),
|
||||
enriched.get("toughness", ""),
|
||||
enriched.get("rarity", ""),
|
||||
enriched.get("set_name", ""),
|
||||
enriched.get("edhrec_rank", 0),
|
||||
enriched.get("tcg_price", 0.0),
|
||||
enriched.get("salt_score", 0.0),
|
||||
enriched.get("is_game_changer", 0),
|
||||
enriched.get("category", "[]"),
|
||||
enriched.get("scryfall_id", ""),
|
||||
enriched.get("tcgplayer_id", ""),
|
||||
quantity,
|
||||
finish,
|
||||
"", # condition
|
||||
"", # language
|
||||
"", # notes
|
||||
"", # tags
|
||||
set_code,
|
||||
collector_number,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
imported += 1
|
||||
|
||||
return {
|
||||
"importedCount": imported,
|
||||
"updatedCount": updated,
|
||||
"failedCount": len(errors),
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Re-enrich Collection from Scryfall
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
@router.post("/api/collection/re-enrich")
|
||||
async def re_enrich_collection():
|
||||
"""
|
||||
Re-fetch Scryfall data for all collection entries to backfill
|
||||
missing fields (mana_cost, power, toughness, rarity, set_name, edhrec_rank).
|
||||
Also refreshes: type_line, oracle_text, keywords, cmc, color_identity, prices.
|
||||
Runs synchronously — may take a while for large collections.
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, set_code, collector_number FROM collection_entries ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
total = len(rows)
|
||||
enriched_count = 0
|
||||
skipped_count = 0
|
||||
errors = []
|
||||
|
||||
for row in rows:
|
||||
card_id = row["id"]
|
||||
name = row["name"]
|
||||
set_code = row["set_code"] or ""
|
||||
collector_number = row["collector_number"] or ""
|
||||
|
||||
try:
|
||||
data = _enrich_from_scryfall(name, set_code, collector_number)
|
||||
except Exception as e:
|
||||
errors.append(f"Error for '{name}': {e}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
if not data or "_error" in data:
|
||||
err_msg = data.get("_error", f"Not found: '{name}'") if data else f"Not found: '{name}'"
|
||||
errors.append(err_msg)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_entries SET
|
||||
type_line = ?, subtypes = ?, is_legendary = ?, is_basic = ?,
|
||||
color_identity = ?, cmc = ?, mana_cost = ?,
|
||||
oracle_text = ?, keywords = ?,
|
||||
power = ?, toughness = ?,
|
||||
rarity = ?, set_name = ?, edhrec_rank = ?,
|
||||
tcg_price = ?, scryfall_id = ?, tcgplayer_id = ?,
|
||||
category = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?""",
|
||||
(
|
||||
data.get("type_line", ""),
|
||||
data.get("subtypes", "[]"),
|
||||
data.get("is_legendary", 0),
|
||||
data.get("is_basic", 0),
|
||||
data.get("color_identity", "[]"),
|
||||
data.get("cmc", 0.0),
|
||||
data.get("mana_cost", ""),
|
||||
data.get("oracle_text", ""),
|
||||
data.get("keywords", "[]"),
|
||||
data.get("power", ""),
|
||||
data.get("toughness", ""),
|
||||
data.get("rarity", ""),
|
||||
data.get("set_name", ""),
|
||||
data.get("edhrec_rank", 0),
|
||||
data.get("tcg_price", 0.0),
|
||||
data.get("scryfall_id", ""),
|
||||
data.get("tcgplayer_id", ""),
|
||||
data.get("category", "[]"),
|
||||
card_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
enriched_count += 1
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"enrichedCount": enriched_count,
|
||||
"skippedCount": skipped_count,
|
||||
"errors": errors[:50], # cap error list
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Auto-Classify Collection
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
@router.post("/api/collection/auto-classify")
|
||||
async def auto_classify_collection():
|
||||
"""
|
||||
Run auto-classification on all collection entries.
|
||||
Uses oracle_text, type_line, and keywords to detect functional roles
|
||||
(Ramp, Draw, Removal, Board Wipe, Anthem, Stax, etc.).
|
||||
Only updates cards whose current category is empty or '[]'.
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT id, oracle_text, type_line, keywords, category FROM collection_entries ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
total = len(rows)
|
||||
classified_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for row in rows:
|
||||
card_id = row["id"]
|
||||
existing_cats = row["category"] or "[]"
|
||||
|
||||
# Parse existing categories
|
||||
try:
|
||||
cats = json.loads(existing_cats)
|
||||
except Exception:
|
||||
cats = []
|
||||
|
||||
# Skip cards that already have manually-set categories
|
||||
if cats:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
oracle_text = row["oracle_text"] or ""
|
||||
type_line = row["type_line"] or ""
|
||||
keywords = row["keywords"] or "[]"
|
||||
|
||||
roles = _detect_card_roles(oracle_text, type_line, keywords)
|
||||
|
||||
if roles:
|
||||
conn.execute(
|
||||
"UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(json.dumps(roles), card_id),
|
||||
)
|
||||
classified_count += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"classifiedCount": classified_count,
|
||||
"skippedCount": skipped_count,
|
||||
"message": f"Classified {classified_count} cards, skipped {skipped_count} (already had categories)",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/collection/auto-classify-all")
|
||||
async def auto_classify_all_collection():
|
||||
"""
|
||||
Force re-classify ALL collection entries, overwriting existing categories.
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT id, oracle_text, type_line, keywords FROM collection_entries ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
total = len(rows)
|
||||
classified_count = 0
|
||||
|
||||
for row in rows:
|
||||
card_id = row["id"]
|
||||
oracle_text = row["oracle_text"] or ""
|
||||
type_line = row["type_line"] or ""
|
||||
keywords = row["keywords"] or "[]"
|
||||
|
||||
roles = _detect_card_roles(oracle_text, type_line, keywords)
|
||||
conn.execute(
|
||||
"UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(json.dumps(roles), card_id),
|
||||
)
|
||||
classified_count += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"classifiedCount": classified_count,
|
||||
"message": f"Re-classified all {classified_count} cards",
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# EDHREC Recommendations Cache + Endpoint
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/api/collection/{cardId}/edhrec")
|
||||
async def get_card_edhrec(cardId: int):
|
||||
"""
|
||||
Get EDHREC recommendations for a collection card.
|
||||
For legendary creatures (potential commanders): fetch commander synergy data.
|
||||
For other cards: fetch 'also played with' data.
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Card with id {cardId} not found")
|
||||
|
||||
card = _row_to_dict(row)
|
||||
name = card["name"]
|
||||
type_line = card.get("type_line", "")
|
||||
is_legendary = card.get("is_legendary", 0)
|
||||
|
||||
# Determine if it's a legendary creature (potential commander)
|
||||
is_commander = bool(
|
||||
is_legendary and
|
||||
re.search(r"\bCreature\b", type_line, re.IGNORECASE)
|
||||
)
|
||||
|
||||
slug = _to_edhrec_slug(name)
|
||||
cache_key = f"edhrec:{'cmd' if is_commander else 'card'}:{slug}"
|
||||
|
||||
cached = _edhrec_cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
recommendations = []
|
||||
links = {
|
||||
"edhrecPage": (
|
||||
f"https://edhrec.com/commanders/{slug}"
|
||||
if is_commander
|
||||
else f"https://edhrec.com/cards/{slug}"
|
||||
),
|
||||
"archidektSearch": f"https://archidekt.com/search/cards?q={name.replace(' ', '+')}",
|
||||
"moxfieldSearch": f"https://www.moxfield.com/search?q={name.replace(' ', '+')}",
|
||||
}
|
||||
|
||||
try:
|
||||
if is_commander:
|
||||
url = f"https://json.edhrec.com/pages/commanders/{slug}.json"
|
||||
else:
|
||||
url = f"https://json.edhrec.com/pages/cards/{slug}.json"
|
||||
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
if is_commander:
|
||||
# Extract top cards from cardlists
|
||||
container = data.get("container", {})
|
||||
json_dict = container.get("json_dict", {})
|
||||
for cardlist in json_dict.get("cardlists", []):
|
||||
tag = cardlist.get("tag", "")
|
||||
for cv in cardlist.get("cardviews", [])[:10]:
|
||||
rec_name = cv.get("name", "")
|
||||
if not rec_name:
|
||||
continue
|
||||
synergy = cv.get("synergy", 0.0)
|
||||
recommendations.append({
|
||||
"name": rec_name,
|
||||
"synergy": synergy,
|
||||
"role": tag or "Recommended",
|
||||
})
|
||||
else:
|
||||
# Extract "also played with" cards
|
||||
container = data.get("container", {})
|
||||
json_dict = container.get("json_dict", {})
|
||||
for cardlist in json_dict.get("cardlists", []):
|
||||
tag = cardlist.get("tag", "")
|
||||
for cv in cardlist.get("cardviews", [])[:10]:
|
||||
rec_name = cv.get("name", "")
|
||||
if not rec_name:
|
||||
continue
|
||||
synergy = cv.get("synergy", 0.0)
|
||||
recommendations.append({
|
||||
"name": rec_name,
|
||||
"synergy": synergy,
|
||||
"role": tag or "Also Played With",
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_deckgen.error(f" Error fetching data for '{name}': {e}")
|
||||
# Graceful degradation — return empty results
|
||||
|
||||
result = {
|
||||
"recommendations": recommendations[:30],
|
||||
"links": links,
|
||||
}
|
||||
_edhrec_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
routes/shared.py
|
||||
================
|
||||
Single source of truth for everything every router needs.
|
||||
|
||||
Contains:
|
||||
- Config class + CFG singleton
|
||||
- Logging setup + all named loggers
|
||||
- FastAPI app instance
|
||||
- In-memory state (active_batches, COMMANDER_META, PRECON_INDEX, AI_PROFILES)
|
||||
- All Pydantic request/response models
|
||||
- SQLite DB helpers (_get_db_conn, init_collection_db)
|
||||
- Scryfall cache + enrichment helpers
|
||||
- Collection helpers (_detect_card_roles, _classify_card_type,
|
||||
_build_collection_filters, _parse_finish, _parse_csv_content,
|
||||
_parse_text_line, _auto_infer_mapping, _row_to_dict, _add_image_url,
|
||||
_snake_to_camel, VALID_SORT_FIELDS, _JSON_FIELDS)
|
||||
- Deck helpers (_get_deck_or_404, _compute_deck_analysis, _check_ratio_limit,
|
||||
_TYPE_TARGETS, _TYPE_PRIORITY, _classify_card_type)
|
||||
- Import helpers (_import_from_url, _fetch_archidekt_deck,
|
||||
_fetch_edhrec_average, _parse_text_decklist, _save_profile_to_dck,
|
||||
_to_edhrec_slug, _http_get, _API_HEADERS, _edhrec_cache_get,
|
||||
_edhrec_cache_set)
|
||||
- Precon helpers (PRECON_DIR, PRECON_INDEX, download_precon_database,
|
||||
load_precon_index, GITHUB_PRECON_URL, PRECON_CACHE_HOURS)
|
||||
- Sim helpers (build_java_command, get_java17, BatchState, parse_dck_file,
|
||||
AI_PROFILES, run_batch_subprocess, _run_process_blocking,
|
||||
_run_deepseek_batch_thread, _load_deck_cards_by_name, _get_deepseek_brain)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request as FastAPIRequest
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Configuration
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# Configuration (imported from models/state)
|
||||
from models.state import Config, CFG
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Logging Setup
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
_LOG_FORMAT = "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
|
||||
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
|
||||
_LOG_MAX_BYTES = int(os.environ.get("CAL_LOG_MAX_MB", "25")) * 1024 * 1024
|
||||
_LOG_BACKUP_COUNT = int(os.environ.get("CAL_LOG_BACKUP_COUNT", "5"))
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO) -> None:
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
root_logger = logging.getLogger("commander_ai_lab")
|
||||
root_logger.setLevel(level)
|
||||
if root_logger.handlers:
|
||||
return
|
||||
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATE_FMT)
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setLevel(level)
|
||||
console.setFormatter(formatter)
|
||||
root_logger.addHandler(console)
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
_LOG_DIR / "commander-ai-lab.log",
|
||||
maxBytes=_LOG_MAX_BYTES,
|
||||
backupCount=_LOG_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(level)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
log_batch = logging.getLogger("commander_ai_lab.batch")
|
||||
log_sim = logging.getLogger("commander_ai_lab.sim")
|
||||
log_coach = logging.getLogger("commander_ai_lab.coach")
|
||||
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
|
||||
log_collect = logging.getLogger("commander_ai_lab.collection")
|
||||
log_scan = logging.getLogger("commander_ai_lab.scanner")
|
||||
log_ml = logging.getLogger("commander_ai_lab.ml")
|
||||
log_cache = logging.getLogger("commander_ai_lab.cache")
|
||||
log_pplx = logging.getLogger("commander_ai_lab.pplx")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# In-Memory State
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# In-Memory State (imported from models/state)
|
||||
from models.state import (
|
||||
BatchState, active_batches, COMMANDER_META,
|
||||
BUILTIN_COMMANDERS, load_commander_meta,
|
||||
)
|
||||
|
||||
|
||||
# Pydantic Models (imported from models/)
|
||||
from models.requests import (
|
||||
StartRequest, ImportUrlRequest, ImportTextRequest, MetaFetchRequest,
|
||||
CreateDeckRequest, UpdateDeckRequest, AddDeckCardRequest,
|
||||
PatchDeckCardRequest, BulkAddRequest, BulkAddRecommendedRequest,
|
||||
DeckGenerationSourceConfig, DeckGenerationRequest,
|
||||
DeckGenV3Request, DeckGenV3SubstituteRequest,
|
||||
)
|
||||
from models.responses import (
|
||||
StartResponse, StatusResponse, DeckInfo, GeneratedDeckCard,
|
||||
)
|
||||
|
||||
|
||||
# Collection Database (imported from services/database.py)
|
||||
from services.database import (
|
||||
COLLECTION_DB_PATH, _get_db_conn, init_collection_db,
|
||||
_JSON_FIELDS, VALID_SORT_FIELDS,
|
||||
_row_to_dict, _snake_to_camel, _add_image_url, _build_collection_filters,
|
||||
)
|
||||
# Card Role Detection (imported from services/card_analysis.py)
|
||||
from services.card_analysis import _detect_card_roles
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Deck Helpers (imported from services/deck_service.py)
|
||||
from services.deck_service import (
|
||||
_TYPE_PRIORITY, _TYPE_TARGETS, _classify_card_type, _get_deck_or_404,
|
||||
_compute_deck_analysis, _check_ratio_limit, _to_edhrec_slug,
|
||||
parse_dck_file, _save_profile_to_dck, _load_deck_cards_by_name,
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Scryfall Cache (imported from services/scryfall.py)
|
||||
from services.scryfall import (
|
||||
SCRYFALL_CACHE_DB_PATH, SCRYFALL_CACHE_TTL_SECONDS, _API_HEADERS,
|
||||
_ScryfallCache, _scryfall_cache, _scryfall_lock, _scryfall_last_call,
|
||||
_scryfall_rate_limit, _fetch_scryfall_api, _enrich_from_scryfall, _scryfall_fuzzy_lookup,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Import Helpers (extracted to services/import_helpers.py)
|
||||
from services.import_helpers import (
|
||||
_http_get, _fetch_archidekt_deck, _fetch_edhrec_average,
|
||||
_import_from_url, _parse_text_decklist, _save_profile_to_dck,
|
||||
_edhrec_cache, _EDHREC_CACHE_TTL, _edhrec_cache_get, _edhrec_cache_set,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Import Helpers (extracted to services/import_service.py)
|
||||
from services.import_service import (
|
||||
_parse_finish, _parse_text_line, _auto_infer_mapping, _parse_csv_content,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Precon Helpers (extracted to services/precon_service.py)
|
||||
from services.precon_service import (
|
||||
PRECON_DIR, PRECON_INDEX, GITHUB_PRECON_URL, PRECON_CACHE_HOURS,
|
||||
load_precon_index, _sanitize_filename, _deck_to_dck, download_precon_database,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# AI Profiles (imported from models/state)
|
||||
from models.state import AI_PROFILES
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Java / Sim Helpers (extracted to services/forge_runner.py)
|
||||
from services.forge_runner import (
|
||||
_find_java17, get_java17, build_java_command, parse_dck_file,
|
||||
run_batch_subprocess, _run_process_blocking,
|
||||
_run_deepseek_batch_thread, _get_deepseek_brain,
|
||||
)
|
||||
|
||||
_ml_logging_enabled = False
|
||||
@@ -0,0 +1 @@
|
||||
"""Services for Commander AI Lab."""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Card role detection, type classification, and deck composition analysis."""
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
|
||||
roles = []
|
||||
ot = (oracle_text or "").lower()
|
||||
tl = (type_line or "").lower()
|
||||
kw_raw = keywords or []
|
||||
if isinstance(kw_raw, str):
|
||||
try:
|
||||
kw_raw = json.loads(kw_raw)
|
||||
except Exception:
|
||||
kw_raw = []
|
||||
kw = " ".join(str(k).lower() for k in kw_raw)
|
||||
|
||||
if ("add {" in ot or "add one mana" in ot
|
||||
or ("search your library for a" in ot and "land" in ot)
|
||||
or "treasure token" in ot or "add mana" in ot):
|
||||
roles.append("Ramp")
|
||||
if ("draw a card" in ot or "draw cards" in ot or "draws a card" in ot
|
||||
or "draw two cards" in ot or "draw three cards" in ot
|
||||
or re.search(r"draw \d+ cards?", ot)
|
||||
or ("whenever" in ot and "draw" in ot)):
|
||||
roles.append("Draw")
|
||||
if ("destroy target" in ot or "exile target" in ot
|
||||
or re.search(r"deals? \d+ damage to (target|any target)", ot)
|
||||
or re.search(r"deals? x damage to (target|any target)", ot)
|
||||
or "target creature gets -" in ot or "fights target" in ot):
|
||||
roles.append("Removal")
|
||||
if ("destroy all" in ot or "exile all" in ot
|
||||
or re.search(r"all creatures get -\d+/-\d+", ot)
|
||||
or re.search(r"each creature gets -\d+/-\d+", ot)
|
||||
or ("deals" in ot and "to each creature" in ot)):
|
||||
roles.append("Board Wipe")
|
||||
if "search your library" in ot and "land" not in ot:
|
||||
roles.append("Tutor")
|
||||
if ("counter target" in ot or "counter that" in ot
|
||||
or "counters" in kw):
|
||||
roles.append("Counter")
|
||||
if ("create" in ot and ("token" in ot or "tokens" in ot)):
|
||||
roles.append("Token")
|
||||
if ("each creature you control gets" in ot or "creatures you control get" in ot
|
||||
or "all creatures you control" in ot or "anthem" in tl.lower()):
|
||||
roles.append("Anthem")
|
||||
if ("target creature gains" in ot and ("hexproof" in ot or "indestructible" in ot or "shroud" in ot))\
|
||||
or ("protection" in kw):
|
||||
roles.append("Protection")
|
||||
if ("sacrifice" in ot and ("whenever" in ot or "you may" in ot)):
|
||||
roles.append("Sacrifice")
|
||||
if ("return" in ot and ("from your graveyard" in ot and "onto the battlefield" in ot)
|
||||
or ("put" in ot and "from a graveyard" in ot and "onto the battlefield" in ot)):
|
||||
roles.append("Recursion")
|
||||
if (("graveyard" in ot and ("mill" in ot or "put" in ot and "into" in ot and "graveyard" in ot))
|
||||
or "mill" in kw):
|
||||
roles.append("Graveyard")
|
||||
if ("gain" in ot and "life" in ot) or "lifelink" in ot or "lifelink" in kw:
|
||||
roles.append("Lifegain")
|
||||
if (re.search(r"deals? \d+ damage to (each|any|target) (opponent|player)", ot)
|
||||
or "each opponent loses" in ot and "life" in ot
|
||||
or "deals damage to each opponent" in ot):
|
||||
roles.append("Burn")
|
||||
if ("can't cast" in ot or "can't attack" in ot or "can't activate" in ot
|
||||
or ("enters the battlefield tapped" in ot and "opponents" in ot)
|
||||
or "each player can't" in ot or "players can't" in ot
|
||||
or ("cost {" in ot and "more to cast" in ot)):
|
||||
roles.append("Stax")
|
||||
if ("flying" in kw or "trample" in kw or "menace" in kw or "shadow" in kw
|
||||
or "fear" in kw or "intimidate" in kw
|
||||
or "can't be blocked" in ot or "unblockable" in ot):
|
||||
roles.append("Evasion")
|
||||
if ("you win the game" in ot or "extra turn" in ot or "infinite" in ot
|
||||
or "loses the game" in ot
|
||||
or ("damage equal to" in ot and ("number" in ot or "total" in ot))):
|
||||
roles.append("Finisher")
|
||||
if ("untap all" in ot or ("copy" in ot and "spell" in ot)
|
||||
or "take an extra" in ot
|
||||
or ("double" in ot and ("damage" in ot or "counters" in ot or "tokens" in ot))):
|
||||
roles.append("Combo")
|
||||
return roles
|
||||
@@ -0,0 +1,267 @@
|
||||
"""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
|
||||
@@ -0,0 +1,194 @@
|
||||
"""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]
|
||||
@@ -0,0 +1,141 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""URL-based deck import helpers: Archidekt, EDHREC, text decklist parsing."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
from models.state import CFG
|
||||
from services.deck_service import _to_edhrec_slug
|
||||
from services.scryfall import _API_HEADERS
|
||||
|
||||
log_collect = logging.getLogger("commander_ai_lab.collection")
|
||||
|
||||
|
||||
def _http_get(url: str) -> str:
|
||||
with urlopen(Request(url, headers=_API_HEADERS), timeout=30) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
|
||||
|
||||
def _fetch_archidekt_deck(deck_id: str) -> dict:
|
||||
url = f"https://archidekt.com/api/decks/{deck_id}/"
|
||||
data = json.loads(_http_get(url))
|
||||
profile = {
|
||||
"name": data.get("name", f"Archidekt {deck_id}"),
|
||||
"commander": None,
|
||||
"source": "Archidekt",
|
||||
"sourceUrl": f"https://archidekt.com/decks/{deck_id}",
|
||||
"commanders": {},
|
||||
"mainboard": {},
|
||||
"colorIdentity": [],
|
||||
"totalCards": 0,
|
||||
}
|
||||
for card_entry in data.get("cards", []):
|
||||
qty = card_entry.get("quantity", 1)
|
||||
oracle = card_entry.get("card", {}).get("oracleCard", {})
|
||||
card_name = oracle.get("name", "Unknown")
|
||||
is_commander = any(c.lower() == "commander" for c in card_entry.get("categories", []))
|
||||
if is_commander:
|
||||
profile["commanders"][card_name] = qty
|
||||
if not profile["commander"]:
|
||||
profile["commander"] = card_name
|
||||
if oracle.get("colorIdentity"):
|
||||
profile["colorIdentity"] = oracle["colorIdentity"]
|
||||
else:
|
||||
profile["mainboard"][card_name] = qty
|
||||
profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values())
|
||||
if not profile["name"] or profile["name"] == f"Archidekt {deck_id}":
|
||||
profile["name"] = f"{profile['commander']} \u2014 Archidekt"
|
||||
return profile
|
||||
|
||||
|
||||
def _fetch_edhrec_average(commander_name: str) -> dict:
|
||||
slug = _to_edhrec_slug(commander_name)
|
||||
url = f"https://json.edhrec.com/pages/average-decks/{slug}.json"
|
||||
data = json.loads(_http_get(url))
|
||||
container = data.get("container", {})
|
||||
json_dict = container.get("json_dict", {})
|
||||
card_info = json_dict.get("card", {})
|
||||
real_name = card_info.get("name", commander_name.title())
|
||||
profile = {
|
||||
"name": f"{real_name} \u2014 EDHREC Average",
|
||||
"commander": real_name,
|
||||
"source": "EDHREC Average",
|
||||
"sourceUrl": f"https://edhrec.com/average-decks/{slug}",
|
||||
"commanders": {real_name: 1},
|
||||
"mainboard": {},
|
||||
"colorIdentity": card_info.get("color_identity", []),
|
||||
"sampleSize": data.get("num_decks_avg"),
|
||||
"totalCards": 0,
|
||||
}
|
||||
for cl in json_dict.get("cardlists", []):
|
||||
for cv in cl.get("cardviews", []):
|
||||
card_name = cv.get("name", "")
|
||||
if card_name:
|
||||
profile["mainboard"][card_name] = 1
|
||||
archidekt_data = data.get("archidekt", [])
|
||||
if archidekt_data:
|
||||
basic_names = []
|
||||
for cl in json_dict.get("cardlists", []):
|
||||
if cl.get("tag") == "basics":
|
||||
for cv in cl.get("cardviews", []):
|
||||
basic_names.append(cv.get("name", ""))
|
||||
basic_quantities = [e["q"] for e in archidekt_data if e.get("q", 1) > 1]
|
||||
for i, name in enumerate(basic_names):
|
||||
if i < len(basic_quantities):
|
||||
profile["mainboard"][name] = basic_quantities[i]
|
||||
profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values())
|
||||
return profile
|
||||
|
||||
|
||||
def _import_from_url(url: str) -> dict:
|
||||
url = url.strip()
|
||||
if "archidekt.com/decks/" in url:
|
||||
m = re.search(r"/decks/(\d+)", url)
|
||||
if not m:
|
||||
raise ValueError("Could not parse Archidekt deck ID from URL")
|
||||
return _fetch_archidekt_deck(m.group(1))
|
||||
if "edhrec.com/average-decks/" in url:
|
||||
slug = re.search(r"/average-decks/([^/?#]+)", url).group(1)
|
||||
return _fetch_edhrec_average(slug.replace("-", " "))
|
||||
if "edhrec.com/commanders/" in url:
|
||||
slug = re.search(r"/commanders/([^/?#]+)", url).group(1)
|
||||
return _fetch_edhrec_average(slug.replace("-", " "))
|
||||
raise ValueError(f"Unsupported URL: {url}. Supported: archidekt.com/decks/..., edhrec.com/average-decks/..., edhrec.com/commanders/...")
|
||||
|
||||
|
||||
def _parse_text_decklist(text: str, commander_override: str = None) -> dict:
|
||||
profile = {
|
||||
"name": "Text Import",
|
||||
"commander": None,
|
||||
"source": "Text Import",
|
||||
"sourceUrl": None,
|
||||
"commanders": {},
|
||||
"mainboard": {},
|
||||
"totalCards": 0,
|
||||
}
|
||||
section = "main"
|
||||
for line in text.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("//") or line.startswith("#"):
|
||||
continue
|
||||
if line.lower().startswith("commander") or line == "[Commander]":
|
||||
section = "commander"; continue
|
||||
if line.lower().startswith("main") or line.lower().startswith("deck") or line == "[Main]":
|
||||
section = "main"; continue
|
||||
if line.lower().startswith("sideboard") or line == "[Sideboard]":
|
||||
section = "sideboard"; continue
|
||||
clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip()
|
||||
clean = re.sub(r"\s*\*.*$", "", clean).strip()
|
||||
m = re.match(r"^(\d+)x?\s+(.+)$", clean)
|
||||
qty, card_name = (int(m.group(1)), m.group(2).strip()) if m else (1, clean)
|
||||
if not card_name:
|
||||
continue
|
||||
if section == "commander":
|
||||
profile["commanders"][card_name] = qty
|
||||
if not profile["commander"]:
|
||||
profile["commander"] = card_name
|
||||
elif section != "sideboard":
|
||||
profile["mainboard"][card_name] = qty
|
||||
if commander_override:
|
||||
profile["commander"] = commander_override
|
||||
if not profile["commanders"]:
|
||||
if commander_override in profile["mainboard"]:
|
||||
qty = profile["mainboard"].pop(commander_override)
|
||||
profile["commanders"][commander_override] = qty
|
||||
else:
|
||||
profile["commanders"][commander_override] = 1
|
||||
if profile["commander"]:
|
||||
profile["name"] = f"{profile['commander']} \u2014 Text Import"
|
||||
profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values())
|
||||
return profile
|
||||
|
||||
|
||||
def _save_profile_to_dck(profile: dict) -> Path:
|
||||
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
|
||||
for name, qty in profile.get("commanders", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
lines += ["", "[Main]"]
|
||||
for name, qty in profile.get("mainboard", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
content = "\n".join(lines)
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", profile.get("name", "imported")).replace(" ", "_").strip()
|
||||
if not safe_name:
|
||||
safe_name = "imported_deck"
|
||||
save_dir = CFG.forge_decks_dir
|
||||
if not save_dir or not os.path.isdir(save_dir):
|
||||
save_dir = os.path.join(Path(__file__).parent.parent, "imported-decks")
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
out_path = Path(save_dir) / f"{safe_name}.dck"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
log_collect.info(f" Saved .dck: {out_path}")
|
||||
return out_path
|
||||
|
||||
|
||||
# EDHREC in-memory cache
|
||||
_edhrec_cache: dict = {}
|
||||
_EDHREC_CACHE_TTL = 3600
|
||||
|
||||
|
||||
def _edhrec_cache_get(key: str):
|
||||
entry = _edhrec_cache.get(key)
|
||||
if not entry:
|
||||
return None
|
||||
if time.time() - entry["ts"] > _EDHREC_CACHE_TTL:
|
||||
del _edhrec_cache[key]
|
||||
return None
|
||||
return entry["data"]
|
||||
|
||||
|
||||
def _edhrec_cache_set(key: str, data):
|
||||
_edhrec_cache[key] = {"data": data, "ts": time.time()}
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Collection import service: CSV/text parsing helpers."""
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _parse_finish(raw: str) -> str:
|
||||
if not raw:
|
||||
return "NORMAL"
|
||||
r = raw.strip().lower()
|
||||
if r in ("etched", "foil etched", "foil_etched", "etched foil"):
|
||||
return "ETCHED"
|
||||
if r in ("yes", "foil", "true", "1"):
|
||||
return "FOIL"
|
||||
return "NORMAL"
|
||||
|
||||
|
||||
def _parse_text_line(line: str) -> dict:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("//"):
|
||||
return {}
|
||||
result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""}
|
||||
qty_match = re.match(r"^(\d+)x?\s+", line)
|
||||
if qty_match:
|
||||
result["quantity"] = int(qty_match.group(1))
|
||||
line = line[qty_match.end():]
|
||||
bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line)
|
||||
if bracket_match:
|
||||
result["set_code"] = bracket_match.group(1).lower()
|
||||
result["collector_number"] = bracket_match.group(2)
|
||||
result["name"] = line[:bracket_match.start()].strip()
|
||||
return result
|
||||
paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line)
|
||||
if paren_coll_match:
|
||||
result["set_code"] = paren_coll_match.group(1).lower()
|
||||
result["collector_number"] = paren_coll_match.group(2)
|
||||
result["name"] = line[:paren_coll_match.start()].strip()
|
||||
return result
|
||||
paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line)
|
||||
if paren_match:
|
||||
result["set_code"] = paren_match.group(1).lower()
|
||||
result["name"] = line[:paren_match.start()].strip()
|
||||
return result
|
||||
result["name"] = line.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _auto_infer_mapping(headers: list) -> dict:
|
||||
mapping = {}
|
||||
header_lower = {h: h.lower().strip() for h in headers}
|
||||
field_patterns = {
|
||||
"name": ["name", "card name", "card_name", "cardname", "title"],
|
||||
"quantity": ["quantity", "qty", "count", "amount", "number", "#"],
|
||||
"set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"],
|
||||
"collector_number": ["collector number", "collector_number", "collectornumber", "col #", "col#", "number", "card number"],
|
||||
"finish": ["finish", "foil", "printing", "treatment"],
|
||||
"condition": ["condition", "cond", "grade"],
|
||||
"language": ["language", "lang", "locale"],
|
||||
"notes": ["notes", "note", "comments", "comment"],
|
||||
"tags": ["tags", "tag", "labels", "label"],
|
||||
}
|
||||
for header, lower_h in header_lower.items():
|
||||
for field, patterns in field_patterns.items():
|
||||
if lower_h in patterns:
|
||||
mapping[header] = field
|
||||
break
|
||||
return mapping
|
||||
|
||||
|
||||
def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list:
|
||||
rows = []
|
||||
if (source or "").upper() == "TEXT":
|
||||
for line in content.splitlines():
|
||||
parsed = _parse_text_line(line)
|
||||
if parsed.get("name"):
|
||||
rows.append(parsed)
|
||||
return rows
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
headers = reader.fieldnames or []
|
||||
col_map = {h: (mapping.get(h, "") if mapping else "") for h in headers}
|
||||
if not mapping:
|
||||
col_map = _auto_infer_mapping(headers)
|
||||
col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()}
|
||||
for csv_row in reader:
|
||||
row = {"name": "", "quantity": 1, "set_code": "", "collector_number": "",
|
||||
"finish": "NORMAL", "condition": "", "language": "", "notes": "", "tags": ""}
|
||||
for header, field in col_map.items():
|
||||
val = csv_row.get(header, "").strip()
|
||||
if not val or field == "ignore" or not field:
|
||||
continue
|
||||
if field == "name":
|
||||
val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip()
|
||||
row["name"] = val
|
||||
elif field == "quantity":
|
||||
try:
|
||||
row["quantity"] = int(float(val))
|
||||
except ValueError:
|
||||
row["quantity"] = 1
|
||||
elif field in ("set_code", "set_code_secondary"):
|
||||
row["set_code"] = val.lower()
|
||||
elif field in ("collector_number", "collector_number_secondary"):
|
||||
row["collector_number"] = val
|
||||
elif field == "finish":
|
||||
row["finish"] = _parse_finish(val)
|
||||
elif field in ("condition", "language", "notes", "tags"):
|
||||
row[field] = val
|
||||
if row["name"]:
|
||||
rows.append(row)
|
||||
return rows
|
||||
@@ -0,0 +1,125 @@
|
||||
"""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}
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Scryfall API client and cache service."""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
# Lazy import to avoid circular dependency
|
||||
def _detect_card_roles(oracle_text, type_line, keywords):
|
||||
from services.card_analysis import _detect_card_roles as _dcr
|
||||
return _dcr(oracle_text, type_line, keywords)
|
||||
|
||||
|
||||
SCRYFALL_CACHE_DB_PATH = Path(__file__).parent.parent / "scryfall_cache.db"
|
||||
SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600))
|
||||
|
||||
_API_HEADERS = {"User-Agent": "CommanderAILab/3.0", "Accept": "application/json"}
|
||||
|
||||
|
||||
class _ScryfallCache:
|
||||
def __init__(self, db_path: Path = None, ttl_seconds: int = None):
|
||||
self._db_path = db_path or SCRYFALL_CACHE_DB_PATH
|
||||
self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS
|
||||
self._lock = threading.Lock()
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scryfall_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
json_blob TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _make_key(name: str, set_code: str, collector_number: str) -> str:
|
||||
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
|
||||
|
||||
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT json_blob FROM scryfall_cache WHERE cache_key = ?", (key,)
|
||||
).fetchone()
|
||||
if row:
|
||||
self._hits += 1
|
||||
return json.loads(row[0])
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
def put(self, name: str, set_code: str, collector_number: str, data: dict):
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
blob = json.dumps(data)
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob) VALUES (?, ?)",
|
||||
(key, blob),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def stats(self) -> dict:
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
return {"cached": count, "hits": self._hits, "misses": self._misses}
|
||||
|
||||
def clear(self) -> int:
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
self._conn.execute("DELETE FROM scryfall_cache")
|
||||
self._conn.commit()
|
||||
self._conn.execute("VACUUM")
|
||||
self._hits = 0; self._misses = 0
|
||||
return count
|
||||
|
||||
def evict_expired(self) -> int:
|
||||
cutoff = datetime.datetime.utcnow().isoformat()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"DELETE FROM scryfall_cache WHERE (julianday(?) - julianday(fetched_at)) * 86400 > ?",
|
||||
(cutoff, self._ttl),
|
||||
)
|
||||
deleted = self._conn.total_changes
|
||||
self._conn.commit()
|
||||
return deleted
|
||||
|
||||
|
||||
_scryfall_cache = _ScryfallCache()
|
||||
_scryfall_lock = threading.Lock()
|
||||
_scryfall_last_call = 0.0
|
||||
|
||||
|
||||
def _scryfall_rate_limit():
|
||||
global _scryfall_last_call
|
||||
with _scryfall_lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - _scryfall_last_call
|
||||
if elapsed < 0.1:
|
||||
time.sleep(0.1 - elapsed)
|
||||
_scryfall_last_call = time.monotonic()
|
||||
|
||||
|
||||
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
from urllib.parse import quote
|
||||
cached = _scryfall_cache.get(name, set_code, collector_number)
|
||||
if cached is not None:
|
||||
return cached
|
||||
card_data = None
|
||||
last_error = ""
|
||||
if set_code and collector_number:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}"
|
||||
with urlopen(Request(url, headers=_API_HEADERS), timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e); card_data = None
|
||||
if not card_data:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
url = f"https://api.scryfall.com/cards/named?exact={quote(name)}"
|
||||
with urlopen(Request(url, headers=_API_HEADERS), timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
if not card_data or card_data.get("object") == "error":
|
||||
err_detail = card_data.get("details", "unknown error") if card_data else last_error
|
||||
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
|
||||
_scryfall_cache.put(name, set_code, collector_number, card_data)
|
||||
resolved_name = card_data.get("name", "")
|
||||
if resolved_name and resolved_name.lower() != name.strip().lower():
|
||||
_scryfall_cache.put(resolved_name, set_code, collector_number, card_data)
|
||||
return card_data
|
||||
|
||||
|
||||
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
card_data = _fetch_scryfall_api(name, set_code, collector_number)
|
||||
if not card_data or "_error" in card_data:
|
||||
return card_data or {}
|
||||
type_line = card_data.get("type_line", "")
|
||||
color_identity = card_data.get("color_identity", [])
|
||||
keywords = card_data.get("keywords", [])
|
||||
subtypes = []
|
||||
for sep in [" \u2014 ", " - "]:
|
||||
if sep in type_line:
|
||||
subtypes = [s.strip() for s in type_line.split(sep, 1)[1].split() if s.strip()]
|
||||
break
|
||||
is_legendary = 1 if "Legendary" in type_line else 0
|
||||
is_basic = 1 if "Basic" in type_line else 0
|
||||
prices = card_data.get("prices", {})
|
||||
try:
|
||||
tcg_price = float(prices.get("usd") or prices.get("usd_foil") or "0") or 0.0
|
||||
except (ValueError, TypeError):
|
||||
tcg_price = 0.0
|
||||
oracle_text = card_data.get("oracle_text", "")
|
||||
if not oracle_text and card_data.get("card_faces"):
|
||||
oracle_text = "\n//\n".join(f.get("oracle_text", "") for f in card_data["card_faces"] if f.get("oracle_text"))
|
||||
power = card_data.get("power", "")
|
||||
toughness = card_data.get("toughness", "")
|
||||
if not power and card_data.get("card_faces"):
|
||||
power = card_data["card_faces"][0].get("power", "")
|
||||
toughness = card_data["card_faces"][0].get("toughness", "")
|
||||
mana_cost = card_data.get("mana_cost", "")
|
||||
if not mana_cost and card_data.get("card_faces"):
|
||||
mana_cost = card_data["card_faces"][0].get("mana_cost", "")
|
||||
auto_roles = _detect_card_roles(oracle_text, type_line, keywords)
|
||||
return {
|
||||
"name": card_data.get("name", name),
|
||||
"type_line": type_line,
|
||||
"subtypes": json.dumps(subtypes),
|
||||
"is_legendary": is_legendary,
|
||||
"is_basic": is_basic,
|
||||
"color_identity": json.dumps(color_identity),
|
||||
"cmc": card_data.get("cmc", 0.0),
|
||||
"mana_cost": mana_cost,
|
||||
"oracle_text": oracle_text,
|
||||
"keywords": json.dumps(keywords),
|
||||
"power": power or "",
|
||||
"toughness": toughness or "",
|
||||
"rarity": card_data.get("rarity", ""),
|
||||
"set_name": card_data.get("set_name", ""),
|
||||
"edhrec_rank": card_data.get("edhrec_rank", 0) or 0,
|
||||
"tcg_price": tcg_price,
|
||||
"salt_score": 0.0,
|
||||
"is_game_changer": 0,
|
||||
"category": json.dumps(auto_roles),
|
||||
"scryfall_id": card_data.get("id", ""),
|
||||
"tcgplayer_id": str(card_data.get("tcgplayer_id", "")),
|
||||
}
|
||||
|
||||
|
||||
def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
|
||||
"""Fuzzy-search Scryfall for a card name. Returns raw Scryfall JSON dict, or None on failure."""
|
||||
from urllib.parse import quote
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
encoded = quote(name)
|
||||
url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}"
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
if data.get("object") == "error":
|
||||
return None
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Commander AI Lab — Pygame Prototype GUI (Phase 4)
|
||||
"""
|
||||
from commander_ai_lab.gui.constants import SCREEN_W, SCREEN_H, FPS
|
||||
from commander_ai_lab.gui.board import BoardRenderer
|
||||
from commander_ai_lab.gui.card_renderer import CardRenderer
|
||||
from commander_ai_lab.gui.hud import HUD
|
||||
from commander_ai_lab.gui.narration_panel import NarrationPanel
|
||||
from commander_ai_lab.gui.game_window import GameWindow
|
||||
|
||||
__all__ = [
|
||||
"SCREEN_W", "SCREEN_H", "FPS",
|
||||
"BoardRenderer", "CardRenderer", "HUD", "NarrationPanel", "GameWindow",
|
||||
]
|
||||
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
Commander AI Lab — Board Renderer (Phase 4)
|
||||
============================================
|
||||
Layouts and renders the 4-player split-screen board.
|
||||
|
||||
Layout (1280 x 960):
|
||||
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Player 2 (top — opponent) │
|
||||
├──────────┬───────────────────┬───────────────┤
|
||||
│ │ STACK / CENTER │ │
|
||||
│ Player 3 │ │ Player 1 │
|
||||
│ (left) │ │ (right) │
|
||||
├──────────┴───────────────────┴───────────────┤
|
||||
│ Player 0 (bottom — human / seat 0) │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
Each quadrant contains:
|
||||
hand zone | battlefield (creatures / non-creatures) | graveyard | exile | command zone
|
||||
|
||||
The narration panel occupies the far right strip (270 px wide).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import pygame
|
||||
_PYGAME_AVAILABLE = True
|
||||
except ImportError:
|
||||
_PYGAME_AVAILABLE = False
|
||||
|
||||
from commander_ai_lab.gui.constants import (
|
||||
SCREEN_W, SCREEN_H,
|
||||
CARD_W, CARD_H,
|
||||
COLOUR_BG, COLOUR_ZONE_BG, COLOUR_ZONE_BORDER,
|
||||
COLOUR_TEXT, COLOUR_TEXT_DIM, COLOUR_HIGHLIGHT,
|
||||
COLOUR_ELIMINATED, COLOUR_THINKING,
|
||||
SEAT_COLOURS,
|
||||
NARRATION_X, NARRATION_W,
|
||||
PHASE_BAR_H,
|
||||
)
|
||||
from commander_ai_lab.gui.card_renderer import CardRenderer
|
||||
|
||||
# Width available to the board (narration panel occupies the right strip)
|
||||
_BOARD_W = NARRATION_X - 4
|
||||
# Quadrant dimensions
|
||||
_TOP_H = 220
|
||||
_BOTTOM_H = 220
|
||||
_MID_H = SCREEN_H - _TOP_H - _BOTTOM_H - PHASE_BAR_H
|
||||
_SIDE_W = 200
|
||||
_CENTER_W = _BOARD_W - 2 * _SIDE_W
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZoneRect:
|
||||
"""Pixel rectangle for a named zone."""
|
||||
name: str
|
||||
x: int
|
||||
y: int
|
||||
w: int
|
||||
h: int
|
||||
|
||||
def contains(self, px: int, py: int) -> bool:
|
||||
return self.x <= px < self.x + self.w and self.y <= py < self.y + self.h
|
||||
|
||||
|
||||
@dataclass
|
||||
class CardHit:
|
||||
"""Result of a click hit-test: which card in which zone."""
|
||||
seat: int
|
||||
zone: str # 'hand', 'battlefield', 'graveyard', 'exile', 'command'
|
||||
card_index: int # index in the list
|
||||
card: dict
|
||||
|
||||
|
||||
class BoardRenderer:
|
||||
"""
|
||||
Manages zone layout and renders the full 4-player board each frame.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
card_renderer : CardRenderer
|
||||
Shared card drawing helper.
|
||||
human_seat : int
|
||||
Seat whose hand is shown face-up (bottom quadrant).
|
||||
"""
|
||||
|
||||
# Seat -> screen quadrant mapping
|
||||
# seat 0 = bottom, 1 = right, 2 = top, 3 = left
|
||||
_SEAT_QUADRANT = {0: "bottom", 1: "right", 2: "top", 3: "left"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
card_renderer: CardRenderer,
|
||||
human_seat: int = 0,
|
||||
) -> None:
|
||||
self.cr = card_renderer
|
||||
self.human_seat = human_seat
|
||||
|
||||
# Highlighted card (legal target indicator)
|
||||
self._highlighted: Optional[CardHit] = None
|
||||
# Selected card in hand (waiting for target)
|
||||
self._selected: Optional[CardHit] = None
|
||||
|
||||
# Zone rects computed once
|
||||
self._zones: dict[int, dict[str, ZoneRect]] = {}
|
||||
self._build_zones()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(
|
||||
self,
|
||||
surface,
|
||||
game_state,
|
||||
active_seat: int,
|
||||
thinking_seats: set[int],
|
||||
highlighted_cards: set[tuple] | None = None, # {(seat, zone, idx)}
|
||||
) -> None:
|
||||
"""
|
||||
Render the full board onto surface for the current frame.
|
||||
|
||||
Args:
|
||||
surface: Main pygame display surface.
|
||||
game_state: CommanderGameState (or compatible stub).
|
||||
active_seat: Seat whose turn it is (highlighted border).
|
||||
thinking_seats: Set of seat indices currently running AI inference.
|
||||
highlighted_cards: Cards to show with a highlight ring.
|
||||
"""
|
||||
if not _PYGAME_AVAILABLE:
|
||||
return
|
||||
|
||||
highlighted_cards = highlighted_cards or set()
|
||||
surface.fill(COLOUR_BG)
|
||||
|
||||
for seat, player in enumerate(game_state.players):
|
||||
self._render_player_zones(
|
||||
surface, seat, player, active_seat,
|
||||
thinking_seats, highlighted_cards,
|
||||
)
|
||||
|
||||
self._render_stack_zone(surface, game_state)
|
||||
self._render_center_dividers(surface)
|
||||
|
||||
def hit_test(
|
||||
self,
|
||||
px: int,
|
||||
py: int,
|
||||
game_state,
|
||||
) -> Optional[CardHit]:
|
||||
"""
|
||||
Return the CardHit under pixel (px, py), or None.
|
||||
Used by the input handler to resolve clicks.
|
||||
"""
|
||||
for seat, zones in self._zones.items():
|
||||
player = game_state.players[seat]
|
||||
for zone_name, zone_rect in zones.items():
|
||||
if not zone_rect.contains(px, py):
|
||||
continue
|
||||
cards = self._get_zone_cards(player, zone_name)
|
||||
idx = self._card_index_at(px, py, zone_rect, cards)
|
||||
if idx is not None:
|
||||
return CardHit(
|
||||
seat=seat,
|
||||
zone=zone_name,
|
||||
card_index=idx,
|
||||
card=cards[idx],
|
||||
)
|
||||
return None
|
||||
|
||||
def set_highlighted(self, hit: Optional[CardHit]) -> None:
|
||||
self._highlighted = hit
|
||||
|
||||
def set_selected(self, hit: Optional[CardHit]) -> None:
|
||||
self._selected = hit
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Zone Layout
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_zones(self) -> None:
|
||||
"""Pre-compute zone rects for all 4 seats."""
|
||||
# seat 0: bottom quadrant
|
||||
self._zones[0] = self._zones_for_bottom()
|
||||
# seat 1: right quadrant
|
||||
self._zones[1] = self._zones_for_right()
|
||||
# seat 2: top quadrant
|
||||
self._zones[2] = self._zones_for_top()
|
||||
# seat 3: left quadrant
|
||||
self._zones[3] = self._zones_for_left()
|
||||
|
||||
def _zones_for_bottom(self) -> dict[str, ZoneRect]:
|
||||
y_bf = SCREEN_H - _BOTTOM_H - PHASE_BAR_H
|
||||
y_hand = SCREEN_H - CARD_H - 20 - PHASE_BAR_H
|
||||
return {
|
||||
"battlefield": ZoneRect("battlefield", _SIDE_W, y_bf, _CENTER_W, _BOTTOM_H - CARD_H - 24),
|
||||
"hand": ZoneRect("hand", 0, y_hand, _BOARD_W - CARD_W * 2 - 8, CARD_H + 8),
|
||||
"graveyard": ZoneRect("graveyard", _BOARD_W - CARD_W * 2 - 4, y_bf, CARD_W + 4, CARD_H + 4),
|
||||
"exile": ZoneRect("exile", _BOARD_W - CARD_W - 2, y_bf, CARD_W + 2, CARD_H + 4),
|
||||
"command": ZoneRect("command", _BOARD_W - CARD_W - 2, y_hand, CARD_W + 2, CARD_H + 4),
|
||||
}
|
||||
|
||||
def _zones_for_top(self) -> dict[str, ZoneRect]:
|
||||
return {
|
||||
"battlefield": ZoneRect("battlefield", _SIDE_W, PHASE_BAR_H + 8, _CENTER_W, _TOP_H - CARD_H - 16),
|
||||
"hand": ZoneRect("hand", _SIDE_W, PHASE_BAR_H + _TOP_H - CARD_H - 8, _CENTER_W, CARD_H + 8),
|
||||
"graveyard": ZoneRect("graveyard", _BOARD_W - CARD_W - 2, PHASE_BAR_H + 4, CARD_W + 2, CARD_H + 4),
|
||||
"exile": ZoneRect("exile", _BOARD_W - CARD_W * 2 - 4, PHASE_BAR_H + 4, CARD_W + 4, CARD_H + 4),
|
||||
"command": ZoneRect("command", 4, PHASE_BAR_H + 4, CARD_W + 2, CARD_H + 4),
|
||||
}
|
||||
|
||||
def _zones_for_left(self) -> dict[str, ZoneRect]:
|
||||
mid_y = _TOP_H + PHASE_BAR_H
|
||||
return {
|
||||
"battlefield": ZoneRect("battlefield", 4, mid_y, _SIDE_W - 4, _MID_H),
|
||||
"hand": ZoneRect("hand", 4, mid_y + _MID_H - CARD_H - 8, _SIDE_W - 4, CARD_H + 8),
|
||||
"graveyard": ZoneRect("graveyard", 4, mid_y + 4, CARD_W + 2, CARD_H + 4),
|
||||
"exile": ZoneRect("exile", CARD_W + 8, mid_y + 4, CARD_W + 2, CARD_H + 4),
|
||||
"command": ZoneRect("command", 4, mid_y + CARD_H + 12, CARD_W + 2, CARD_H + 4),
|
||||
}
|
||||
|
||||
def _zones_for_right(self) -> dict[str, ZoneRect]:
|
||||
mid_y = _TOP_H + PHASE_BAR_H
|
||||
rx = _BOARD_W - _SIDE_W + 4
|
||||
return {
|
||||
"battlefield": ZoneRect("battlefield", rx, mid_y, _SIDE_W - 8, _MID_H),
|
||||
"hand": ZoneRect("hand", rx, mid_y + _MID_H - CARD_H - 8, _SIDE_W - 8, CARD_H + 8),
|
||||
"graveyard": ZoneRect("graveyard", rx, mid_y + 4, CARD_W + 2, CARD_H + 4),
|
||||
"exile": ZoneRect("exile", rx + CARD_W + 4, mid_y + 4, CARD_W + 2, CARD_H + 4),
|
||||
"command": ZoneRect("command", rx, mid_y + CARD_H + 12, CARD_W + 2, CARD_H + 4),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rendering helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _render_player_zones(
|
||||
self,
|
||||
surface,
|
||||
seat: int,
|
||||
player,
|
||||
active_seat: int,
|
||||
thinking_seats: set[int],
|
||||
highlighted_cards: set,
|
||||
) -> None:
|
||||
import pygame
|
||||
seat_col = SEAT_COLOURS[seat % len(SEAT_COLOURS)]
|
||||
is_active = (seat == active_seat)
|
||||
is_elim = getattr(player, "eliminated", False)
|
||||
is_think = seat in thinking_seats
|
||||
|
||||
zones = self._zones.get(seat, {})
|
||||
|
||||
for zone_name, zr in zones.items():
|
||||
bg_col = COLOUR_ZONE_BG
|
||||
border_col = COLOUR_ELIMINATED if is_elim else (
|
||||
COLOUR_THINKING if is_think else (
|
||||
seat_col if is_active else COLOUR_ZONE_BORDER
|
||||
)
|
||||
)
|
||||
border_w = 2 if is_active or is_think else 1
|
||||
|
||||
rect = pygame.Rect(zr.x, zr.y, zr.w, zr.h)
|
||||
pygame.draw.rect(surface, bg_col, rect, border_radius=4)
|
||||
pygame.draw.rect(surface, border_col, rect, width=border_w, border_radius=4)
|
||||
|
||||
# Zone label
|
||||
# (font rendering omitted here — HUD handles text labels)
|
||||
|
||||
# Draw cards
|
||||
cards = self._get_zone_cards(player, zone_name)
|
||||
face_up = (seat == self.human_seat or zone_name in ("battlefield", "graveyard", "command"))
|
||||
|
||||
for idx, card in enumerate(cards):
|
||||
cx, cy = self._card_position(zr, idx, len(cards))
|
||||
is_hl = (seat, zone_name, idx) in highlighted_cards
|
||||
|
||||
if face_up:
|
||||
self.cr.draw_card(surface, card, cx, cy, highlighted=is_hl)
|
||||
else:
|
||||
self.cr.draw_card_back(surface, cx, cy, label=str(len(cards)))
|
||||
break # only draw one face-down back per hidden hand
|
||||
|
||||
# Player name tag
|
||||
# (rendered by HUD.render_player_tags)
|
||||
|
||||
def _render_stack_zone(self, surface, game_state) -> None:
|
||||
"""Render the stack in the center of the board."""
|
||||
import pygame
|
||||
from commander_ai_lab.gui.constants import (
|
||||
ZONE_STACK_X, ZONE_STACK_Y, ZONE_STACK_W,
|
||||
)
|
||||
stack = getattr(game_state, "stack", [])
|
||||
if not stack:
|
||||
return
|
||||
rect = pygame.Rect(ZONE_STACK_X, ZONE_STACK_Y, ZONE_STACK_W, min(len(stack) * 20 + 16, 180))
|
||||
pygame.draw.rect(surface, COLOUR_ZONE_BG, rect, border_radius=4)
|
||||
pygame.draw.rect(surface, COLOUR_HIGHLIGHT, rect, width=2, border_radius=4)
|
||||
|
||||
def _render_center_dividers(self, surface) -> None:
|
||||
"""Draw subtle lines separating the four quadrants."""
|
||||
import pygame
|
||||
col = COLOUR_ZONE_BORDER
|
||||
# Horizontal dividers
|
||||
y_top_end = _TOP_H + PHASE_BAR_H
|
||||
y_bot_start = SCREEN_H - _BOTTOM_H - PHASE_BAR_H
|
||||
pygame.draw.line(surface, col, (0, y_top_end), (_BOARD_W, y_top_end), 1)
|
||||
pygame.draw.line(surface, col, (0, y_bot_start), (_BOARD_W, y_bot_start), 1)
|
||||
# Vertical dividers
|
||||
pygame.draw.line(surface, col, (_SIDE_W, y_top_end), (_SIDE_W, y_bot_start), 1)
|
||||
pygame.draw.line(surface, col, (_BOARD_W - _SIDE_W, y_top_end), (_BOARD_W - _SIDE_W, y_bot_start), 1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Card positioning within a zone
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _card_position(zone_rect: ZoneRect, idx: int, total: int) -> tuple[int, int]:
|
||||
"""Compute pixel position for card at index idx in a zone."""
|
||||
if total == 0:
|
||||
return zone_rect.x + 4, zone_rect.y + 4
|
||||
spacing = min(CARD_W + 6, max(20, (zone_rect.w - 8) // max(total, 1)))
|
||||
x = zone_rect.x + 4 + idx * spacing
|
||||
y = zone_rect.y + 4
|
||||
return x, y
|
||||
|
||||
@staticmethod
|
||||
def _card_index_at(px: int, py: int, zone_rect: ZoneRect, cards: list) -> Optional[int]:
|
||||
"""Return the card index at pixel position, or None."""
|
||||
if not cards:
|
||||
return None
|
||||
total = len(cards)
|
||||
spacing = min(CARD_W + 6, max(20, (zone_rect.w - 8) // max(total, 1)))
|
||||
for idx in reversed(range(total)): # top-most card first
|
||||
cx = zone_rect.x + 4 + idx * spacing
|
||||
cy = zone_rect.y + 4
|
||||
if cx <= px < cx + CARD_W and cy <= py < cy + CARD_H:
|
||||
return idx
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_zone_cards(player, zone_name: str) -> list:
|
||||
mapping = {
|
||||
"hand": "hand",
|
||||
"battlefield": "battlefield",
|
||||
"graveyard": "graveyard",
|
||||
"exile": "exile",
|
||||
"command": "command_zone",
|
||||
}
|
||||
attr = mapping.get(zone_name, zone_name)
|
||||
return getattr(player, attr, []) or []
|
||||
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Commander AI Lab — Card Renderer (Phase 4)
|
||||
==========================================
|
||||
Draws individual cards onto a Pygame surface.
|
||||
|
||||
Features:
|
||||
- Tapped / untapped state (rotated 90 degrees)
|
||||
- Name, P/T or loyalty, mana cost text
|
||||
- Highlight ring (legal target indicator)
|
||||
- Scryfall art placeholder (grey box + card name until image loads)
|
||||
- Async art loading via a shared image cache (non-blocking)
|
||||
- Right-click context menu data (returned as list of option strings,
|
||||
rendered by the caller)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import pygame
|
||||
_PYGAME_AVAILABLE = True
|
||||
except ImportError:
|
||||
_PYGAME_AVAILABLE = False
|
||||
|
||||
from commander_ai_lab.gui.constants import (
|
||||
CARD_W, CARD_H, CARD_RADIUS, CARD_TAPPED_ANGLE,
|
||||
COLOUR_CARD_BACK, COLOUR_CARD_FACE, COLOUR_CARD_TAPPED,
|
||||
COLOUR_HIGHLIGHT, COLOUR_TARGET_RING, COLOUR_TEXT, COLOUR_TEXT_DIM,
|
||||
COLOUR_ZONE_BORDER,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scryfall image cache (shared across all CardRenderer instances)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _ScryfallCache:
|
||||
"""
|
||||
Lazy per-name image cache.
|
||||
|
||||
Fetches art from Scryfall API in a background thread when a card is
|
||||
first drawn. Surfaces are stored as pygame.Surface objects once loaded.
|
||||
Until loaded, None is stored (caller renders placeholder).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._cache: dict[str, Optional[object]] = {} # name -> Surface | None
|
||||
self._pending: set[str] = set()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, card_name: str) -> Optional[object]:
|
||||
"""Return cached Surface or None (triggers background fetch if first call)."""
|
||||
with self._lock:
|
||||
if card_name in self._cache:
|
||||
return self._cache[card_name]
|
||||
if card_name not in self._pending:
|
||||
self._pending.add(card_name)
|
||||
t = threading.Thread(
|
||||
target=self._fetch,
|
||||
args=(card_name,),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
return None
|
||||
|
||||
def _fetch(self, card_name: str) -> None:
|
||||
"""Background thread: fetch art from Scryfall and decode into Surface."""
|
||||
try:
|
||||
from urllib.request import urlopen
|
||||
from urllib.parse import quote
|
||||
import json
|
||||
import io
|
||||
|
||||
# Step 1: resolve card by name
|
||||
name_enc = quote(card_name)
|
||||
api_url = f"https://api.scryfall.com/cards/named?fuzzy={name_enc}"
|
||||
with urlopen(api_url, timeout=5) as r:
|
||||
data = json.loads(r.read())
|
||||
|
||||
# Step 2: get small image URI
|
||||
image_uris = data.get("image_uris") or (
|
||||
data.get("card_faces", [{}])[0].get("image_uris", {})
|
||||
)
|
||||
img_url = image_uris.get("small") or image_uris.get("normal")
|
||||
if not img_url:
|
||||
raise ValueError("No image URI found")
|
||||
|
||||
# Step 3: download image bytes
|
||||
with urlopen(img_url, timeout=10) as r:
|
||||
img_bytes = r.read()
|
||||
|
||||
# Step 4: decode into pygame Surface
|
||||
if _PYGAME_AVAILABLE:
|
||||
import pygame
|
||||
buf = io.BytesIO(img_bytes)
|
||||
surf = pygame.image.load(buf, "card.jpg")
|
||||
surf = pygame.transform.scale(surf, (CARD_W, CARD_H))
|
||||
else:
|
||||
surf = None
|
||||
|
||||
with self._lock:
|
||||
self._cache[card_name] = surf
|
||||
self._pending.discard(card_name)
|
||||
|
||||
except Exception:
|
||||
# On any failure, store None so we fall back to placeholder
|
||||
with self._lock:
|
||||
self._cache[card_name] = None
|
||||
self._pending.discard(card_name)
|
||||
|
||||
|
||||
# Module-level shared cache
|
||||
_ART_CACHE = _ScryfallCache()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CardRenderer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CardRenderer:
|
||||
"""
|
||||
Stateless card drawing helper.
|
||||
|
||||
All draw methods accept a target pygame.Surface and a card dict.
|
||||
Card dict schema (same as game_state.CommanderPlayer.battlefield):
|
||||
{
|
||||
"name": str,
|
||||
"type": str, # creature / instant / land / …
|
||||
"pt": str | None, # "3/3"
|
||||
"tapped": bool,
|
||||
"cmc": int,
|
||||
"oracle": str | None,
|
||||
"is_commander": bool,
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, font_small=None, font_med=None) -> None:
|
||||
self._font_small = font_small
|
||||
self._font_med = font_med
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def draw_card(
|
||||
self,
|
||||
surface,
|
||||
card: dict,
|
||||
x: int,
|
||||
y: int,
|
||||
highlighted: bool = False,
|
||||
show_art: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Draw a single card at (x, y) on surface.
|
||||
Tapped cards are rotated 90 degrees clockwise.
|
||||
"""
|
||||
if not _PYGAME_AVAILABLE:
|
||||
return
|
||||
|
||||
import pygame
|
||||
tapped = card.get("tapped", False)
|
||||
|
||||
# Build card surface at standard orientation
|
||||
card_surf = self._build_card_surface(card, highlighted, show_art)
|
||||
|
||||
if tapped:
|
||||
card_surf = pygame.transform.rotate(card_surf, -CARD_TAPPED_ANGLE)
|
||||
|
||||
surface.blit(card_surf, (x, y))
|
||||
|
||||
def draw_card_back(
|
||||
self,
|
||||
surface,
|
||||
x: int,
|
||||
y: int,
|
||||
label: str = "",
|
||||
) -> None:
|
||||
"""Draw a face-down card back (used for hidden hands)."""
|
||||
if not _PYGAME_AVAILABLE:
|
||||
return
|
||||
import pygame
|
||||
rect = pygame.Rect(x, y, CARD_W, CARD_H)
|
||||
pygame.draw.rect(surface, COLOUR_CARD_BACK, rect, border_radius=CARD_RADIUS)
|
||||
pygame.draw.rect(surface, COLOUR_ZONE_BORDER, rect, width=2, border_radius=CARD_RADIUS)
|
||||
if label and self._font_small:
|
||||
lbl = self._font_small.render(label[:12], True, COLOUR_TEXT_DIM)
|
||||
surface.blit(lbl, (x + 4, y + CARD_H - 18))
|
||||
|
||||
def get_context_menu_options(self, card: dict) -> list[str]:
|
||||
"""
|
||||
Return right-click context menu options for a card.
|
||||
Options depend on card type and zone.
|
||||
"""
|
||||
options = []
|
||||
if card.get("type", "").lower() == "land":
|
||||
options.append("Tap for mana")
|
||||
if card.get("is_creature") or "creature" in card.get("type", "").lower():
|
||||
options.append("Declare attacker")
|
||||
if card.get("oracle"):
|
||||
options.append("View oracle text")
|
||||
options.append("Move to graveyard")
|
||||
options.append("Move to exile")
|
||||
return options
|
||||
|
||||
def card_rect(
|
||||
self,
|
||||
x: int,
|
||||
y: int,
|
||||
tapped: bool = False,
|
||||
) -> tuple:
|
||||
"""Return the bounding (x, y, w, h) tuple for hit-testing."""
|
||||
if tapped:
|
||||
return (x, y, CARD_H, CARD_W) # rotated dimensions
|
||||
return (x, y, CARD_W, CARD_H)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_card_surface(self, card: dict, highlighted: bool, show_art: bool):
|
||||
"""Construct a CARD_W x CARD_H surface for the card."""
|
||||
import pygame
|
||||
surf = pygame.Surface((CARD_W, CARD_H), pygame.SRCALPHA)
|
||||
rect = pygame.Rect(0, 0, CARD_W, CARD_H)
|
||||
|
||||
# Background
|
||||
face_col = COLOUR_CARD_TAPPED if card.get("tapped") else COLOUR_CARD_FACE
|
||||
pygame.draw.rect(surf, face_col, rect, border_radius=CARD_RADIUS)
|
||||
|
||||
# Scryfall art (or placeholder)
|
||||
if show_art:
|
||||
art = _ART_CACHE.get(card.get("name", ""))
|
||||
if art:
|
||||
surf.blit(art, (0, 0))
|
||||
else:
|
||||
# Art placeholder: grey block in upper portion
|
||||
art_rect = pygame.Rect(4, 4, CARD_W - 8, 54)
|
||||
pygame.draw.rect(surf, (80, 90, 110), art_rect, border_radius=3)
|
||||
|
||||
# Border
|
||||
border_col = COLOUR_HIGHLIGHT if highlighted else COLOUR_ZONE_BORDER
|
||||
border_w = 3 if highlighted else 1
|
||||
pygame.draw.rect(surf, border_col, rect, width=border_w, border_radius=CARD_RADIUS)
|
||||
|
||||
# Commander indicator (gold star)
|
||||
if card.get("is_commander"):
|
||||
pygame.draw.circle(surf, (255, 210, 0), (CARD_W - 10, 10), 6)
|
||||
|
||||
# Text overlay
|
||||
if self._font_small:
|
||||
name = card.get("name", "Unknown")[:14]
|
||||
name_surf = self._font_small.render(name, True, (10, 10, 10))
|
||||
surf.blit(name_surf, (4, CARD_H - 38))
|
||||
|
||||
pt = card.get("pt")
|
||||
if pt and self._font_small:
|
||||
pt_surf = self._font_small.render(pt, True, (10, 10, 10))
|
||||
surf.blit(pt_surf, (CARD_W - 28, CARD_H - 18))
|
||||
|
||||
cmc = card.get("cmc")
|
||||
if cmc is not None and self._font_small:
|
||||
cmc_str = str(int(cmc)) if isinstance(cmc, float) else str(cmc)
|
||||
cmc_surf = self._font_small.render(cmc_str, True, (10, 10, 10))
|
||||
surf.blit(cmc_surf, (4, CARD_H - 18))
|
||||
|
||||
# Target ring
|
||||
if highlighted:
|
||||
pygame.draw.rect(
|
||||
surf, COLOUR_TARGET_RING, rect,
|
||||
width=3, border_radius=CARD_RADIUS,
|
||||
)
|
||||
|
||||
return surf
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Commander AI Lab — GUI Constants (Phase 4)
|
||||
==========================================
|
||||
All magic numbers in one place. Adjust here to re-skin the entire UI.
|
||||
"""
|
||||
|
||||
# --- Window ---
|
||||
SCREEN_W: int = 1280
|
||||
SCREEN_H: int = 960
|
||||
FPS: int = 60
|
||||
TITLE: str = "Commander AI Lab"
|
||||
|
||||
# --- Card dimensions ---
|
||||
CARD_W: int = 90
|
||||
CARD_H: int = 126
|
||||
CARD_RADIUS: int = 6 # rounded corner radius
|
||||
CARD_TAPPED_ANGLE: float = 90.0 # degrees clockwise
|
||||
|
||||
# --- Zone layout (4-player split: top / left / right / bottom) ---
|
||||
# Each player occupies one quadrant. Zones inside each quadrant:
|
||||
ZONE_HAND_H: int = CARD_H + 16
|
||||
ZONE_BF_H: int = 220 # battlefield height per player
|
||||
ZONE_GRAVEYARD_W: int = CARD_W + 12
|
||||
ZONE_EXILE_W: int = CARD_W + 12
|
||||
ZONE_COMMAND_W: int = CARD_W + 12
|
||||
ZONE_STACK_W: int = 160
|
||||
ZONE_STACK_X: int = (SCREEN_W - ZONE_STACK_W) // 2
|
||||
ZONE_STACK_Y: int = (SCREEN_H - 180) // 2
|
||||
|
||||
# --- Narration panel (right side) ---
|
||||
NARRATION_X: int = SCREEN_W - 270
|
||||
NARRATION_Y: int = 40
|
||||
NARRATION_W: int = 260
|
||||
NARRATION_H: int = SCREEN_H - 80
|
||||
NARRATION_MAX_LINES: int = 18
|
||||
|
||||
# --- Phase / step indicator ---
|
||||
PHASE_BAR_H: int = 32
|
||||
|
||||
# --- Life total / commander damage matrix ---
|
||||
LIFE_PANEL_W: int = 120
|
||||
LIFE_PANEL_H: int = 100
|
||||
|
||||
# --- Colours (R, G, B) ---
|
||||
COLOUR_BG = (18, 20, 30)
|
||||
COLOUR_ZONE_BG = (28, 32, 48)
|
||||
COLOUR_ZONE_BORDER = (60, 70, 100)
|
||||
COLOUR_CARD_BACK = (40, 55, 90)
|
||||
COLOUR_CARD_FACE = (230, 220, 195)
|
||||
COLOUR_CARD_TAPPED = (150, 130, 80)
|
||||
COLOUR_HIGHLIGHT = (255, 220, 50)
|
||||
COLOUR_TARGET_RING = (255, 80, 80)
|
||||
COLOUR_TEXT = (240, 240, 240)
|
||||
COLOUR_TEXT_DIM = (140, 140, 160)
|
||||
COLOUR_PHASE_BAR = (35, 40, 60)
|
||||
COLOUR_PHASE_ACTIVE= (60, 130, 220)
|
||||
COLOUR_NARRATION_BG= (22, 26, 40)
|
||||
COLOUR_LIFE_HIGH = (80, 200, 100)
|
||||
COLOUR_LIFE_MED = (220, 180, 50)
|
||||
COLOUR_LIFE_LOW = (220, 60, 60)
|
||||
COLOUR_ELIMINATED = (80, 80, 80)
|
||||
COLOUR_THINKING = (100, 180, 255)
|
||||
|
||||
# Player seat colours (used to tint zone borders and name labels)
|
||||
SEAT_COLOURS = [
|
||||
(70, 140, 255), # seat 0 — blue
|
||||
(255, 100, 80), # seat 1 — red
|
||||
(80, 210, 120), # seat 2 — green
|
||||
(220, 170, 50), # seat 3 — gold
|
||||
]
|
||||
|
||||
# --- Commander phases list (for phase bar) ---
|
||||
COMMANDER_PHASES = [
|
||||
"untap", "upkeep", "draw",
|
||||
"main1", "begin_combat", "declare_attackers",
|
||||
"declare_blockers", "combat_damage", "end_combat",
|
||||
"main2", "end_step", "cleanup",
|
||||
]
|
||||
|
||||
PHASE_LABELS = {
|
||||
"untap": "Untap",
|
||||
"upkeep": "Upkeep",
|
||||
"draw": "Draw",
|
||||
"main1": "Main 1",
|
||||
"begin_combat": "Begin Combat",
|
||||
"declare_attackers": "Attackers",
|
||||
"declare_blockers": "Blockers",
|
||||
"combat_damage": "Damage",
|
||||
"end_combat": "End Combat",
|
||||
"main2": "Main 2",
|
||||
"end_step": "End Step",
|
||||
"cleanup": "Cleanup",
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
Commander AI Lab — GameWindow (Phase 4)
|
||||
========================================
|
||||
Top-level Pygame application class.
|
||||
|
||||
Responsibilities:
|
||||
- Initialise Pygame display, fonts, clock
|
||||
- Own the main game loop (run())
|
||||
- Bridge between CommanderTurnManager events and GUI components
|
||||
- Handle human player input (click-to-play, right-click context menu)
|
||||
- Show end-of-game summary screen with win condition + final stats
|
||||
|
||||
Usage (blocking, runs until window closes)::
|
||||
|
||||
from commander_ai_lab.gui.game_window import GameWindow
|
||||
from commander_ai_lab.sim.ai_opponent import create_four_player_ai_roster
|
||||
from commander_ai_lab.sim.game_state import CommanderGameState
|
||||
|
||||
gs = CommanderGameState(...) # build your game state
|
||||
ai_roster = create_four_player_ai_roster()
|
||||
|
||||
window = GameWindow(game_state=gs, ai_opponents=ai_roster, human_seat=0)
|
||||
window.run() # blocks until game over or window closed
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import pygame
|
||||
_PYGAME_AVAILABLE = True
|
||||
except ImportError:
|
||||
_PYGAME_AVAILABLE = False
|
||||
|
||||
from commander_ai_lab.gui.constants import (
|
||||
SCREEN_W, SCREEN_H, FPS, TITLE,
|
||||
COLOUR_BG, COLOUR_TEXT, COLOUR_TEXT_DIM, COLOUR_HIGHLIGHT,
|
||||
SEAT_COLOURS, NARRATION_X, NARRATION_W,
|
||||
)
|
||||
from commander_ai_lab.gui.board import BoardRenderer
|
||||
from commander_ai_lab.gui.card_renderer import CardRenderer
|
||||
from commander_ai_lab.gui.hud import HUD
|
||||
from commander_ai_lab.gui.narration_panel import NarrationPanel
|
||||
from commander_ai_lab.sim.turn_manager import CommanderTurnManager, GameEvent, TurnManagerConfig
|
||||
|
||||
|
||||
class GameWindow:
|
||||
"""
|
||||
Main Pygame application window for Commander AI Lab.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
game_state : CommanderGameState — mutable game state.
|
||||
ai_opponents : List of AIOpponent instances (Phase 2).
|
||||
human_seat : Seat index for the human player (default 0).
|
||||
Pass None for a fully AI game.
|
||||
config : TurnManagerConfig for the turn manager.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
game_state,
|
||||
ai_opponents: list,
|
||||
human_seat: Optional[int] = 0,
|
||||
config: Optional[TurnManagerConfig] = None,
|
||||
) -> None:
|
||||
self.gs = game_state
|
||||
self.human_seat = human_seat
|
||||
self._ai_opponents = ai_opponents
|
||||
self._config = config or TurnManagerConfig(ai_decision_delay=1.0)
|
||||
|
||||
# Turn manager
|
||||
human_seats = {human_seat} if human_seat is not None else set()
|
||||
self._tm = CommanderTurnManager(
|
||||
game_state=game_state,
|
||||
ai_opponents=ai_opponents,
|
||||
human_seats=human_seats,
|
||||
on_event=self._on_game_event,
|
||||
on_thinking=self._on_thinking,
|
||||
config=self._config,
|
||||
)
|
||||
|
||||
# GUI state
|
||||
self._thinking_seats: set[int] = set()
|
||||
self._highlighted_cards: set = set()
|
||||
self._selected_card = None
|
||||
self._context_menu = None # (x, y, options)
|
||||
self._game_over_info: Optional[dict] = None
|
||||
|
||||
# Pygame objects (initialised in run())
|
||||
self._screen = None
|
||||
self._clock = None
|
||||
self._board = None
|
||||
self._hud = None
|
||||
self._narration = None
|
||||
self._cr = None
|
||||
|
||||
# Commander damage matrix: {(dealer, receiver): int}
|
||||
self._cmd_damage: dict[tuple, int] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self) -> None:
|
||||
"""Initialise Pygame and run the main loop (blocking)."""
|
||||
if not _PYGAME_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"Pygame is not installed. Run: pip install pygame"
|
||||
)
|
||||
|
||||
pygame.init()
|
||||
pygame.display.set_caption(TITLE)
|
||||
self._screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
|
||||
self._clock = pygame.time.Clock()
|
||||
|
||||
# Fonts
|
||||
font_small = pygame.font.SysFont("segoeui", 11)
|
||||
font_med = pygame.font.SysFont("segoeui", 13)
|
||||
font_large = pygame.font.SysFont("segoeui", 28, bold=True)
|
||||
|
||||
# GUI components
|
||||
self._cr = CardRenderer(font_small=font_small, font_med=font_med)
|
||||
self._board = BoardRenderer(card_renderer=self._cr, human_seat=self.human_seat or 0)
|
||||
self._hud = HUD(font_small=font_small, font_med=font_med, font_large=font_large)
|
||||
self._narration = NarrationPanel(font_small=font_small)
|
||||
|
||||
# Start the async turn manager in a background thread
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._game_thread = threading.Thread(
|
||||
target=self._run_game_loop_async,
|
||||
daemon=True,
|
||||
)
|
||||
self._game_thread.start()
|
||||
|
||||
# Main render loop
|
||||
running = True
|
||||
while running:
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT:
|
||||
running = False
|
||||
elif event.type == pygame.MOUSEBUTTONDOWN:
|
||||
self._handle_mouse_click(event)
|
||||
elif event.type == pygame.MOUSEWHEEL:
|
||||
if event.y > 0:
|
||||
self._narration.scroll_up()
|
||||
else:
|
||||
self._narration.scroll_down()
|
||||
elif event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_ESCAPE:
|
||||
running = False
|
||||
|
||||
self._render_frame()
|
||||
self._clock.tick(FPS)
|
||||
|
||||
pygame.quit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _render_frame(self) -> None:
|
||||
if self._game_over_info:
|
||||
self._render_game_over_screen()
|
||||
return
|
||||
|
||||
# Board
|
||||
self._board.render(
|
||||
self._screen,
|
||||
self.gs,
|
||||
active_seat=self.gs.active_player_seat,
|
||||
thinking_seats=self._thinking_seats,
|
||||
highlighted_cards=self._highlighted_cards,
|
||||
)
|
||||
|
||||
# HUD
|
||||
self._hud.render_phase_bar(
|
||||
self._screen,
|
||||
current_phase=self.gs.current_phase,
|
||||
active_seat=self.gs.active_player_seat,
|
||||
player_name=self.gs.players[self.gs.active_player_seat].name,
|
||||
)
|
||||
self._hud.render_life_totals(self._screen, self.gs.players)
|
||||
self._hud.render_commander_damage_matrix(
|
||||
self._screen, self.gs.players, self._cmd_damage,
|
||||
)
|
||||
self._hud.render_player_tags(
|
||||
self._screen, self.gs.players,
|
||||
self.gs.active_player_seat, self._thinking_seats,
|
||||
)
|
||||
|
||||
# Narration panel
|
||||
self._narration.render(self._screen, turn=self.gs.turn)
|
||||
|
||||
# Context menu (right-click)
|
||||
if self._context_menu:
|
||||
self._render_context_menu()
|
||||
|
||||
pygame.display.flip()
|
||||
|
||||
def _render_game_over_screen(self) -> None:
|
||||
"""Full-screen game over summary."""
|
||||
import pygame
|
||||
self._screen.fill(COLOUR_BG)
|
||||
info = self._game_over_info or {}
|
||||
winner_seat = info.get("winner", 0)
|
||||
reason = info.get("reason", "")
|
||||
|
||||
font_title = pygame.font.SysFont("segoeui", 48, bold=True)
|
||||
font_body = pygame.font.SysFont("segoeui", 20)
|
||||
font_small = pygame.font.SysFont("segoeui", 14)
|
||||
|
||||
winner_name = self.gs.players[winner_seat].name if winner_seat is not None else "Draw"
|
||||
col = SEAT_COLOURS[winner_seat % len(SEAT_COLOURS)] if winner_seat is not None else COLOUR_TEXT
|
||||
|
||||
title = font_title.render(f"{winner_name} wins!", True, col)
|
||||
self._screen.blit(title, (SCREEN_W // 2 - title.get_width() // 2, 140))
|
||||
|
||||
sub = font_body.render(f"by {reason}", True, COLOUR_TEXT_DIM)
|
||||
self._screen.blit(sub, (SCREEN_W // 2 - sub.get_width() // 2, 200))
|
||||
|
||||
# Final stats per player
|
||||
y = 270
|
||||
for seat, player in enumerate(self.gs.players):
|
||||
name = player.name
|
||||
life = player.life
|
||||
elim = player.eliminated
|
||||
stat_col = COLOUR_TEXT_DIM if elim else SEAT_COLOURS[seat % len(SEAT_COLOURS)]
|
||||
row = font_body.render(
|
||||
f"{'[ELIM] ' if elim else ' '}P{seat} {name:<16} Life: {life:>3}",
|
||||
True, stat_col,
|
||||
)
|
||||
self._screen.blit(row, (SCREEN_W // 2 - 200, y))
|
||||
y += 30
|
||||
|
||||
hint = font_small.render("Press ESC to exit", True, COLOUR_TEXT_DIM)
|
||||
self._screen.blit(hint, (SCREEN_W // 2 - hint.get_width() // 2, y + 30))
|
||||
|
||||
pygame.display.flip()
|
||||
|
||||
def _render_context_menu(
|
||||
self,
|
||||
) -> None:
|
||||
"""Draw right-click context menu."""
|
||||
import pygame
|
||||
if not self._context_menu:
|
||||
return
|
||||
x, y, options = self._context_menu
|
||||
font = pygame.font.SysFont("segoeui", 13)
|
||||
item_h = 22
|
||||
w = 180
|
||||
bg = pygame.Rect(x, y, w, item_h * len(options) + 8)
|
||||
pygame.draw.rect(self._screen, (30, 35, 50), bg, border_radius=4)
|
||||
pygame.draw.rect(self._screen, COLOUR_HIGHLIGHT, bg, width=1, border_radius=4)
|
||||
for i, opt in enumerate(options):
|
||||
txt = font.render(opt, True, COLOUR_TEXT)
|
||||
self._screen.blit(txt, (x + 6, y + 4 + i * item_h))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Input handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_mouse_click(self, event) -> None:
|
||||
import pygame
|
||||
px, py = event.pos
|
||||
|
||||
# Dismiss context menu on any click
|
||||
if self._context_menu:
|
||||
self._context_menu = None
|
||||
return
|
||||
|
||||
hit = self._board.hit_test(px, py, self.gs)
|
||||
if hit is None:
|
||||
return
|
||||
|
||||
if event.button == 3: # right-click
|
||||
options = self._cr.get_context_menu_options(hit.card)
|
||||
self._context_menu = (px, py, options)
|
||||
return
|
||||
|
||||
# Left-click: human play flow
|
||||
if self.human_seat is not None and hit.seat == self.human_seat:
|
||||
if hit.zone == "hand":
|
||||
# Select card → highlight legal targets
|
||||
if self._selected_card and self._selected_card.card_index == hit.card_index:
|
||||
# Confirm: inject move into turn manager
|
||||
legal = self._tm.get_legal_moves(self.human_seat)
|
||||
move_id = self._find_move_for_card(hit.card, legal)
|
||||
if move_id is not None:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._tm.human_play(move_id), self._loop
|
||||
)
|
||||
self._selected_card = None
|
||||
self._highlighted_cards.clear()
|
||||
else:
|
||||
# First click: select + highlight
|
||||
self._selected_card = hit
|
||||
self._board.set_selected(hit)
|
||||
self._highlighted_cards = self._compute_legal_targets(hit)
|
||||
|
||||
@staticmethod
|
||||
def _find_move_for_card(card: dict, legal_moves: list) -> Optional[int]:
|
||||
"""Match a hand card to a legal move by name."""
|
||||
name = card.get("name", "").lower()
|
||||
for m in legal_moves:
|
||||
if name in m.get("description", "").lower():
|
||||
return m["id"]
|
||||
return legal_moves[0]["id"] if legal_moves else None
|
||||
|
||||
def _compute_legal_targets(
|
||||
self,
|
||||
selected: object,
|
||||
) -> set:
|
||||
"""Return set of (seat, zone, idx) tuples for legal targets."""
|
||||
# Simplified: highlight all opponent battlefield cards as targets
|
||||
targets = set()
|
||||
if self.human_seat is None:
|
||||
return targets
|
||||
for seat, player in enumerate(self.gs.players):
|
||||
if seat == self.human_seat:
|
||||
continue
|
||||
for idx in range(len(getattr(player, "battlefield", []))):
|
||||
targets.add((seat, "battlefield", idx))
|
||||
return targets
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Async game loop (runs in background thread)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_game_loop_async(self) -> None:
|
||||
"""Run the turn manager coroutine in a dedicated event loop thread."""
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._loop.run_until_complete(self._tm.run_game())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Turn manager callbacks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _on_game_event(self, event: GameEvent) -> None:
|
||||
"""Handle game events from the turn manager."""
|
||||
if event.event_type == "game_over":
|
||||
self._game_over_info = event.extra
|
||||
|
||||
elif event.event_type == "action" and event.narration:
|
||||
self._narration.add(
|
||||
seat=event.seat,
|
||||
text=f"{event.player_name}: {event.narration}",
|
||||
turn=event.turn,
|
||||
)
|
||||
|
||||
elif event.event_type == "phase_change":
|
||||
self._narration.add(
|
||||
seat=-1,
|
||||
text=f"T{event.turn} {event.player_name} — {event.phase}",
|
||||
turn=event.turn,
|
||||
)
|
||||
|
||||
elif event.event_type == "elimination":
|
||||
self._narration.add(
|
||||
seat=event.seat,
|
||||
text=f"{event.player_name} has been eliminated!",
|
||||
turn=event.turn,
|
||||
)
|
||||
|
||||
async def _on_thinking(self, seat: int, is_thinking: bool) -> None:
|
||||
"""Show / hide the AI thinking indicator."""
|
||||
if is_thinking:
|
||||
self._thinking_seats.add(seat)
|
||||
self._narration.add_thinking(seat)
|
||||
else:
|
||||
self._thinking_seats.discard(seat)
|
||||
self._narration.clear_thinking(seat)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Commander AI Lab — HUD (Phase 4)
|
||||
=================================
|
||||
Renders the phase/step indicator bar, life total trackers,
|
||||
4-player commander damage matrix, and player name tags.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
import pygame
|
||||
_PYGAME_AVAILABLE = True
|
||||
except ImportError:
|
||||
_PYGAME_AVAILABLE = False
|
||||
|
||||
from commander_ai_lab.gui.constants import (
|
||||
SCREEN_W, SCREEN_H, PHASE_BAR_H,
|
||||
COMMANDER_PHASES, PHASE_LABELS,
|
||||
COLOUR_PHASE_BAR, COLOUR_PHASE_ACTIVE, COLOUR_TEXT, COLOUR_TEXT_DIM,
|
||||
COLOUR_LIFE_HIGH, COLOUR_LIFE_MED, COLOUR_LIFE_LOW,
|
||||
COLOUR_ELIMINATED, COLOUR_THINKING, COLOUR_BG,
|
||||
SEAT_COLOURS, NARRATION_X,
|
||||
LIFE_PANEL_W, LIFE_PANEL_H,
|
||||
)
|
||||
|
||||
_BOARD_W = NARRATION_X - 4
|
||||
|
||||
|
||||
class HUD:
|
||||
"""
|
||||
Draws all HUD elements onto the display surface.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
font_small, font_med, font_large : pygame.font.Font | None
|
||||
Pass pre-loaded fonts; None falls back to system default.
|
||||
"""
|
||||
|
||||
def __init__(self, font_small=None, font_med=None, font_large=None) -> None:
|
||||
self._fs = font_small
|
||||
self._fm = font_med
|
||||
self._fl = font_large
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public render methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render_phase_bar(
|
||||
self,
|
||||
surface,
|
||||
current_phase: str,
|
||||
active_seat: int,
|
||||
player_name: str,
|
||||
) -> None:
|
||||
"""
|
||||
Draw the phase / step indicator bar at the very top of the screen.
|
||||
Highlights the current phase, shows active player name on the left.
|
||||
"""
|
||||
if not _PYGAME_AVAILABLE:
|
||||
return
|
||||
import pygame
|
||||
|
||||
bar_rect = pygame.Rect(0, 0, _BOARD_W, PHASE_BAR_H)
|
||||
pygame.draw.rect(surface, COLOUR_PHASE_BAR, bar_rect)
|
||||
|
||||
# Active player label
|
||||
seat_col = SEAT_COLOURS[active_seat % len(SEAT_COLOURS)]
|
||||
if self._fs:
|
||||
lbl = self._fs.render(f"{player_name}", True, seat_col)
|
||||
surface.blit(lbl, (6, (PHASE_BAR_H - lbl.get_height()) // 2))
|
||||
|
||||
# Phase tabs
|
||||
total_phases = len(COMMANDER_PHASES)
|
||||
tab_w = (_BOARD_W - 90) // total_phases
|
||||
for i, phase in enumerate(COMMANDER_PHASES):
|
||||
tx = 90 + i * tab_w
|
||||
trect = pygame.Rect(tx, 0, tab_w - 2, PHASE_BAR_H)
|
||||
is_active_phase = (phase == current_phase)
|
||||
bg = COLOUR_PHASE_ACTIVE if is_active_phase else COLOUR_PHASE_BAR
|
||||
pygame.draw.rect(surface, bg, trect, border_radius=3)
|
||||
if self._fs:
|
||||
label = PHASE_LABELS.get(phase, phase)
|
||||
col = COLOUR_TEXT if is_active_phase else COLOUR_TEXT_DIM
|
||||
txt = self._fs.render(label[:8], True, col)
|
||||
surface.blit(txt, (tx + 3, (PHASE_BAR_H - txt.get_height()) // 2))
|
||||
|
||||
def render_life_totals(
|
||||
self,
|
||||
surface,
|
||||
players: list,
|
||||
) -> None:
|
||||
"""
|
||||
Draw life total boxes in the four corners of the board.
|
||||
Corner positions match seat quadrant positions.
|
||||
"""
|
||||
if not _PYGAME_AVAILABLE:
|
||||
return
|
||||
import pygame
|
||||
|
||||
# Corner positions: (x, y) per seat
|
||||
corners = {
|
||||
0: (_BOARD_W - LIFE_PANEL_W - 4, SCREEN_H - LIFE_PANEL_H - 4), # bottom-right
|
||||
1: (_BOARD_W - LIFE_PANEL_W - 4, SCREEN_H // 2 - LIFE_PANEL_H // 2), # right-mid
|
||||
2: (_BOARD_W - LIFE_PANEL_W - 4, PHASE_BAR_H + 4), # top-right
|
||||
3: (4, SCREEN_H // 2 - LIFE_PANEL_H // 2), # left-mid
|
||||
}
|
||||
|
||||
for seat, player in enumerate(players):
|
||||
if seat not in corners:
|
||||
continue
|
||||
cx, cy = corners[seat]
|
||||
life = getattr(player, "life", 40)
|
||||
elim = getattr(player, "eliminated", False)
|
||||
name = getattr(player, "name", f"P{seat}")
|
||||
|
||||
# Life colour
|
||||
if elim:
|
||||
life_col = COLOUR_ELIMINATED
|
||||
elif life >= 30:
|
||||
life_col = COLOUR_LIFE_HIGH
|
||||
elif life >= 15:
|
||||
life_col = COLOUR_LIFE_MED
|
||||
else:
|
||||
life_col = COLOUR_LIFE_LOW
|
||||
|
||||
panel = pygame.Rect(cx, cy, LIFE_PANEL_W, LIFE_PANEL_H)
|
||||
pygame.draw.rect(surface, (22, 26, 40), panel, border_radius=6)
|
||||
pygame.draw.rect(surface, SEAT_COLOURS[seat % len(SEAT_COLOURS)], panel, width=2, border_radius=6)
|
||||
|
||||
if self._fl:
|
||||
life_txt = self._fl.render(str(life), True, life_col)
|
||||
surface.blit(life_txt, (cx + 10, cy + 10))
|
||||
if self._fs:
|
||||
name_txt = self._fs.render(name[:12], True, COLOUR_TEXT_DIM)
|
||||
surface.blit(name_txt, (cx + 6, cy + LIFE_PANEL_H - 20))
|
||||
|
||||
def render_commander_damage_matrix(
|
||||
self,
|
||||
surface,
|
||||
players: list,
|
||||
commander_damage: dict, # {(dealer_seat, receiver_seat): int}
|
||||
) -> None:
|
||||
"""
|
||||
Draw the 4x4 commander damage grid.
|
||||
Rows = damage dealer, columns = damage receiver.
|
||||
Positioned in the center-bottom of the board.
|
||||
"""
|
||||
if not _PYGAME_AVAILABLE or not self._fs:
|
||||
return
|
||||
import pygame
|
||||
|
||||
n = len(players)
|
||||
cell = 28
|
||||
ox = (_BOARD_W - n * cell - 30) // 2
|
||||
oy = SCREEN_H - PHASE_BAR_H - n * cell - 40
|
||||
|
||||
# Header labels
|
||||
for i, player in enumerate(players):
|
||||
col = SEAT_COLOURS[i % len(SEAT_COLOURS)]
|
||||
lbl = self._fs.render(f"P{i}", True, col)
|
||||
surface.blit(lbl, (ox + 32 + i * cell, oy - 16))
|
||||
surface.blit(lbl, (ox + 4, oy + i * cell))
|
||||
|
||||
# Cells
|
||||
for dealer in range(n):
|
||||
for recv in range(n):
|
||||
if dealer == recv:
|
||||
continue
|
||||
dmg = commander_damage.get((dealer, recv), 0)
|
||||
rect = pygame.Rect(ox + 32 + recv * cell, oy + dealer * cell, cell - 2, cell - 2)
|
||||
bg = (60, 20, 20) if dmg >= 21 else (28, 32, 48)
|
||||
pygame.draw.rect(surface, bg, rect, border_radius=3)
|
||||
pygame.draw.rect(surface, COLOUR_ZONE_BORDER, rect, width=1, border_radius=3)
|
||||
if dmg > 0:
|
||||
col = (255, 80, 80) if dmg >= 21 else COLOUR_TEXT_DIM
|
||||
txt = self._fs.render(str(dmg), True, col)
|
||||
surface.blit(txt, (rect.x + 6, rect.y + 6))
|
||||
|
||||
def render_player_tags(
|
||||
self,
|
||||
surface,
|
||||
players: list,
|
||||
active_seat: int,
|
||||
thinking_seats: set[int],
|
||||
) -> None:
|
||||
"""Render player name tags near each quadrant."""
|
||||
if not _PYGAME_AVAILABLE or not self._fm:
|
||||
return
|
||||
import pygame
|
||||
|
||||
tag_positions = {
|
||||
0: (4, SCREEN_H - PHASE_BAR_H - 20),
|
||||
1: (_BOARD_W - 130, SCREEN_H // 2),
|
||||
2: (4, PHASE_BAR_H + 4),
|
||||
3: (4, SCREEN_H // 2),
|
||||
}
|
||||
|
||||
for seat, player in enumerate(players):
|
||||
if seat not in tag_positions:
|
||||
continue
|
||||
tx, ty = tag_positions[seat]
|
||||
name = getattr(player, "name", f"P{seat}")
|
||||
elim = getattr(player, "eliminated", False)
|
||||
col = COLOUR_ELIMINATED if elim else (
|
||||
COLOUR_THINKING if seat in thinking_seats else
|
||||
SEAT_COLOURS[seat % len(SEAT_COLOURS)]
|
||||
)
|
||||
think_sfx = " [thinking...]" if seat in thinking_seats else ""
|
||||
tag = self._fm.render(f"{name}{think_sfx}", True, col)
|
||||
surface.blit(tag, (tx, ty))
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Commander AI Lab — Narration Panel (Phase 4)
|
||||
=============================================
|
||||
Scrolling log panel (right-side strip) that shows AI flavor text,
|
||||
game events, and phase transitions in a chat-style feed.
|
||||
|
||||
Features:
|
||||
- Colour-coded by player seat
|
||||
- Auto-scrolls to latest message
|
||||
- Capped at NARRATION_MAX_LINES to avoid overflow
|
||||
- "AI is thinking..." animated indicator for active inference seats
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import pygame
|
||||
_PYGAME_AVAILABLE = True
|
||||
except ImportError:
|
||||
_PYGAME_AVAILABLE = False
|
||||
|
||||
from commander_ai_lab.gui.constants import (
|
||||
SCREEN_H, NARRATION_X, NARRATION_Y, NARRATION_W, NARRATION_H,
|
||||
NARRATION_MAX_LINES,
|
||||
COLOUR_NARRATION_BG, COLOUR_TEXT, COLOUR_TEXT_DIM,
|
||||
COLOUR_THINKING, COLOUR_ZONE_BORDER,
|
||||
SEAT_COLOURS,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NarrationEntry:
|
||||
seat: int
|
||||
text: str
|
||||
is_thinking: bool = False
|
||||
turn: int = 0
|
||||
|
||||
|
||||
class NarrationPanel:
|
||||
"""
|
||||
Scrolling AI narration / event log panel.
|
||||
|
||||
Usage::
|
||||
|
||||
panel = NarrationPanel(font_small=font)
|
||||
panel.add(seat=1, text="Timmy attacks with everything!")
|
||||
panel.add_thinking(seat=2) # show spinner
|
||||
panel.clear_thinking(seat=2) # remove spinner when done
|
||||
panel.render(surface)
|
||||
"""
|
||||
|
||||
def __init__(self, font_small=None) -> None:
|
||||
self._font = font_small
|
||||
self._entries: list[NarrationEntry] = []
|
||||
self._thinking: dict[int, bool] = {} # seat -> thinking flag
|
||||
self._scroll_offset: int = 0
|
||||
self._anim_tick: int = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add(
|
||||
self,
|
||||
seat: int,
|
||||
text: str,
|
||||
turn: int = 0,
|
||||
is_event: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Append a narration or event line.
|
||||
|
||||
Args:
|
||||
seat: Seat index (0–3) for colour coding. Use -1 for system events.
|
||||
text: Line text. Long lines are auto-wrapped.
|
||||
turn: Current game turn (shown as prefix).
|
||||
is_event: True for phase/system events (shown dimmer).
|
||||
"""
|
||||
# Word-wrap to fit panel width
|
||||
for line in self._wrap(text, max_chars=32):
|
||||
self._entries.append(NarrationEntry(
|
||||
seat=seat,
|
||||
text=line,
|
||||
is_thinking=False,
|
||||
turn=turn,
|
||||
))
|
||||
|
||||
# Trim to max lines
|
||||
if len(self._entries) > NARRATION_MAX_LINES * 3:
|
||||
self._entries = self._entries[-(NARRATION_MAX_LINES * 3):]
|
||||
|
||||
# Auto-scroll to bottom
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def add_thinking(
|
||||
self,
|
||||
seat: int,
|
||||
) -> None:
|
||||
"""Show 'thinking...' animation for seat."""
|
||||
self._thinking[seat] = True
|
||||
|
||||
def clear_thinking(
|
||||
self,
|
||||
seat: int,
|
||||
) -> None:
|
||||
"""Remove thinking indicator for seat."""
|
||||
self._thinking.pop(seat, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all entries."""
|
||||
self._entries.clear()
|
||||
self._thinking.clear()
|
||||
self._scroll_offset = 0
|
||||
|
||||
def render(
|
||||
self,
|
||||
surface,
|
||||
turn: int = 0,
|
||||
) -> None:
|
||||
"""
|
||||
Render the narration panel onto the display surface.
|
||||
Call once per frame from the main game loop.
|
||||
"""
|
||||
if not _PYGAME_AVAILABLE:
|
||||
return
|
||||
import pygame
|
||||
self._anim_tick += 1
|
||||
|
||||
panel_rect = pygame.Rect(NARRATION_X, NARRATION_Y, NARRATION_W, NARRATION_H)
|
||||
pygame.draw.rect(surface, COLOUR_NARRATION_BG, panel_rect, border_radius=6)
|
||||
pygame.draw.rect(surface, COLOUR_ZONE_BORDER, panel_rect, width=1, border_radius=6)
|
||||
|
||||
if not self._font:
|
||||
return
|
||||
|
||||
line_h = self._font.get_linesize() + 2
|
||||
visible = NARRATION_H // line_h
|
||||
start = max(0, len(self._entries) - visible - self._scroll_offset)
|
||||
shown = self._entries[start: start + visible]
|
||||
|
||||
y = NARRATION_Y + 6
|
||||
for entry in shown:
|
||||
col = SEAT_COLOURS[entry.seat % len(SEAT_COLOURS)] if entry.seat >= 0 else COLOUR_TEXT_DIM
|
||||
txt = self._font.render(entry.text[:36], True, col)
|
||||
surface.blit(txt, (NARRATION_X + 6, y))
|
||||
y += line_h
|
||||
|
||||
# Thinking indicators (animated dots)
|
||||
for seat, is_thinking in self._thinking.items():
|
||||
if not is_thinking:
|
||||
continue
|
||||
dots = "." * ((self._anim_tick // 20) % 4)
|
||||
p_name = f"P{seat}"
|
||||
think_txt = self._font.render(
|
||||
f"{p_name} thinking{dots}", True, COLOUR_THINKING
|
||||
)
|
||||
surface.blit(think_txt, (NARRATION_X + 6, y))
|
||||
y += line_h
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _scroll_to_bottom(self) -> None:
|
||||
self._scroll_offset = 0
|
||||
|
||||
def scroll_up(self, lines: int = 3) -> None:
|
||||
self._scroll_offset = min(
|
||||
self._scroll_offset + lines,
|
||||
max(0, len(self._entries) - 1),
|
||||
)
|
||||
|
||||
def scroll_down(self, lines: int = 3) -> None:
|
||||
self._scroll_offset = max(0, self._scroll_offset - lines)
|
||||
|
||||
@staticmethod
|
||||
def _wrap(text: str, max_chars: int) -> list[str]:
|
||||
"""Simple word-wrap to max_chars per line."""
|
||||
words = text.split()
|
||||
lines = []
|
||||
current = ""
|
||||
for word in words:
|
||||
if len(current) + len(word) + 1 <= max_chars:
|
||||
current = (current + " " + word).strip()
|
||||
else:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = word
|
||||
if current:
|
||||
lines.append(current)
|
||||
return lines or [text[:max_chars]]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Commander AI Lab — Multiplayer Package (Phase 8)
|
||||
|
||||
Exposes:
|
||||
LobbyManager — room creation, player slots, ready-check, game launch
|
||||
ChatChannel — per-room in-game chat with history + moderation
|
||||
SpectatorManager — read-only WebSocket feeds for spectators
|
||||
LANDiscovery — UDP beacon broadcast / listener for LAN host discovery
|
||||
PrivateHandBus — routes private-hand snapshots to the correct seat only
|
||||
"""
|
||||
from commander_ai_lab.multiplayer.lobby import LobbyManager, LobbyRoom, LobbySlot, RoomState
|
||||
from commander_ai_lab.multiplayer.chat import ChatChannel, ChatMessage, ChatRole
|
||||
from commander_ai_lab.multiplayer.spectator import SpectatorManager
|
||||
from commander_ai_lab.multiplayer.lan import LANDiscovery, LANBeacon
|
||||
from commander_ai_lab.multiplayer.hands import PrivateHandBus
|
||||
|
||||
__all__ = [
|
||||
"LobbyManager", "LobbyRoom", "LobbySlot", "RoomState",
|
||||
"ChatChannel", "ChatMessage", "ChatRole",
|
||||
"SpectatorManager",
|
||||
"LANDiscovery", "LANBeacon",
|
||||
"PrivateHandBus",
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Commander AI Lab — In-Game Chat Channel (Phase 8)
|
||||
==================================================
|
||||
Per-room chat with:
|
||||
- Role-based message tagging (PLAYER, SPECTATOR, SYSTEM, AI)
|
||||
- Message history (capped at MAX_HISTORY)
|
||||
- Simple profanity / command filter hooks
|
||||
- Async broadcast to registered WebSocket send callbacks
|
||||
- System message helpers (player joined, game events, deal offers)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Callable, Awaitable, Optional
|
||||
|
||||
|
||||
MAX_HISTORY = 500
|
||||
MAX_MSG_LENGTH = 400
|
||||
|
||||
|
||||
class ChatRole(Enum):
|
||||
PLAYER = auto()
|
||||
SPECTATOR = auto()
|
||||
SYSTEM = auto()
|
||||
AI = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatMessage:
|
||||
sender: str
|
||||
text: str
|
||||
role: ChatRole = ChatRole.PLAYER
|
||||
seat: Optional[int] = None
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
room_id: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"sender": self.sender,
|
||||
"text": self.text,
|
||||
"role": self.role.name,
|
||||
"seat": self.seat,
|
||||
"timestamp": self.timestamp,
|
||||
"room_id": self.room_id,
|
||||
}
|
||||
|
||||
|
||||
# Async send callable: receives a serialised message dict
|
||||
ChatSendFn = Callable[[dict], Awaitable[None]]
|
||||
|
||||
|
||||
class ChatChannel:
|
||||
"""
|
||||
Chat bus for a single game room.
|
||||
|
||||
Usage::
|
||||
|
||||
chat = ChatChannel(room_id="abc123")
|
||||
chat.register(seat=0, send_fn=ws_send)
|
||||
await chat.send(sender="Alice", text="Let's make a deal", seat=0)
|
||||
await chat.system("Game started!")
|
||||
"""
|
||||
|
||||
def __init__(self, room_id: str) -> None:
|
||||
self.room_id = room_id
|
||||
self._history: list[ChatMessage] = []
|
||||
# seat -> send callback (players)
|
||||
self._player_handlers: dict[int, ChatSendFn] = {}
|
||||
# list of callbacks for spectators + global listeners
|
||||
self._spectator_handlers: list[ChatSendFn] = []
|
||||
self._global_handlers: list[ChatSendFn] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def register(self, seat: int, send_fn: ChatSendFn) -> None:
|
||||
"""Register a player WebSocket send callback."""
|
||||
self._player_handlers[seat] = send_fn
|
||||
|
||||
def register_spectator(self, send_fn: ChatSendFn) -> None:
|
||||
self._spectator_handlers.append(send_fn)
|
||||
|
||||
def register_global(self, send_fn: ChatSendFn) -> None:
|
||||
"""Receives every message regardless of role."""
|
||||
self._global_handlers.append(send_fn)
|
||||
|
||||
def unregister(self, seat: int) -> None:
|
||||
self._player_handlers.pop(seat, None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sending
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send(
|
||||
self,
|
||||
sender: str,
|
||||
text: str,
|
||||
seat: Optional[int] = None,
|
||||
role: ChatRole = ChatRole.PLAYER,
|
||||
) -> ChatMessage:
|
||||
"""
|
||||
Broadcast a chat message to all connected seats and spectators.
|
||||
Returns the stored ChatMessage.
|
||||
"""
|
||||
text = text[:MAX_MSG_LENGTH]
|
||||
msg = ChatMessage(
|
||||
sender=sender,
|
||||
text=text,
|
||||
role=role,
|
||||
seat=seat,
|
||||
room_id=self.room_id,
|
||||
)
|
||||
self._store(msg)
|
||||
await self._broadcast(msg)
|
||||
return msg
|
||||
|
||||
async def system(self, text: str) -> None:
|
||||
"""Broadcast a SYSTEM message (e.g. 'P2 has conceded')."""
|
||||
await self.send(sender="System", text=text, role=ChatRole.SYSTEM)
|
||||
|
||||
async def ai_says(
|
||||
self,
|
||||
ai_name: str,
|
||||
seat: int,
|
||||
text: str,
|
||||
) -> None:
|
||||
"""Broadcast a message from an AI persona."""
|
||||
await self.send(sender=ai_name, text=text, seat=seat, role=ChatRole.AI)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# History
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def history(
|
||||
self,
|
||||
last_n: int = 50,
|
||||
role_filter: Optional[ChatRole] = None,
|
||||
) -> list[dict]:
|
||||
msgs = self._history
|
||||
if role_filter:
|
||||
msgs = [m for m in msgs if m.role == role_filter]
|
||||
return [m.to_dict() for m in msgs[-last_n:]]
|
||||
|
||||
def clear(self) -> None:
|
||||
self._history.clear()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _store(self, msg: ChatMessage) -> None:
|
||||
self._history.append(msg)
|
||||
if len(self._history) > MAX_HISTORY:
|
||||
self._history = self._history[-MAX_HISTORY:]
|
||||
|
||||
async def _broadcast(self, msg: ChatMessage) -> None:
|
||||
payload = msg.to_dict()
|
||||
# Players
|
||||
for send_fn in self._player_handlers.values():
|
||||
try:
|
||||
await send_fn(payload)
|
||||
except Exception:
|
||||
pass
|
||||
# Spectators
|
||||
for send_fn in self._spectator_handlers:
|
||||
try:
|
||||
await send_fn(payload)
|
||||
except Exception:
|
||||
pass
|
||||
# Global
|
||||
for send_fn in self._global_handlers:
|
||||
try:
|
||||
await send_fn(payload)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Commander AI Lab — Private Hand Bus (Phase 8)
|
||||
=============================================
|
||||
Ensures each player only receives their own private hand data.
|
||||
|
||||
The game engine publishes full state snapshots (including all hands)
|
||||
to the PrivateHandBus. The bus strips all hands from the public
|
||||
broadcast and sends each player only their own hand via a
|
||||
dedicated per-seat WebSocket callback.
|
||||
|
||||
This design means:
|
||||
- The public state broadcast never contains card-in-hand data
|
||||
- Each player receives one extra message per state change: their own hand
|
||||
- Spectators never see any hand data (enforced in SpectatorManager)
|
||||
- AI opponent hands are never transmitted over the network
|
||||
|
||||
Usage::
|
||||
|
||||
bus = PrivateHandBus()
|
||||
bus.register(seat=0, send_fn=player_ws_send)
|
||||
bus.register(seat=1, send_fn=ai_seat_noop) # or skip entirely
|
||||
|
||||
# After each state change:
|
||||
await bus.dispatch(full_state_snapshot, current_turn=5)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Callable, Awaitable, Optional
|
||||
|
||||
|
||||
HandSendFn = Callable[[str], Awaitable[None]]
|
||||
|
||||
|
||||
class PrivateHandBus:
|
||||
"""
|
||||
Routes private hand data to the correct seat only.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._handlers: dict[int, HandSendFn] = {}
|
||||
self._human_seats: set[int] = set()
|
||||
|
||||
def register(
|
||||
self,
|
||||
seat: int,
|
||||
send_fn: HandSendFn,
|
||||
is_human: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Register a send callback for a seat.
|
||||
Only human seats should have real callbacks; AI seats are skipped.
|
||||
"""
|
||||
self._handlers[seat] = send_fn
|
||||
if is_human:
|
||||
self._human_seats.add(seat)
|
||||
|
||||
def unregister(self, seat: int) -> None:
|
||||
self._handlers.pop(seat, None)
|
||||
self._human_seats.discard(seat)
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
full_snapshot: dict,
|
||||
current_turn: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Send each human seat their private hand, then return a
|
||||
scrubbed public snapshot (all hand arrays removed).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
full_snapshot : Full game state dict including all players' hands.
|
||||
current_turn : Current game turn (included in hand payload).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Public snapshot with all hand data stripped.
|
||||
"""
|
||||
players = full_snapshot.get("players", [])
|
||||
|
||||
for player in players:
|
||||
seat = player.get("seat")
|
||||
if seat not in self._human_seats:
|
||||
continue
|
||||
send_fn = self._handlers.get(seat)
|
||||
if send_fn is None:
|
||||
continue
|
||||
hand_payload = json.dumps({
|
||||
"type": "hand",
|
||||
"seat": seat,
|
||||
"turn": current_turn,
|
||||
"cards": player.get("hand", []),
|
||||
})
|
||||
try:
|
||||
await send_fn(hand_payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Strip all hands for public broadcast
|
||||
return self._strip_hands(full_snapshot)
|
||||
|
||||
def registered_seats(self) -> list[int]:
|
||||
return sorted(self._handlers.keys())
|
||||
|
||||
@staticmethod
|
||||
def _strip_hands(snapshot: dict) -> dict:
|
||||
import copy
|
||||
safe = copy.deepcopy(snapshot)
|
||||
for player in safe.get("players", []):
|
||||
player.pop("hand", None)
|
||||
return safe
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Commander AI Lab — LAN Discovery (Phase 8)
|
||||
==========================================
|
||||
UDP broadcast/listen system for discovering Commander AI Lab hosts
|
||||
on the local network without manual IP entry.
|
||||
|
||||
Protocol:
|
||||
- Host sends a UDP broadcast on LAN_PORT every BEACON_INTERVAL seconds
|
||||
- Payload is a compact JSON beacon: {"app": "commander-ai-lab",
|
||||
"version": "...", "room": ..., "host": ..., "port": int, "players": int}
|
||||
- Clients listen on LAN_PORT, collect beacons, and surface them as
|
||||
available games
|
||||
- Beacons expire after BEACON_TTL seconds of silence from a host
|
||||
|
||||
Usage::
|
||||
|
||||
# On the host machine
|
||||
discovery = LANDiscovery(mode="host", room_info={...})
|
||||
await discovery.start()
|
||||
...
|
||||
await discovery.stop()
|
||||
|
||||
# On a client machine
|
||||
discovery = LANDiscovery(mode="client")
|
||||
await discovery.start()
|
||||
beacons = discovery.discovered_hosts()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
APP_ID = "commander-ai-lab"
|
||||
APP_VERSION = "0.8.0"
|
||||
LAN_PORT = 42424
|
||||
BEACON_INTERVAL = 3.0 # seconds between host broadcasts
|
||||
BEACON_TTL = 10.0 # seconds before a beacon is considered stale
|
||||
MAX_PACKET_SIZE = 2048
|
||||
|
||||
|
||||
@dataclass
|
||||
class LANBeacon:
|
||||
host_ip: str
|
||||
host_port: int
|
||||
room_id: str
|
||||
room_name: str
|
||||
player_count: int
|
||||
max_players: int
|
||||
has_password: bool
|
||||
version: str = APP_VERSION
|
||||
last_seen: float = field(default_factory=time.time)
|
||||
|
||||
def is_stale(self) -> bool:
|
||||
return (time.time() - self.last_seen) > BEACON_TTL
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"host_ip": self.host_ip,
|
||||
"host_port": self.host_port,
|
||||
"room_id": self.room_id,
|
||||
"room_name": self.room_name,
|
||||
"player_count": self.player_count,
|
||||
"max_players": self.max_players,
|
||||
"has_password": self.has_password,
|
||||
"version": self.version,
|
||||
"last_seen": self.last_seen,
|
||||
}
|
||||
|
||||
|
||||
class LANDiscovery:
|
||||
"""
|
||||
Manages host broadcasting and client listening for LAN game discovery.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mode : 'host' | 'client'
|
||||
room_info : Required for mode='host'. Dict with room metadata.
|
||||
bind_port : UDP port to use (default LAN_PORT).
|
||||
game_port : TCP/WebSocket port the FastAPI server is listening on.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode: str,
|
||||
room_info: Optional[dict] = None,
|
||||
bind_port: int = LAN_PORT,
|
||||
game_port: int = 8000,
|
||||
) -> None:
|
||||
if mode not in ("host", "client"):
|
||||
raise ValueError("mode must be 'host' or 'client'")
|
||||
self.mode = mode
|
||||
self.room_info = room_info or {}
|
||||
self.bind_port = bind_port
|
||||
self.game_port = game_port
|
||||
|
||||
self._running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
# client-side discovered hosts: host_ip -> LANBeacon
|
||||
self._discovered: dict[str, LANBeacon] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self) -> None:
|
||||
self._running = True
|
||||
if self.mode == "host":
|
||||
self._task = asyncio.create_task(self._broadcast_loop())
|
||||
else:
|
||||
self._task = asyncio.create_task(self._listen_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def discovered_hosts(self) -> list[dict]:
|
||||
"""Return all non-stale discovered hosts (client mode only)."""
|
||||
self._prune_stale()
|
||||
return [b.to_dict() for b in self._discovered.values()]
|
||||
|
||||
def update_room_info(self, room_info: dict) -> None:
|
||||
"""Update broadcast payload (e.g. when player count changes)."""
|
||||
self.room_info = room_info
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Host — broadcast loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _broadcast_loop(self) -> None:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
while self._running:
|
||||
payload = self._build_beacon_payload()
|
||||
data = json.dumps(payload).encode()
|
||||
try:
|
||||
sock.sendto(data, ("<broadcast>", self.bind_port))
|
||||
except OSError:
|
||||
pass
|
||||
await asyncio.sleep(BEACON_INTERVAL)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def _build_beacon_payload(self) -> dict:
|
||||
return {
|
||||
"app": APP_ID,
|
||||
"version": APP_VERSION,
|
||||
"room_id": self.room_info.get("room_id", ""),
|
||||
"room_name": self.room_info.get("room_name", "Unnamed Room"),
|
||||
"player_count": self.room_info.get("player_count", 1),
|
||||
"max_players": self.room_info.get("max_players", 4),
|
||||
"has_password": self.room_info.get("has_password", False),
|
||||
"host_port": self.game_port,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Client — listen loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _listen_loop(self) -> None:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.bind(("", self.bind_port))
|
||||
except OSError:
|
||||
sock.close()
|
||||
return
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
data, addr = await loop.run_in_executor(
|
||||
None, lambda: sock.recvfrom(MAX_PACKET_SIZE)
|
||||
)
|
||||
self._handle_beacon(data, addr[0])
|
||||
except (OSError, BlockingIOError):
|
||||
await asyncio.sleep(0.1)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def _handle_beacon(
|
||||
self,
|
||||
data: bytes,
|
||||
sender_ip: str,
|
||||
) -> None:
|
||||
try:
|
||||
payload = json.loads(data.decode())
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return
|
||||
if payload.get("app") != APP_ID:
|
||||
return
|
||||
beacon = LANBeacon(
|
||||
host_ip=sender_ip,
|
||||
host_port=payload.get("host_port", 8000),
|
||||
room_id=payload.get("room_id", ""),
|
||||
room_name=payload.get("room_name", "Unknown"),
|
||||
player_count=payload.get("player_count", 1),
|
||||
max_players=payload.get("max_players", 4),
|
||||
has_password=payload.get("has_password", False),
|
||||
version=payload.get("version", ""),
|
||||
)
|
||||
self._discovered[sender_ip] = beacon
|
||||
|
||||
def _prune_stale(self) -> None:
|
||||
stale = [ip for ip, b in self._discovered.items() if b.is_stale()]
|
||||
for ip in stale:
|
||||
del self._discovered[ip]
|
||||
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Commander AI Lab — Lobby Manager (Phase 8)
|
||||
==========================================
|
||||
Manages game rooms from creation through launch:
|
||||
|
||||
RoomState WAITING → READY → IN_PROGRESS → FINISHED
|
||||
|
||||
Flow:
|
||||
1. Host calls LobbyManager.create_room() → returns room_id
|
||||
2. Players join via LobbyManager.join_room(room_id, player_name)
|
||||
3. Each player calls set_ready(room_id, seat) when prepared
|
||||
4. When all human slots are ready, room transitions to READY
|
||||
5. Host calls launch_game(room_id) → fires on_launch callback
|
||||
6. Room moves to IN_PROGRESS; late-joins become spectators
|
||||
|
||||
AI slots are pre-filled and always considered ready.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Optional, Callable, Awaitable
|
||||
|
||||
|
||||
MAX_PLAYERS = 4
|
||||
MAX_SPECTATORS = 20
|
||||
ROOM_ID_BYTES = 6
|
||||
|
||||
|
||||
class RoomState(Enum):
|
||||
WAITING = auto()
|
||||
READY = auto()
|
||||
IN_PROGRESS = auto()
|
||||
FINISHED = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class LobbySlot:
|
||||
seat: int
|
||||
player_name: Optional[str] = None
|
||||
is_ai: bool = False
|
||||
ai_personality: Optional[str] = None
|
||||
is_host: bool = False
|
||||
is_ready: bool = False
|
||||
connection_id: Optional[str] = None # WebSocket connection tag
|
||||
|
||||
@property
|
||||
def is_filled(self) -> bool:
|
||||
return self.player_name is not None or self.is_ai
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"seat": self.seat,
|
||||
"player_name": self.player_name,
|
||||
"is_ai": self.is_ai,
|
||||
"ai_personality": self.ai_personality,
|
||||
"is_host": self.is_host,
|
||||
"is_ready": self.is_ready,
|
||||
}
|
||||
|
||||
|
||||
# Callback fired when a room is launched: async fn(room_id, room)
|
||||
LaunchCallback = Callable[[str, "LobbyRoom"], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LobbyRoom:
|
||||
room_id: str
|
||||
room_name: str
|
||||
max_players: int = MAX_PLAYERS
|
||||
state: RoomState = RoomState.WAITING
|
||||
slots: list[LobbySlot] = field(default_factory=list)
|
||||
spectators: list[str] = field(default_factory=list) # connection_ids
|
||||
password: Optional[str] = None
|
||||
chat: Optional[object] = None # ChatChannel injected post-construction
|
||||
created_at: float = field(default_factory=lambda: __import__('time').time())
|
||||
launched_at: Optional[float] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.slots:
|
||||
self.slots = [LobbySlot(seat=i) for i in range(self.max_players)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Slot helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def open_seats(self) -> list[int]:
|
||||
return [s.seat for s in self.slots if not s.is_filled]
|
||||
|
||||
def human_seats(self) -> list[int]:
|
||||
return [s.seat for s in self.slots if s.is_filled and not s.is_ai]
|
||||
|
||||
def all_humans_ready(self) -> bool:
|
||||
humans = [s for s in self.slots if s.is_filled and not s.is_ai]
|
||||
return bool(humans) and all(s.is_ready for s in humans)
|
||||
|
||||
def all_seats_filled(self) -> bool:
|
||||
return all(s.is_filled for s in self.slots)
|
||||
|
||||
def get_slot(self, seat: int) -> Optional[LobbySlot]:
|
||||
return next((s for s in self.slots if s.seat == seat), None)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"room_id": self.room_id,
|
||||
"room_name": self.room_name,
|
||||
"state": self.state.name,
|
||||
"slots": [s.to_dict() for s in self.slots],
|
||||
"spectators": len(self.spectators),
|
||||
"has_password": self.password is not None,
|
||||
}
|
||||
|
||||
|
||||
class LobbyManager:
|
||||
"""
|
||||
Singleton-style registry of all active lobby rooms.
|
||||
|
||||
Usage::
|
||||
|
||||
mgr = LobbyManager()
|
||||
rid = await mgr.create_room("Friday Night Commander", host_name="Alice")
|
||||
seat = await mgr.join_room(rid, "Bob")
|
||||
await mgr.set_ready(rid, seat)
|
||||
await mgr.launch_game(rid)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_launch: Optional[LaunchCallback] = None,
|
||||
) -> None:
|
||||
self._rooms: dict[str, LobbyRoom] = {}
|
||||
self._on_launch = on_launch
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Room lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def create_room(
|
||||
self,
|
||||
room_name: str,
|
||||
host_name: str,
|
||||
max_players: int = MAX_PLAYERS,
|
||||
ai_slots: Optional[list[dict]] = None, # [{seat:int, personality:str}, ...]
|
||||
password: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a new lobby room. Returns room_id.
|
||||
Host occupies seat 0. AI slots are pre-filled.
|
||||
"""
|
||||
room_id = secrets.token_hex(ROOM_ID_BYTES)
|
||||
room = LobbyRoom(
|
||||
room_id=room_id,
|
||||
room_name=room_name,
|
||||
max_players=max_players,
|
||||
password=password,
|
||||
)
|
||||
# Fill host into seat 0
|
||||
room.slots[0].player_name = host_name
|
||||
room.slots[0].is_host = True
|
||||
room.slots[0].is_ready = False
|
||||
|
||||
# Fill AI slots
|
||||
for ai in (ai_slots or []):
|
||||
seat = ai.get("seat")
|
||||
if seat is None or seat >= max_players:
|
||||
continue
|
||||
slot = room.get_slot(seat)
|
||||
if slot and not slot.is_filled:
|
||||
slot.is_ai = True
|
||||
slot.player_name = f"AI-{ai.get('personality','spike').title()}"
|
||||
slot.ai_personality = ai.get("personality", "spike")
|
||||
slot.is_ready = True # AI always ready
|
||||
|
||||
self._rooms[room_id] = room
|
||||
return room_id
|
||||
|
||||
async def join_room(
|
||||
self,
|
||||
room_id: str,
|
||||
player_name: str,
|
||||
password: Optional[str] = None,
|
||||
connection_id: Optional[str] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Join an existing room. Returns the assigned seat number.
|
||||
Raises ValueError on invalid room / wrong password / full room.
|
||||
"""
|
||||
room = self._get_room(room_id)
|
||||
if room.state != RoomState.WAITING:
|
||||
raise ValueError("Room is not accepting new players.")
|
||||
if room.password and room.password != password:
|
||||
raise ValueError("Incorrect room password.")
|
||||
open_seats = room.open_seats()
|
||||
if not open_seats:
|
||||
raise ValueError("Room is full.")
|
||||
seat = open_seats[0]
|
||||
slot = room.get_slot(seat)
|
||||
slot.player_name = player_name
|
||||
slot.connection_id = connection_id
|
||||
return seat
|
||||
|
||||
async def add_spectator(
|
||||
self,
|
||||
room_id: str,
|
||||
connection_id: str,
|
||||
) -> None:
|
||||
"""Add a spectator connection to a room."""
|
||||
room = self._get_room(room_id)
|
||||
if len(room.spectators) >= MAX_SPECTATORS:
|
||||
raise ValueError("Spectator limit reached.")
|
||||
if connection_id not in room.spectators:
|
||||
room.spectators.append(connection_id)
|
||||
|
||||
async def set_ready(
|
||||
self,
|
||||
room_id: str,
|
||||
seat: int,
|
||||
ready: bool = True,
|
||||
) -> RoomState:
|
||||
"""Mark a seat as ready. Returns the new room state."""
|
||||
room = self._get_room(room_id)
|
||||
slot = room.get_slot(seat)
|
||||
if slot is None:
|
||||
raise ValueError(f"Seat {seat} not found.")
|
||||
slot.is_ready = ready
|
||||
if room.all_humans_ready() and room.all_seats_filled():
|
||||
room.state = RoomState.READY
|
||||
else:
|
||||
room.state = RoomState.WAITING
|
||||
return room.state
|
||||
|
||||
async def launch_game(
|
||||
self,
|
||||
room_id: str,
|
||||
) -> LobbyRoom:
|
||||
"""Launch the game. Room must be in READY state."""
|
||||
room = self._get_room(room_id)
|
||||
if room.state != RoomState.READY:
|
||||
raise ValueError(f"Room not ready (state={room.state.name}).")
|
||||
room.state = RoomState.IN_PROGRESS
|
||||
room.launched_at = __import__('time').time()
|
||||
if self._on_launch:
|
||||
await self._on_launch(room_id, room)
|
||||
return room
|
||||
|
||||
async def finish_room(
|
||||
self,
|
||||
room_id: str,
|
||||
) -> None:
|
||||
room = self._get_room(room_id)
|
||||
room.state = RoomState.FINISHED
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_room(self, room_id: str) -> Optional[LobbyRoom]:
|
||||
return self._rooms.get(room_id)
|
||||
|
||||
def list_open_rooms(self) -> list[dict]:
|
||||
return [
|
||||
r.to_dict() for r in self._rooms.values()
|
||||
if r.state == RoomState.WAITING
|
||||
]
|
||||
|
||||
def list_all_rooms(self) -> list[dict]:
|
||||
return [r.to_dict() for r in self._rooms.values()]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_room(self, room_id: str) -> LobbyRoom:
|
||||
room = self._rooms.get(room_id)
|
||||
if room is None:
|
||||
raise ValueError(f"Room '{room_id}' not found.")
|
||||
return room
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Commander AI Lab — Spectator Manager (Phase 8)
|
||||
===============================================
|
||||
Provides read-only game-state feeds to spectator WebSocket connections.
|
||||
|
||||
Features:
|
||||
- Spectators receive the same public state snapshots as players
|
||||
- Private hands are NEVER sent to spectators
|
||||
- Spectators are tracked per room; dead connections are pruned
|
||||
- Optional delay_turns: hide state N turns behind live game
|
||||
(prevents spectators from feeding information to players)
|
||||
- SpectatorManager.broadcast() is called by the WebSocket router
|
||||
after every state change
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Awaitable, Any, Optional
|
||||
|
||||
|
||||
BroadcastFn = Callable[[str], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpectatorConnection:
|
||||
connection_id: str
|
||||
send_fn: BroadcastFn
|
||||
room_id: str
|
||||
joined_at: float = field(default_factory=lambda: __import__('time').time())
|
||||
delay_turns: int = 0
|
||||
alive: bool = True
|
||||
|
||||
|
||||
class SpectatorManager:
|
||||
"""
|
||||
Registry of active spectator connections per room.
|
||||
|
||||
Usage::
|
||||
|
||||
mgr = SpectatorManager()
|
||||
mgr.add(room_id, connection_id, send_fn)
|
||||
await mgr.broadcast(room_id, state_snapshot)
|
||||
mgr.remove(room_id, connection_id)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# room_id -> {connection_id -> SpectatorConnection}
|
||||
self._rooms: dict[str, dict[str, SpectatorConnection]] = {}
|
||||
|
||||
def add(
|
||||
self,
|
||||
room_id: str,
|
||||
connection_id: str,
|
||||
send_fn: BroadcastFn,
|
||||
delay_turns: int = 0,
|
||||
) -> None:
|
||||
self._rooms.setdefault(room_id, {})
|
||||
self._rooms[room_id][connection_id] = SpectatorConnection(
|
||||
connection_id=connection_id,
|
||||
send_fn=send_fn,
|
||||
room_id=room_id,
|
||||
delay_turns=delay_turns,
|
||||
)
|
||||
|
||||
def remove(
|
||||
self,
|
||||
room_id: str,
|
||||
connection_id: str,
|
||||
) -> None:
|
||||
self._rooms.get(room_id, {}).pop(connection_id, None)
|
||||
|
||||
def count(self, room_id: str) -> int:
|
||||
return len(self._rooms.get(room_id, {}))
|
||||
|
||||
async def broadcast(
|
||||
self,
|
||||
room_id: str,
|
||||
snapshot: dict,
|
||||
) -> None:
|
||||
"""
|
||||
Send a public state snapshot to all spectators of room_id.
|
||||
Private hand data must be stripped from snapshot before calling.
|
||||
Dead connections are pruned automatically.
|
||||
"""
|
||||
room_conns = self._rooms.get(room_id, {})
|
||||
if not room_conns:
|
||||
return
|
||||
|
||||
# Ensure no private hand data leaks
|
||||
safe = self._strip_hands(snapshot)
|
||||
payload = json.dumps({"type": "spectate", "data": safe})
|
||||
|
||||
dead: list[str] = []
|
||||
for cid, conn in room_conns.items():
|
||||
if not conn.alive:
|
||||
dead.append(cid)
|
||||
continue
|
||||
try:
|
||||
await conn.send_fn(payload)
|
||||
except Exception:
|
||||
conn.alive = False
|
||||
dead.append(cid)
|
||||
|
||||
for cid in dead:
|
||||
room_conns.pop(cid, None)
|
||||
|
||||
async def broadcast_chat(
|
||||
self,
|
||||
room_id: str,
|
||||
chat_payload: dict,
|
||||
) -> None:
|
||||
"""Forward a chat message to all spectators of room_id."""
|
||||
room_conns = self._rooms.get(room_id, {})
|
||||
payload = json.dumps({"type": "chat", "data": chat_payload})
|
||||
dead: list[str] = []
|
||||
for cid, conn in room_conns.items():
|
||||
try:
|
||||
await conn.send_fn(payload)
|
||||
except Exception:
|
||||
dead.append(cid)
|
||||
for cid in dead:
|
||||
room_conns.pop(cid, None)
|
||||
|
||||
def list_spectators(
|
||||
self,
|
||||
room_id: str,
|
||||
) -> list[str]:
|
||||
return list(self._rooms.get(room_id, {}).keys())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _strip_hands(snapshot: dict) -> dict:
|
||||
"""
|
||||
Remove private hand arrays from a state snapshot dict.
|
||||
Preserves hand_count for display purposes.
|
||||
"""
|
||||
import copy
|
||||
safe = copy.deepcopy(snapshot)
|
||||
for player in safe.get("players", []):
|
||||
player.pop("hand", None)
|
||||
return safe
|
||||
@@ -0,0 +1,521 @@
|
||||
"""
|
||||
Commander AI Lab — AIOpponent (Phase 2)
|
||||
========================================
|
||||
High-level AI player that wraps DeepSeekBrain + Phase 1 prompt builder
|
||||
+ personality prompts to produce per-turn decisions and narration.
|
||||
|
||||
Design:
|
||||
- Each seat gets one AIOpponent instance (supports 2–4 simultaneous).
|
||||
- decide_action() builds the full LLM prompt using Phase 1 helpers,
|
||||
calls GPT-OSS 20B via Ollama, parses the response, validates the
|
||||
action index against legal_moves, and falls back to heuristic on failure.
|
||||
- narrate_play() calls the LLM with a short narration prompt and
|
||||
enforces a 30-word cap to prevent UI overflow.
|
||||
- All fallback events are logged via StateLogger for tuning.
|
||||
|
||||
Usage:
|
||||
from commander_ai_lab.sim.ai_opponent import AIOpponent
|
||||
from commander_ai_lab.sim.personality_prompts import AGGRO_TIMMY
|
||||
|
||||
ai = AIOpponent(
|
||||
name="Timmy",
|
||||
seat=1,
|
||||
personality=AGGRO_TIMMY,
|
||||
deck=[...], # list[Card]
|
||||
)
|
||||
ai.brain.check_connection()
|
||||
|
||||
move_id = ai.decide_action(game_state, legal_moves)
|
||||
flavor = ai.narrate_play(move_id, legal_moves)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
|
||||
from commander_ai_lab.sim.game_state import CommanderGameState
|
||||
from commander_ai_lab.sim.personality_prompts import Personality, AGGRO_TIMMY
|
||||
from commander_ai_lab.sim.prompt_builder import build_full_prompt
|
||||
from commander_ai_lab.sim.state_logger import StateLogger
|
||||
|
||||
logger = logging.getLogger("ai_opponent")
|
||||
|
||||
_MAX_NARRATION_WORDS = 30
|
||||
|
||||
|
||||
# ── Memory Log Entry ────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class MemoryEntry:
|
||||
"""One entry in the AI's short-term memory log."""
|
||||
turn: int
|
||||
phase: str
|
||||
chosen_move_id: Optional[int]
|
||||
move_description: str
|
||||
narration: str
|
||||
fallback_used: bool
|
||||
latency_ms: float
|
||||
|
||||
|
||||
# ── AIOpponent ─────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class AIOpponent:
|
||||
"""
|
||||
A single AI seat in a Commander game.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Display name shown in the UI.
|
||||
seat : int
|
||||
Seat index in the game (0–3).
|
||||
personality : Personality
|
||||
One of the four archetype personalities from personality_prompts.py.
|
||||
deck : list
|
||||
List of Card objects for this player's deck (used for deck context).
|
||||
brain_config : DeepSeekConfig | None
|
||||
LLM configuration. Defaults to GPT-OSS 20B via Ollama localhost.
|
||||
log_dir : str | None
|
||||
Directory for StateLogger snapshots. Defaults to logs/snapshots/.
|
||||
"""
|
||||
|
||||
name: str
|
||||
seat: int
|
||||
personality: Personality = field(default_factory=lambda: AGGRO_TIMMY)
|
||||
deck: list = field(default_factory=list)
|
||||
brain_config: Optional[DeepSeekConfig] = None
|
||||
log_dir: Optional[str] = None
|
||||
|
||||
# Populated post-init
|
||||
brain: DeepSeekBrain = field(init=False)
|
||||
state_logger: StateLogger = field(init=False)
|
||||
memory_log: list[MemoryEntry] = field(init=False, default_factory=list)
|
||||
|
||||
# Stats
|
||||
_total_decisions: int = field(init=False, default=0)
|
||||
_total_fallbacks: int = field(init=False, default=0)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.brain = DeepSeekBrain(config=self.brain_config)
|
||||
self.state_logger = StateLogger(log_dir=self.log_dir)
|
||||
|
||||
# ── Core API ─────────────────────────────────────────────────
|
||||
|
||||
def decide_action(
|
||||
self,
|
||||
game_state: CommanderGameState,
|
||||
legal_moves: list[dict],
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Choose a legal move for this AI seat.
|
||||
|
||||
Workflow:
|
||||
1. Build the full LLM prompt (Phase 1 helpers + personality).
|
||||
2. Send to GPT-OSS 20B via DeepSeekBrain.
|
||||
3. Parse LLM response for a move index.
|
||||
4. Validate the index is in legal_moves.
|
||||
5. On failure, fall back to heuristic and log the event.
|
||||
|
||||
Args:
|
||||
game_state: Current CommanderGameState.
|
||||
legal_moves: List of legal move dicts from the rules engine.
|
||||
Each dict: {"id": int, "category": str, "description": str, ...}
|
||||
|
||||
Returns:
|
||||
int: The chosen move's "id" value, or None if no legal moves.
|
||||
"""
|
||||
if not legal_moves:
|
||||
return None
|
||||
|
||||
t_start = time.time()
|
||||
self._total_decisions += 1
|
||||
fallback_used = False
|
||||
chosen_id: Optional[int] = None
|
||||
|
||||
# Build the complete prompt
|
||||
prompt = build_full_prompt(
|
||||
gs=game_state,
|
||||
viewer_seat=self.seat,
|
||||
moves=legal_moves,
|
||||
personality=self.personality.system_prompt,
|
||||
)
|
||||
|
||||
# ── Attempt LLM decision ────────────────────────────────
|
||||
if self.brain._connected:
|
||||
try:
|
||||
chosen_id = self._call_llm_for_move(prompt, legal_moves)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s seat=%d] LLM decision failed: %s — using fallback",
|
||||
self.name, self.seat, exc,
|
||||
)
|
||||
chosen_id = None
|
||||
|
||||
# ── Fallback if LLM failed or not connected ────────────────
|
||||
if chosen_id is None:
|
||||
fallback_used = True
|
||||
self._total_fallbacks += 1
|
||||
chosen_id = self._heuristic_move(game_state, legal_moves)
|
||||
logger.info(
|
||||
"[%s seat=%d] Fallback triggered. Chose move id=%s",
|
||||
self.name, self.seat, chosen_id,
|
||||
)
|
||||
|
||||
latency_ms = round((time.time() - t_start) * 1000, 1)
|
||||
move_desc = self._move_description(chosen_id, legal_moves)
|
||||
|
||||
# ── Save snapshot for debugging ───────────────────────────
|
||||
try:
|
||||
self.state_logger.save(
|
||||
game_state=game_state,
|
||||
prompt=prompt,
|
||||
chosen_move_id=chosen_id,
|
||||
seat=self.seat,
|
||||
fallback_used=fallback_used,
|
||||
extra={"latency_ms": latency_ms, "personality": self.personality.key},
|
||||
)
|
||||
except Exception as log_exc:
|
||||
logger.warning("[%s] State logger failed: %s", self.name, log_exc)
|
||||
|
||||
# ── Memory log ────────────────────────────────────────────
|
||||
self.memory_log.append(MemoryEntry(
|
||||
turn=game_state.turn,
|
||||
phase=game_state.current_phase,
|
||||
chosen_move_id=chosen_id,
|
||||
move_description=move_desc,
|
||||
narration="", # filled in by narrate_play()
|
||||
fallback_used=fallback_used,
|
||||
latency_ms=latency_ms,
|
||||
))
|
||||
|
||||
return chosen_id
|
||||
|
||||
def narrate_play(
|
||||
self,
|
||||
move_id: Optional[int],
|
||||
legal_moves: list[dict],
|
||||
game_state: Optional[CommanderGameState] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a short in-character flavor line for the chosen action.
|
||||
|
||||
Calls GPT-OSS 20B with a lightweight narration prompt.
|
||||
Falls back to a canned line if the LLM is unavailable.
|
||||
Always enforces the 30-word cap.
|
||||
|
||||
Args:
|
||||
move_id: The chosen move id (from decide_action).
|
||||
legal_moves: Full legal moves list (to look up description).
|
||||
game_state: Optional state for richer context.
|
||||
|
||||
Returns:
|
||||
A narration string of at most 30 words.
|
||||
"""
|
||||
move_desc = self._move_description(move_id, legal_moves)
|
||||
narration = ""
|
||||
|
||||
if self.brain._connected:
|
||||
try:
|
||||
narration = self._call_llm_for_narration(move_desc)
|
||||
except Exception as exc:
|
||||
logger.debug("[%s] Narration LLM failed: %s", self.name, exc)
|
||||
|
||||
if not narration:
|
||||
narration = self._canned_narration(move_desc)
|
||||
|
||||
# Enforce word cap
|
||||
words = narration.split()
|
||||
if len(words) > _MAX_NARRATION_WORDS:
|
||||
narration = " ".join(words[:_MAX_NARRATION_WORDS]) + "…"
|
||||
|
||||
# Back-fill narration into most recent memory entry
|
||||
if self.memory_log:
|
||||
self.memory_log[-1].narration = narration
|
||||
|
||||
return narration
|
||||
|
||||
# ── LLM Helpers ────────────────────────────────────────────────
|
||||
|
||||
def _call_llm_for_move(
|
||||
self,
|
||||
prompt: str,
|
||||
legal_moves: list[dict],
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Send the full prompt to the LLM and parse a move index from the response.
|
||||
Returns the move id or None if parsing fails.
|
||||
"""
|
||||
from urllib.request import Request, urlopen
|
||||
import json as _json
|
||||
|
||||
cfg = self.brain.config
|
||||
payload = {
|
||||
"model": cfg.model,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": cfg.temperature,
|
||||
"max_tokens": 64, # We only need a number
|
||||
"top_p": cfg.top_p,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
url = cfg.api_base.rstrip("/") + "/v1/chat/completions"
|
||||
req_data = _json.dumps(payload).encode("utf-8")
|
||||
req = Request(url, data=req_data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
with urlopen(req, timeout=cfg.request_timeout) as resp:
|
||||
resp_data = _json.loads(resp.read())
|
||||
|
||||
choices = resp_data.get("choices", [])
|
||||
if not choices:
|
||||
return None
|
||||
|
||||
raw = choices[0].get("message", {}).get("content", "").strip()
|
||||
return self._parse_move_id(raw, legal_moves)
|
||||
|
||||
def _call_llm_for_narration(self, move_desc: str) -> str:
|
||||
"""
|
||||
Call the LLM with a short narration prompt.
|
||||
Returns raw narration text (word-capping applied in narrate_play).
|
||||
"""
|
||||
from urllib.request import Request, urlopen
|
||||
import json as _json
|
||||
|
||||
narration_prompt = (
|
||||
f"{self.personality.system_prompt}\n\n"
|
||||
f"You just played: {move_desc}\n"
|
||||
f"Give a single in-character reaction of at most {_MAX_NARRATION_WORDS} words. "
|
||||
f"No explanation. Just the flavor line."
|
||||
)
|
||||
|
||||
cfg = self.brain.config
|
||||
payload = {
|
||||
"model": cfg.model,
|
||||
"messages": [{"role": "user", "content": narration_prompt}],
|
||||
"temperature": 0.8, # Higher temperature for creative narration
|
||||
"max_tokens": 80,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
url = cfg.api_base.rstrip("/") + "/v1/chat/completions"
|
||||
req_data = _json.dumps(payload).encode("utf-8")
|
||||
req = Request(url, data=req_data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
with urlopen(req, timeout=10.0) as resp:
|
||||
resp_data = _json.loads(resp.read())
|
||||
|
||||
choices = resp_data.get("choices", [])
|
||||
if not choices:
|
||||
return ""
|
||||
return choices[0].get("message", {}).get("content", "").strip()
|
||||
|
||||
# ── Response Parsing ───────────────────────────────────────────────
|
||||
|
||||
def _parse_move_id(
|
||||
self,
|
||||
raw: str,
|
||||
legal_moves: list[dict],
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Extract a valid move id from the LLM's raw text response.
|
||||
|
||||
Strategies (in order):
|
||||
1. Raw text is just a number → use it if valid.
|
||||
2. First integer found in text → use it if valid.
|
||||
3. JSON with an 'id' or 'action' key → extract and validate.
|
||||
4. Return None (triggers fallback).
|
||||
"""
|
||||
valid_ids = {m["id"] for m in legal_moves}
|
||||
|
||||
# Strip think blocks (reasoning models)
|
||||
raw = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL).strip()
|
||||
|
||||
# Strategy 1: whole response is a number
|
||||
stripped = raw.strip()
|
||||
if stripped.isdigit():
|
||||
mid = int(stripped)
|
||||
if mid in valid_ids:
|
||||
return mid
|
||||
|
||||
# Strategy 2: first integer in text
|
||||
nums = re.findall(r'\b(\d+)\b', raw)
|
||||
for n in nums:
|
||||
mid = int(n)
|
||||
if mid in valid_ids:
|
||||
return mid
|
||||
|
||||
# Strategy 3: JSON object with id/action key
|
||||
json_match = re.search(r'\{.*?\}', raw, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
parsed = json.loads(json_match.group(0))
|
||||
for key in ("id", "move_id", "choice", "action"):
|
||||
if key in parsed:
|
||||
mid = int(parsed[key])
|
||||
if mid in valid_ids:
|
||||
return mid
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
logger.debug("[%s] Could not parse move id from: %r", self.name, raw[:100])
|
||||
return None
|
||||
|
||||
# ── Heuristic Fallback ───────────────────────────────────────────────
|
||||
|
||||
def _heuristic_move(self, game_state: CommanderGameState, legal_moves: list[dict]) -> int:
|
||||
"""
|
||||
Priority-based heuristic fallback.
|
||||
|
||||
Priority order (personality-agnostic safe default):
|
||||
1. play_land
|
||||
2. cast_commander
|
||||
3. cast_spell
|
||||
4. attack
|
||||
5. activate_ability
|
||||
6. pass_priority / pass_turn
|
||||
7. Any other move (first available)
|
||||
"""
|
||||
priority_categories = [
|
||||
"play_land",
|
||||
"cast_commander",
|
||||
"cast_spell",
|
||||
"attack",
|
||||
"activate_ability",
|
||||
"pass_priority",
|
||||
"pass_turn",
|
||||
]
|
||||
|
||||
moves_by_cat: dict[str, list[dict]] = {}
|
||||
for m in legal_moves:
|
||||
cat = m.get("category", "other")
|
||||
moves_by_cat.setdefault(cat, []).append(m)
|
||||
|
||||
for cat in priority_categories:
|
||||
if cat in moves_by_cat:
|
||||
return moves_by_cat[cat][0]["id"]
|
||||
|
||||
# Absolute last resort
|
||||
return legal_moves[0]["id"]
|
||||
|
||||
# ── Canned Narration ───────────────────────────────────────────────
|
||||
|
||||
_CANNED: dict[str, dict[str, str]] = {
|
||||
"aggro_timmy": {
|
||||
"play_land": "More mana, more mayhem!",
|
||||
"cast_spell": "Here comes the pain!",
|
||||
"attack": "ATTACK! Take everything!",
|
||||
"pass_priority": "Fine, go… but I’m watching you.",
|
||||
"default": "Let’s smash something!",
|
||||
},
|
||||
"control_spike": {
|
||||
"play_land": "Mana secured. Proceeding.",
|
||||
"cast_spell": "Optimal play. As expected.",
|
||||
"attack": "Calculated aggression — acceptable risk.",
|
||||
"pass_priority": "Holding interaction mana. Predictable of you.",
|
||||
"default": "Inevitable victory approaches.",
|
||||
},
|
||||
"combo_johnny": {
|
||||
"play_land": "One step closer to the combo!",
|
||||
"cast_spell": "Assembling the pieces…",
|
||||
"attack": "Buying time until the combo fires.",
|
||||
"pass_priority": "Patience. The combo is coming.",
|
||||
"default": "The pieces are falling into place!",
|
||||
},
|
||||
"political_negotiator": {
|
||||
"play_land": "Just tapping a land. Nothing threatening here.",
|
||||
"cast_spell": "This helps us all, really.",
|
||||
"attack": "I’m afraid I must — for the good of the table.",
|
||||
"pass_priority": "I’ll let this resolve. For now.",
|
||||
"default": "Let’s all be reasonable about this.",
|
||||
},
|
||||
}
|
||||
|
||||
def _canned_narration(self, move_desc: str) -> str:
|
||||
"""Return a canned flavor line matching personality and move category."""
|
||||
canned = self._CANNED.get(self.personality.key, {})
|
||||
desc_lower = move_desc.lower()
|
||||
for key in ("play_land", "cast_spell", "attack", "pass_priority"):
|
||||
if key.replace("_", " ") in desc_lower or key in desc_lower:
|
||||
return canned.get(key, canned.get("default", "Interesting move."))
|
||||
return canned.get("default", "Interesting move.")
|
||||
|
||||
# ── Utilities ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _move_description(move_id: Optional[int], legal_moves: list[dict]) -> str:
|
||||
if move_id is None:
|
||||
return "(no move)"
|
||||
for m in legal_moves:
|
||||
if m["id"] == move_id:
|
||||
return m.get("description", f"Move {move_id}")
|
||||
return f"Move {move_id}"
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Return per-opponent decision statistics."""
|
||||
return {
|
||||
"name": self.name,
|
||||
"seat": self.seat,
|
||||
"personality": self.personality.key,
|
||||
"total_decisions": self._total_decisions,
|
||||
"total_fallbacks": self._total_fallbacks,
|
||||
"fallback_rate": (
|
||||
round(self._total_fallbacks / max(self._total_decisions, 1), 3)
|
||||
),
|
||||
"memory_entries": len(self.memory_log),
|
||||
"llm_stats": self.brain.get_stats(),
|
||||
}
|
||||
|
||||
def new_game(self) -> None:
|
||||
"""Reset per-game state for a new game."""
|
||||
self.memory_log.clear()
|
||||
self._total_decisions = 0
|
||||
self._total_fallbacks = 0
|
||||
self.brain.new_game(ai_player_index=self.seat)
|
||||
|
||||
|
||||
# ── Factory ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def create_four_player_ai_roster(
|
||||
names: list[str] | None = None,
|
||||
brain_config: DeepSeekConfig | None = None,
|
||||
log_dir: str | None = None,
|
||||
) -> list[AIOpponent]:
|
||||
"""
|
||||
Create a standard 4-player AI roster with one of each personality.
|
||||
|
||||
Args:
|
||||
names: Optional list of 4 display names. Defaults to personality display names.
|
||||
brain_config: Shared LLM config for all opponents.
|
||||
log_dir: Shared snapshot log directory.
|
||||
|
||||
Returns:
|
||||
List of 4 AIOpponent instances (seats 0–3).
|
||||
"""
|
||||
from commander_ai_lab.sim.personality_prompts import (
|
||||
AGGRO_TIMMY, CONTROL_SPIKE, COMBO_JOHNNY, POLITICAL_NEGOTIATOR
|
||||
)
|
||||
|
||||
archetypes = [AGGRO_TIMMY, CONTROL_SPIKE, COMBO_JOHNNY, POLITICAL_NEGOTIATOR]
|
||||
default_names = [p.display_name for p in archetypes]
|
||||
display_names = names if (names and len(names) == 4) else default_names
|
||||
|
||||
return [
|
||||
AIOpponent(
|
||||
name=display_names[i],
|
||||
seat=i,
|
||||
personality=archetypes[i],
|
||||
brain_config=brain_config,
|
||||
log_dir=log_dir,
|
||||
)
|
||||
for i in range(4)
|
||||
]
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user