Compare commits
289
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a88b371889 | ||
|
|
cf4e2981e6 | ||
|
|
d795e1a8fd | ||
|
|
01d49e78a5 | ||
|
|
c528fdb0b1 | ||
|
|
e4bc3c5ce6 | ||
|
|
d3c9e293d8 | ||
|
|
978fb12969 | ||
|
|
8c2a8cf1c1 | ||
|
|
fd18d98b9b | ||
|
|
5c21564c78 | ||
|
|
e35df6fe36 | ||
|
|
7760c589b9 | ||
|
|
7fa262d963 | ||
|
|
cffd8d8109 | ||
|
|
473c453abf | ||
|
|
54080eec37 | ||
|
|
5cb4bc3316 | ||
|
|
099e40d7c5 | ||
|
|
57c4d6a26c | ||
|
|
ea3d488a41 | ||
|
|
83ee5ba9cc | ||
|
|
5b532787af | ||
|
|
1b88cd362f | ||
|
|
834f8d9aad | ||
|
|
a7bc98c227 | ||
|
|
3e08d2bed1 | ||
|
|
e4730ad8c7 | ||
|
|
e843528d8b | ||
|
|
dd2e945214 | ||
|
|
106ec716a5 | ||
|
|
9bec84e997 | ||
|
|
ae00dd47e2 | ||
|
|
dc66ed78e5 | ||
|
|
0f6faa5fe4 | ||
|
|
1164285a5b | ||
|
|
d9ee09b848 | ||
|
|
d5c6746503 | ||
|
|
1b95659f14 | ||
|
|
871934d101 | ||
|
|
11978736a1 | ||
|
|
8ed3fbc627 | ||
|
|
4d50449145 | ||
|
|
f986d236ab | ||
|
|
3957285a2e | ||
|
|
3cca49c5a9 | ||
|
|
f290523ce1 | ||
|
|
448cec128c | ||
|
|
6612f3d213 | ||
|
|
06f007e605 | ||
|
|
1397f93b91 | ||
|
|
f86a4af86f | ||
|
|
0e99f814fc | ||
|
|
8c34cb0966 | ||
|
|
c67ce422d8 | ||
|
|
cb4b48e362 | ||
|
|
4a8db4db6b | ||
|
|
9c0fa64a69 | ||
|
|
c338f16965 | ||
|
|
a7ab0e43a5 | ||
|
|
8b527d7c30 | ||
|
|
36760dd922 | ||
|
|
963a5db638 | ||
|
|
b1996a8deb | ||
|
|
c44d0b32bb | ||
|
|
a78df75014 | ||
|
|
9020d29745 | ||
|
|
b0a34ae9e2 | ||
|
|
4c4f5ce0ff | ||
|
|
1e7f4b4e31 | ||
|
|
488575f3df | ||
|
|
fd7f0258f3 | ||
|
|
7284b8617d | ||
|
|
678cc13fe0 | ||
|
|
19f83a546b | ||
|
|
c53e82bfeb | ||
|
|
f8b13703b8 | ||
|
|
eb59a2cd2e | ||
|
|
ff6040b53e | ||
|
|
db7bc62d7e | ||
|
|
fbffe81846 | ||
|
|
9369cf5450 | ||
|
|
e38401a740 | ||
|
|
111764615d | ||
|
|
2088da6fd8 | ||
|
|
a2fbc30b2b | ||
|
|
a62640668b | ||
|
|
b8e12dff1b | ||
|
|
ea0a785174 | ||
|
|
9f1277f233 | ||
|
|
66ce887fe3 | ||
|
|
80f63f192a | ||
|
|
46ed2f5606 | ||
|
|
93a421b90a | ||
|
|
ee95f26bc4 | ||
|
|
2ee574f678 | ||
|
|
473a2bdcc7 | ||
|
|
a6e50ee0f0 | ||
|
|
e2a6a75fd9 | ||
|
|
030bdf2b81 | ||
|
|
c83f7e0141 | ||
|
|
e5377ef4bc | ||
|
|
56ea2602b4 | ||
|
|
d86109c5c0 | ||
|
|
d9dd098ca3 | ||
|
|
69ce5b340e | ||
|
|
103a74a0e1 | ||
|
|
147121582b | ||
|
|
8be96ab47d | ||
|
|
e2653b3f72 | ||
|
|
7efac34279 | ||
|
|
b00df14f30 | ||
|
|
3542efd982 | ||
|
|
1f93b5510d | ||
|
|
5c31be6023 | ||
|
|
76139477be | ||
|
|
0e5d7ba7d3 | ||
|
|
4ec6ed57d5 | ||
|
|
08d81a4336 | ||
|
|
f6ed40a253 | ||
|
|
9fd39e42c7 | ||
|
|
72107df2ae | ||
|
|
2738bbdba4 | ||
|
|
0bbc78ef23 | ||
|
|
c2b470e251 | ||
|
|
6135feb644 | ||
|
|
b3b664872b | ||
|
|
a80e8908e1 | ||
|
|
a8ef10d5b7 | ||
|
|
86af4b6de5 | ||
|
|
332606d6b5 | ||
|
|
70dfa3d3e0 | ||
|
|
f970b6d571 | ||
|
|
5ade916207 | ||
|
|
cc53c58344 | ||
|
|
3e64aa67eb | ||
|
|
70a7c07b81 | ||
|
|
1b4e9f5a66 | ||
|
|
c962d1f16a | ||
|
|
dcdf15c56a | ||
|
|
b0214009f4 | ||
|
|
5fe0ac2bd6 | ||
|
|
fc09249ad3 | ||
|
|
d2c7783ce1 | ||
|
|
0ccf35f241 | ||
|
|
9a5105b0d6 | ||
|
|
6a688d1cd0 | ||
|
|
d9639a8414 | ||
|
|
633fd43b28 | ||
|
|
45c72aadc9 | ||
|
|
81ab7ac34a | ||
|
|
e0fe4144ae | ||
|
|
68aa6eac3f | ||
|
|
c870dd35fb | ||
|
|
35ce67ed6f | ||
|
|
232750a951 | ||
|
|
46c1c8cb01 | ||
|
|
64a4cd3c7c | ||
|
|
3f6b66fce3 | ||
|
|
2887c09550 | ||
|
|
84d84a211f | ||
|
|
12f90dd276 | ||
|
|
0b755c8428 | ||
|
|
50f36f284c | ||
|
|
369cc9243f | ||
|
|
22d3b64c2d | ||
|
|
beb3dac4d5 | ||
|
|
330801107c | ||
|
|
95c5a93584 | ||
|
|
377a704a90 | ||
|
|
cc481bd71b | ||
|
|
c6d02fd38c | ||
|
|
0f2d3583ac | ||
|
|
2b355ed9ba | ||
|
|
b4d8c2f50e | ||
|
|
9db1ed112f | ||
|
|
75568bfdb1 | ||
|
|
d834247885 | ||
|
|
310a408509 | ||
|
|
ef7bde841f | ||
|
|
87abfd801e | ||
|
|
8eba39cca7 | ||
|
|
bc9656a59f | ||
|
|
86b96c09f9 | ||
|
|
9a46b0f80c | ||
|
|
0d2da18783 | ||
|
|
6a84708c65 | ||
|
|
9b892e5289 | ||
|
|
f2a782f0bd | ||
|
|
da67879491 | ||
|
|
18beb9192e | ||
|
|
ce80b6d9c5 | ||
|
|
d8e355de2b | ||
|
|
9d2a043d3f | ||
|
|
85224f082e | ||
|
|
c6c3bfabf8 | ||
|
|
c6329d377a | ||
|
|
3f7f40d626 | ||
|
|
bc023ab6f6 | ||
|
|
83a8f288b7 | ||
|
|
e3f0787c73 | ||
|
|
3802bc3d78 | ||
|
|
2936fd37eb | ||
|
|
cad2f00ec7 | ||
|
|
6b50c6ee7d | ||
|
|
40a166188a | ||
|
|
a501a9755d | ||
|
|
2d8a55fdc6 | ||
|
|
b0d0b75dbb | ||
|
|
94b20f1347 | ||
|
|
c980e1b6d5 | ||
|
|
7c7be013c2 | ||
|
|
14eb72aaff | ||
|
|
81a1bc9022 | ||
|
|
6534380f67 | ||
|
|
1ee55d4428 | ||
|
|
432a16e87d | ||
|
|
c5a1f65e13 | ||
|
|
a95ae45e18 | ||
|
|
ed255c1a21 | ||
|
|
9899da9b98 | ||
|
|
86ae69112f | ||
|
|
1cc0b696d3 | ||
|
|
b248be867b | ||
|
|
2a9b043a24 | ||
|
|
9863f7590b | ||
|
|
9436276b69 | ||
|
|
d06618031a | ||
|
|
58c6908b85 | ||
|
|
682c4191f9 | ||
|
|
2add53d3c2 | ||
|
|
918d760dcb | ||
|
|
18ddd3027b | ||
|
|
a82a7d4dc6 | ||
|
|
d5fdc150f7 | ||
|
|
173a7f4f3b | ||
|
|
a988779bb4 | ||
|
|
7901fef495 | ||
|
|
d21928da53 | ||
|
|
9b1654a81e | ||
|
|
2435e67a6e | ||
|
|
69fe6cbffd | ||
|
|
3fe3ba0e62 | ||
|
|
643529e874 | ||
|
|
9746a41178 | ||
|
|
b0f48cde62 | ||
|
|
ec9719659a | ||
|
|
729283c3bd | ||
|
|
52dcf95c59 | ||
|
|
4f5135e85b | ||
|
|
ff3b134cb9 | ||
|
|
1c85733743 | ||
|
|
a71abfa7d0 | ||
|
|
abb1e08249 | ||
|
|
57b0598717 | ||
|
|
292801d445 | ||
|
|
71c90a588f | ||
|
|
5550c27744 | ||
|
|
986c501410 | ||
|
|
d3431ff246 | ||
|
|
36dba9e90a | ||
|
|
b9369af52f | ||
|
|
6dd1de894c | ||
|
|
3f1bcb4d12 | ||
|
|
52762ee0aa | ||
|
|
af8e59ed9a | ||
|
|
0923b75d79 | ||
|
|
20172c90b4 | ||
|
|
089c1abc8d | ||
|
|
646da59e2a | ||
|
|
10a7662d51 | ||
|
|
a6171b5180 | ||
|
|
b09fe4b48b | ||
|
|
cb8867a808 | ||
|
|
3d6365cfb4 | ||
|
|
397d02240e | ||
|
|
0c842b97fd | ||
|
|
7e94b29c6d | ||
|
|
fbc3bdb6b5 | ||
|
|
a5b9d70f04 | ||
|
|
8103a06992 | ||
|
|
85df46364a | ||
|
|
32f3c9dfa3 | ||
|
|
7bc4aac0aa | ||
|
|
70ad16fc79 | ||
|
|
6298f83abd | ||
|
|
d1422d6f64 | ||
|
|
4e8a999ebd | ||
|
|
aa5e381c59 |
No files matched your search
+22
-1
@@ -1,4 +1,4 @@
|
||||
# Commander AI Lab — Environment Variables
|
||||
# Commander AI Lab - Environment Variables
|
||||
# Copy this file to .env and fill in your API keys.
|
||||
# .env is gitignored and will NOT be committed.
|
||||
|
||||
@@ -9,3 +9,24 @@ XIMILAR_API_KEY=your-ximilar-api-key-here
|
||||
# Perplexity API Key (AI deck research & generation)
|
||||
# Get yours at: https://www.perplexity.ai/settings/api
|
||||
PPLX_API_KEY=your-perplexity-api-key-here
|
||||
|
||||
# CORS allowed origins (comma-separated)
|
||||
# Default: http://localhost:5173,http://localhost:8080
|
||||
# Add your LAN IP if accessing from another machine on your network
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||
|
||||
# ── Dual-Model AI Brain ─────────────────────────────────────
|
||||
# Primary brain: GPT-OSS 20B (turn planner / decision maker)
|
||||
BRAIN_API_BASE=http://localhost:11434
|
||||
BRAIN_MODEL=gpt-oss:20b
|
||||
BRAIN_TEMPERATURE=0.3
|
||||
BRAIN_MAX_TOKENS=1024
|
||||
BRAIN_TIMEOUT=120.0
|
||||
|
||||
# Validator brain: DeepSeek-R1-Distill-Qwen-14B (legality + explanation)
|
||||
VALIDATOR_ENABLED=true
|
||||
VALIDATOR_API_BASE=http://localhost:11434
|
||||
VALIDATOR_MODEL=deepseek-r1:14b
|
||||
VALIDATOR_TEMPERATURE=0.0
|
||||
VALIDATOR_MAX_TOKENS=2048
|
||||
VALIDATOR_TIMEOUT=120.0
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Unity Build (Windows + WebGL)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'unity-client/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'unity-client/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
UNITY_PROJECT_PATH: unity-client
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.targetPlatform }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
targetPlatform:
|
||||
- StandaloneWindows64
|
||||
- WebGL
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Cache Unity Library
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.UNITY_PROJECT_PATH }}/Library
|
||||
key: Library-${{ matrix.targetPlatform }}-${{ hashFiles(format('{0}/Assets/**', env.UNITY_PROJECT_PATH)) }}
|
||||
restore-keys: |
|
||||
Library-${{ matrix.targetPlatform }}-
|
||||
Library-
|
||||
|
||||
- name: Build Unity project
|
||||
uses: game-ci/unity-builder@v4
|
||||
env:
|
||||
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
|
||||
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
|
||||
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
|
||||
with:
|
||||
projectPath: ${{ env.UNITY_PROJECT_PATH }}
|
||||
targetPlatform: ${{ matrix.targetPlatform }}
|
||||
buildName: CommanderAILab
|
||||
versioning: Semantic
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Build-${{ matrix.targetPlatform }}
|
||||
path: build/${{ matrix.targetPlatform }}
|
||||
retention-days: 14
|
||||
@@ -56,15 +56,15 @@ class PerplexityClient:
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, model: str = SONAR,
|
||||
timeout: int = 120):
|
||||
if not api_key:
|
||||
raise ValueError("Perplexity API key is required")
|
||||
timeout: int = 120, base_url: str = None):
|
||||
if not api_key and not base_url:
|
||||
raise ValueError("Perplexity API key is required (or provide base_url for local)")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self._client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://api.perplexity.ai",
|
||||
base_url=base_url or "https://api.perplexity.ai",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@@ -72,26 +72,30 @@ class PerplexityClient:
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
json_schema: dict,
|
||||
json_schema: dict = None,
|
||||
schema_name: str = "response",
|
||||
model: str = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 8192,
|
||||
) -> PerplexityResponse:
|
||||
"""
|
||||
Send a chat request with structured JSON output.
|
||||
Send a chat request with JSON output.
|
||||
|
||||
Uses Sonar models do not support the json_schema response_format.
|
||||
Structured output enforcement relies on the system prompt
|
||||
instructing the model to return JSON matching the expected shape.
|
||||
|
||||
Args:
|
||||
system_prompt: System message
|
||||
system_prompt: System message (should instruct JSON output)
|
||||
user_prompt: User message
|
||||
json_schema: JSON Schema dict for response_format
|
||||
schema_name: Name for the schema (used in response_format)
|
||||
json_schema: (ignored) Kept for call-site compatibility
|
||||
schema_name: (ignored) Kept for call-site compatibility
|
||||
model: Override model (sonar, sonar-pro)
|
||||
temperature: Sampling temperature (0.0–1.0)
|
||||
temperature: Sampling temperature (0.0-1.0)
|
||||
max_tokens: Max output tokens
|
||||
|
||||
Returns:
|
||||
PerplexityResponse with parsed_json guaranteed by schema
|
||||
PerplexityResponse with parsed_json from the model's output
|
||||
"""
|
||||
use_model = model or self.model
|
||||
|
||||
@@ -106,14 +110,7 @@ class PerplexityClient:
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
response_format={
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": schema_name,
|
||||
"schema": json_schema,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
content = choice.message.content or ""
|
||||
|
||||
@@ -22,7 +22,7 @@ from .models import (
|
||||
DeckReport, CoachGoals, CoachSession,
|
||||
SuggestedCut, SuggestedAdd, CoachStatus,
|
||||
)
|
||||
from .llm_client import LMStudioClient
|
||||
from .llm_client import LLMClient
|
||||
from .embeddings import MTGEmbeddingIndex
|
||||
from .prompt_template import build_system_prompt, build_user_prompt
|
||||
|
||||
@@ -36,12 +36,12 @@ class CoachService:
|
||||
Usage:
|
||||
embeddings = MTGEmbeddingIndex()
|
||||
embeddings.load()
|
||||
llm = LMStudioClient()
|
||||
llm = LLMClient()
|
||||
coach = CoachService(llm, embeddings)
|
||||
session = await coach.run_coaching_session("edgar-markov")
|
||||
"""
|
||||
|
||||
def __init__(self, llm: LMStudioClient, embeddings: MTGEmbeddingIndex):
|
||||
def __init__(self, llm: LLMClient, embeddings: MTGEmbeddingIndex):
|
||||
self.llm = llm
|
||||
self.embeddings = embeddings
|
||||
ensure_dirs()
|
||||
|
||||
+14
-13
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Coach Configuration
|
||||
═══════════════════════════════════════
|
||||
Central config for LM Studio, embeddings, and coach service paths.
|
||||
Central config for Ollama, embeddings, and coach service paths.
|
||||
All paths are relative to the commander-ai-lab root directory.
|
||||
"""
|
||||
|
||||
@@ -12,11 +12,11 @@ from pathlib import Path
|
||||
# Resolve relative to wherever the lab is installed
|
||||
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
|
||||
|
||||
# ── LM Studio (OpenAI-compatible API) ──────────────────────
|
||||
LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://192.168.0.122:1234/v1")
|
||||
LM_STUDIO_MODEL = os.environ.get("LM_STUDIO_MODEL", "local-model")
|
||||
LM_STUDIO_TIMEOUT = int(os.environ.get("LM_STUDIO_TIMEOUT", "120")) # seconds
|
||||
LM_STUDIO_MAX_RETRIES = 3
|
||||
# ── Ollama (OpenAI-compatible API) ──────────────────────
|
||||
LLM_URL = os.environ.get("LLM_URL", "http://192.168.0.240:11434/v1")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
|
||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
|
||||
LLM_MAX_RETRIES = 3
|
||||
|
||||
# ── Embeddings ─────────────────────────────────────────────
|
||||
EMBEDDINGS_DIR = LAB_ROOT / "data"
|
||||
@@ -28,12 +28,12 @@ DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
|
||||
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
|
||||
|
||||
# ── Prompt Limits ──────────────────────────────────────────
|
||||
MAX_PROMPT_TOKENS = 4096
|
||||
MAX_CANDIDATES_PER_UNDERPERFORMER = 10
|
||||
MAX_UNDERPERFORMERS = 8
|
||||
MAX_PROMPT_TOKENS = 2048
|
||||
MAX_CANDIDATES_PER_UNDERPERFORMER = 5
|
||||
MAX_UNDERPERFORMERS = 4
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
|
||||
|
||||
# ── LLM Generation Settings (Coach — LM Studio) ──────────────
|
||||
# ── LLM Generation Settings (Coach — Ollama) ──────────────
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
|
||||
# so we need a generous limit to avoid truncation
|
||||
@@ -41,9 +41,10 @@ DEFAULT_MAX_TOKENS = 8192
|
||||
|
||||
# ── Perplexity Deck Generation Settings ───────────────────────
|
||||
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "perplexity")
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
|
||||
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "sonar")
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
|
||||
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://192.168.0.240:11434/v1")
|
||||
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
|
||||
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
|
||||
|
||||
@@ -53,7 +54,7 @@ SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY"
|
||||
# Maximum number of alternatives to suggest per missing card
|
||||
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
|
||||
# Model for substitution fallback (sonar is fine — small focused queries)
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "sonar")
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "gpt-oss:20b")
|
||||
# Enable Perplexity fallback for low-confidence embedding matches
|
||||
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
|
||||
|
||||
|
||||
+19
-19
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — LM Studio Client
|
||||
Commander AI Lab — Ollama Client
|
||||
════════════════════════════════════
|
||||
Async HTTP client targeting LM Studio's OpenAI-compatible
|
||||
Async HTTP client targeting Ollama's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Includes retry logic for
|
||||
non-JSON responses and configurable timeout.
|
||||
"""
|
||||
@@ -14,8 +14,8 @@ from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
from .config import (
|
||||
LM_STUDIO_URL, LM_STUDIO_MODEL,
|
||||
LM_STUDIO_TIMEOUT, LM_STUDIO_MAX_RETRIES,
|
||||
LLM_URL, LLM_MODEL,
|
||||
LLM_TIMEOUT, LLM_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
@@ -34,12 +34,12 @@ class LLMResponse:
|
||||
self.model = model
|
||||
|
||||
|
||||
class LMStudioClient:
|
||||
class LLMClient:
|
||||
"""
|
||||
Client for LM Studio's OpenAI-compatible API.
|
||||
Client for Ollama's OpenAI-compatible API.
|
||||
|
||||
Usage:
|
||||
client = LMStudioClient()
|
||||
client = LLMClient()
|
||||
response = await client.chat(system_prompt, user_prompt)
|
||||
if response.parsed_json:
|
||||
suggestions = response.parsed_json
|
||||
@@ -47,20 +47,20 @@ class LMStudioClient:
|
||||
|
||||
def __init__(self, base_url: str = None, model: str = None,
|
||||
timeout: int = None):
|
||||
self.base_url = (base_url or LM_STUDIO_URL).rstrip("/")
|
||||
self.model = model or LM_STUDIO_MODEL
|
||||
self.timeout = timeout or LM_STUDIO_TIMEOUT
|
||||
self.base_url = (base_url or LLM_URL).rstrip("/")
|
||||
self.model = model or LLM_MODEL
|
||||
self.timeout = timeout or LLM_TIMEOUT
|
||||
self._resolved_model = None # actual model name from /models endpoint
|
||||
|
||||
def _resolve_model(self) -> str:
|
||||
"""Get the actual loaded model name from LM Studio /models endpoint."""
|
||||
"""Get the actual loaded model name from Ollama /models endpoint."""
|
||||
if self._resolved_model:
|
||||
return self._resolved_model
|
||||
try:
|
||||
status = self.check_connection()
|
||||
if status.get("connected") and status.get("active_model"):
|
||||
self._resolved_model = status["active_model"]
|
||||
logger.info("Resolved LM Studio model: %s", self._resolved_model)
|
||||
logger.info("Resolved Ollama model: %s", self._resolved_model)
|
||||
return self._resolved_model
|
||||
except Exception:
|
||||
pass
|
||||
@@ -196,8 +196,8 @@ class LMStudioClient:
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
"""
|
||||
Synchronous chat call to LM Studio.
|
||||
Retries up to LM_STUDIO_MAX_RETRIES on failure or non-JSON response.
|
||||
Synchronous chat call to Ollama.
|
||||
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
|
||||
"""
|
||||
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
||||
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
||||
@@ -205,9 +205,9 @@ class LMStudioClient:
|
||||
temp, tokens, force_json)
|
||||
|
||||
last_error = None
|
||||
for attempt in range(1, LM_STUDIO_MAX_RETRIES + 1):
|
||||
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||
try:
|
||||
logger.info("LLM call attempt %d/%d", attempt, LM_STUDIO_MAX_RETRIES)
|
||||
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
|
||||
req = Request(
|
||||
f"{self.base_url}/chat/completions",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
@@ -239,12 +239,12 @@ class LMStudioClient:
|
||||
body = self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False)
|
||||
|
||||
if attempt < LM_STUDIO_MAX_RETRIES:
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
import time
|
||||
time.sleep(2 * attempt) # Simple backoff
|
||||
|
||||
raise ConnectionError(
|
||||
f"LLM call failed after {LM_STUDIO_MAX_RETRIES} attempts: {last_error}"
|
||||
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
|
||||
)
|
||||
|
||||
async def chat(self, system_prompt: str, user_prompt: str,
|
||||
@@ -263,7 +263,7 @@ class LMStudioClient:
|
||||
|
||||
def check_connection(self) -> dict:
|
||||
"""
|
||||
Check if LM Studio is reachable. Returns status dict.
|
||||
Check if Ollama is reachable. Returns status dict.
|
||||
"""
|
||||
try:
|
||||
req = Request(
|
||||
|
||||
@@ -25,6 +25,9 @@ class CardAlternative(BaseModel):
|
||||
|
||||
class DeckCardWithStatus(BaseModel):
|
||||
"""A deck card enriched with ownership and substitution data."""
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
name: str
|
||||
count: int = 1
|
||||
category: str = ""
|
||||
|
||||
@@ -247,7 +247,7 @@ class DeckGeneratorV3:
|
||||
truncated = card_count < 90 # Commander decks should have ~100 cards
|
||||
logger.info("Deck generated: %d cards, bracket %d%s",
|
||||
card_count,
|
||||
raw_deck.get("bracket", {}).get("level", 0),
|
||||
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
|
||||
" (TRUNCATED — response may have hit token limit)" if truncated else "")
|
||||
|
||||
# Step 5: Check ownership and build enriched card list
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
name,quantity,set_code
|
||||
Sol Ring,1,C21
|
||||
Command Tower,1,C21
|
||||
Arcane Signet,1,C21
|
||||
@@ -0,0 +1,129 @@
|
||||
# Closed-Loop Iterative Distillation — Implementation Roadmap
|
||||
|
||||
> **Goal:** Create a feedback loop where the PPO self-play agent exports its game decisions
|
||||
> as JSONL files, which the supervised training pipeline incorporates alongside Forge
|
||||
> heuristic data. The supervised baseline gradually improves as the RL agent discovers
|
||||
> better play patterns — the RL agent teaches the supervised model.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — PPO Decision Exporter
|
||||
|
||||
**Goal:** PPO self-play writes decisions in the same JSONL format as Forge batch sims.
|
||||
|
||||
- [ ] Add a `DecisionExporter` class in `ml/training/self_play.py` that captures each
|
||||
(state, action, reward, game_context) during PPO rollouts
|
||||
- [ ] Write to `data/results/ml-decisions-ppo-{batch_id}.jsonl` using the exact same
|
||||
schema as the Forge `DecisionSnapshot` files
|
||||
- [ ] Include metadata fields to distinguish PPO-generated data:
|
||||
- `"source": "ppo"`
|
||||
- `"model_version"`
|
||||
- `"reward"`
|
||||
- `"episode_return"`
|
||||
- [ ] Only export decisions from **winning** games initially (higher signal-to-noise)
|
||||
|
||||
**Files touched:** `ml/training/self_play.py`, new `ml/training/decision_exporter.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Dataset Builder Source Awareness
|
||||
|
||||
**Goal:** `dataset_builder.py` can ingest both Forge and PPO decision files with
|
||||
configurable mixing.
|
||||
|
||||
- [ ] Update `build_dataset()` to scan for both `ml-decisions-sim-*.jsonl` (Forge) and
|
||||
`ml-decisions-ppo-*.jsonl` (PPO) files
|
||||
- [ ] Add a `source_weights` config param — e.g., `{"forge": 1.0, "ppo": 0.5}` — to
|
||||
control sampling ratios so PPO data doesn't overwhelm the Forge baseline early on
|
||||
- [ ] Add a `min_reward_threshold` filter for PPO data — only include decisions from
|
||||
games above a certain reward score
|
||||
- [ ] Tag each sample with its source for downstream analysis
|
||||
|
||||
**Files touched:** `ml/data/dataset_builder.py`, `ml/config/scope.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Quality Gate & Validation
|
||||
|
||||
**Goal:** Prevent bad PPO data from degrading the supervised model.
|
||||
|
||||
- [ ] Add a validation split in `dataset_builder.py` (hold out 10-15% of Forge data as
|
||||
a fixed eval set)
|
||||
- [ ] After each supervised training run, evaluate on the held-out Forge set — if
|
||||
accuracy drops below a threshold compared to the previous model, reject the PPO
|
||||
data batch
|
||||
- [ ] Track win rate of the PPO agent — only export decisions once the PPO model exceeds
|
||||
a configurable win rate (e.g., >30% in 4-player pods, above the 25% random baseline)
|
||||
- [ ] Log data quality metrics: action distribution entropy, reward distribution of
|
||||
exported games
|
||||
|
||||
**Files touched:** `ml/training/trainer.py`, `ml/eval/`, new `ml/data/quality_gate.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Distillation Loop Orchestrator
|
||||
|
||||
**Goal:** Automate the full cycle: supervised train -> PPO self-play -> export -> retrain.
|
||||
|
||||
- [ ] New `ml/training/distillation_loop.py` that orchestrates:
|
||||
1. Supervised train on current dataset (Forge + any existing PPO data)
|
||||
2. Run N episodes of PPO self-play using the new supervised model as starting policy
|
||||
3. Export winning PPO decisions to JSONL
|
||||
4. Quality gate check
|
||||
5. If passed, merge into dataset and go to step 1
|
||||
6. If failed, discard PPO batch, adjust hyperparameters, retry
|
||||
- [ ] Add `max_iterations` and `convergence_check` (stop if win rate plateaus across
|
||||
iterations)
|
||||
- [ ] Each iteration is a "generation" — tag data and models with generation number
|
||||
|
||||
**Files touched:** new `ml/training/distillation_loop.py`, `ml/config/scope.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — UI Integration & Monitoring
|
||||
|
||||
**Goal:** Expose the distillation loop in the Commander AI Lab web UI.
|
||||
|
||||
- [ ] Add a "Distillation" tab or section in `ui/ai-lab.js` showing:
|
||||
- Current generation number
|
||||
- Per-generation win rate trend chart
|
||||
- Data composition pie chart (% Forge vs % PPO per generation)
|
||||
- Start / stop / pause controls for the loop
|
||||
- [ ] API endpoints in `routes/ml.py`:
|
||||
- `POST /api/ml/distill/start`
|
||||
- `GET /api/ml/distill/status`
|
||||
- `POST /api/ml/distill/stop`
|
||||
- [ ] History of each generation's metrics stored as JSON for the trends view
|
||||
|
||||
**Files touched:** `routes/ml.py`, `ui/ai-lab.js`, `ui/ai-lab.css`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Advanced: ELO Tracking & Model Tournament
|
||||
|
||||
**Goal:** Quantify improvement across generations.
|
||||
|
||||
- [ ] Pit each generation's model against previous generations and the Forge heuristic
|
||||
in batch sims
|
||||
- [ ] Compute ELO ratings per generation
|
||||
- [ ] Store in `data/elo_history.json` and visualize in the UI as a line chart
|
||||
- [ ] Use ELO delta as a convergence signal — stop the loop when ELO gain per generation
|
||||
drops below a threshold
|
||||
|
||||
**Files touched:** new `ml/eval/elo_tracker.py`, `routes/ml.py`, `ui/ai-lab.js`
|
||||
|
||||
---
|
||||
|
||||
## Priority
|
||||
|
||||
| Phase | Priority | Description |
|
||||
|-------|----------|-------------|
|
||||
| 1 | **Critical** | PPO decision export — foundation for everything |
|
||||
| 2 | **Critical** | Dataset builder ingests both sources |
|
||||
| 3 | **Critical** | Quality gate prevents model regression |
|
||||
| 4 | High | Automation of the full loop |
|
||||
| 5 | Medium | UI visibility and controls |
|
||||
| 6 | Low | ELO tracking and tournament system |
|
||||
|
||||
> **Phases 1-3** are the core — get those working and you have a functional closed loop.
|
||||
> **Phase 4** automates it. **Phases 5-6** are polish and observability.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Phase 1 — Self-Play Integration Checklist
|
||||
|
||||
> **Issue:** #65 — PPO Decision Exporter
|
||||
> **Ref:** `docs/CLOSED-LOOP-DISTILLATION.md`
|
||||
> **Status:** In Progress
|
||||
|
||||
---
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] Create `ml/training/decision_exporter.py` with `DecisionExporter` class
|
||||
- [x] `ExporterConfig` dataclass (output_dir, only_wins, model_version, min_episode_steps)
|
||||
- [x] `build_decision_record()` — converts PPO step data into Forge-compatible JSONL format
|
||||
- [x] `DecisionExporter.begin_episode()` / `record_step()` / `end_episode()` / `flush()` lifecycle
|
||||
- [x] Write to `data/results/ml-decisions-ppo-{batch_id}.jsonl`
|
||||
- [x] Include PPO metadata fields: `source`, `model_version`, `reward`, `episode_return`
|
||||
- [x] Win-only filtering (only export decisions from winning games)
|
||||
- [x] Short-episode gating (`min_episode_steps` threshold)
|
||||
- [x] `_macro_action_to_forge_type()` mapping (MacroAction enum → Forge action type strings)
|
||||
- [x] Export stats tracking (`stats` property)
|
||||
|
||||
---
|
||||
|
||||
## Remaining — Integrate into `ml/training/self_play.py`
|
||||
|
||||
### Import
|
||||
|
||||
- [ ] Add import: `from ml.training.decision_exporter import DecisionExporter`
|
||||
|
||||
### Modify `run_self_play_episode()`
|
||||
|
||||
- [ ] Add `exporter: DecisionExporter = None` parameter to function signature
|
||||
- [ ] After each `trajectory.append(...)` block (3 locations), add:
|
||||
```python
|
||||
if exporter is not None:
|
||||
exporter.record_step(
|
||||
game_state_snapshot=prev_snapshot,
|
||||
action_idx=action_idx,
|
||||
reward=reward,
|
||||
)
|
||||
```
|
||||
|
||||
### Modify `collect_rollouts()`
|
||||
|
||||
- [ ] Add `exporter: DecisionExporter = None` parameter to function signature
|
||||
- [ ] Before each episode, call:
|
||||
```python
|
||||
if exporter is not None:
|
||||
exporter.begin_episode(agent_seat=agent_seat, playstyle=playstyle)
|
||||
```
|
||||
- [ ] Pass `exporter=exporter` to `run_self_play_episode()`
|
||||
- [ ] After outcome tracking, call:
|
||||
```python
|
||||
if exporter is not None:
|
||||
won = trajectory and trajectory[-1]["done"] and trajectory[-1]["reward"] > 0.5
|
||||
ep_return = sum(s["reward"] for s in trajectory)
|
||||
exporter.end_episode(won=won, episode_return=ep_return)
|
||||
```
|
||||
|
||||
### Commit
|
||||
|
||||
- [ ] `git add ml/training/self_play.py`
|
||||
- [ ] `git commit -m "feat(ml): integrate DecisionExporter into self-play rollouts (#65)"`
|
||||
- [ ] `git push origin main`
|
||||
|
||||
---
|
||||
|
||||
## Integration Pattern
|
||||
|
||||
```
|
||||
collect_rollouts(exporter=exporter)
|
||||
└─ for each episode:
|
||||
├─ exporter.begin_episode(seat, playstyle)
|
||||
├─ run_self_play_episode(..., exporter=exporter)
|
||||
│ └─ for each step:
|
||||
│ ├─ trajectory.append(step_data)
|
||||
│ └─ exporter.record_step(snapshot, action, reward)
|
||||
└─ exporter.end_episode(won, episode_return)
|
||||
# Caller does: exporter.flush()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Terminal Commands
|
||||
|
||||
```bash
|
||||
cd commander-ai-lab
|
||||
git pull origin main
|
||||
# Make edits to ml/training/self_play.py per checklist above
|
||||
git add ml/training/self_play.py
|
||||
git commit -m "feat(ml): integrate DecisionExporter into self-play rollouts (#65)"
|
||||
git push origin main
|
||||
```
|
||||
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,73 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>commander-ai-lab-ui</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
-4266
File diff suppressed because it is too large.
Load diff
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "commander-ai-lab-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^3.8.0",
|
||||
"tailwindcss": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { PageLayout } from './components/layout/PageLayout'
|
||||
import { BatchSimPage } from './pages/BatchSimPage'
|
||||
import { CollectionPage } from './pages/CollectionPage'
|
||||
import { DecksPage } from './pages/DecksPage'
|
||||
import { AutoGenPage } from './pages/AutoGenPage'
|
||||
import { SimulatorPage } from './pages/SimulatorPage'
|
||||
import { CoachPage } from './pages/CoachPage'
|
||||
import { TrainingPage } from './pages/TrainingPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<PageLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<BatchSimPage />} />
|
||||
<Route path="/collection" element={<CollectionPage />} />
|
||||
<Route path="/decks" element={<DecksPage />} />
|
||||
<Route path="/autogen" element={<AutoGenPage />} />
|
||||
<Route path="/simulator" element={<SimulatorPage />} />
|
||||
<Route path="/coach" element={<CoachPage />} />
|
||||
<Route path="/training" element={<TrainingPage />} />
|
||||
</Routes>
|
||||
</PageLayout>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Base API client — all fetch calls go through here
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
const BASE = '' // same origin; Vite proxy handles /api in dev
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponse<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
let message = `HTTP ${res.status}`
|
||||
try {
|
||||
const json = JSON.parse(body)
|
||||
message = json.detail || json.error || message
|
||||
} catch {
|
||||
if (body) message = body
|
||||
}
|
||||
throw new ApiError(res.status, message)
|
||||
}
|
||||
// Handle 204 No Content
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||
const url = new URL(BASE + path, window.location.origin)
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== '') url.searchParams.set(k, String(v))
|
||||
})
|
||||
}
|
||||
const res = await fetch(url.toString())
|
||||
return handleResponse<T>(res)
|
||||
}
|
||||
|
||||
export async function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
return handleResponse<T>(res)
|
||||
}
|
||||
|
||||
export async function postForm<T>(path: string, formData: FormData): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
return handleResponse<T>(res)
|
||||
}
|
||||
|
||||
export async function put<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handleResponse<T>(res)
|
||||
}
|
||||
|
||||
export async function patch<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(BASE + path, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return handleResponse<T>(res)
|
||||
}
|
||||
|
||||
export async function del<T>(path: string): Promise<T> {
|
||||
const res = await fetch(BASE + path, { method: 'DELETE' })
|
||||
return handleResponse<T>(res)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { get, post } from './client'
|
||||
import type {
|
||||
CoachStatus, CoachDeck, CoachSession, CoachSessionSummary,
|
||||
CoachChatResponse, CoachApplyResult, CoachCardLikeResult
|
||||
} from '../types'
|
||||
|
||||
export async function getCoachStatus() {
|
||||
return get<CoachStatus>('/api/coach/status')
|
||||
}
|
||||
|
||||
export async function getCoachDecks() {
|
||||
return get<CoachDeck[]>('/api/coach/decks')
|
||||
}
|
||||
|
||||
export async function getCoachDeckReport(deckId: string) {
|
||||
return get<Record<string, unknown>>(`/api/coach/decks/${deckId}/report`)
|
||||
}
|
||||
|
||||
export async function runCoachSession(deckId: string, goals?: Record<string, unknown>) {
|
||||
return post<CoachSession>(`/api/coach/decks/${deckId}`, goals ? { goals } : undefined)
|
||||
}
|
||||
|
||||
export async function getCoachSessions(deckId?: string) {
|
||||
const params: Record<string, string> = {}
|
||||
if (deckId) params.deck_id = deckId
|
||||
return get<{ sessions: CoachSessionSummary[] }>('/api/coach/sessions', params)
|
||||
}
|
||||
|
||||
export async function getCoachSession(sessionId: string) {
|
||||
return get<CoachSession>(`/api/coach/sessions/${sessionId}`)
|
||||
}
|
||||
|
||||
export async function downloadEmbeddings() {
|
||||
return post<{ success: boolean; cards: number }>('/api/coach/embeddings/download')
|
||||
}
|
||||
|
||||
export async function coachChat(
|
||||
deckId: string,
|
||||
messages: { role: string; content: string }[],
|
||||
goals?: Record<string, unknown>
|
||||
) {
|
||||
return post<CoachChatResponse>('/api/coach/chat', {
|
||||
deck_id: deckId,
|
||||
messages,
|
||||
goals,
|
||||
stream: false,
|
||||
})
|
||||
}
|
||||
|
||||
export async function coachChatStream(
|
||||
deckId: string,
|
||||
messages: { role: string; content: string }[],
|
||||
goals?: Record<string, unknown>,
|
||||
onChunk?: (text: string) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
const res = await fetch('/api/coach/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
deck_id: deckId,
|
||||
messages,
|
||||
goals,
|
||||
stream: true,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(body || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) throw new Error('No response body')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let fullText = ''
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim()
|
||||
if (data === '[DONE]') break
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.content) {
|
||||
fullText += parsed.content
|
||||
onChunk?.(fullText)
|
||||
}
|
||||
} catch { /* skip bad chunks */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fullText
|
||||
}
|
||||
|
||||
export async function applyCoachSuggestions(
|
||||
sessionId: string,
|
||||
deckId: number,
|
||||
acceptedCuts: string[],
|
||||
acceptedAdds: string[]
|
||||
) {
|
||||
return post<CoachApplyResult>('/api/coach/apply', {
|
||||
session_id: sessionId,
|
||||
deck_id: deckId,
|
||||
accepted_cuts: acceptedCuts,
|
||||
accepted_adds: acceptedAdds,
|
||||
})
|
||||
}
|
||||
|
||||
export async function searchCardsLike(card: string, colors?: string, topN = 10) {
|
||||
const params: Record<string, string | number> = { card, top_n: topN }
|
||||
if (colors) params.colors = colors
|
||||
return get<{ query: string; results: CoachCardLikeResult[] }>('/api/coach/cards-like', params)
|
||||
}
|
||||
|
||||
export async function generateReport(deckId?: number) {
|
||||
return post<{ status: string; decksUpdated: string[]; count: number }>('/api/coach/reports/generate',
|
||||
deckId ? { deck_id: deckId } : undefined
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { get, post, patch, postForm } from './client'
|
||||
import type { CollectionCard, CollectionFilters, SetInfo, ScanResult } from '../types'
|
||||
|
||||
export async function searchCollection(filters: CollectionFilters) {
|
||||
// Map frontend snake_case filter names to backend camelCase param names
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
if (filters.q) params.q = filters.q
|
||||
if (filters.page) params.page = filters.page
|
||||
if (filters.page_size) params.pageSize = filters.page_size
|
||||
if (filters.sort_by) params.sortField = filters.sort_by
|
||||
if (filters.sort_dir) params.sortDir = filters.sort_dir
|
||||
if (filters.color) params.colors = filters.color
|
||||
if (filters.type_line) params.types = filters.type_line
|
||||
if (filters.rarity) params.rarity = filters.rarity
|
||||
if (filters.set_code) params.setCode = filters.set_code
|
||||
if (filters.cmc_min !== undefined) params.cmcMin = filters.cmc_min
|
||||
if (filters.cmc_max !== undefined) params.cmcMax = filters.cmc_max
|
||||
if (filters.price_min !== undefined) params.priceMin = filters.price_min
|
||||
if (filters.price_max !== undefined) params.priceMax = filters.price_max
|
||||
if (filters.owned_only !== undefined) params.owned_only = filters.owned_only
|
||||
|
||||
const res = await get<{ items?: CollectionCard[]; cards?: CollectionCard[]; total: number; page: number; pageSize?: number; page_size?: number }>(
|
||||
'/api/collection', params
|
||||
)
|
||||
return {
|
||||
cards: res.items || res.cards || [],
|
||||
total: res.total || 0,
|
||||
page: res.page || 1,
|
||||
page_size: res.pageSize || res.page_size || 48,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCard(cardId: number) {
|
||||
return get<CollectionCard>(`/api/collection/${cardId}`)
|
||||
}
|
||||
|
||||
export async function updateCard(cardId: number, updates: Partial<CollectionCard>) {
|
||||
return patch<CollectionCard>(`/api/collection/${cardId}`, updates)
|
||||
}
|
||||
|
||||
export async function getSets() {
|
||||
const res = await get<SetInfo[] | { sets: SetInfo[] }>('/api/collection/sets')
|
||||
return Array.isArray(res) ? res : (res as { sets: SetInfo[] }).sets || []
|
||||
}
|
||||
|
||||
export async function getKeywords() {
|
||||
const res = await get<string[] | { keywords: string[] }>('/api/collection/keywords')
|
||||
return Array.isArray(res) ? res : (res as { keywords: string[] }).keywords || []
|
||||
}
|
||||
|
||||
export async function exportCollection(format: string = 'csv') {
|
||||
const res = await fetch(`/api/collection/export?format=${format}`)
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
export async function importCollection(file: File) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return postForm<{ imported: number; errors: string[] }>('/api/collection/import', fd)
|
||||
}
|
||||
|
||||
export async function scanCard(file: File) {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return postForm<ScanResult>('/api/collection/scan', fd)
|
||||
}
|
||||
|
||||
export async function addScanResult(match: { name: string; set_code: string; scryfall_id: string; quantity?: number }) {
|
||||
return post<{ success: boolean; card_id: number }>('/api/collection/scan/add', match)
|
||||
}
|
||||
|
||||
export async function reEnrichCards() {
|
||||
return post<{ updated: number }>('/api/collection/re-enrich')
|
||||
}
|
||||
|
||||
export async function autoClassify(cardId: number) {
|
||||
return post<{ classifications: string[] }>(`/api/collection/auto-classify`, { card_id: cardId })
|
||||
}
|
||||
|
||||
export async function autoClassifyAll() {
|
||||
return post<{ processed: number }>('/api/collection/auto-classify-all')
|
||||
}
|
||||
|
||||
export async function getEdhrecData(cardId: number) {
|
||||
return get<Record<string, unknown>>(`/api/collection/${cardId}/edhrec`)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { get, post } from './client'
|
||||
import type { DeckGenV3Request, DeckGenV3Status, DeckGenV3Result, CommanderSearchResult } from '../types'
|
||||
|
||||
export async function getV3Status() {
|
||||
return get<DeckGenV3Status>('/api/deck/v3/status')
|
||||
}
|
||||
|
||||
export async function searchCommanders(q: string) {
|
||||
return get<CommanderSearchResult[]>('/api/deck-generator/commander-search', { q })
|
||||
}
|
||||
|
||||
export async function generateDeckV3(req: DeckGenV3Request) {
|
||||
return post<DeckGenV3Result>('/api/deck/v3/generate', req)
|
||||
}
|
||||
|
||||
export async function commitDeckV3(req: DeckGenV3Request) {
|
||||
return post<DeckGenV3Result & { deck_id: number; dck_path: string }>('/api/deck/v3/commit', req)
|
||||
}
|
||||
|
||||
export async function exportDeckV3(format: 'csv' | 'dck' | 'moxfield' | 'shopping', req: DeckGenV3Request) {
|
||||
return post<Record<string, unknown>>(`/api/deck/v3/export/${format}`, req)
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { get, post, put, del, patch } from './client'
|
||||
import type { Deck, DeckCard, DeckAnalysis, EdhRecsResponse, CollectionRecsResponse, PplxStatus } from '../types'
|
||||
|
||||
export async function listDecks() {
|
||||
const res = await get<{ decks: Record<string, unknown>[] } | Record<string, unknown>[]>('/api/decks')
|
||||
const raw = Array.isArray(res) ? res : (res as { decks: Record<string, unknown>[] }).decks || []
|
||||
return raw.map(d => ({
|
||||
...d,
|
||||
commander: d.commander || d.commander_name || '',
|
||||
card_count: d.card_count ?? d.total_cards ?? 0,
|
||||
total_price: d.total_price ?? 0,
|
||||
color_identity: d.color_identity || [],
|
||||
created_date: d.created_date || d.created_at || '',
|
||||
updated_date: d.updated_date || d.updated_at || '',
|
||||
})) as Deck[]
|
||||
}
|
||||
|
||||
export async function getDeck(deckId: number) {
|
||||
return get<Deck>(`/api/decks/${deckId}`)
|
||||
}
|
||||
|
||||
export async function createDeck(deck: { name: string; commander?: string; format?: string }) {
|
||||
return post<Deck>('/api/decks', deck)
|
||||
}
|
||||
|
||||
export async function updateDeck(deckId: number, updates: Partial<Deck>) {
|
||||
return put<Deck>(`/api/decks/${deckId}`, updates)
|
||||
}
|
||||
|
||||
export async function deleteDeck(deckId: number) {
|
||||
return del<void>(`/api/decks/${deckId}`)
|
||||
}
|
||||
|
||||
export async function deleteAllDecks() {
|
||||
return del<void>('/api/decks')
|
||||
}
|
||||
|
||||
export async function getDeckCards(deckId: number) {
|
||||
const res = await get<{ cards: Record<string, unknown>[]; total: number } | Record<string, unknown>[]>(`/api/decks/${deckId}/cards`)
|
||||
const raw = Array.isArray(res) ? res : (res as { cards: Record<string, unknown>[] }).cards || []
|
||||
return raw.map(c => ({
|
||||
...c,
|
||||
name: c.name || c.card_name || '',
|
||||
category: c.category || c.role_tag || '',
|
||||
})) as DeckCard[]
|
||||
}
|
||||
|
||||
export async function addCardToDeck(deckId: number, card: { name: string; scryfall_id?: string; quantity?: number; category?: string }) {
|
||||
return post<DeckCard>(`/api/decks/${deckId}/cards`, card)
|
||||
}
|
||||
|
||||
export async function removeCardFromDeck(deckId: number, cardId: number) {
|
||||
return del<void>(`/api/decks/${deckId}/cards/${cardId}`)
|
||||
}
|
||||
|
||||
export async function updateDeckCard(deckId: number, cardId: number, updates: Partial<DeckCard>) {
|
||||
return patch<DeckCard>(`/api/decks/${deckId}/cards/${cardId}`, updates)
|
||||
}
|
||||
|
||||
export async function getDeckAnalysis(deckId: number) {
|
||||
const raw = await get<Record<string, unknown>>(`/api/decks/${deckId}/analysis`)
|
||||
// Backend returns: counts_by_type, targets, deltas, mana_curve, color_pips, total_cards, roles
|
||||
// Frontend DeckAnalysis expects: card_count, land_count, creature_count, noncreature_count, avg_cmc, mana_curve, color_distribution, type_distribution, total_price, owned_count, missing_count
|
||||
const counts = (raw.counts_by_type || {}) as Record<string, number>
|
||||
const totalCards = (raw.total_cards as number) || 0
|
||||
const landCount = counts['Land'] || 0
|
||||
const creatureCount = counts['Creature'] || 0
|
||||
const manaCurve = (raw.mana_curve || {}) as Record<string | number, number>
|
||||
const colorPips = (raw.color_pips || {}) as Record<string, number>
|
||||
// Compute avg CMC from mana curve
|
||||
let totalMana = 0, nonLandCards = 0
|
||||
for (const [cmc, count] of Object.entries(manaCurve)) {
|
||||
const cmcNum = cmc === '6+' ? 6 : Number(cmc)
|
||||
totalMana += cmcNum * count
|
||||
nonLandCards += count
|
||||
}
|
||||
return {
|
||||
card_count: totalCards,
|
||||
land_count: landCount,
|
||||
creature_count: creatureCount,
|
||||
noncreature_count: totalCards - landCount - creatureCount,
|
||||
avg_cmc: nonLandCards > 0 ? totalMana / nonLandCards : 0,
|
||||
mana_curve: manaCurve as Record<number, number>,
|
||||
color_distribution: colorPips,
|
||||
type_distribution: counts,
|
||||
total_price: 0,
|
||||
owned_count: totalCards,
|
||||
missing_count: 0,
|
||||
} as DeckAnalysis
|
||||
}
|
||||
|
||||
export async function getCollectionRecs(deckId: number) {
|
||||
return get<CollectionRecsResponse>(`/api/decks/${deckId}/recommended-from-collection`)
|
||||
}
|
||||
|
||||
export async function getEdhRecs(deckId: number, onlyOwned = false, maxResults = 30) {
|
||||
return get<EdhRecsResponse>(`/api/decks/${deckId}/edh-recs?only_owned=${onlyOwned}&max_results=${maxResults}`)
|
||||
}
|
||||
|
||||
export async function bulkAddCards(deckId: number, cards: { name: string; quantity?: number }[]) {
|
||||
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add`, { cards })
|
||||
}
|
||||
|
||||
export async function bulkAddRecommended(deckId: number, cards: string[]) {
|
||||
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add-recommended`, { card_names: cards })
|
||||
}
|
||||
|
||||
export async function exportDeckToSim(deckId: number) {
|
||||
return post<{ path: string }>(`/api/decks/${deckId}/export-to-sim`)
|
||||
}
|
||||
|
||||
export async function importDeckToDeck(deckId: number, data: { text?: string; url?: string }) {
|
||||
return post<{ imported: number }>(`/api/decks/${deckId}/import`, data)
|
||||
}
|
||||
|
||||
export async function importNewDeck(data: { text?: string; url?: string; name?: string }) {
|
||||
return post<Deck>('/api/decks/import-new', data)
|
||||
}
|
||||
|
||||
export async function getPplxStatus() {
|
||||
return get<PplxStatus>('/api/pplx/status')
|
||||
}
|
||||
|
||||
export async function deckResearch(payload: { commander: string; strategy?: string }) {
|
||||
return post<{ research: string }>('/api/deck-research', payload)
|
||||
}
|
||||
|
||||
export async function deckGenerate(payload: { commander: string; strategy?: string; bracket?: number }) {
|
||||
return post<{ cards: Array<{ name: string; quantity: number; category: string }> }>('/api/deck-generate', payload)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export * as collectionApi from './collection'
|
||||
export * as decksApi from './decks'
|
||||
export * as deckgenApi from './deckgen'
|
||||
export * as labApi from './lab'
|
||||
export * as simApi from './sim'
|
||||
export * as coachApi from './coach'
|
||||
export * as trainingApi from './training'
|
||||
@@ -1,65 +0,0 @@
|
||||
import { get, post } from './client'
|
||||
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
|
||||
|
||||
export async function getLabDecks() {
|
||||
const res = await get<{ decks: LabDeck[] } | LabDeck[]>('/api/lab/decks')
|
||||
return Array.isArray(res) ? res : (res as { decks: LabDeck[] }).decks || []
|
||||
}
|
||||
|
||||
export async function startBatchSim(config: { decks: string[]; games: number; threads?: number; use_deepseek?: boolean; deepseek_deck?: string }) {
|
||||
return post<{ run_id: string }>('/api/lab/start', config)
|
||||
}
|
||||
|
||||
export async function startDeepSeekSim(config: { decks: string[]; games: number; deepseek_deck: string }) {
|
||||
return post<{ run_id: string }>('/api/lab/start-deepseek', config)
|
||||
}
|
||||
|
||||
export async function getLabStatus() {
|
||||
return get<LabStatus>('/api/lab/status')
|
||||
}
|
||||
|
||||
export async function getLabResult() {
|
||||
return get<LabResult>('/api/lab/result')
|
||||
}
|
||||
|
||||
export async function getLabHistory() {
|
||||
const res = await get<{ results: LabHistoryEntry[] } | LabHistoryEntry[]>('/api/lab/history')
|
||||
return Array.isArray(res) ? res : (res as { results: LabHistoryEntry[] }).results || []
|
||||
}
|
||||
|
||||
export async function getPrecons() {
|
||||
const res = await get<{ precons: PreconDeck[] } | PreconDeck[]>('/api/lab/precons')
|
||||
return Array.isArray(res) ? res : (res as { precons: PreconDeck[] }).precons || []
|
||||
}
|
||||
|
||||
export async function installPrecon(name: string) {
|
||||
return post<{ installed: boolean }>('/api/lab/precons/install', { name })
|
||||
}
|
||||
|
||||
export async function installPreconBatch(names: string[]) {
|
||||
return post<{ installed: number }>('/api/lab/precons/install-batch', { names })
|
||||
}
|
||||
|
||||
export async function getMetaCommanders() {
|
||||
return get<Array<{ name: string; color_identity: string[] }>>('/api/lab/meta/commanders')
|
||||
}
|
||||
|
||||
export async function searchMetaCommanders(q: string) {
|
||||
return get<Array<{ name: string; color_identity: string[]; source: string }>>('/api/lab/meta/search', { q })
|
||||
}
|
||||
|
||||
export async function fetchMetaDeck(commander: string) {
|
||||
return post<{ deck_name: string; cards: Array<{ name: string; quantity: number }> }>('/api/lab/meta/fetch', { commander })
|
||||
}
|
||||
|
||||
export async function importDeckFromUrl(url: string) {
|
||||
return post<{ deck_name: string }>('/api/lab/import/url', { url })
|
||||
}
|
||||
|
||||
export async function importDeckFromText(text: string, name?: string) {
|
||||
return post<{ deck_name: string }>('/api/lab/import/text', { text, name })
|
||||
}
|
||||
|
||||
export async function getLabLog() {
|
||||
return get<{ log: string }>('/api/lab/log')
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { get, post } from './client'
|
||||
import type { SimStatus, SimResult, DeepSeekStatus } from '../types'
|
||||
|
||||
export async function runSim(config: { deck_a: string; deck_b: string; games: number }) {
|
||||
return post<{ sim_id: string }>('/api/sim/run', config)
|
||||
}
|
||||
|
||||
export async function runSimFromDeck(deckId: number, config: { opponent?: string; games?: number }) {
|
||||
return post<{ sim_id: string }>('/api/sim/run-from-deck', { deck_id: deckId, ...config })
|
||||
}
|
||||
|
||||
export async function runDeepSeekSim(config: { deck_a: string; deck_b: string; games: number; deepseek_deck: string }) {
|
||||
return post<{ sim_id: string }>('/api/sim/run-deepseek', config)
|
||||
}
|
||||
|
||||
export async function getSimStatus(simId: string) {
|
||||
return get<SimStatus>('/api/sim/status', { simId })
|
||||
}
|
||||
|
||||
export async function getSimResult(simId: string) {
|
||||
return get<SimResult>('/api/sim/result', { simId })
|
||||
}
|
||||
|
||||
export async function connectDeepSeek() {
|
||||
return post<DeepSeekStatus>('/api/deepseek/connect')
|
||||
}
|
||||
|
||||
export async function getDeepSeekStatus() {
|
||||
return get<DeepSeekStatus>('/api/deepseek/status')
|
||||
}
|
||||
|
||||
export async function configureDeepSeek(config: { model?: string; base_url?: string }) {
|
||||
return post<DeepSeekStatus>('/api/deepseek/configure', config)
|
||||
}
|
||||
|
||||
export async function getDeepSeekLogs() {
|
||||
return get<Array<{ filename: string; size: number; modified: string }>>('/api/deepseek/logs')
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { get, post } from './client'
|
||||
import type {
|
||||
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
|
||||
TournamentStatus, TournamentResults, MLModelInfo
|
||||
} from '../types'
|
||||
|
||||
// ── ML Status & Toggle ──────────────────────────────────────
|
||||
export async function getMLStatus() {
|
||||
return get<MLStatus>('/api/ml/status')
|
||||
}
|
||||
|
||||
export async function toggleML(enable = true) {
|
||||
return post<{ ml_logging_enabled: boolean; message: string }>(`/api/ml/toggle?enable=${enable}`)
|
||||
}
|
||||
|
||||
// ── Data & Model Info ───────────────────────────────────────
|
||||
export async function getDataStatus() {
|
||||
return get<MLDataStatus>('/api/ml/data/status')
|
||||
}
|
||||
|
||||
export async function getModelInfo() {
|
||||
return get<MLModelInfo>('/api/ml/model')
|
||||
}
|
||||
|
||||
export async function reloadModel(checkpoint?: string) {
|
||||
return post<{ success: boolean; status: MLModelInfo }>('/api/ml/reload', checkpoint ? { checkpoint } : undefined)
|
||||
}
|
||||
|
||||
// ── Supervised Training ─────────────────────────────────────
|
||||
export async function startTraining(config: {
|
||||
epochs?: number
|
||||
lr?: number
|
||||
batchSize?: number
|
||||
patience?: number
|
||||
rebuildDataset?: boolean
|
||||
}) {
|
||||
return post<{ status: string; config: Record<string, unknown> }>('/api/ml/train', config)
|
||||
}
|
||||
|
||||
export async function getTrainStatus() {
|
||||
return get<MLTrainStatus>('/api/ml/train/status')
|
||||
}
|
||||
|
||||
// ── PPO Training ────────────────────────────────────────────
|
||||
export async function startPPOTraining(config: {
|
||||
iterations?: number
|
||||
episodesPerIter?: number
|
||||
ppoEpochs?: number
|
||||
batchSize?: number
|
||||
lr?: number
|
||||
clipEpsilon?: number
|
||||
entropyCoeff?: number
|
||||
opponent?: string
|
||||
playstyle?: string
|
||||
loadSupervised?: string
|
||||
}) {
|
||||
return post<{ status: string; iterations: number }>('/api/ml/train/ppo', config)
|
||||
}
|
||||
|
||||
export async function getPPOStatus() {
|
||||
return get<PPOTrainStatus>('/api/ml/train/ppo/status')
|
||||
}
|
||||
|
||||
// ── Tournament ──────────────────────────────────────────────
|
||||
export async function startTournament(config: {
|
||||
episodes?: number
|
||||
playstyle?: string
|
||||
}) {
|
||||
return post<{ status: string; episodes: number }>('/api/ml/tournament', config)
|
||||
}
|
||||
|
||||
export async function getTournamentStatus() {
|
||||
return get<TournamentStatus>('/api/ml/tournament/status')
|
||||
}
|
||||
|
||||
export async function getTournamentResults() {
|
||||
return get<TournamentResults>('/api/ml/tournament/results')
|
||||
}
|
||||
|
||||
// ── Decision Files ──────────────────────────────────────────
|
||||
export async function getDecisionFile(filename: string, limit = 100, offset = 0) {
|
||||
return get<{ file: string; offset: number; limit: number; count: number; decisions: Record<string, unknown>[] }>(
|
||||
`/api/ml/decisions/${filename}?limit=${limit}&offset=${offset}`
|
||||
)
|
||||
}
|
||||
|
||||
// ── Prediction ──────────────────────────────────────────────
|
||||
export async function predict(state: Record<string, unknown>) {
|
||||
return post<{ action: string; action_index: number; confidence: number; probabilities: Record<string, number>; inference_ms: number }>(
|
||||
'/api/ml/predict', state
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface CardImageProps {
|
||||
src: string
|
||||
alt: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: 'w-16 h-[22px]', // tiny inline
|
||||
md: 'w-[146px] h-[204px]', // grid thumbnail
|
||||
lg: 'w-[244px] h-[340px]', // detail view
|
||||
xl: 'w-[336px] h-[468px]', // full size
|
||||
}
|
||||
|
||||
export function CardImage({ src, alt, size = 'md', className = '', onClick }: CardImageProps) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
// Build Scryfall URL if needed — ensure we get the right size
|
||||
const imgUrl = src || ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-lg bg-bg-tertiary ${SIZES[size]} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-accent-blue/50 transition-all' : ''} ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{!error ? (
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full text-text-tertiary text-xs text-center p-2">
|
||||
{alt}
|
||||
</div>
|
||||
)}
|
||||
{!loaded && !error && (
|
||||
<div className="absolute inset-0 bg-bg-tertiary animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Icon className="w-12 h-12 text-text-tertiary mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-1">{title}</h3>
|
||||
{description && <p className="text-sm text-text-secondary max-w-md mb-4">{description}</p>}
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Parse MTG mana cost strings like "{2}{W}{U}" into colored symbols
|
||||
|
||||
const MANA_COLORS: Record<string, string> = {
|
||||
W: 'bg-mana-white text-black',
|
||||
U: 'bg-mana-blue text-white',
|
||||
B: 'bg-mana-black text-white',
|
||||
R: 'bg-mana-red text-white',
|
||||
G: 'bg-mana-green text-white',
|
||||
C: 'bg-mana-colorless text-white',
|
||||
}
|
||||
|
||||
interface ManaSymbolsProps {
|
||||
cost: string
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export function ManaSymbols({ cost, size = 'sm' }: ManaSymbolsProps) {
|
||||
if (!cost) return null
|
||||
|
||||
const symbols = cost.match(/\{([^}]+)\}/g)
|
||||
if (!symbols) return <span className="text-text-tertiary">{cost}</span>
|
||||
|
||||
const sz = size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs'
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{symbols.map((s, i) => {
|
||||
const val = s.replace(/[{}]/g, '')
|
||||
const colorClass = MANA_COLORS[val] || 'bg-mana-colorless text-white'
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`${sz} inline-flex items-center justify-center rounded-full font-bold ${colorClass}`}
|
||||
>
|
||||
{val}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Color identity dots
|
||||
interface ColorDotsProps {
|
||||
colors: string[]
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export function ColorDots({ colors, size = 'sm' }: ColorDotsProps) {
|
||||
if (!colors || colors.length === 0) return <span className="text-text-tertiary text-xs">Colorless</span>
|
||||
|
||||
const sz = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'
|
||||
|
||||
const DOT_COLORS: Record<string, string> = {
|
||||
W: 'bg-mana-white',
|
||||
U: 'bg-mana-blue',
|
||||
B: 'bg-mana-black border border-border-secondary',
|
||||
R: 'bg-mana-red',
|
||||
G: 'bg-mana-green',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{colors.map(c => (
|
||||
<span key={c} className={`${sz} rounded-full ${DOT_COLORS[c] || 'bg-mana-colorless'}`} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
interface SpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const SIZES = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-8 h-8' }
|
||||
|
||||
export function Spinner({ size = 'md', className = '' }: SpinnerProps) {
|
||||
return (
|
||||
<svg
|
||||
className={`animate-spin ${SIZES[size]} ${className}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
cx="12" cy="12" r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" className="text-accent-blue" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
|
||||
|
||||
type Variant = 'success' | 'error' | 'warning' | 'info' | 'neutral'
|
||||
|
||||
interface StatusBadgeProps {
|
||||
variant: Variant
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const STYLES: Record<Variant, string> = {
|
||||
success: 'bg-status-success/15 text-status-success border-status-success/30',
|
||||
error: 'bg-status-error/15 text-status-error border-status-error/30',
|
||||
warning: 'bg-status-warning/15 text-status-warning border-status-warning/30',
|
||||
info: 'bg-status-info/15 text-status-info border-status-info/30',
|
||||
neutral: 'bg-bg-tertiary text-text-secondary border-border-primary',
|
||||
}
|
||||
|
||||
const ICONS: Record<Variant, typeof CheckCircle> = {
|
||||
success: CheckCircle,
|
||||
error: XCircle,
|
||||
warning: AlertTriangle,
|
||||
info: Info,
|
||||
neutral: Info,
|
||||
}
|
||||
|
||||
export function StatusBadge({ variant, label, className = '' }: StatusBadgeProps) {
|
||||
const Icon = ICONS[variant]
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md border ${STYLES[variant]} ${className}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { Spinner, PageSpinner } from './Spinner'
|
||||
export { StatusBadge } from './StatusBadge'
|
||||
export { CardImage } from './CardImage'
|
||||
export { ManaSymbols, ColorDots } from './ManaSymbols'
|
||||
export { EmptyState } from './EmptyState'
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function PageLayout({ children }: PageLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary">
|
||||
<Sidebar />
|
||||
<main className="ml-56 min-h-screen">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', label: 'Batch Sim', icon: '⚗️' },
|
||||
{ to: '/collection', label: 'Collection', icon: '📦' },
|
||||
{ to: '/decks', label: 'Decks', icon: '🃏' },
|
||||
{ to: '/autogen', label: 'Auto Gen', icon: '⚡' },
|
||||
{ to: '/simulator', label: 'Simulator', icon: '🎮' },
|
||||
{ to: '/coach', label: 'Coach', icon: '🧠' },
|
||||
{ to: '/training', label: 'Training', icon: '📈' },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 bottom-0 w-56 bg-bg-secondary border-r border-border-primary flex flex-col z-30">
|
||||
{/* Logo */}
|
||||
<div className="px-5 py-5 border-b border-border-primary">
|
||||
<h1 className="text-base font-bold text-text-primary tracking-tight">
|
||||
Commander AI Lab
|
||||
</h1>
|
||||
<p className="text-xs text-text-tertiary mt-0.5">MTG Intelligence Platform</p>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-3 overflow-y-auto">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-5 py-2.5 text-sm transition-colors duration-150 ${
|
||||
isActive
|
||||
? 'bg-accent-blue/10 text-accent-blue border-r-2 border-accent-blue'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-hover'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-border-primary">
|
||||
<p className="text-xs text-text-tertiary">v4.0 — React + TypeScript</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/** Polls a function at the given interval while `enabled` is true */
|
||||
export function usePolling(fn: () => void, intervalMs: number, enabled: boolean) {
|
||||
const savedFn = useRef(fn)
|
||||
savedFn.current = fn
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
const id = setInterval(() => savedFn.current(), intervalMs)
|
||||
return () => clearInterval(id)
|
||||
}, [intervalMs, enabled])
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Commander AI Lab — Design Tokens (Tailwind v4 CSS Theme)
|
||||
Fresh dark theme with MTG-inspired accents
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
@theme {
|
||||
/* ── Backgrounds ── */
|
||||
--color-bg-primary: #0f1117;
|
||||
--color-bg-secondary: #161921;
|
||||
--color-bg-tertiary: #1c2030;
|
||||
--color-bg-elevated: #222738;
|
||||
--color-bg-hover: #2a3048;
|
||||
|
||||
/* ── Borders ── */
|
||||
--color-border-primary: #2a2f42;
|
||||
--color-border-secondary: #363c54;
|
||||
--color-border-accent: #4f6ef7;
|
||||
|
||||
/* ── Text ── */
|
||||
--color-text-primary: #e8eaf0;
|
||||
--color-text-secondary: #9ba1b8;
|
||||
--color-text-tertiary: #6b7190;
|
||||
--color-text-inverse: #0f1117;
|
||||
|
||||
/* ── Accents ── */
|
||||
--color-accent-blue: #4f6ef7;
|
||||
--color-accent-blue-hover: #6b84ff;
|
||||
--color-accent-teal: #2dd4bf;
|
||||
--color-accent-teal-hover: #5eead4;
|
||||
--color-accent-purple: #a78bfa;
|
||||
--color-accent-amber: #fbbf24;
|
||||
--color-accent-red: #f87171;
|
||||
--color-accent-green: #4ade80;
|
||||
|
||||
/* ── MTG Mana Colors ── */
|
||||
--color-mana-white: #f9faf4;
|
||||
--color-mana-blue: #0e67ab;
|
||||
--color-mana-black: #2b2424;
|
||||
--color-mana-red: #d3202a;
|
||||
--color-mana-green: #00733e;
|
||||
--color-mana-colorless: #706f6f;
|
||||
|
||||
/* ── Status ── */
|
||||
--color-status-success: #4ade80;
|
||||
--color-status-warning: #fbbf24;
|
||||
--color-status-error: #f87171;
|
||||
--color-status-info: #60a5fa;
|
||||
|
||||
/* ── Radius ── */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
|
||||
/* ── Shadows ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow-blue: 0 0 20px rgba(79, 110, 247, 0.15);
|
||||
--shadow-glow-teal: 0 0 20px rgba(45, 212, 191, 0.15);
|
||||
}
|
||||
|
||||
/* ── Base Styles ── */
|
||||
html {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Utility animations ── */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(79, 110, 247, 0.2); }
|
||||
50% { box-shadow: 0 0 16px 4px rgba(79, 110, 247, 0.15); }
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,436 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Search, Wand2, Save, AlertCircle,
|
||||
ChevronDown, Sparkles, RefreshCw, Check
|
||||
} from 'lucide-react'
|
||||
import { Spinner, StatusBadge, ManaSymbols, CardImage, ColorDots } from '../components/common'
|
||||
import { deckgenApi } from '../api'
|
||||
import type { DeckGenV3Status, DeckGenV3Result, DeckGenV3Card, CommanderSearchResult } from '../types'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
|
||||
// ── Commander Search ──────────────────────────────────────────
|
||||
function CommanderSearch({ onSelect }: { onSelect: (c: CommanderSearchResult) => void }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<CommanderSearchResult[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
function handleSearch(q: string) {
|
||||
setQuery(q)
|
||||
clearTimeout(debounceRef.current)
|
||||
if (q.length < 2) { setResults([]); return }
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setSearching(true)
|
||||
try {
|
||||
const r = await deckgenApi.searchCommanders(q)
|
||||
setResults(r)
|
||||
} catch { setResults([]) }
|
||||
setSearching(false)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for a commander..."
|
||||
value={query}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
|
||||
/>
|
||||
{searching && <Spinner size="sm" className="absolute right-3 top-1/2 -translate-y-1/2 text-accent-blue" />}
|
||||
</div>
|
||||
{results.length > 0 && (
|
||||
<div className="absolute z-20 top-full mt-1 w-full bg-bg-elevated border border-border-primary rounded-xl shadow-lg max-h-80 overflow-y-auto">
|
||||
{results.map(c => (
|
||||
<button
|
||||
key={c.scryfall_id}
|
||||
onClick={() => { onSelect(c); setQuery(c.name); setResults([]) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-bg-hover text-left transition-colors"
|
||||
>
|
||||
<CardImage src={c.image_url} alt={c.name} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{c.name}</p>
|
||||
<p className="text-xs text-text-secondary truncate">{c.type_line}</p>
|
||||
</div>
|
||||
<ColorDots colors={c.color_identity} />
|
||||
{c.in_collection && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-accent-green/15 text-accent-green rounded">Owned</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Generated Deck View ───────────────────────────────────────
|
||||
function GeneratedDeckView({ result, onCommit }: { result: DeckGenV3Result; onCommit: () => void }) {
|
||||
const [view, setView] = useState<'visual' | 'list'>('visual')
|
||||
const [committing, setCommitting] = useState(false)
|
||||
const [selectedCard, setSelectedCard] = useState<DeckGenV3Card | null>(null)
|
||||
|
||||
// Group by category
|
||||
const grouped = result.cards.reduce<Record<string, DeckGenV3Card[]>>((acc, c) => {
|
||||
const cat = c.category || 'Other'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat].push(c)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const curveData = Object.entries(result.stats.mana_curve)
|
||||
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
|
||||
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
|
||||
|
||||
async function handleCommit() {
|
||||
setCommitting(true)
|
||||
try { onCommit() } catch { /* ignore */ }
|
||||
setCommitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
{/* Commander header */}
|
||||
<div className="flex gap-5 bg-bg-secondary rounded-xl border border-border-primary p-5">
|
||||
<CardImage src={result.commander.image_url} alt={result.commander.name} size="lg" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-primary">{result.commander.name}</h2>
|
||||
<p className="text-sm text-text-secondary">{result.commander.type_line}</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<ManaSymbols cost={result.commander.mana_cost} size="md" />
|
||||
<ColorDots colors={result.commander.color_identity} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-text-primary whitespace-pre-wrap">{result.commander.oracle_text}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-text-secondary">
|
||||
<span>Strategy: <span className="text-accent-blue font-medium">{result.strategy}</span></span>
|
||||
<span>Bracket: <span className="text-accent-amber font-medium">{result.bracket}</span></span>
|
||||
<span>Model: <span className="text-accent-purple font-medium">{result.model}</span></span>
|
||||
</div>
|
||||
{result.substitution && (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-accent-green">{result.substitution.owned} owned</span>
|
||||
<span className="text-accent-amber">{result.substitution.substituted} substituted</span>
|
||||
<span className="text-accent-red">{result.substitution.missing} missing</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={handleCommit} disabled={committing}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent-green/15 text-accent-green text-sm font-medium rounded-lg hover:bg-accent-green/25 border border-accent-green/30 transition-colors disabled:opacity-50">
|
||||
{committing ? <Spinner size="sm" /> : <Save className="w-4 h-4" />}
|
||||
Save to Decks
|
||||
</button>
|
||||
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
|
||||
<button onClick={() => setView('visual')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'visual' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
|
||||
Visual
|
||||
</button>
|
||||
<button onClick={() => setView('list')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
{/* Stats summary */}
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-text-tertiary">{result.stats.card_count} cards</span>
|
||||
<span className="text-xs text-accent-teal font-medium">${result.stats.total_price.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{/* Mana curve mini */}
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">Mana Curve</h4>
|
||||
<div className="h-28">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={curveData} barSize={20}>
|
||||
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 10 }} />
|
||||
<YAxis hide />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 11 }} />
|
||||
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
|
||||
{curveData.map((_, i) => <Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card grid/list */}
|
||||
{view === 'visual' ? (
|
||||
<div className="space-y-5">
|
||||
{Object.entries(grouped).map(([cat, cards]) => (
|
||||
<div key={cat}>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-3">{cat} ({cards.length})</h4>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-2">
|
||||
{cards.map(card => (
|
||||
<div key={card.name} className="group cursor-pointer relative" onClick={() => setSelectedCard(card)}>
|
||||
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
|
||||
{card.is_substitute && (
|
||||
<div className="absolute top-1 right-1 w-4 h-4 bg-accent-amber rounded-full flex items-center justify-center">
|
||||
<RefreshCw className="w-2.5 h-2.5 text-bg-primary" />
|
||||
</div>
|
||||
)}
|
||||
{card.owned && (
|
||||
<div className="absolute top-1 left-1 w-4 h-4 bg-accent-green rounded-full flex items-center justify-center">
|
||||
<Check className="w-2.5 h-2.5 text-bg-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-primary text-left">
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Category</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Role</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-primary">
|
||||
{result.cards.map(card => (
|
||||
<tr key={card.name} className="hover:bg-bg-hover transition-colors">
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="font-medium text-text-primary">{card.quantity}x {card.name}</span>
|
||||
{card.is_substitute && (
|
||||
<span className="ml-2 text-[10px] text-accent-amber">sub for {card.original_name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.category}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.role}</td>
|
||||
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
|
||||
<td className="px-4 py-2.5 text-right text-accent-teal text-xs">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded ${card.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary'}`}>
|
||||
{card.owned ? `Owned (${card.owned_qty})` : 'Missing'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card detail popup */}
|
||||
{selectedCard && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={() => setSelectedCard(null)}>
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-2xl w-full mx-4 p-6 animate-fade-in" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex gap-5">
|
||||
<CardImage src={selectedCard.image_url} alt={selectedCard.name} size="lg" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<h3 className="text-lg font-bold text-text-primary">{selectedCard.name}</h3>
|
||||
<p className="text-sm text-text-secondary">{selectedCard.type_line}</p>
|
||||
<ManaSymbols cost={selectedCard.mana_cost} size="md" />
|
||||
<p className="text-sm text-text-primary whitespace-pre-wrap">{selectedCard.reason}</p>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-text-tertiary">Role: <span className="text-accent-blue">{selectedCard.role}</span></span>
|
||||
{selectedCard.is_substitute && (
|
||||
<span className="text-accent-amber">Substituted for {selectedCard.original_name} ({(selectedCard.similarity_score ?? 0 * 100).toFixed(0)}%)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Auto Gen Page ────────────────────────────────────────
|
||||
export function AutoGenPage() {
|
||||
const [status, setStatus] = useState<DeckGenV3Status | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [result, setResult] = useState<DeckGenV3Result | null>(null)
|
||||
const [commander, setCommander] = useState<CommanderSearchResult | null>(null)
|
||||
const [strategy, setStrategy] = useState('')
|
||||
const [bracket, setBracket] = useState(3)
|
||||
const [budget, setBudget] = useState<string>('')
|
||||
const [useCollection, setUseCollection] = useState(true)
|
||||
const [runSub, setRunSub] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
deckgenApi.getV3Status().then(s => { setStatus(s); setLoading(false) }).catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!commander) return
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await deckgenApi.generateDeckV3({
|
||||
commander_name: commander.name,
|
||||
strategy: strategy || undefined,
|
||||
target_bracket: bracket,
|
||||
budget_usd: budget ? Number(budget) : undefined,
|
||||
use_collection: useCollection,
|
||||
run_substitution: runSub,
|
||||
})
|
||||
setResult(res)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Generation failed')
|
||||
}
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
async function handleCommit() {
|
||||
if (!commander || !result) return
|
||||
try {
|
||||
await deckgenApi.commitDeckV3({
|
||||
commander_name: commander.name,
|
||||
strategy: strategy || undefined,
|
||||
target_bracket: bracket,
|
||||
budget_usd: budget ? Number(budget) : undefined,
|
||||
use_collection: useCollection,
|
||||
run_substitution: runSub,
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<Sparkles className="w-6 h-6 text-accent-purple" />
|
||||
Auto Gen V3
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">AI-powered deck generation with Perplexity</p>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge
|
||||
variant={status?.initialized ? 'success' : 'error'}
|
||||
label={status?.initialized ? 'Ready' : 'Not Initialized'}
|
||||
/>
|
||||
<StatusBadge
|
||||
variant={status?.pplx_configured ? 'success' : 'warning'}
|
||||
label={status?.pplx_configured ? 'Perplexity Connected' : 'Perplexity Not Set'}
|
||||
/>
|
||||
{status?.embeddings_loaded && (
|
||||
<StatusBadge variant="info" label={`${status.embedding_cards.toLocaleString()} embeddings`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!result ? (
|
||||
/* Config form */
|
||||
<div className="max-w-2xl space-y-5">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text-primary block mb-2">Commander</label>
|
||||
<CommanderSearch onSelect={setCommander} />
|
||||
{commander && (
|
||||
<div className="mt-2 flex items-center gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
|
||||
<CardImage src={commander.image_url} alt={commander.name} size="sm" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{commander.name}</p>
|
||||
<p className="text-xs text-text-secondary">{commander.type_line}</p>
|
||||
</div>
|
||||
<ColorDots colors={commander.color_identity} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text-primary block mb-2">Strategy (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Voltron, Aristocrats, Storm..."
|
||||
value={strategy}
|
||||
onChange={e => setStrategy(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text-primary block mb-2">Target Bracket</label>
|
||||
<select
|
||||
value={bracket}
|
||||
onChange={e => setBracket(Number(e.target.value))}
|
||||
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
>
|
||||
<option value={1}>1 — Casual</option>
|
||||
<option value={2}>2 — Low Power</option>
|
||||
<option value={3}>3 — Mid Power</option>
|
||||
<option value={4}>4 — High Power</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-text-primary block mb-2">Budget (USD, optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="No limit"
|
||||
value={budget}
|
||||
onChange={e => setBudget(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={useCollection} onChange={e => setUseCollection(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
|
||||
<span className="text-sm text-text-secondary">Use collection cards</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={runSub} onChange={e => setRunSub(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
|
||||
<span className="text-sm text-text-secondary">Run substitution pass</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!commander || generating || !status?.initialized}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-accent-blue text-white text-sm font-semibold rounded-lg hover:bg-accent-blue-hover shadow-glow-blue transition-all disabled:opacity-50 disabled:shadow-none"
|
||||
>
|
||||
{generating ? (
|
||||
<><Spinner size="sm" /> Generating...</>
|
||||
) : (
|
||||
<><Wand2 className="w-4 h-4" /> Generate Deck</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Results */
|
||||
<div>
|
||||
<button onClick={() => setResult(null)}
|
||||
className="flex items-center gap-2 mb-4 text-sm text-text-secondary hover:text-text-primary transition-colors">
|
||||
<ChevronDown className="w-4 h-4 rotate-90" />
|
||||
Back to Config
|
||||
</button>
|
||||
<GeneratedDeckView result={result} onCommit={handleCommit} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
FlaskConical, Play, Clock, Trophy,
|
||||
Upload, Link, FileText, History, ChevronRight, AlertCircle,
|
||||
Check
|
||||
} from 'lucide-react'
|
||||
import { Spinner } from '../components/common'
|
||||
import { labApi } from '../api'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
|
||||
// ── Results Chart ─────────────────────────────────────────────
|
||||
function ResultsChart({ result }: { result: LabResult }) {
|
||||
const data = result.decks
|
||||
.sort((a, b) => b.win_rate - a.win_rate)
|
||||
.map(d => ({
|
||||
name: d.name.length > 20 ? d.name.slice(0, 18) + '...' : d.name,
|
||||
win_rate: Math.round(d.win_rate * 100),
|
||||
fullName: d.name,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} layout="vertical" barSize={20} margin={{ left: 10, right: 20 }}>
|
||||
<XAxis type="number" domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis type="category" dataKey="name" width={150} tick={{ fill: '#9ba1b8', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
|
||||
formatter={(value: unknown) => [`${value}%`, 'Win Rate']}
|
||||
labelFormatter={(label: unknown) => data.find(d => d.name === String(label))?.fullName || String(label)}
|
||||
/>
|
||||
<Bar dataKey="win_rate" radius={[0, 4, 4, 0]}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={i === 0 ? '#4ade80' : i === 1 ? '#4f6ef7' : '#363c54'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Deck Selector ─────────────────────────────────────────────
|
||||
function DeckSelector({
|
||||
decks,
|
||||
selected,
|
||||
onToggle,
|
||||
precons,
|
||||
onInstallPrecon,
|
||||
}: {
|
||||
decks: LabDeck[]
|
||||
selected: Set<string>
|
||||
onToggle: (name: string) => void
|
||||
precons: PreconDeck[]
|
||||
onInstallPrecon: (name: string) => void
|
||||
}) {
|
||||
const [showPrecons, setShowPrecons] = useState(false)
|
||||
const [installing, setInstalling] = useState<string | null>(null)
|
||||
|
||||
async function handleInstall(name: string) {
|
||||
setInstalling(name)
|
||||
await onInstallPrecon(name)
|
||||
setInstalling(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Available Decks</h3>
|
||||
{decks.length === 0 ? (
|
||||
<p className="text-xs text-text-tertiary">No decks available. Import or install precons.</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{decks.map(d => (
|
||||
<label key={d.name} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover cursor-pointer transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(d.name)}
|
||||
onChange={() => onToggle(d.name)}
|
||||
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-text-primary truncate block">{d.name}</span>
|
||||
<span className="text-[10px] text-text-tertiary">{d.source} · {d.card_count} cards</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowPrecons(!showPrecons)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium bg-bg-tertiary rounded-lg text-text-secondary hover:bg-bg-hover border border-border-primary transition-colors"
|
||||
>
|
||||
Precon Decks
|
||||
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showPrecons ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showPrecons && (
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto animate-fade-in">
|
||||
{precons.map(p => (
|
||||
<div key={p.name} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-bg-tertiary">
|
||||
<span className="flex-1 text-xs text-text-secondary truncate">{p.name}</span>
|
||||
{p.installed ? (
|
||||
<span className="text-[10px] text-accent-green flex items-center gap-1"><Check className="w-3 h-3" /> Installed</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleInstall(p.name)}
|
||||
disabled={installing === p.name}
|
||||
className="text-[10px] text-accent-blue hover:text-accent-blue-hover transition-colors"
|
||||
>
|
||||
{installing === p.name ? <Spinner size="sm" /> : 'Install'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Import Panel ──────────────────────────────────────────────
|
||||
function ImportPanel({ onImported }: { onImported: () => void }) {
|
||||
const [importUrl, setImportUrl] = useState('')
|
||||
const [importText, setImportText] = useState('')
|
||||
const [importName, setImportName] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importMode, setImportMode] = useState<'url' | 'text'>('url')
|
||||
|
||||
async function handleImport() {
|
||||
setImporting(true)
|
||||
try {
|
||||
if (importMode === 'url' && importUrl.trim()) {
|
||||
await labApi.importDeckFromUrl(importUrl.trim())
|
||||
} else if (importMode === 'text' && importText.trim()) {
|
||||
await labApi.importDeckFromText(importText.trim(), importName || undefined)
|
||||
}
|
||||
setImportUrl('')
|
||||
setImportText('')
|
||||
setImportName('')
|
||||
onImported()
|
||||
} catch { /* ignore */ }
|
||||
setImporting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<Upload className="w-4 h-4 text-accent-blue" />
|
||||
Import Deck
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setImportMode('url')}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'url' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
|
||||
<Link className="w-3 h-3 inline mr-1" />URL
|
||||
</button>
|
||||
<button onClick={() => setImportMode('text')}
|
||||
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'text' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
|
||||
<FileText className="w-3 h-3 inline mr-1" />Text
|
||||
</button>
|
||||
</div>
|
||||
{importMode === 'url' ? (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Moxfield, Archidekt, or deck URL..."
|
||||
value={importUrl}
|
||||
onChange={e => setImportUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Deck name"
|
||||
value={importName}
|
||||
onChange={e => setImportName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
<textarea
|
||||
placeholder={"1x Sol Ring\n1x Command Tower\n..."}
|
||||
value={importText}
|
||||
onChange={e => setImportText(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button onClick={handleImport} disabled={importing}
|
||||
className="w-full px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
|
||||
{importing ? <Spinner size="sm" /> : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Batch Sim Page ───────────────────────────────────────
|
||||
export function BatchSimPage() {
|
||||
const [decks, setDecks] = useState<LabDeck[]>([])
|
||||
const [precons, setPrecons] = useState<PreconDeck[]>([])
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [games, setGames] = useState(100)
|
||||
const [status, setStatus] = useState<LabStatus | null>(null)
|
||||
const [result, setResult] = useState<LabResult | null>(null)
|
||||
const [history, setHistory] = useState<LabHistoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
async function fetchDecks() {
|
||||
try {
|
||||
const [d, p] = await Promise.all([labApi.getLabDecks(), labApi.getPrecons()])
|
||||
setDecks(d)
|
||||
setPrecons(p)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchDecks()
|
||||
labApi.getLabStatus().then(setStatus).catch(() => {})
|
||||
labApi.getLabHistory().then(setHistory).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Poll while running
|
||||
usePolling(async () => {
|
||||
try {
|
||||
const s = await labApi.getLabStatus()
|
||||
setStatus(s)
|
||||
if (!s.running && s.run_id) {
|
||||
const r = await labApi.getLabResult()
|
||||
setResult(r)
|
||||
labApi.getLabHistory().then(setHistory).catch(() => {})
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000, status?.running || false)
|
||||
|
||||
function toggleDeck(name: string) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
if (selected.size < 2) { setError('Select at least 2 decks'); return }
|
||||
setStarting(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
await labApi.startBatchSim({ decks: [...selected], games })
|
||||
const s = await labApi.getLabStatus()
|
||||
setStatus(s)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start')
|
||||
}
|
||||
setStarting(false)
|
||||
}
|
||||
|
||||
async function handleInstallPrecon(name: string) {
|
||||
try {
|
||||
await labApi.installPrecon(name)
|
||||
fetchDecks()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<FlaskConical className="w-6 h-6 text-accent-teal" />
|
||||
Batch Simulator
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">Run mass simulations between decks</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5">
|
||||
{/* Left sidebar: deck selection + import */}
|
||||
<div className="w-72 flex-shrink-0 space-y-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32"><Spinner size="md" className="text-accent-blue" /></div>
|
||||
) : (
|
||||
<DeckSelector
|
||||
decks={decks}
|
||||
selected={selected}
|
||||
onToggle={toggleDeck}
|
||||
precons={precons}
|
||||
onInstallPrecon={handleInstallPrecon}
|
||||
/>
|
||||
)}
|
||||
<ImportPanel onImported={fetchDecks} />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 space-y-5">
|
||||
{/* Config + Start */}
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
max={10000}
|
||||
step={10}
|
||||
value={games}
|
||||
onChange={e => setGames(Number(e.target.value))}
|
||||
className="mt-1 w-28 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-text-tertiary">Selected: {selected.size} decks</p>
|
||||
</div>
|
||||
{status?.running ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner size="sm" className="text-accent-blue" />
|
||||
<span className="text-sm text-text-secondary">
|
||||
{status.games_completed} / {status.total_games} games
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-48 h-2 bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-blue rounded-full transition-all"
|
||||
style={{ width: `${status.total_games ? (status.games_completed / status.total_games) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleStart} disabled={starting || selected.size < 2}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-accent-teal/15 text-accent-teal text-sm font-semibold rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50">
|
||||
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
|
||||
Start Simulation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-3 flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||
<AlertCircle className="w-4 h-4" />{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4 text-accent-amber" />
|
||||
Results
|
||||
</h3>
|
||||
<span className="text-xs text-text-tertiary">{result.total_games} games · {new Date(result.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
<ResultsChart result={result} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-primary">
|
||||
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium uppercase tracking-wider">Deck</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Wins</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Games</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Win Rate</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Avg Turns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-primary">
|
||||
{result.decks.sort((a, b) => b.win_rate - a.win_rate).map((d, i) => (
|
||||
<tr key={d.name} className="hover:bg-bg-hover transition-colors">
|
||||
<td className="px-3 py-2.5 flex items-center gap-2">
|
||||
{i === 0 && <Trophy className="w-3.5 h-3.5 text-accent-amber" />}
|
||||
<span className="font-medium text-text-primary">{d.name}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right text-text-secondary">{d.wins}</td>
|
||||
<td className="px-3 py-2.5 text-right text-text-secondary">{d.games}</td>
|
||||
<td className="px-3 py-2.5 text-right font-semibold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</td>
|
||||
<td className="px-3 py-2.5 text-right text-text-secondary">{d.avg_turns.toFixed(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<div>
|
||||
<button onClick={() => setShowHistory(!showHistory)}
|
||||
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors">
|
||||
<History className="w-4 h-4" />
|
||||
Run History ({history.length})
|
||||
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showHistory ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
{showHistory && history.length > 0 && (
|
||||
<div className="mt-3 space-y-2 animate-fade-in">
|
||||
{history.map(h => (
|
||||
<div key={h.run_id} className="flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary">
|
||||
<Clock className="w-4 h-4 text-text-tertiary" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-text-primary">{h.decks.join(' vs ')}</p>
|
||||
<p className="text-xs text-text-tertiary">{new Date(h.timestamp).toLocaleString()} · {h.total_games} games</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,949 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
GraduationCap, MessageSquare, Send, Sparkles, AlertCircle, X, Check,
|
||||
ChevronRight, ChevronDown, Download, Settings2, Search, Zap,
|
||||
Minus, Plus, History, ArrowRightLeft, Lightbulb, DollarSign, Loader2,
|
||||
ClipboardList, Brain, BarChart3
|
||||
} from 'lucide-react'
|
||||
import { Spinner, StatusBadge, EmptyState } from '../components/common'
|
||||
import { coachApi } from '../api'
|
||||
import type {
|
||||
CoachStatus, CoachDeck, CoachSession, CoachSessionSummary,
|
||||
CoachMessage, CoachGoals, CoachApplyResult, CoachCardLikeResult
|
||||
} from '../types'
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Goals Config Panel
|
||||
// ════════════════════════════════════════════════════════════
|
||||
function GoalsPanel({
|
||||
goals, onChange, collapsed, onToggle
|
||||
}: {
|
||||
goals: CoachGoals
|
||||
onChange: (g: CoachGoals) => void
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const strategies = ['aggro', 'control', 'combo', 'midrange', 'stax', 'voltron', 'aristocrats', 'tokens']
|
||||
const budgets = [
|
||||
{ value: 'budget', label: 'Budget', desc: 'Under $5/card' },
|
||||
{ value: 'medium', label: 'Medium', desc: 'Under $20/card' },
|
||||
{ value: 'no-limit', label: 'No Limit', desc: 'Any price' },
|
||||
]
|
||||
const focusOptions = ['ramp', 'card draw', 'removal', 'protection', 'threats', 'mana base', 'combos', 'synergy']
|
||||
|
||||
return (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-bg-hover transition-colors rounded-xl"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-accent-teal" />
|
||||
<span className="text-sm font-medium text-text-primary flex-1">Coaching Goals</span>
|
||||
{collapsed ? <ChevronRight className="w-4 h-4 text-text-tertiary" /> : <ChevronDown className="w-4 h-4 text-text-tertiary" />}
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-4 pb-4 space-y-4 border-t border-border-primary pt-3">
|
||||
{/* Power Level Slider */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-text-secondary mb-1 block">
|
||||
Power Level: {goals.targetPowerLevel ?? 'Any'}
|
||||
</label>
|
||||
<input
|
||||
type="range" min={1} max={10}
|
||||
value={goals.targetPowerLevel ?? 5}
|
||||
onChange={e => onChange({ ...goals, targetPowerLevel: parseInt(e.target.value) })}
|
||||
className="w-full accent-accent-teal"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-text-tertiary">
|
||||
<span>Casual</span><span>Focused</span><span>cEDH</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategy */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Strategy Focus</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{strategies.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onChange({ ...goals, metaFocus: goals.metaFocus === s ? null : s })}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||
goals.metaFocus === s
|
||||
? 'bg-accent-teal/20 border-accent-teal/40 text-accent-teal'
|
||||
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Budget</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{budgets.map(b => (
|
||||
<button
|
||||
key={b.value}
|
||||
onClick={() => onChange({ ...goals, budget: goals.budget === b.value ? null : b.value })}
|
||||
className={`px-3 py-2 text-xs rounded-lg border transition-colors ${
|
||||
goals.budget === b.value
|
||||
? 'bg-accent-teal/20 border-accent-teal/40 text-accent-teal'
|
||||
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{b.label}</div>
|
||||
<div className="text-[10px] opacity-60">{b.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Focus Areas */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Focus Areas</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{focusOptions.map(f => {
|
||||
const active = (goals.focusAreas || []).includes(f)
|
||||
return (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => {
|
||||
const areas = goals.focusAreas || []
|
||||
onChange({
|
||||
...goals,
|
||||
focusAreas: active ? areas.filter(a => a !== f) : [...areas, f]
|
||||
})
|
||||
}}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||
active
|
||||
? 'bg-accent-blue/20 border-accent-blue/40 text-accent-blue'
|
||||
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Session View — Shows coaching results with apply functionality
|
||||
// ════════════════════════════════════════════════════════════
|
||||
function SessionView({
|
||||
session, deckId, onClose, onApplied
|
||||
}: {
|
||||
session: CoachSession
|
||||
deckId: number
|
||||
onClose: () => void
|
||||
onApplied?: () => void
|
||||
}) {
|
||||
const [selectedCuts, setSelectedCuts] = useState<Set<string>>(new Set())
|
||||
const [selectedAdds, setSelectedAdds] = useState<Set<string>>(new Set())
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [applyResult, setApplyResult] = useState<CoachApplyResult | null>(null)
|
||||
|
||||
const toggleCut = (name: string) => {
|
||||
const s = new Set(selectedCuts)
|
||||
s.has(name) ? s.delete(name) : s.add(name)
|
||||
setSelectedCuts(s)
|
||||
}
|
||||
const toggleAdd = (name: string) => {
|
||||
const s = new Set(selectedAdds)
|
||||
s.has(name) ? s.delete(name) : s.add(name)
|
||||
setSelectedAdds(s)
|
||||
}
|
||||
|
||||
const handleApply = async () => {
|
||||
if (selectedCuts.size === 0 && selectedAdds.size === 0) return
|
||||
setApplying(true)
|
||||
try {
|
||||
const result = await coachApi.applyCoachSuggestions(
|
||||
session.sessionId, deckId,
|
||||
[...selectedCuts], [...selectedAdds]
|
||||
)
|
||||
setApplyResult(result)
|
||||
onApplied?.()
|
||||
} catch (e) {
|
||||
console.error('Apply failed:', e)
|
||||
}
|
||||
setApplying(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-primary">Coaching Session</h3>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{new Date(session.timestamp).toLocaleString()} · {session.modelUsed}
|
||||
{session.promptTokens > 0 && ` · ${session.promptTokens + session.completionTokens} tokens`}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-bg-hover text-text-tertiary">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{session.summary && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
|
||||
<h4 className="text-xs font-medium text-text-secondary mb-2 flex items-center gap-1.5">
|
||||
<Brain className="w-3.5 h-3.5" /> Summary
|
||||
</h4>
|
||||
<p className="text-sm text-text-primary leading-relaxed">{session.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heuristic Hints */}
|
||||
{session.heuristicHints?.length > 0 && (
|
||||
<div className="bg-accent-amber/5 rounded-xl border border-accent-amber/20 p-4">
|
||||
<h4 className="text-xs font-medium text-accent-amber mb-2 flex items-center gap-1.5">
|
||||
<Lightbulb className="w-3.5 h-3.5" /> Strategic Tips
|
||||
</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{session.heuristicHints.map((h, i) => (
|
||||
<li key={i} className="text-sm text-text-primary flex items-start gap-2">
|
||||
<span className="text-accent-amber mt-0.5">•</span> {h}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mana Base Advice */}
|
||||
{session.manaBaseAdvice && (
|
||||
<div className="bg-accent-blue/5 rounded-xl border border-accent-blue/20 p-4">
|
||||
<h4 className="text-xs font-medium text-accent-blue mb-2 flex items-center gap-1.5">
|
||||
<BarChart3 className="w-3.5 h-3.5" /> Mana Base
|
||||
</h4>
|
||||
<p className="text-sm text-text-primary">{session.manaBaseAdvice}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested Cuts */}
|
||||
{session.suggestedCuts?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-status-error mb-2 flex items-center gap-1.5">
|
||||
<Minus className="w-4 h-4" /> Suggested Cuts ({session.suggestedCuts.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{session.suggestedCuts.map((cut, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => toggleCut(cut.cardName)}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedCuts.has(cut.cardName)
|
||||
? 'bg-status-error/10 border-status-error/30'
|
||||
: 'bg-bg-secondary border-border-primary hover:border-border-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className={`mt-0.5 w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||
selectedCuts.has(cut.cardName)
|
||||
? 'bg-status-error border-status-error text-white'
|
||||
: 'border-border-secondary'
|
||||
}`}>
|
||||
{selectedCuts.has(cut.cardName) && <Check className="w-3 h-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary">{cut.cardName}</p>
|
||||
<p className="text-xs text-text-secondary mt-0.5">{cut.reason}</p>
|
||||
{cut.replacementOptions?.length > 0 && (
|
||||
<p className="text-[10px] text-text-tertiary mt-1">
|
||||
Replace with: {cut.replacementOptions.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested Adds */}
|
||||
{session.suggestedAdds?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-status-success mb-2 flex items-center gap-1.5">
|
||||
<Plus className="w-4 h-4" /> Suggested Adds ({session.suggestedAdds.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{session.suggestedAdds.map((add, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => toggleAdd(add.cardName)}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedAdds.has(add.cardName)
|
||||
? 'bg-status-success/10 border-status-success/30'
|
||||
: 'bg-bg-secondary border-border-primary hover:border-border-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className={`mt-0.5 w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||
selectedAdds.has(add.cardName)
|
||||
? 'bg-status-success border-status-success text-white'
|
||||
: 'border-border-secondary'
|
||||
}`}>
|
||||
{selectedAdds.has(add.cardName) && <Check className="w-3 h-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-text-primary">{add.cardName}</p>
|
||||
{add.role && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-accent-blue/15 text-accent-blue rounded">
|
||||
{add.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary mt-0.5">{add.reason}</p>
|
||||
{add.synergyWith?.length > 0 && (
|
||||
<p className="text-[10px] text-text-tertiary mt-1">
|
||||
Synergy: {add.synergyWith.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apply Button */}
|
||||
{!applyResult && (selectedCuts.size > 0 || selectedAdds.size > 0) && (
|
||||
<div className="sticky bottom-0 bg-bg-primary/95 backdrop-blur-sm border-t border-border-primary py-3 -mx-6 px-6">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applying}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{applying ? <Spinner size="sm" /> : <ArrowRightLeft className="w-4 h-4" />}
|
||||
Apply {selectedCuts.size > 0 && `${selectedCuts.size} cuts`}
|
||||
{selectedCuts.size > 0 && selectedAdds.size > 0 && ' + '}
|
||||
{selectedAdds.size > 0 && `${selectedAdds.size} adds`} to Deck
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apply Results */}
|
||||
{applyResult && (
|
||||
<div className="bg-status-success/5 rounded-xl border border-status-success/20 p-4">
|
||||
<h4 className="text-sm font-medium text-status-success mb-2 flex items-center gap-1.5">
|
||||
<Check className="w-4 h-4" /> Changes Applied
|
||||
</h4>
|
||||
<p className="text-sm text-text-primary">
|
||||
{applyResult.total_cuts > 0 && `Removed ${applyResult.total_cuts} card(s). `}
|
||||
{applyResult.total_adds > 0 && `Added ${applyResult.total_adds} card(s). `}
|
||||
{applyResult.errors.length > 0 && `${applyResult.errors.length} error(s).`}
|
||||
</p>
|
||||
{applyResult.errors.length > 0 && (
|
||||
<ul className="mt-2 text-xs text-status-error">
|
||||
{applyResult.errors.map((e, i) => (
|
||||
<li key={i}>{e.name}: {e.error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw Explanation */}
|
||||
{session.rawTextExplanation && (
|
||||
<details className="group">
|
||||
<summary className="text-xs text-text-tertiary cursor-pointer hover:text-text-secondary">
|
||||
Full analysis...
|
||||
</summary>
|
||||
<p className="mt-2 text-sm text-text-secondary whitespace-pre-wrap leading-relaxed bg-bg-secondary rounded-lg p-4 border border-border-primary">
|
||||
{session.rawTextExplanation}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Cards Like Search
|
||||
// ════════════════════════════════════════════════════════════
|
||||
function CardsLikePanel() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<CoachCardLikeResult[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) return
|
||||
setSearching(true)
|
||||
try {
|
||||
const data = await coachApi.searchCardsLike(query.trim(), undefined, 12)
|
||||
setResults(data.results)
|
||||
} catch { /* ignore */ }
|
||||
setSearching(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Enter a card name..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !query.trim()}
|
||||
className="px-4 py-2 bg-accent-teal/15 text-accent-teal text-sm font-medium rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{searching ? <Spinner size="sm" /> : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{results.map((card, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{card.name}</p>
|
||||
<span className="text-[10px] text-text-tertiary whitespace-nowrap">
|
||||
{(card.similarity * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-secondary mt-0.5">{card.types}</p>
|
||||
{card.text && (
|
||||
<p className="text-[10px] text-text-tertiary mt-1 line-clamp-2">{card.text}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1.5 text-[10px]">
|
||||
{card.tcg_price != null && (
|
||||
<span className="text-accent-amber flex items-center gap-0.5">
|
||||
<DollarSign className="w-2.5 h-2.5" />{card.tcg_price.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{card.owned_qty > 0 && (
|
||||
<span className="text-status-success">Owned: {card.owned_qty}</span>
|
||||
)}
|
||||
<span className="text-text-tertiary">MV: {card.mana_value}</span>
|
||||
</div>
|
||||
</div>
|
||||
{card.image_url && (
|
||||
<img src={card.image_url} alt={card.name} className="w-14 h-auto rounded" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Live Chat View
|
||||
// ════════════════════════════════════════════════════════════
|
||||
function LiveChat({
|
||||
deckId, deckName, goals, onClose
|
||||
}: {
|
||||
deckId: string
|
||||
deckName: string
|
||||
goals: CoachGoals
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [messages, setMessages] = useState<CoachMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [streamText, setStreamText] = useState('')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, streamText])
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!input.trim() || streaming) return
|
||||
const userMsg: CoachMessage = { role: 'user', content: input.trim() }
|
||||
const allMessages = [...messages, userMsg]
|
||||
setMessages(allMessages)
|
||||
setInput('')
|
||||
setStreaming(true)
|
||||
setStreamText('')
|
||||
|
||||
const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0
|
||||
? {
|
||||
targetPowerLevel: goals.targetPowerLevel,
|
||||
metaFocus: goals.metaFocus,
|
||||
budget: goals.budget,
|
||||
focusAreas: goals.focusAreas,
|
||||
}
|
||||
: undefined
|
||||
|
||||
try {
|
||||
abortRef.current = new AbortController()
|
||||
const fullText = await coachApi.coachChatStream(
|
||||
deckId,
|
||||
allMessages.map(m => ({ role: m.role, content: m.content })),
|
||||
goalsDict,
|
||||
(text) => setStreamText(text),
|
||||
abortRef.current.signal
|
||||
)
|
||||
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: fullText }])
|
||||
setStreamText('')
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== 'AbortError') {
|
||||
// Fallback to non-streaming
|
||||
try {
|
||||
const resp = await coachApi.coachChat(
|
||||
deckId,
|
||||
allMessages.map(m => ({ role: m.role, content: m.content })),
|
||||
goalsDict
|
||||
)
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: resp.content }])
|
||||
} catch (e2) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: `Error: ${(e2 as Error).message || 'Failed to get response'}`
|
||||
}])
|
||||
}
|
||||
}
|
||||
setStreamText('')
|
||||
}
|
||||
setStreaming(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-180px)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border-primary">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-accent-blue" />
|
||||
Chat: {deckName}
|
||||
</h3>
|
||||
<p className="text-[10px] text-text-tertiary">
|
||||
{goals.metaFocus && `${goals.metaFocus} · `}
|
||||
{goals.targetPowerLevel && `Power ${goals.targetPowerLevel} · `}
|
||||
{goals.budget && `${goals.budget}`}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-bg-hover text-text-tertiary">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.length === 0 && !streaming && (
|
||||
<div className="text-center py-12">
|
||||
<GraduationCap className="w-10 h-10 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-sm text-text-secondary">Ask your coach anything about this deck.</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2 justify-center">
|
||||
{[
|
||||
'What should I cut?',
|
||||
'How is my mana curve?',
|
||||
'Suggest win conditions',
|
||||
'Rate my removal suite',
|
||||
].map(q => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => { setInput(q); }}
|
||||
className="px-3 py-1.5 text-xs bg-bg-secondary border border-border-primary rounded-full text-text-secondary hover:text-text-primary hover:border-border-secondary transition-colors"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? 'bg-accent-blue/15 text-text-primary border border-accent-blue/20'
|
||||
: 'bg-bg-secondary text-text-primary border border-border-primary'
|
||||
}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{streaming && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[85%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed bg-bg-secondary text-text-primary border border-border-primary">
|
||||
{streamText || (
|
||||
<span className="flex items-center gap-2 text-text-tertiary">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Thinking...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-border-primary p-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage()}
|
||||
placeholder="Ask the coach..."
|
||||
disabled={streaming}
|
||||
className="flex-1 px-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={streaming || !input.trim()}
|
||||
className="px-3 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Main Coach Page
|
||||
// ════════════════════════════════════════════════════════════
|
||||
type Tab = 'decks' | 'chat' | 'session' | 'history' | 'search'
|
||||
|
||||
export function CoachPage() {
|
||||
const [status, setStatus] = useState<CoachStatus | null>(null)
|
||||
const [decks, setDecks] = useState<CoachDeck[]>([])
|
||||
const [sessions, setSessions] = useState<CoachSessionSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// UI state
|
||||
const [tab, setTab] = useState<Tab>('decks')
|
||||
const [selectedDeck, setSelectedDeck] = useState<CoachDeck | null>(null)
|
||||
const [selectedSession, setSelectedSession] = useState<CoachSession | null>(null)
|
||||
const [goals, setGoals] = useState<CoachGoals>({})
|
||||
const [goalsCollapsed, setGoalsCollapsed] = useState(true)
|
||||
const [generatingReport, setGeneratingReport] = useState(false)
|
||||
const [runningSession, setRunningSession] = useState<number | null>(null)
|
||||
const [downloadingEmbeddings, setDownloadingEmbeddings] = useState(false)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [s, d, sess] = await Promise.all([
|
||||
coachApi.getCoachStatus(),
|
||||
coachApi.getCoachDecks().catch(() => []),
|
||||
coachApi.getCoachSessions().catch(() => ({ sessions: [] })),
|
||||
])
|
||||
setStatus(s)
|
||||
setDecks(Array.isArray(d) ? d : [])
|
||||
setSessions(Array.isArray(sess) ? sess : (sess as { sessions: CoachSessionSummary[] }).sessions || [])
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const handleDownloadEmbeddings = async () => {
|
||||
setDownloadingEmbeddings(true)
|
||||
try {
|
||||
await coachApi.downloadEmbeddings()
|
||||
const s = await coachApi.getCoachStatus()
|
||||
setStatus(s)
|
||||
} catch { /* ignore */ }
|
||||
setDownloadingEmbeddings(false)
|
||||
}
|
||||
|
||||
const handleGenerateReports = async () => {
|
||||
setGeneratingReport(true)
|
||||
setError(null)
|
||||
try {
|
||||
await coachApi.generateReport()
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Report generation failed')
|
||||
}
|
||||
setGeneratingReport(false)
|
||||
}
|
||||
|
||||
const handleRunSession = async (deck: CoachDeck) => {
|
||||
setRunningSession(deck.deck_id)
|
||||
setError(null)
|
||||
try {
|
||||
const deckSlug = deck.deck_name.toLowerCase().replace(/\s+/g, '-')
|
||||
const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0
|
||||
? goals : undefined
|
||||
const session = await coachApi.runCoachSession(deckSlug, goalsDict as Record<string, unknown>)
|
||||
setSelectedSession(session)
|
||||
setSelectedDeck(deck)
|
||||
setTab('session')
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Session failed')
|
||||
}
|
||||
setRunningSession(null)
|
||||
}
|
||||
|
||||
const handleOpenChat = (deck: CoachDeck) => {
|
||||
setSelectedDeck(deck)
|
||||
setTab('chat')
|
||||
}
|
||||
|
||||
const handleViewSession = async (sessionId: string) => {
|
||||
try {
|
||||
const s = await coachApi.getCoachSession(sessionId)
|
||||
setSelectedSession(s)
|
||||
// Find the matching deck
|
||||
const matchDeck = decks.find(d =>
|
||||
d.deck_name.toLowerCase().replace(/\s+/g, '-') === s.deckId.toLowerCase() ||
|
||||
d.deck_name.toLowerCase() === s.deckId.toLowerCase()
|
||||
)
|
||||
if (matchDeck) setSelectedDeck(matchDeck)
|
||||
setTab('session')
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-64">
|
||||
<Spinner size="lg" className="text-accent-blue" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<GraduationCap className="w-6 h-6 text-accent-amber" />
|
||||
Deck Coach
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">
|
||||
AI-powered deck analysis, coaching, and live chat
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<StatusBadge
|
||||
variant={status?.llmConnected ? 'success' : 'warning'}
|
||||
label={status?.llmConnected ? `LLM: ${status.llmModel || 'Connected'}` : 'LLM: Disconnected'}
|
||||
/>
|
||||
<StatusBadge
|
||||
variant={status?.embeddingsLoaded ? 'success' : 'neutral'}
|
||||
label={status?.embeddingsLoaded ? `${(status.embeddingCards || 0).toLocaleString()} embeddings` : 'No embeddings'}
|
||||
/>
|
||||
{status?.deckReportsAvailable != null && status.deckReportsAvailable > 0 && (
|
||||
<StatusBadge variant="success" label={`${status.deckReportsAvailable} reports`} />
|
||||
)}
|
||||
{!status?.embeddingsLoaded && (
|
||||
<button onClick={handleDownloadEmbeddings} disabled={downloadingEmbeddings}
|
||||
className="flex items-center gap-1.5 text-xs text-accent-blue hover:text-accent-blue-hover transition-colors disabled:opacity-50">
|
||||
{downloadingEmbeddings ? <Spinner size="sm" /> : <Download className="w-3 h-3" />}
|
||||
Download Embeddings
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />{error}
|
||||
<button onClick={() => setError(null)} className="ml-auto"><X className="w-3 h-3" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Nav */}
|
||||
<div className="flex gap-1 bg-bg-secondary rounded-lg p-1 border border-border-primary">
|
||||
{([
|
||||
{ id: 'decks', label: 'Decks', icon: ClipboardList },
|
||||
{ id: 'history', label: 'Sessions', icon: History },
|
||||
{ id: 'search', label: 'Cards Like', icon: Search },
|
||||
] as { id: Tab; label: string; icon: typeof ClipboardList }[]).map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
tab === t.id
|
||||
? 'bg-bg-primary text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="w-3.5 h-3.5" />
|
||||
{t.label}
|
||||
{t.id === 'history' && sessions.length > 0 && (
|
||||
<span className="text-[10px] bg-bg-tertiary px-1.5 rounded-full">{sessions.length}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{tab === 'chat' && selectedDeck && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-bg-primary text-accent-blue shadow-sm">
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
Chat
|
||||
</div>
|
||||
)}
|
||||
{tab === 'session' && selectedSession && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-bg-primary text-accent-amber shadow-sm">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Results
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Goals Panel (shown when on decks tab) */}
|
||||
{tab === 'decks' && (
|
||||
<GoalsPanel
|
||||
goals={goals}
|
||||
onChange={setGoals}
|
||||
collapsed={goalsCollapsed}
|
||||
onToggle={() => setGoalsCollapsed(!goalsCollapsed)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Decks Tab ─────────────────────────────────────── */}
|
||||
{tab === 'decks' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-text-primary">Your Decks</h2>
|
||||
<button
|
||||
onClick={handleGenerateReports}
|
||||
disabled={generatingReport}
|
||||
className="flex items-center gap-1.5 text-xs text-accent-amber hover:text-accent-amber transition-colors disabled:opacity-50"
|
||||
>
|
||||
{generatingReport ? <Spinner size="sm" /> : <Zap className="w-3 h-3" />}
|
||||
Generate Reports
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{decks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={GraduationCap}
|
||||
title="No decks available"
|
||||
description="Create decks in the Deck Builder to start coaching."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{decks.map(deck => (
|
||||
<div key={deck.deck_id} className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">{deck.deck_name}</h3>
|
||||
<p className="text-xs text-text-secondary">{deck.commander}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-text-tertiary">
|
||||
<span>{deck.card_count} cards</span>
|
||||
{deck.has_report && (
|
||||
<span className="flex items-center gap-1 text-status-success">
|
||||
<Check className="w-2.5 h-2.5" /> Report
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleRunSession(deck)}
|
||||
disabled={runningSession === deck.deck_id || !status?.llmConnected}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-amber/15 text-accent-amber text-xs font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{runningSession === deck.deck_id ? <Spinner size="sm" /> : <Sparkles className="w-3 h-3" />}
|
||||
Analyze
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenChat(deck)}
|
||||
disabled={!status?.llmConnected}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-blue/15 text-accent-blue text-xs font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chat Tab ──────────────────────────────────────── */}
|
||||
{tab === 'chat' && selectedDeck && (
|
||||
<LiveChat
|
||||
deckId={selectedDeck.deck_name.toLowerCase().replace(/\s+/g, '-')}
|
||||
deckName={selectedDeck.deck_name}
|
||||
goals={goals}
|
||||
onClose={() => setTab('decks')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Session Results Tab ───────────────────────────── */}
|
||||
{tab === 'session' && selectedSession && selectedDeck && (
|
||||
<SessionView
|
||||
session={selectedSession}
|
||||
deckId={selectedDeck.deck_id}
|
||||
onClose={() => setTab('decks')}
|
||||
onApplied={() => loadData()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── History Tab ───────────────────────────────────── */}
|
||||
{tab === 'history' && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-text-primary">Coaching Sessions</h2>
|
||||
{sessions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={History}
|
||||
title="No sessions yet"
|
||||
description="Run an analysis on a deck to create your first coaching session."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sessions.map(s => (
|
||||
<button
|
||||
key={s.sessionId}
|
||||
onClick={() => handleViewSession(s.sessionId)}
|
||||
className="w-full flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary hover:border-border-secondary text-left transition-colors"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-accent-amber flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-text-primary">{s.deckId}</p>
|
||||
<span className="text-[10px] text-text-tertiary">
|
||||
{s.cutsCount > 0 && `${s.cutsCount} cuts`}
|
||||
{s.cutsCount > 0 && s.addsCount > 0 && ' · '}
|
||||
{s.addsCount > 0 && `${s.addsCount} adds`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary mt-0.5 truncate">{s.summary || 'No summary'}</p>
|
||||
<p className="text-[10px] text-text-tertiary mt-0.5">{new Date(s.timestamp).toLocaleString()}</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-text-tertiary flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Cards Like Tab ────────────────────────────────── */}
|
||||
{tab === 'search' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-1">Cards Like This</h2>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Find similar cards using AI embeddings. Search by card name to discover alternatives.
|
||||
</p>
|
||||
</div>
|
||||
<CardsLikePanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Search, Filter, Upload, Download, Camera, RefreshCw,
|
||||
ChevronLeft, ChevronRight, X, Package, SlidersHorizontal,
|
||||
Plus, Grid3x3, List
|
||||
} from 'lucide-react'
|
||||
import { Spinner, EmptyState, ManaSymbols, CardImage } from '../components/common'
|
||||
import { collectionApi } from '../api'
|
||||
import type { CollectionCard, CollectionFilters, SetInfo } from '../types'
|
||||
|
||||
// ── Card Detail Modal ─────────────────────────────────────────
|
||||
function CardDetailModal({ card, onClose }: { card: CollectionCard; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
|
||||
<div
|
||||
className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto animate-fade-in"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between p-5 border-b border-border-primary">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-primary">{card.name}</h2>
|
||||
<p className="text-sm text-text-secondary mt-0.5">{card.type_line}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-bg-hover text-text-tertiary">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row gap-6 p-5">
|
||||
{/* Large card image */}
|
||||
<div className="flex-shrink-0">
|
||||
<CardImage src={card.image_url} alt={card.name} size="xl" />
|
||||
</div>
|
||||
{/* Card info */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Mana Cost</label>
|
||||
<div className="mt-1"><ManaSymbols cost={card.mana_cost} size="md" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Oracle Text</label>
|
||||
<p className="mt-1 text-sm text-text-primary whitespace-pre-wrap leading-relaxed">{card.oracle_text}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
|
||||
<p className="mt-1 text-sm text-text-primary">{card.set_name} ({card.set_code.toUpperCase()})</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
|
||||
<p className={`mt-1 text-sm font-medium capitalize ${
|
||||
card.rarity === 'mythic' ? 'text-accent-amber' :
|
||||
card.rarity === 'rare' ? 'text-accent-amber/80' :
|
||||
card.rarity === 'uncommon' ? 'text-accent-teal' : 'text-text-secondary'
|
||||
}`}>{card.rarity}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC</label>
|
||||
<p className="mt-1 text-sm text-text-primary">{card.cmc}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">EDHREC Rank</label>
|
||||
<p className="mt-1 text-sm text-text-primary">{card.edhrec_rank ?? 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Quantity</label>
|
||||
<p className="mt-1 text-sm text-text-primary">{card.quantity} (+ {card.foil_quantity} foil)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Price</label>
|
||||
<p className="mt-1 text-sm text-accent-teal font-semibold">
|
||||
${card.tcg_price?.toFixed(2) ?? '—'}
|
||||
{card.foil_price ? <span className="text-text-tertiary ml-2">Foil: ${card.foil_price.toFixed(2)}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Scanner Panel ─────────────────────────────────────────────
|
||||
function ScannerPanel({ onCardAdded }: { onCardAdded: () => void }) {
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [results, setResults] = useState<import('../types').ScanMatch[]>([])
|
||||
const [adding, setAdding] = useState<string | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function handleScan(file: File) {
|
||||
setScanning(true)
|
||||
setResults([])
|
||||
try {
|
||||
const res = await collectionApi.scanCard(file)
|
||||
setResults(res.matches || [])
|
||||
} catch {
|
||||
setResults([])
|
||||
}
|
||||
setScanning(false)
|
||||
}
|
||||
|
||||
async function handleAdd(match: import('../types').ScanMatch) {
|
||||
setAdding(match.scryfall_id)
|
||||
try {
|
||||
await collectionApi.addScanResult({
|
||||
name: match.name,
|
||||
set_code: match.set_code,
|
||||
scryfall_id: match.scryfall_id,
|
||||
})
|
||||
onCardAdded()
|
||||
} catch { /* ignore */ }
|
||||
setAdding(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2 mb-3">
|
||||
<Camera className="w-4 h-4 text-accent-blue" />
|
||||
Card Scanner
|
||||
</h3>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => e.target.files?.[0] && handleScan(e.target.files[0])}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={scanning}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue/10 text-accent-blue border border-accent-blue/30 rounded-lg text-sm font-medium hover:bg-accent-blue/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{scanning ? <Spinner size="sm" /> : <Camera className="w-4 h-4" />}
|
||||
{scanning ? 'Scanning...' : 'Scan a Card'}
|
||||
</button>
|
||||
{results.length > 0 && (
|
||||
<div className="mt-3 space-y-2 max-h-60 overflow-y-auto">
|
||||
{results.map(m => (
|
||||
<div key={m.scryfall_id} className="flex items-center gap-3 p-2 rounded-lg bg-bg-tertiary">
|
||||
<CardImage src={m.image_url} alt={m.name} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{m.name}</p>
|
||||
<p className="text-xs text-text-tertiary">{m.set_name} — {(m.confidence * 100).toFixed(0)}%</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAdd(m)}
|
||||
disabled={adding === m.scryfall_id}
|
||||
className="p-1.5 rounded-lg bg-accent-green/15 text-accent-green hover:bg-accent-green/25 transition-colors"
|
||||
>
|
||||
{adding === m.scryfall_id ? <Spinner size="sm" /> : <Plus className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Collection Page ──────────────────────────────────────
|
||||
export function CollectionPage() {
|
||||
const [cards, setCards] = useState<CollectionCard[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sets, setSets] = useState<SetInfo[]>([])
|
||||
const [selectedCard, setSelectedCard] = useState<CollectionCard | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [enriching, setEnriching] = useState(false)
|
||||
const importRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [filters, setFilters] = useState<CollectionFilters>({
|
||||
q: '',
|
||||
page: 1,
|
||||
page_size: 48,
|
||||
sort_by: 'name',
|
||||
sort_dir: 'asc',
|
||||
})
|
||||
|
||||
const fetchCards = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await collectionApi.searchCollection(filters)
|
||||
setCards(res.cards)
|
||||
setTotal(res.total)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [filters])
|
||||
|
||||
useEffect(() => { fetchCards() }, [fetchCards])
|
||||
|
||||
useEffect(() => {
|
||||
collectionApi.getSets().then(setSets).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const totalPages = Math.ceil(total / (filters.page_size || 48))
|
||||
|
||||
function updateFilter(key: keyof CollectionFilters, value: string | number | boolean | undefined) {
|
||||
setFilters(prev => ({ ...prev, [key]: value, page: 1 }))
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const blob = await collectionApi.exportCollection('csv')
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'collection.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function handleImport(file: File) {
|
||||
setImporting(true)
|
||||
try {
|
||||
await collectionApi.importCollection(file)
|
||||
fetchCards()
|
||||
} catch { /* ignore */ }
|
||||
setImporting(false)
|
||||
}
|
||||
|
||||
async function handleReEnrich() {
|
||||
setEnriching(true)
|
||||
try {
|
||||
await collectionApi.reEnrichCards()
|
||||
fetchCards()
|
||||
} catch { /* ignore */ }
|
||||
setEnriching(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Collection</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">{total.toLocaleString()} cards in your collection</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleReEnrich} disabled={enriching}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
|
||||
{enriching ? <Spinner size="sm" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Re-Enrich
|
||||
</button>
|
||||
<input ref={importRef} type="file" accept=".csv,.txt" className="hidden"
|
||||
onChange={e => e.target.files?.[0] && handleImport(e.target.files[0])} />
|
||||
<button onClick={() => importRef.current?.click()} disabled={importing}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
|
||||
{importing ? <Spinner size="sm" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
Import
|
||||
</button>
|
||||
<button onClick={handleExport}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5">
|
||||
{/* Left sidebar: search + filters + scanner */}
|
||||
<div className="w-72 flex-shrink-0 space-y-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search cards..."
|
||||
value={filters.q || ''}
|
||||
onChange={e => updateFilter('q', e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-secondary hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
Filters
|
||||
</span>
|
||||
<Filter className={`w-3.5 h-3.5 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showFilters && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3 animate-fade-in">
|
||||
{/* Rarity */}
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
|
||||
<select
|
||||
value={filters.rarity || ''}
|
||||
onChange={e => updateFilter('rarity', e.target.value || undefined)}
|
||||
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="common">Common</option>
|
||||
<option value="uncommon">Uncommon</option>
|
||||
<option value="rare">Rare</option>
|
||||
<option value="mythic">Mythic</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Color</label>
|
||||
<select
|
||||
value={filters.color || ''}
|
||||
onChange={e => updateFilter('color', e.target.value || undefined)}
|
||||
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="W">White</option>
|
||||
<option value="U">Blue</option>
|
||||
<option value="B">Black</option>
|
||||
<option value="R">Red</option>
|
||||
<option value="G">Green</option>
|
||||
<option value="C">Colorless</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Set */}
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
|
||||
<select
|
||||
value={filters.set_code || ''}
|
||||
onChange={e => updateFilter('set_code', e.target.value || undefined)}
|
||||
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||
>
|
||||
<option value="">All Sets</option>
|
||||
{sets.map(s => (
|
||||
<option key={s.code} value={s.code}>{s.name} ({s.card_count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* CMC Range */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Min</label>
|
||||
<input type="number" min="0" max="20"
|
||||
value={filters.cmc_min ?? ''}
|
||||
onChange={e => updateFilter('cmc_min', e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Max</label>
|
||||
<input type="number" min="0" max="20"
|
||||
value={filters.cmc_max ?? ''}
|
||||
onChange={e => updateFilter('cmc_max', e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sort */}
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Sort By</label>
|
||||
<select
|
||||
value={filters.sort_by || 'name'}
|
||||
onChange={e => updateFilter('sort_by', e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="cmc">CMC</option>
|
||||
<option value="tcg_price">Price</option>
|
||||
<option value="rarity">Rarity</option>
|
||||
<option value="set_code">Set</option>
|
||||
<option value="added_date">Date Added</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scanner */}
|
||||
<ScannerPanel onCardAdded={fetchCards} />
|
||||
</div>
|
||||
|
||||
{/* Main content: card grid/list */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* View toggle + pagination */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-1.5 rounded-md transition-colors ${viewMode === 'grid' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
|
||||
>
|
||||
<Grid3x3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-1.5 rounded-md transition-colors ${viewMode === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>Page {filters.page} of {totalPages || 1}</span>
|
||||
<button
|
||||
onClick={() => setFilters(p => ({ ...p, page: Math.max(1, (p.page || 1) - 1) }))}
|
||||
disabled={!filters.page || filters.page <= 1}
|
||||
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilters(p => ({ ...p, page: Math.min(totalPages, (p.page || 1) + 1) }))}
|
||||
disabled={(filters.page || 1) >= totalPages}
|
||||
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" className="text-accent-blue" />
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="No cards found"
|
||||
description="Try adjusting your search or filters, or import a collection."
|
||||
/>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
{cards.map(card => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => setSelectedCard(card)}
|
||||
>
|
||||
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
|
||||
<div className="mt-1.5 px-0.5">
|
||||
<p className="text-xs font-medium text-text-primary truncate group-hover:text-accent-blue transition-colors">{card.name}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-text-tertiary">{card.set_code.toUpperCase()}</span>
|
||||
{card.tcg_price != null && (
|
||||
<span className="text-[10px] text-accent-teal font-medium">${card.tcg_price.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-primary text-left">
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Type</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Set</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Qty</th>
|
||||
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-primary">
|
||||
{cards.map(card => (
|
||||
<tr key={card.id} className="hover:bg-bg-hover cursor-pointer transition-colors" onClick={() => setSelectedCard(card)}>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="font-medium text-text-primary">{card.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.type_line}</td>
|
||||
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
|
||||
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.set_code.toUpperCase()}</td>
|
||||
<td className="px-4 py-2.5 text-text-secondary">{card.quantity}</td>
|
||||
<td className="px-4 py-2.5 text-right text-accent-teal font-medium">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card detail modal */}
|
||||
{selectedCard && <CardDetailModal card={selectedCard} onClose={() => setSelectedCard(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,967 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Plus, Trash2, Download, ChevronRight,
|
||||
BarChart3, Lightbulb, Layers, X, Library,
|
||||
Globe, XCircle, ShoppingCart,
|
||||
ChevronDown, ChevronUp, Sparkles
|
||||
} from 'lucide-react'
|
||||
import { Spinner, EmptyState, ManaSymbols, ColorDots } from '../components/common'
|
||||
import { decksApi } from '../api'
|
||||
import type {
|
||||
Deck, DeckCard, DeckAnalysis,
|
||||
EdhRecCard, EdhRecsResponse,
|
||||
CollectionRecsResponse
|
||||
} from '../types'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
|
||||
// ── Role Badge ──────────────────────────────────────────────
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
Ramp: 'bg-green-500/15 text-green-400 border-green-500/25',
|
||||
Draw: 'bg-blue-500/15 text-blue-400 border-blue-500/25',
|
||||
Removal: 'bg-red-500/15 text-red-400 border-red-500/25',
|
||||
BoardWipe: 'bg-orange-500/15 text-orange-400 border-orange-500/25',
|
||||
Tutor: 'bg-purple-500/15 text-purple-400 border-purple-500/25',
|
||||
Protection: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/25',
|
||||
Finisher: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/25',
|
||||
Recursion: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/25',
|
||||
Other: 'bg-slate-500/15 text-slate-400 border-slate-500/25',
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
const colors = ROLE_COLORS[role] || ROLE_COLORS.Other
|
||||
return (
|
||||
<span className={'text-[10px] font-medium px-1.5 py-0.5 rounded border ' + colors}>
|
||||
{role}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Deck Card List ────────────────────────────────────────────
|
||||
function DeckCardList({ deckId, cards, onUpdate }: { deckId: number; cards: DeckCard[]; onUpdate: () => void }) {
|
||||
const [removing, setRemoving] = useState<number | null>(null)
|
||||
|
||||
// Group cards by category
|
||||
const grouped = cards.reduce<Record<string, DeckCard[]>>((acc, c) => {
|
||||
const cat = c.category || 'Other'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat].push(c)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const categoryOrder = ['Commander', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
|
||||
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
||||
const ia = categoryOrder.indexOf(a)
|
||||
const ib = categoryOrder.indexOf(b)
|
||||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
|
||||
})
|
||||
|
||||
async function handleRemove(cardId: number) {
|
||||
setRemoving(cardId)
|
||||
try {
|
||||
await decksApi.removeCardFromDeck(deckId, cardId)
|
||||
onUpdate()
|
||||
} catch { /* ignore */ }
|
||||
setRemoving(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sortedCategories.map(cat => (
|
||||
<div key={cat}>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">
|
||||
{cat} ({grouped[cat].length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{grouped[cat].map(card => (
|
||||
<div key={card.id} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover group transition-colors">
|
||||
<span className="text-xs text-text-tertiary w-5 text-right">{card.quantity}x</span>
|
||||
<ManaSymbols cost={card.mana_cost} />
|
||||
<span className="flex-1 text-sm text-text-primary truncate">{card.name}</span>
|
||||
{card.tcg_price != null && (
|
||||
<span className="text-xs text-accent-teal">${card.tcg_price.toFixed(2)}</span>
|
||||
)}
|
||||
{!card.is_commander && (
|
||||
<button
|
||||
onClick={() => handleRemove(card.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-text-tertiary hover:text-accent-red transition-all"
|
||||
>
|
||||
{removing === card.id ? <Spinner size="sm" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Mana Curve Chart ──────────────────────────────────────────
|
||||
function ManaCurveChart({ analysis }: { analysis: DeckAnalysis }) {
|
||||
const data = Object.entries(analysis.mana_curve)
|
||||
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
|
||||
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
|
||||
|
||||
return (
|
||||
<div className="h-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} barSize={24}>
|
||||
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 11 }} />
|
||||
<YAxis hide />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
|
||||
labelStyle={{ color: '#9ba1b8' }}
|
||||
itemStyle={{ color: '#e8eaf0' }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── EDHREC Recommendations Sub-tab ──────────────────────────
|
||||
function EdhRecTab({ deckId, onAddedCards }: { deckId: number; onAddedCards: () => void }) {
|
||||
const [data, setData] = useState<EdhRecsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [onlyOwned, setOnlyOwned] = useState(false)
|
||||
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
|
||||
const [addingCards, setAddingCards] = useState<Set<string>>(new Set())
|
||||
const [bulkAdding, setBulkAdding] = useState(false)
|
||||
const [hoveredCard, setHoveredCard] = useState<EdhRecCard | null>(null)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
async function fetchRecs() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await decksApi.getEdhRecs(deckId, onlyOwned)
|
||||
setData(result)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load EDHREC recommendations')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchRecs() }, [deckId, onlyOwned])
|
||||
|
||||
function toggleSelect(name: string) {
|
||||
setSelectedCards(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
if (!data) return
|
||||
setSelectedCards(new Set(data.recommendations.map(r => r.name)))
|
||||
}
|
||||
|
||||
function selectNone() { setSelectedCards(new Set()) }
|
||||
|
||||
function selectOwned() {
|
||||
if (!data) return
|
||||
setSelectedCards(new Set(data.recommendations.filter(r => r.owned).map(r => r.name)))
|
||||
}
|
||||
|
||||
async function handleAddSingle(name: string) {
|
||||
setAddingCards(prev => new Set(prev).add(name))
|
||||
try {
|
||||
await decksApi.bulkAddRecommended(deckId, [name])
|
||||
onAddedCards()
|
||||
// Remove from list
|
||||
if (data) {
|
||||
setData({
|
||||
...data,
|
||||
recommendations: data.recommendations.filter(r => r.name !== name),
|
||||
total: data.total - 1,
|
||||
})
|
||||
}
|
||||
selectedCards.delete(name)
|
||||
setSelectedCards(new Set(selectedCards))
|
||||
} catch { /* ignore */ }
|
||||
setAddingCards(prev => { const n = new Set(prev); n.delete(name); return n })
|
||||
}
|
||||
|
||||
async function handleBulkAdd() {
|
||||
if (selectedCards.size === 0) return
|
||||
setBulkAdding(true)
|
||||
try {
|
||||
await decksApi.bulkAddRecommended(deckId, Array.from(selectedCards))
|
||||
onAddedCards()
|
||||
if (data) {
|
||||
setData({
|
||||
...data,
|
||||
recommendations: data.recommendations.filter(r => !selectedCards.has(r.name)),
|
||||
total: data.total - selectedCards.size,
|
||||
})
|
||||
}
|
||||
setSelectedCards(new Set())
|
||||
} catch { /* ignore */ }
|
||||
setBulkAdding(false)
|
||||
}
|
||||
|
||||
function toggleGroup(groupName: string) {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(groupName)) next.delete(groupName)
|
||||
else next.add(groupName)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Group recommendations by role
|
||||
function groupByRole(recs: EdhRecCard[]): Record<string, EdhRecCard[]> {
|
||||
const groups: Record<string, EdhRecCard[]> = {}
|
||||
for (const rec of recs) {
|
||||
const role = rec.role || 'Other'
|
||||
if (!groups[role]) groups[role] = []
|
||||
groups[role].push(rec)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-5 text-center">
|
||||
<XCircle className="w-6 h-6 text-accent-red mx-auto mb-2" />
|
||||
<p className="text-sm text-accent-red font-medium">{error}</p>
|
||||
<button onClick={fetchRecs} className="mt-3 px-4 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.recommendations.length === 0) {
|
||||
return <EmptyState icon={Globe} title="No EDHREC data" description="No recommendations found for this commander on EDHREC." />
|
||||
}
|
||||
|
||||
const grouped = groupByRole(data.recommendations)
|
||||
const roleOrder = ['Ramp', 'Draw', 'Removal', 'BoardWipe', 'Tutor', 'Protection', 'Finisher', 'Recursion', 'Other']
|
||||
const sortedRoles = Object.keys(grouped).sort((a, b) => {
|
||||
const ia = roleOrder.indexOf(a)
|
||||
const ib = roleOrder.indexOf(b)
|
||||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
|
||||
})
|
||||
|
||||
const ownedCount = data.recommendations.filter(r => r.owned).length
|
||||
const missingCount = data.recommendations.length - ownedCount
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Sparkles className="w-4 h-4 text-accent-blue" />
|
||||
<span className="text-text-primary font-medium">{data.commander}</span>
|
||||
<span className="text-text-tertiary">via {data.source}</span>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-accent-green/15 text-accent-green">{ownedCount} owned</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-bg-tertiary text-text-tertiary">{missingCount} missing</span>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyOwned}
|
||||
onChange={e => setOnlyOwned(e.target.checked)}
|
||||
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5"
|
||||
/>
|
||||
Show only owned
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Selection toolbar */}
|
||||
<div className="flex items-center justify-between bg-bg-secondary rounded-xl border border-border-primary px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-tertiary">Select:</span>
|
||||
<button onClick={selectAll} className="text-xs text-accent-blue hover:underline">All</button>
|
||||
<span className="text-text-tertiary">/</span>
|
||||
<button onClick={selectNone} className="text-xs text-accent-blue hover:underline">None</button>
|
||||
<span className="text-text-tertiary">/</span>
|
||||
<button onClick={selectOwned} className="text-xs text-accent-blue hover:underline">Owned</button>
|
||||
{selectedCards.size > 0 && (
|
||||
<span className="text-xs text-text-secondary ml-2">({selectedCards.size} selected)</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBulkAdd}
|
||||
disabled={selectedCards.size === 0 || bulkAdding}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{bulkAdding ? <Spinner size="sm" /> : <ShoppingCart className="w-3.5 h-3.5" />}
|
||||
Add {selectedCards.size > 0 ? selectedCards.size : ''} to Deck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grouped cards */}
|
||||
{sortedRoles.map(role => {
|
||||
const cards = grouped[role]
|
||||
const isCollapsed = collapsedGroups.has(role)
|
||||
return (
|
||||
<div key={role} className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
|
||||
{/* Group header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(role)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RoleBadge role={role} />
|
||||
<span className="text-xs text-text-tertiary">({cards.length})</span>
|
||||
</div>
|
||||
{isCollapsed ? <ChevronDown className="w-4 h-4 text-text-tertiary" /> : <ChevronUp className="w-4 h-4 text-text-tertiary" />}
|
||||
</button>
|
||||
{/* Card list */}
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-border-primary divide-y divide-border-primary">
|
||||
{cards.map(rec => (
|
||||
<div
|
||||
key={rec.name}
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-hover transition-colors group relative"
|
||||
onMouseEnter={() => setHoveredCard(rec)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCards.has(rec.name)}
|
||||
onChange={() => toggleSelect(rec.name)}
|
||||
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5 flex-shrink-0"
|
||||
/>
|
||||
{/* Card image mini */}
|
||||
{rec.image_url && (
|
||||
<div className="w-8 h-11 rounded overflow-hidden bg-bg-tertiary flex-shrink-0">
|
||||
<img src={rec.image_url} alt={rec.name} className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
{/* Card info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{rec.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] text-text-tertiary truncate">{rec.type_line}</span>
|
||||
{rec.roles.length > 0 && rec.roles.map(r => <RoleBadge key={r} role={r} />)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Owned badge */}
|
||||
<span className={'text-xs px-2 py-0.5 rounded flex-shrink-0 ' + (rec.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary')}>
|
||||
{rec.owned ? (rec.owned_qty + 'x owned') : 'Missing'}
|
||||
</span>
|
||||
{/* Add button */}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleAddSingle(rec.name) }}
|
||||
disabled={addingCards.has(rec.name)}
|
||||
className="opacity-0 group-hover:opacity-100 px-2.5 py-1 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-all disabled:opacity-50 flex-shrink-0"
|
||||
>
|
||||
{addingCards.has(rec.name) ? <Spinner size="sm" /> : '+ Add'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Hover preview */}
|
||||
{hoveredCard && hoveredCard.image_url && (
|
||||
<div className="fixed top-20 right-8 z-50 pointer-events-none animate-fade-in">
|
||||
<img
|
||||
src={hoveredCard.image_url}
|
||||
alt={hoveredCard.name}
|
||||
className="w-[244px] rounded-xl shadow-2xl shadow-black/60 border border-border-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Collection Recommendations Sub-tab ──────────────────────
|
||||
function CollectionRecTab({ deckId, onAddedCards }: { deckId: number; onAddedCards: () => void }) {
|
||||
const [data, setData] = useState<CollectionRecsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
|
||||
const [addingCards, setAddingCards] = useState<Set<string>>(new Set())
|
||||
const [bulkAdding, setBulkAdding] = useState(false)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
async function fetchRecs() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await decksApi.getCollectionRecs(deckId)
|
||||
setData(result)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load collection recommendations')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchRecs() }, [deckId])
|
||||
|
||||
function toggleSelect(name: string) {
|
||||
setSelectedCards(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
if (!data) return
|
||||
const all = new Set<string>()
|
||||
Object.values(data.grouped).flat().forEach(c => all.add(c.name))
|
||||
setSelectedCards(all)
|
||||
}
|
||||
|
||||
function selectNone() { setSelectedCards(new Set()) }
|
||||
|
||||
async function handleAddSingle(name: string) {
|
||||
setAddingCards(prev => new Set(prev).add(name))
|
||||
try {
|
||||
await decksApi.bulkAddRecommended(deckId, [name])
|
||||
onAddedCards()
|
||||
// Remove from groupings
|
||||
if (data) {
|
||||
const newGrouped = { ...data.grouped }
|
||||
for (const key of Object.keys(newGrouped)) {
|
||||
newGrouped[key] = newGrouped[key].filter(c => c.name !== name)
|
||||
if (newGrouped[key].length === 0) delete newGrouped[key]
|
||||
}
|
||||
setData({ ...data, grouped: newGrouped, total: data.total - 1 })
|
||||
}
|
||||
selectedCards.delete(name)
|
||||
setSelectedCards(new Set(selectedCards))
|
||||
} catch { /* ignore */ }
|
||||
setAddingCards(prev => { const n = new Set(prev); n.delete(name); return n })
|
||||
}
|
||||
|
||||
async function handleBulkAdd() {
|
||||
if (selectedCards.size === 0) return
|
||||
setBulkAdding(true)
|
||||
try {
|
||||
await decksApi.bulkAddRecommended(deckId, Array.from(selectedCards))
|
||||
onAddedCards()
|
||||
if (data) {
|
||||
const newGrouped = { ...data.grouped }
|
||||
for (const key of Object.keys(newGrouped)) {
|
||||
newGrouped[key] = newGrouped[key].filter(c => !selectedCards.has(c.name))
|
||||
if (newGrouped[key].length === 0) delete newGrouped[key]
|
||||
}
|
||||
setData({ ...data, grouped: newGrouped, total: data.total - selectedCards.size })
|
||||
}
|
||||
setSelectedCards(new Set())
|
||||
} catch { /* ignore */ }
|
||||
setBulkAdding(false)
|
||||
}
|
||||
|
||||
function toggleGroup(groupName: string) {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(groupName)) next.delete(groupName)
|
||||
else next.add(groupName)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-5 text-center">
|
||||
<XCircle className="w-6 h-6 text-accent-red mx-auto mb-2" />
|
||||
<p className="text-sm text-accent-red font-medium">{error}</p>
|
||||
<button onClick={fetchRecs} className="mt-3 px-4 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.total === 0) {
|
||||
return <EmptyState icon={Library} title="No collection matches" description="No cards in your collection match this deck's needs." />
|
||||
}
|
||||
|
||||
const typeOrder = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
|
||||
const sortedTypes = Object.keys(data.grouped).sort((a, b) => {
|
||||
const ia = typeOrder.indexOf(a)
|
||||
const ib = typeOrder.indexOf(b)
|
||||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Shortfall alert */}
|
||||
{data.shortfall_types.length > 0 && (
|
||||
<div className="flex items-start gap-3 bg-accent-blue/8 border border-accent-blue/20 rounded-xl px-4 py-3">
|
||||
<Lightbulb className="w-4 h-4 text-accent-blue mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-accent-blue">Deck needs more:</p>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{data.shortfall_types.map(t => (
|
||||
<span key={t} className="text-[10px] font-medium px-2 py-0.5 rounded bg-accent-blue/15 text-accent-blue border border-accent-blue/25">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection toolbar */}
|
||||
<div className="flex items-center justify-between bg-bg-secondary rounded-xl border border-border-primary px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-tertiary">Select:</span>
|
||||
<button onClick={selectAll} className="text-xs text-accent-blue hover:underline">All</button>
|
||||
<span className="text-text-tertiary">/</span>
|
||||
<button onClick={selectNone} className="text-xs text-accent-blue hover:underline">None</button>
|
||||
{selectedCards.size > 0 && (
|
||||
<span className="text-xs text-text-secondary ml-2">({selectedCards.size} selected)</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBulkAdd}
|
||||
disabled={selectedCards.size === 0 || bulkAdding}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{bulkAdding ? <Spinner size="sm" /> : <ShoppingCart className="w-3.5 h-3.5" />}
|
||||
Add {selectedCards.size > 0 ? selectedCards.size : ''} to Deck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grouped cards */}
|
||||
{sortedTypes.map(type => {
|
||||
const cards = data.grouped[type]
|
||||
const isCollapsed = collapsedGroups.has(type)
|
||||
const isShortfall = data.shortfall_types.includes(type)
|
||||
return (
|
||||
<div key={type} className={'bg-bg-secondary rounded-xl border overflow-hidden ' + (isShortfall ? 'border-accent-blue/30' : 'border-border-primary')}>
|
||||
{/* Group header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(type)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-text-primary uppercase tracking-wider">{type}</span>
|
||||
<span className="text-xs text-text-tertiary">({cards.length})</span>
|
||||
{isShortfall && <span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-accent-blue/15 text-accent-blue border border-accent-blue/25">Shortfall</span>}
|
||||
</div>
|
||||
{isCollapsed ? <ChevronDown className="w-4 h-4 text-text-tertiary" /> : <ChevronUp className="w-4 h-4 text-text-tertiary" />}
|
||||
</button>
|
||||
{/* Card list */}
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-border-primary divide-y divide-border-primary">
|
||||
{cards.map(rec => (
|
||||
<div key={rec.scryfall_id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-hover transition-colors group">
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCards.has(rec.name)}
|
||||
onChange={() => toggleSelect(rec.name)}
|
||||
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5 flex-shrink-0"
|
||||
/>
|
||||
{/* Card image mini */}
|
||||
{rec.image_url && (
|
||||
<div className="w-8 h-11 rounded overflow-hidden bg-bg-tertiary flex-shrink-0">
|
||||
<img src={rec.image_url} alt={rec.name} className="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
{/* Card info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{rec.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] text-text-tertiary">{rec.cmc} CMC</span>
|
||||
{rec.roles.length > 0 && rec.roles.map(r => <RoleBadge key={r} role={r} />)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Score */}
|
||||
<span className="text-xs text-accent-teal font-medium flex-shrink-0">Score: {rec.score}</span>
|
||||
{/* Owned qty */}
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-accent-green/15 text-accent-green flex-shrink-0">{rec.owned_qty}x</span>
|
||||
{/* Add button */}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleAddSingle(rec.name) }}
|
||||
disabled={addingCards.has(rec.name)}
|
||||
className="opacity-0 group-hover:opacity-100 px-2.5 py-1 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-all disabled:opacity-50 flex-shrink-0"
|
||||
>
|
||||
{addingCards.has(rec.name) ? <Spinner size="sm" /> : '+ Add'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Deck Detail View ──────────────────────────────────────────
|
||||
function DeckDetail({ deck, onBack }: { deck: Deck; onBack: () => void }) {
|
||||
const [cards, setCards] = useState<DeckCard[]>([])
|
||||
const [analysis, setAnalysis] = useState<DeckAnalysis | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tab, setTab] = useState<'cards' | 'analysis' | 'recs'>('cards')
|
||||
const [recSource, setRecSource] = useState<'edhrec' | 'collection'>('edhrec')
|
||||
const [addingCard, setAddingCard] = useState('')
|
||||
const [bulkText, setBulkText] = useState('')
|
||||
const [showBulk, setShowBulk] = useState(false)
|
||||
|
||||
async function fetchAll() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [c, a] = await Promise.all([
|
||||
decksApi.getDeckCards(deck.id),
|
||||
decksApi.getDeckAnalysis(deck.id),
|
||||
])
|
||||
setCards(c)
|
||||
setAnalysis(a)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchAll() }, [deck.id])
|
||||
|
||||
async function handleAddCard() {
|
||||
if (!addingCard.trim()) return
|
||||
try {
|
||||
await decksApi.addCardToDeck(deck.id, { name: addingCard.trim() })
|
||||
setAddingCard('')
|
||||
fetchAll()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleBulkAdd() {
|
||||
if (!bulkText.trim()) return
|
||||
const cardLines = bulkText.split('\n').filter(l => l.trim())
|
||||
const cardParsed = cardLines.map(l => {
|
||||
const match = l.match(/^(\d+)x?\s+(.+)$/)
|
||||
return match ? { name: match[2].trim(), quantity: Number(match[1]) } : { name: l.trim() }
|
||||
})
|
||||
try {
|
||||
await decksApi.bulkAddCards(deck.id, cardParsed)
|
||||
setBulkText('')
|
||||
setShowBulk(false)
|
||||
fetchAll()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleExportToSim() {
|
||||
try {
|
||||
await decksApi.exportDeckToSim(deck.id)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={onBack} className="p-2 rounded-lg hover:bg-bg-hover text-text-secondary transition-colors">
|
||||
<ChevronRight className="w-5 h-5 rotate-180" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-text-primary">{deck.name}</h2>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-sm text-text-secondary">{deck.commander}</span>
|
||||
<ColorDots colors={deck.color_identity} />
|
||||
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
|
||||
<span className="text-xs text-accent-teal">${deck.total_price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleExportToSim}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Export to Sim
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-border-primary">
|
||||
{(['cards', 'analysis', 'recs'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors capitalize ${
|
||||
tab === t
|
||||
? 'border-accent-blue text-accent-blue'
|
||||
: 'border-transparent text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{t === 'recs' ? 'Recommendations' : t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||
) : tab === 'cards' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Add card */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add card by name..."
|
||||
value={addingCard}
|
||||
onChange={e => setAddingCard(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCard()}
|
||||
className="flex-1 px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
<button onClick={handleAddCard}
|
||||
className="px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
|
||||
Add
|
||||
</button>
|
||||
<button onClick={() => setShowBulk(!showBulk)}
|
||||
className="px-3 py-2.5 bg-bg-tertiary text-text-secondary text-sm rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
|
||||
<Layers className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{showBulk && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 animate-fade-in space-y-3">
|
||||
<textarea
|
||||
placeholder={"1x Sol Ring\n1x Command Tower\n2x Counterspell"}
|
||||
value={bulkText}
|
||||
onChange={e => setBulkText(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full px-4 py-3 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
|
||||
/>
|
||||
<button onClick={handleBulkAdd}
|
||||
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
|
||||
Bulk Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<DeckCardList deckId={deck.id} cards={cards} onUpdate={fetchAll} />
|
||||
</div>
|
||||
) : tab === 'analysis' && analysis ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Stats */}
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-accent-blue" />
|
||||
Deck Stats
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'Cards', value: analysis.card_count },
|
||||
{ label: 'Lands', value: analysis.land_count },
|
||||
{ label: 'Creatures', value: analysis.creature_count },
|
||||
{ label: 'Non-Creature', value: analysis.noncreature_count },
|
||||
{ label: 'Avg CMC', value: analysis.avg_cmc.toFixed(2) },
|
||||
{ label: 'Owned', value: `${analysis.owned_count} / ${analysis.card_count}` },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className="px-3 py-2 rounded-lg bg-bg-tertiary">
|
||||
<p className="text-xs text-text-tertiary">{stat.label}</p>
|
||||
<p className="text-lg font-bold text-text-primary">{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mana Curve */}
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Mana Curve</h3>
|
||||
<ManaCurveChart analysis={analysis} />
|
||||
</div>
|
||||
{/* Type Distribution */}
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-3 md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Type Distribution</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(analysis.type_distribution).map(([type, count]) => (
|
||||
<span key={type} className="px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs text-text-secondary">
|
||||
{type}: <span className="font-semibold text-text-primary">{count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : tab === 'recs' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Sub-tab toggle */}
|
||||
<div className="flex items-center gap-1 bg-bg-tertiary rounded-lg p-1 w-fit">
|
||||
<button
|
||||
onClick={() => setRecSource('edhrec')}
|
||||
className={'flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium rounded-md transition-all ' +
|
||||
(recSource === 'edhrec'
|
||||
? 'bg-accent-blue text-white shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary')}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
EDHREC
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRecSource('collection')}
|
||||
className={'flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium rounded-md transition-all ' +
|
||||
(recSource === 'collection'
|
||||
? 'bg-accent-blue text-white shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary')}
|
||||
>
|
||||
<Library className="w-3.5 h-3.5" />
|
||||
From Collection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sub-tab content */}
|
||||
{recSource === 'edhrec' ? (
|
||||
<EdhRecTab deckId={deck.id} onAddedCards={fetchAll} />
|
||||
) : (
|
||||
<CollectionRecTab deckId={deck.id} onAddedCards={fetchAll} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Decks Page ───────────────────────────────────────────
|
||||
export function DecksPage() {
|
||||
const [decks, setDecks] = useState<Deck[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedDeck, setSelectedDeck] = useState<Deck | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newDeckName, setNewDeckName] = useState('')
|
||||
const [newCommander, setNewCommander] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
async function fetchDecks() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const d = await decksApi.listDecks()
|
||||
setDecks(d)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchDecks() }, [])
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newDeckName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const deck = await decksApi.createDeck({ name: newDeckName.trim(), commander: newCommander.trim() || undefined })
|
||||
setNewDeckName('')
|
||||
setNewCommander('')
|
||||
setShowCreate(false)
|
||||
setSelectedDeck(deck)
|
||||
fetchDecks()
|
||||
} catch { /* ignore */ }
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
async function handleDelete(deckId: number) {
|
||||
try {
|
||||
await decksApi.deleteDeck(deckId)
|
||||
fetchDecks()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (selectedDeck) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<DeckDetail deck={selectedDeck} onBack={() => { setSelectedDeck(null); fetchDecks() }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Decks</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">{decks.length} deck{decks.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Deck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 animate-fade-in space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Create New Deck</h3>
|
||||
<button onClick={() => setShowCreate(false)} className="text-text-tertiary hover:text-text-secondary">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Deck name"
|
||||
value={newDeckName}
|
||||
onChange={e => setNewDeckName(e.target.value)}
|
||||
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Commander (optional)"
|
||||
value={newCommander}
|
||||
onChange={e => setNewCommander(e.target.value)}
|
||||
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleCreate} disabled={creating}
|
||||
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
|
||||
{creating ? <Spinner size="sm" /> : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deck list */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||
) : decks.length === 0 ? (
|
||||
<EmptyState icon={Layers} title="No decks yet" description="Create your first deck to get started." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{decks.map(deck => (
|
||||
<div
|
||||
key={deck.id}
|
||||
className="bg-bg-secondary rounded-xl border border-border-primary hover:border-border-secondary p-5 cursor-pointer transition-all group"
|
||||
onClick={() => setSelectedDeck(deck)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-text-primary truncate group-hover:text-accent-blue transition-colors">{deck.name}</h3>
|
||||
<p className="text-sm text-text-secondary mt-0.5 truncate">{deck.commander}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDelete(deck.id) }}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 text-text-tertiary hover:text-accent-red transition-all"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorDots colors={deck.color_identity} />
|
||||
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-accent-teal">${deck.total_price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Gamepad2, Play, Wifi, WifiOff, Settings,
|
||||
AlertCircle, Trophy, Clock, Brain
|
||||
} from 'lucide-react'
|
||||
import { Spinner, StatusBadge } from '../components/common'
|
||||
import { simApi, labApi } from '../api'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import type { SimStatus, SimResult, DeepSeekStatus, LabDeck } from '../types'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
|
||||
export function SimulatorPage() {
|
||||
const [decks, setDecks] = useState<LabDeck[]>([])
|
||||
const [deckA, setDeckA] = useState('')
|
||||
const [deckB, setDeckB] = useState('')
|
||||
const [games, setGames] = useState(50)
|
||||
const [useDeepSeek, setUseDeepSeek] = useState(false)
|
||||
const [deepSeekDeck, setDeepSeekDeck] = useState('')
|
||||
const [dsStatus, setDsStatus] = useState<DeepSeekStatus | null>(null)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [simId, setSimId] = useState<string | null>(null)
|
||||
const [simStatus, setSimStatus] = useState<SimStatus | null>(null)
|
||||
const [result, setResult] = useState<SimResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [starting, setStarting] = useState(false)
|
||||
|
||||
// DS config
|
||||
const [showDsConfig, setShowDsConfig] = useState(false)
|
||||
const [dsModel, setDsModel] = useState('')
|
||||
const [dsUrl, setDsUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
labApi.getLabDecks().then(setDecks).catch(() => {})
|
||||
simApi.getDeepSeekStatus().then(s => {
|
||||
setDsStatus(s)
|
||||
if (s.model) setDsModel(s.model)
|
||||
if (s.base_url) setDsUrl(s.base_url)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Poll while running
|
||||
usePolling(async () => {
|
||||
if (!simId) return
|
||||
try {
|
||||
const s = await simApi.getSimStatus(simId)
|
||||
setSimStatus(s)
|
||||
if (!s.running) {
|
||||
const r = await simApi.getSimResult(simId)
|
||||
setResult(r)
|
||||
setRunning(false)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000, running)
|
||||
|
||||
async function handleStart() {
|
||||
if (!deckA || !deckB) { setError('Select both decks'); return }
|
||||
setStarting(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
let res
|
||||
if (useDeepSeek && deepSeekDeck) {
|
||||
res = await simApi.runDeepSeekSim({ deck_a: deckA, deck_b: deckB, games, deepseek_deck: deepSeekDeck })
|
||||
} else {
|
||||
res = await simApi.runSim({ deck_a: deckA, deck_b: deckB, games })
|
||||
}
|
||||
setSimId(res.sim_id)
|
||||
setRunning(true)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start')
|
||||
}
|
||||
setStarting(false)
|
||||
}
|
||||
|
||||
async function handleDsConnect() {
|
||||
try {
|
||||
const s = await simApi.connectDeepSeek()
|
||||
setDsStatus(s)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleDsConfigure() {
|
||||
try {
|
||||
const s = await simApi.configureDeepSeek({
|
||||
model: dsModel || undefined,
|
||||
base_url: dsUrl || undefined,
|
||||
})
|
||||
setDsStatus(s)
|
||||
setShowDsConfig(false)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<Gamepad2 className="w-6 h-6 text-accent-purple" />
|
||||
Simulator
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">Run head-to-head deck simulations</p>
|
||||
</div>
|
||||
|
||||
{/* DeepSeek status */}
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge
|
||||
variant={dsStatus?.connected ? 'success' : 'warning'}
|
||||
label={dsStatus?.connected ? `DeepSeek: ${dsStatus.model}` : 'DeepSeek: Disconnected'}
|
||||
/>
|
||||
<button onClick={handleDsConnect}
|
||||
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
|
||||
{dsStatus?.connected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
||||
{dsStatus?.connected ? 'Reconnect' : 'Connect'}
|
||||
</button>
|
||||
<button onClick={() => setShowDsConfig(!showDsConfig)}
|
||||
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
|
||||
<Settings className="w-3 h-3" />
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDsConfig && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 max-w-md animate-fade-in space-y-3">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-accent-purple" />
|
||||
DeepSeek Configuration
|
||||
</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Model name"
|
||||
value={dsModel}
|
||||
onChange={e => setDsModel(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Base URL (e.g. http://192.168.0.122:1234)"
|
||||
value={dsUrl}
|
||||
onChange={e => setDsUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
<button onClick={handleDsConfigure}
|
||||
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sim config */}
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck A</label>
|
||||
<select
|
||||
value={deckA}
|
||||
onChange={e => setDeckA(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
>
|
||||
<option value="">Select deck...</option>
|
||||
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck B</label>
|
||||
<select
|
||||
value={deckB}
|
||||
onChange={e => setDeckB(e.target.value)}
|
||||
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
>
|
||||
<option value="">Select deck...</option>
|
||||
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
|
||||
<input
|
||||
type="number" min={1} max={1000}
|
||||
value={games}
|
||||
onChange={e => setGames(Number(e.target.value))}
|
||||
className="mt-1 w-24 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer mt-5">
|
||||
<input type="checkbox" checked={useDeepSeek} onChange={e => setUseDeepSeek(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-purple focus:ring-accent-purple/30" />
|
||||
<span className="text-sm text-text-secondary">Use DeepSeek AI pilot</span>
|
||||
</label>
|
||||
{useDeepSeek && (
|
||||
<div className="mt-5">
|
||||
<select
|
||||
value={deepSeekDeck}
|
||||
onChange={e => setDeepSeekDeck(e.target.value)}
|
||||
className="px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
>
|
||||
<option value="">AI pilots which deck?</option>
|
||||
{[deckA, deckB].filter(Boolean).map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
|
||||
<AlertCircle className="w-4 h-4" />{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{running ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Spinner size="md" className="text-accent-blue" />
|
||||
<div>
|
||||
<p className="text-sm text-text-primary">Simulation running...</p>
|
||||
{simStatus && (
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{simStatus.games_completed ?? 0} / {simStatus.total_games ?? games} games
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleStart} disabled={starting || !deckA || !deckB}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-semibold rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-50">
|
||||
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
|
||||
Run Simulation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4 text-accent-amber" />
|
||||
Results
|
||||
</h3>
|
||||
<span className="text-xs text-text-tertiary flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{(result.duration_ms / 1000).toFixed(1)}s · {result.total_games} games
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={result.decks.map(d => ({ name: d.name, win_rate: Math.round(d.win_rate * 100) }))} barSize={40}>
|
||||
<XAxis dataKey="name" tick={{ fill: '#9ba1b8', fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
|
||||
formatter={(value: unknown) => [`${value}%`, 'Win Rate']} />
|
||||
<Bar dataKey="win_rate" radius={[4, 4, 0, 0]}>
|
||||
{result.decks.map((d, i) => (
|
||||
<Cell key={i} fill={d.win_rate === Math.max(...result.decks.map(x => x.win_rate)) ? '#4ade80' : '#4f6ef7'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{result.decks.map(d => (
|
||||
<div key={d.name} className="px-4 py-3 rounded-lg bg-bg-tertiary">
|
||||
<p className="text-sm font-semibold text-text-primary">{d.name}</p>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
<div>
|
||||
<p className="text-[10px] text-text-tertiary uppercase">Wins</p>
|
||||
<p className="text-lg font-bold text-accent-green">{d.wins}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-text-tertiary uppercase">Win Rate</p>
|
||||
<p className="text-lg font-bold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-text-tertiary uppercase">Avg Turn</p>
|
||||
<p className="text-lg font-bold text-text-primary">{d.avg_turn.toFixed(1)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,822 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Brain, Play, RotateCw, Database, Trophy, AlertCircle,
|
||||
Activity, Zap, Swords, FileText, Cpu, CheckCircle,
|
||||
ChevronDown, ChevronUp, Settings
|
||||
} from 'lucide-react'
|
||||
import { Spinner, StatusBadge } from '../components/common'
|
||||
import { trainingApi } from '../api'
|
||||
import { usePolling } from '../hooks/usePolling'
|
||||
import type {
|
||||
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
|
||||
TournamentStatus, MLModelInfo
|
||||
} from '../types'
|
||||
|
||||
// ── Progress Bar ──────────────────────────────────────────────
|
||||
function ProgressBar({ value, max, label, color = 'bg-accent-blue' }: { value: number; max: number; label: string; color?: string }) {
|
||||
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-text-secondary">{label}</span>
|
||||
<span className="text-text-tertiary">{value} / {max} ({pct.toFixed(0)}%)</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div className={'h-full rounded-full transition-all duration-500 ' + color} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Phase Badge ──────────────────────────────────────────────
|
||||
const PHASE_STYLES: Record<string, string> = {
|
||||
idle: 'bg-bg-tertiary text-text-tertiary',
|
||||
starting: 'bg-accent-blue/15 text-accent-blue animate-pulse',
|
||||
building: 'bg-accent-purple/15 text-accent-purple animate-pulse',
|
||||
training: 'bg-accent-blue/15 text-accent-blue animate-pulse',
|
||||
evaluating: 'bg-accent-teal/15 text-accent-teal animate-pulse',
|
||||
running: 'bg-accent-amber/15 text-accent-amber animate-pulse',
|
||||
done: 'bg-accent-green/15 text-accent-green',
|
||||
error: 'bg-accent-red/15 text-accent-red',
|
||||
}
|
||||
|
||||
function PhaseBadge({ phase }: { phase: string }) {
|
||||
const cls = PHASE_STYLES[phase] || PHASE_STYLES.idle
|
||||
return (
|
||||
<span className={'text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded ' + cls}>
|
||||
{phase}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Metric Card ──────────────────────────────────────────────
|
||||
function MetricCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||
return (
|
||||
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
|
||||
<p className="text-[10px] text-text-tertiary">{label}</p>
|
||||
<p className={'text-sm font-bold ' + (accent || 'text-text-primary')}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Collapsible Section ─────────────────────────────────────
|
||||
function Section({ title, icon, children, defaultOpen = true, color = 'text-accent-blue' }: {
|
||||
title: string; icon: React.ReactNode; children: React.ReactNode; defaultOpen?: boolean; color?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
|
||||
<span className={color}>{icon}</span>
|
||||
{title}
|
||||
</h3>
|
||||
{open ? <ChevronUp className="w-4 h-4 text-text-tertiary" /> : <ChevronDown className="w-4 h-4 text-text-tertiary" />}
|
||||
</button>
|
||||
{open && <div className="px-5 pb-5 space-y-4 border-t border-border-primary pt-4">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Main Training Page
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
export function TrainingPage() {
|
||||
// Status states
|
||||
const [mlStatus, setMlStatus] = useState<MLStatus | null>(null)
|
||||
const [dataStatus, setDataStatus] = useState<MLDataStatus | null>(null)
|
||||
const [modelInfo, setModelInfo] = useState<MLModelInfo | null>(null)
|
||||
const [trainStatus, setTrainStatus] = useState<MLTrainStatus | null>(null)
|
||||
const [ppoStatus, setPpoStatus] = useState<PPOTrainStatus | null>(null)
|
||||
const [tournStatus, setTournStatus] = useState<TournamentStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Supervised training config
|
||||
const [epochs, setEpochs] = useState(50)
|
||||
const [lr, setLr] = useState(0.001)
|
||||
const [batchSize, setBatchSize] = useState(256)
|
||||
const [patience, setPatience] = useState(10)
|
||||
const [rebuildDataset, setRebuildDataset] = useState(true)
|
||||
|
||||
// PPO config
|
||||
const [ppoIterations, setPpoIterations] = useState(100)
|
||||
const [ppoEpisodes, setPpoEpisodes] = useState(64)
|
||||
const [ppoEpochs, setPpoEpochs] = useState(4)
|
||||
const [ppoBatchSize, setPpoBatchSize] = useState(256)
|
||||
const [ppoLr, setPpoLr] = useState(0.0003)
|
||||
const [ppoClipEps, setPpoClipEps] = useState(0.2)
|
||||
const [ppoEntropy, setPpoEntropy] = useState(0.01)
|
||||
const [ppoOpponent, setPpoOpponent] = useState('heuristic')
|
||||
const [ppoPlaystyle, _setPpoPlaystyle] = useState('midrange') // eslint-disable-line
|
||||
|
||||
// Tournament config
|
||||
const [tournEpisodes, setTournEpisodes] = useState(50)
|
||||
const [tournPlaystyle, setTournPlaystyle] = useState('midrange')
|
||||
|
||||
// Action states
|
||||
const [startingTrain, setStartingTrain] = useState(false)
|
||||
const [startingPpo, setStartingPpo] = useState(false)
|
||||
const [startingTourn, setStartingTourn] = useState(false)
|
||||
const [reloading, setReloading] = useState(false)
|
||||
const [showSupAdvanced, setShowSupAdvanced] = useState(false)
|
||||
const [showPpoAdvanced, setShowPpoAdvanced] = useState(false)
|
||||
|
||||
// ── Initial Load ──────────────────────────────────────────
|
||||
const loadAll = useCallback(async () => {
|
||||
try {
|
||||
const [ml, data, model, train, ppo, tourn] = await Promise.all([
|
||||
trainingApi.getMLStatus().catch(() => null),
|
||||
trainingApi.getDataStatus().catch(() => null),
|
||||
trainingApi.getModelInfo().catch(() => null),
|
||||
trainingApi.getTrainStatus().catch(() => null),
|
||||
trainingApi.getPPOStatus().catch(() => null),
|
||||
trainingApi.getTournamentStatus().catch(() => null),
|
||||
])
|
||||
if (ml) setMlStatus(ml)
|
||||
if (data) setDataStatus(data)
|
||||
if (model) setModelInfo(model)
|
||||
if (train) setTrainStatus(train)
|
||||
if (ppo) setPpoStatus(ppo)
|
||||
if (tourn) setTournStatus(tourn)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadAll() }, [loadAll])
|
||||
|
||||
// ── Polling ───────────────────────────────────────────────
|
||||
const isTraining = trainStatus?.running || false
|
||||
const isPpo = ppoStatus?.running || false
|
||||
const isTourn = tournStatus?.running || false
|
||||
const anyRunning = isTraining || isPpo || isTourn
|
||||
|
||||
usePolling(async () => {
|
||||
try {
|
||||
const s = await trainingApi.getTrainStatus()
|
||||
setTrainStatus(s)
|
||||
if (!s.running && s.phase === 'done') {
|
||||
// Refresh model info + data when training completes
|
||||
const [model, data] = await Promise.all([
|
||||
trainingApi.getModelInfo().catch(() => null),
|
||||
trainingApi.getDataStatus().catch(() => null),
|
||||
])
|
||||
if (model) setModelInfo(model)
|
||||
if (data) setDataStatus(data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000, isTraining)
|
||||
|
||||
usePolling(async () => {
|
||||
try {
|
||||
const s = await trainingApi.getPPOStatus()
|
||||
setPpoStatus(s)
|
||||
if (!s.running && s.phase === 'done') {
|
||||
const [model, data] = await Promise.all([
|
||||
trainingApi.getModelInfo().catch(() => null),
|
||||
trainingApi.getDataStatus().catch(() => null),
|
||||
])
|
||||
if (model) setModelInfo(model)
|
||||
if (data) setDataStatus(data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000, isPpo)
|
||||
|
||||
usePolling(async () => {
|
||||
try {
|
||||
const s = await trainingApi.getTournamentStatus()
|
||||
setTournStatus(s)
|
||||
} catch { /* ignore */ }
|
||||
}, 2000, isTourn)
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────
|
||||
async function handleToggleLogging() {
|
||||
try {
|
||||
const newState = !(mlStatus?.ml_logging_enabled ?? false)
|
||||
await trainingApi.toggleML(newState)
|
||||
const ml = await trainingApi.getMLStatus()
|
||||
setMlStatus(ml)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handleReloadModel() {
|
||||
setReloading(true)
|
||||
try {
|
||||
const res = await trainingApi.reloadModel()
|
||||
setModelInfo(res.status)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to reload model')
|
||||
}
|
||||
setReloading(false)
|
||||
}
|
||||
|
||||
async function handleStartTrain() {
|
||||
setStartingTrain(true)
|
||||
setError(null)
|
||||
try {
|
||||
await trainingApi.startTraining({ epochs, lr, batchSize, patience, rebuildDataset })
|
||||
const s = await trainingApi.getTrainStatus()
|
||||
setTrainStatus(s)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start training')
|
||||
}
|
||||
setStartingTrain(false)
|
||||
}
|
||||
|
||||
async function handleStartPpo() {
|
||||
setStartingPpo(true)
|
||||
setError(null)
|
||||
try {
|
||||
await trainingApi.startPPOTraining({
|
||||
iterations: ppoIterations,
|
||||
episodesPerIter: ppoEpisodes,
|
||||
ppoEpochs,
|
||||
batchSize: ppoBatchSize,
|
||||
lr: ppoLr,
|
||||
clipEpsilon: ppoClipEps,
|
||||
entropyCoeff: ppoEntropy,
|
||||
opponent: ppoOpponent,
|
||||
playstyle: ppoPlaystyle,
|
||||
})
|
||||
const s = await trainingApi.getPPOStatus()
|
||||
setPpoStatus(s)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start PPO training')
|
||||
}
|
||||
setStartingPpo(false)
|
||||
}
|
||||
|
||||
async function handleStartTournament() {
|
||||
setStartingTourn(true)
|
||||
setError(null)
|
||||
try {
|
||||
await trainingApi.startTournament({ episodes: tournEpisodes, playstyle: tournPlaystyle })
|
||||
const s = await trainingApi.getTournamentStatus()
|
||||
setTournStatus(s)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start tournament')
|
||||
}
|
||||
setStartingTourn(false)
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
|
||||
}
|
||||
|
||||
const trainDataset = dataStatus?.datasets?.['train']
|
||||
const hasDecisions = (mlStatus?.total_decisions ?? 0) > 0
|
||||
const hasModel = modelInfo?.loaded === true
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
<Brain className="w-6 h-6 text-accent-green" />
|
||||
ML Training Pipeline
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-0.5">Collect data, train models, and evaluate performance</p>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-accent-red/10 border border-accent-red/30 rounded-lg text-sm text-accent-red">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1">{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-accent-red/60 hover:text-accent-red text-xs">dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline overview bar */}
|
||||
<div className="flex flex-wrap items-center gap-3 bg-bg-secondary rounded-xl border border-border-primary px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={'w-2 h-2 rounded-full ' + (mlStatus?.ml_logging_enabled ? 'bg-accent-green' : 'bg-text-tertiary')} />
|
||||
<span className="text-xs text-text-secondary">Logging {mlStatus?.ml_logging_enabled ? 'On' : 'Off'}</span>
|
||||
</div>
|
||||
<span className="text-border-primary">|</span>
|
||||
<span className="text-xs text-text-secondary">{mlStatus?.total_decisions ?? 0} decisions</span>
|
||||
<span className="text-border-primary">|</span>
|
||||
<span className="text-xs text-text-secondary">{trainDataset ? `${trainDataset.samples.toLocaleString()} training samples` : 'No dataset'}</span>
|
||||
<span className="text-border-primary">|</span>
|
||||
<span className="text-xs text-text-secondary">{(dataStatus?.checkpoints?.length ?? 0)} checkpoints</span>
|
||||
<span className="text-border-primary">|</span>
|
||||
<StatusBadge
|
||||
variant={hasModel ? 'success' : 'neutral'}
|
||||
label={hasModel ? `Model on ${modelInfo?.device ?? '?'}` : 'No model'}
|
||||
/>
|
||||
{anyRunning && (
|
||||
<>
|
||||
<span className="text-border-primary">|</span>
|
||||
<span className="text-xs font-medium text-accent-blue animate-pulse">Pipeline running...</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ Phase 1: Data Collection ═══ */}
|
||||
<Section title="Phase 1 — Data Collection" icon={<FileText className="w-4 h-4" />} color="text-accent-teal">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-text-secondary">
|
||||
Run batch simulations with ML logging enabled to collect decision data.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleToggleLogging}
|
||||
className={'flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ' +
|
||||
(mlStatus?.ml_logging_enabled
|
||||
? 'bg-accent-green/15 text-accent-green border-accent-green/30 hover:bg-accent-green/25'
|
||||
: 'bg-bg-tertiary text-text-secondary border-border-primary hover:bg-bg-hover'
|
||||
)}
|
||||
>
|
||||
<div className={'w-2 h-2 rounded-full ' + (mlStatus?.ml_logging_enabled ? 'bg-accent-green' : 'bg-text-tertiary')} />
|
||||
{mlStatus?.ml_logging_enabled ? 'Logging Enabled' : 'Enable Logging'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Decision files */}
|
||||
{(dataStatus?.decisionFiles?.length ?? 0) > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Decision Files</h4>
|
||||
<div className="max-h-36 overflow-y-auto space-y-1">
|
||||
{dataStatus!.decisionFiles.map(f => (
|
||||
<div key={f.name} className="flex items-center justify-between px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs">
|
||||
<span className="text-text-secondary font-mono truncate">{f.name}</span>
|
||||
<div className="flex items-center gap-3 text-text-tertiary">
|
||||
<span>{f.decisions.toLocaleString()} decisions</span>
|
||||
<span>{(f.size / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-tertiary italic">No decision files yet. Run batch sims with logging enabled.</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ═══ Phase 2: Dataset & Checkpoints ═══ */}
|
||||
<Section title="Phase 2 — Dataset & Model Artifacts" icon={<Database className="w-4 h-4" />} color="text-accent-purple">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Dataset splits */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Dataset Splits</h4>
|
||||
{Object.keys(dataStatus?.datasets ?? {}).length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['train', 'val', 'test'] as const).map(split => {
|
||||
const ds = dataStatus?.datasets?.[split]
|
||||
return (
|
||||
<div key={split} className="px-3 py-2 bg-bg-tertiary rounded-lg">
|
||||
<p className="text-[10px] text-text-tertiary uppercase">{split}</p>
|
||||
{ds ? (
|
||||
<>
|
||||
<p className="text-sm font-bold text-text-primary">{ds.samples.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-text-tertiary">{ds.features} features</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-text-tertiary italic">Not built</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-tertiary italic">No dataset built yet. Training will build it automatically.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Checkpoints */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Checkpoints</h4>
|
||||
{(dataStatus?.checkpoints?.length ?? 0) > 0 ? (
|
||||
<div className="max-h-28 overflow-y-auto space-y-1">
|
||||
{dataStatus!.checkpoints.map(ckpt => (
|
||||
<div key={ckpt.name} className="flex items-center justify-between px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs">
|
||||
<span className="text-text-secondary font-mono truncate">{ckpt.name}</span>
|
||||
<span className="text-text-tertiary">{(ckpt.size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-tertiary italic">No checkpoints yet. Train a model to generate them.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model info + reload */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border-primary">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cpu className="w-4 h-4 text-text-tertiary" />
|
||||
<div className="text-xs">
|
||||
{hasModel ? (
|
||||
<span className="text-accent-green font-medium">
|
||||
Model loaded on {modelInfo?.device} | {modelInfo?.input_dim} features | {modelInfo?.num_actions} actions
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-tertiary">No model loaded{modelInfo?.error ? ` — ${modelInfo.error}` : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReloadModel}
|
||||
disabled={reloading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
{reloading ? <Spinner size="sm" /> : <RotateCw className="w-3 h-3" />}
|
||||
Reload Model
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Eval results */}
|
||||
{dataStatus?.evalResults && (
|
||||
<div className="pt-2 border-t border-border-primary space-y-2">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex items-center gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||
Latest Evaluation
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{Object.entries(dataStatus.evalResults).map(([k, v]) => (
|
||||
<MetricCard
|
||||
key={k}
|
||||
label={k.replace(/_/g, ' ')}
|
||||
value={typeof v === 'number' ? (k.includes('accuracy') || k.includes('rate') ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)) : String(v)}
|
||||
accent={k.includes('accuracy') ? 'text-accent-green' : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ═══ Phase 3: Supervised Training ═══ */}
|
||||
<Section title="Phase 3 — Supervised Training" icon={<Activity className="w-4 h-4" />}>
|
||||
{/* Config */}
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Epochs</label>
|
||||
<input type="number" min={1} max={1000} value={epochs} onChange={e => setEpochs(Number(e.target.value))}
|
||||
disabled={isTraining}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Learning Rate</label>
|
||||
<input type="number" min={0.00001} max={1} step={0.0001} value={lr} onChange={e => setLr(Number(e.target.value))}
|
||||
disabled={isTraining}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced toggle */}
|
||||
<button onClick={() => setShowSupAdvanced(!showSupAdvanced)} className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
|
||||
<Settings className="w-3 h-3" />
|
||||
{showSupAdvanced ? 'Hide' : 'Show'} advanced options
|
||||
</button>
|
||||
{showSupAdvanced && (
|
||||
<div className="grid grid-cols-3 gap-3 animate-fade-in">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Batch Size</label>
|
||||
<input type="number" min={16} max={2048} value={batchSize} onChange={e => setBatchSize(Number(e.target.value))}
|
||||
disabled={isTraining}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Early Stop Patience</label>
|
||||
<input type="number" min={1} max={100} value={patience} onChange={e => setPatience(Number(e.target.value))}
|
||||
disabled={isTraining}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer select-none">
|
||||
<input type="checkbox" checked={rebuildDataset} onChange={e => setRebuildDataset(e.target.checked)}
|
||||
disabled={isTraining}
|
||||
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5" />
|
||||
Rebuild dataset
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Training status / start button */}
|
||||
{isTraining || (trainStatus?.phase && trainStatus.phase !== 'idle') ? (
|
||||
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<PhaseBadge phase={trainStatus!.phase} />
|
||||
<span className="text-xs text-text-secondary">{trainStatus!.message}</span>
|
||||
</div>
|
||||
{trainStatus!.started_at && (
|
||||
<span className="text-[10px] text-text-tertiary">Started: {new Date(trainStatus!.started_at).toLocaleTimeString()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar — only in training phase */}
|
||||
{(trainStatus!.phase === 'training' || trainStatus!.phase === 'done') && trainStatus!.total_epochs > 0 && (
|
||||
<ProgressBar
|
||||
value={trainStatus!.current_epoch}
|
||||
max={trainStatus!.total_epochs}
|
||||
label="Epoch progress"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Live metrics during training */}
|
||||
{trainStatus!.metrics && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{Object.entries(trainStatus!.metrics).map(([k, v]) => {
|
||||
if (typeof v !== 'number') return null
|
||||
const isAcc = k.includes('accuracy') || k.includes('acc')
|
||||
const isLoss = k.includes('loss')
|
||||
return (
|
||||
<MetricCard
|
||||
key={k}
|
||||
label={k.replace(/_/g, ' ')}
|
||||
value={isAcc ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)}
|
||||
accent={isAcc ? 'text-accent-green' : isLoss ? 'text-accent-amber' : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final result */}
|
||||
{trainStatus!.phase === 'done' && trainStatus!.result && (
|
||||
<div className="pt-2 border-t border-border-primary space-y-2">
|
||||
<h4 className="text-xs font-semibold text-accent-green flex items-center gap-1.5">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> Training Complete
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<MetricCard label="Device" value={trainStatus!.result.device} />
|
||||
<MetricCard label="Checkpoint" value={trainStatus!.result.checkpoint.split('/').pop() || '—'} />
|
||||
{trainStatus!.result.evaluation && Object.entries(trainStatus!.result.evaluation).map(([k, v]) => (
|
||||
typeof v === 'number' ? (
|
||||
<MetricCard key={k} label={k.replace(/_/g, ' ')}
|
||||
value={k.includes('accuracy') ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)}
|
||||
accent={k.includes('accuracy') ? 'text-accent-green' : undefined} />
|
||||
) : null
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{trainStatus!.phase === 'error' && trainStatus!.error && (
|
||||
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
|
||||
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
|
||||
<span className="text-xs text-accent-red">{trainStatus!.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleStartTrain}
|
||||
disabled={startingTrain || !hasDecisions || isPpo}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue/15 text-accent-blue text-sm font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{startingTrain ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
|
||||
Start Training
|
||||
</button>
|
||||
{!hasDecisions && (
|
||||
<span className="text-xs text-text-tertiary italic">Enable logging and run batch sims first to generate decision data.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ═══ Phase 4: PPO Reinforcement Learning ═══ */}
|
||||
<Section title="Phase 4 — PPO Reinforcement Learning" icon={<Zap className="w-4 h-4" />} color="text-accent-purple" defaultOpen={false}>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Fine-tune the supervised model using PPO self-play against an opponent policy.
|
||||
</p>
|
||||
|
||||
{/* Config */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Iterations</label>
|
||||
<input type="number" min={1} max={1000} value={ppoIterations} onChange={e => setPpoIterations(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Opponent</label>
|
||||
<select value={ppoOpponent} onChange={e => setPpoOpponent(e.target.value)}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50">
|
||||
<option value="heuristic">Heuristic</option>
|
||||
<option value="random">Random</option>
|
||||
<option value="self">Self-Play</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={() => setShowPpoAdvanced(!showPpoAdvanced)} className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
|
||||
<Settings className="w-3 h-3" />
|
||||
{showPpoAdvanced ? 'Hide' : 'Show'} advanced options
|
||||
</button>
|
||||
{showPpoAdvanced && (
|
||||
<div className="grid grid-cols-3 gap-3 animate-fade-in">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Episodes / Iter</label>
|
||||
<input type="number" min={1} max={512} value={ppoEpisodes} onChange={e => setPpoEpisodes(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">PPO Epochs</label>
|
||||
<input type="number" min={1} max={20} value={ppoEpochs} onChange={e => setPpoEpochs(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Batch Size</label>
|
||||
<input type="number" min={16} max={2048} value={ppoBatchSize} onChange={e => setPpoBatchSize(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Learning Rate</label>
|
||||
<input type="number" min={0.000001} max={0.01} step={0.0001} value={ppoLr} onChange={e => setPpoLr(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Clip Epsilon</label>
|
||||
<input type="number" min={0.05} max={0.5} step={0.05} value={ppoClipEps} onChange={e => setPpoClipEps(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Entropy Coeff</label>
|
||||
<input type="number" min={0} max={0.1} step={0.005} value={ppoEntropy} onChange={e => setPpoEntropy(Number(e.target.value))}
|
||||
disabled={isPpo}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PPO running status */}
|
||||
{isPpo || (ppoStatus?.phase && ppoStatus.phase !== 'idle') ? (
|
||||
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
|
||||
<div className="flex items-center gap-2">
|
||||
<PhaseBadge phase={ppoStatus!.phase} />
|
||||
<span className="text-xs text-text-secondary">{ppoStatus!.message}</span>
|
||||
</div>
|
||||
|
||||
{(ppoStatus!.phase === 'training' || ppoStatus!.phase === 'done') && ppoStatus!.total_iterations > 0 && (
|
||||
<ProgressBar
|
||||
value={ppoStatus!.iteration}
|
||||
max={ppoStatus!.total_iterations}
|
||||
label="PPO iteration"
|
||||
color="bg-accent-purple"
|
||||
/>
|
||||
)}
|
||||
|
||||
{ppoStatus!.metrics && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{ppoStatus!.metrics.win_rate != null && (
|
||||
<MetricCard label="Win Rate" value={`${(ppoStatus!.metrics.win_rate * 100).toFixed(1)}%`} accent="text-accent-green" />
|
||||
)}
|
||||
{ppoStatus!.metrics.avg_reward != null && (
|
||||
<MetricCard label="Avg Reward" value={ppoStatus!.metrics.avg_reward.toFixed(3)} accent="text-accent-amber" />
|
||||
)}
|
||||
{ppoStatus!.metrics.policy_loss != null && (
|
||||
<MetricCard label="Policy Loss" value={ppoStatus!.metrics.policy_loss.toFixed(4)} />
|
||||
)}
|
||||
{ppoStatus!.metrics.entropy != null && (
|
||||
<MetricCard label="Entropy" value={ppoStatus!.metrics.entropy.toFixed(4)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ppoStatus!.phase === 'done' && ppoStatus!.result && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border-primary">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
|
||||
<span className="text-xs text-accent-green font-medium">
|
||||
PPO Training Complete — Best Win Rate: {
|
||||
typeof ppoStatus!.result.best_win_rate === 'number'
|
||||
? `${(ppoStatus!.result.best_win_rate as number * 100).toFixed(1)}%`
|
||||
: '—'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ppoStatus!.phase === 'error' && ppoStatus!.error && (
|
||||
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
|
||||
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
|
||||
<span className="text-xs text-accent-red">{ppoStatus!.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStartPpo}
|
||||
disabled={startingPpo || isTraining || !hasModel}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-medium rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{startingPpo ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
|
||||
Start PPO Training
|
||||
</button>
|
||||
)}
|
||||
{!hasModel && !(isPpo || (ppoStatus?.phase && ppoStatus.phase !== 'idle')) && (
|
||||
<p className="text-xs text-text-tertiary italic">Train a supervised model first (Phase 3) before running PPO.</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ═══ Phase 5: Tournament Evaluation ═══ */}
|
||||
<Section title="Phase 5 — Tournament Evaluation" icon={<Swords className="w-4 h-4" />} color="text-accent-amber" defaultOpen={false}>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Pit the trained model against heuristic, random, and other policy variants.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Episodes per Matchup</label>
|
||||
<input type="number" min={10} max={1000} value={tournEpisodes} onChange={e => setTournEpisodes(Number(e.target.value))}
|
||||
disabled={isTourn}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-tertiary block mb-1">Playstyle</label>
|
||||
<select value={tournPlaystyle} onChange={e => setTournPlaystyle(e.target.value)}
|
||||
disabled={isTourn}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50">
|
||||
<option value="midrange">Midrange</option>
|
||||
<option value="aggro">Aggro</option>
|
||||
<option value="control">Control</option>
|
||||
<option value="combo">Combo</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tournament status */}
|
||||
{isTourn || (tournStatus?.phase && tournStatus.phase !== 'idle') ? (
|
||||
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
|
||||
<div className="flex items-center gap-2">
|
||||
<PhaseBadge phase={tournStatus!.phase} />
|
||||
<span className="text-xs text-text-secondary">{tournStatus!.message}</span>
|
||||
</div>
|
||||
|
||||
{tournStatus!.phase === 'error' && tournStatus!.error && (
|
||||
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
|
||||
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
|
||||
<span className="text-xs text-accent-red">{tournStatus!.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStartTournament}
|
||||
disabled={startingTourn || isTraining || isPpo}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-accent-amber/15 text-accent-amber text-sm font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{startingTourn ? <Spinner size="sm" /> : <Trophy className="w-4 h-4" />}
|
||||
Start Tournament
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Tournament results */}
|
||||
{(tournStatus?.result?.players?.length ?? 0) > 0 && (
|
||||
<div className="pt-2 border-t border-border-primary space-y-2">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex items-center gap-2">
|
||||
<Trophy className="w-3.5 h-3.5 text-accent-amber" />
|
||||
Leaderboard
|
||||
</h4>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-primary">
|
||||
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium w-8">#</th>
|
||||
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium">Policy</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Wins</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Games</th>
|
||||
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Win Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-primary">
|
||||
{tournStatus!.result!.players
|
||||
.sort((a, b) => b.win_rate - a.win_rate)
|
||||
.map((p, i) => (
|
||||
<tr key={p.name} className="hover:bg-bg-hover transition-colors">
|
||||
<td className="px-3 py-2 text-text-tertiary text-xs">
|
||||
{i === 0 ? <Trophy className="w-3.5 h-3.5 text-accent-amber" /> : i + 1}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="text-text-primary font-medium capitalize">{p.name}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-text-secondary">{p.wins}</td>
|
||||
<td className="px-3 py-2 text-right text-text-secondary">{p.games}</td>
|
||||
<td className={'px-3 py-2 text-right font-semibold ' + (i === 0 ? 'text-accent-green' : 'text-accent-blue')}>
|
||||
{(p.win_rate * 100).toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Commander AI Lab — TypeScript Type Definitions
|
||||
// Derived from the FastAPI backend's Pydantic models + SQLite schema
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// ── Cards & Collection ──────────────────────────────────────
|
||||
|
||||
export interface CollectionCard {
|
||||
id: number
|
||||
name: string
|
||||
scryfall_id: string
|
||||
set_code: string
|
||||
set_name: string
|
||||
collector_number: string
|
||||
rarity: string
|
||||
type_line: string
|
||||
mana_cost: string
|
||||
cmc: number
|
||||
oracle_text: string
|
||||
color_identity: string[]
|
||||
quantity: number
|
||||
foil_quantity: number
|
||||
condition: string
|
||||
tcg_price: number | null
|
||||
foil_price: number | null
|
||||
image_url: string
|
||||
edhrec_rank: number | null
|
||||
keywords: string
|
||||
added_date: string
|
||||
updated_date: string
|
||||
}
|
||||
|
||||
export interface CollectionFilters {
|
||||
q?: string
|
||||
type_line?: string
|
||||
color?: string
|
||||
rarity?: string
|
||||
set_code?: string
|
||||
cmc_min?: number
|
||||
cmc_max?: number
|
||||
price_min?: number
|
||||
price_max?: number
|
||||
owned_only?: boolean
|
||||
sort_by?: string
|
||||
sort_dir?: 'asc' | 'desc'
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface CollectionStats {
|
||||
total_cards: number
|
||||
total_unique: number
|
||||
total_value: number
|
||||
sets_count: number
|
||||
}
|
||||
|
||||
export interface SetInfo {
|
||||
code: string
|
||||
name: string
|
||||
card_count?: number
|
||||
}
|
||||
|
||||
// ── Scan ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ScanResult {
|
||||
matches: ScanMatch[]
|
||||
image_url?: string
|
||||
}
|
||||
|
||||
export interface ScanMatch {
|
||||
name: string
|
||||
set_code: string
|
||||
set_name: string
|
||||
collector_number: string
|
||||
scryfall_id: string
|
||||
confidence: number
|
||||
image_url: string
|
||||
type_line: string
|
||||
oracle_text: string
|
||||
mana_cost: string
|
||||
tcg_price: number | null
|
||||
}
|
||||
|
||||
// ── Decks ────────────────────────────────────────────────────
|
||||
|
||||
export interface Deck {
|
||||
id: number
|
||||
name: string
|
||||
commander: string
|
||||
commander_scryfall_id?: string
|
||||
commander_image_url?: string
|
||||
color_identity: string[]
|
||||
format: string
|
||||
card_count: number
|
||||
total_price: number
|
||||
created_date: string
|
||||
updated_date: string
|
||||
}
|
||||
|
||||
export interface DeckCard {
|
||||
id: number
|
||||
deck_id: number
|
||||
name: string
|
||||
scryfall_id: string
|
||||
quantity: number
|
||||
is_commander: boolean
|
||||
category: string
|
||||
type_line: string
|
||||
mana_cost: string
|
||||
cmc: number
|
||||
oracle_text: string
|
||||
tcg_price: number | null
|
||||
image_url: string
|
||||
owned_qty: number
|
||||
set_code: string
|
||||
}
|
||||
|
||||
export interface DeckAnalysis {
|
||||
card_count: number
|
||||
land_count: number
|
||||
creature_count: number
|
||||
noncreature_count: number
|
||||
avg_cmc: number
|
||||
mana_curve: Record<number, number>
|
||||
color_distribution: Record<string, number>
|
||||
type_distribution: Record<string, number>
|
||||
total_price: number
|
||||
owned_count: number
|
||||
missing_count: number
|
||||
}
|
||||
|
||||
export interface DeckRecommendation {
|
||||
name: string
|
||||
scryfall_id: string
|
||||
type_line: string
|
||||
mana_cost: string
|
||||
oracle_text: string
|
||||
image_url: string
|
||||
tcg_price: number | null
|
||||
reason: string
|
||||
score: number
|
||||
owned: boolean
|
||||
}
|
||||
|
||||
// ── EDHREC Recommendations ──────────────────────────────────
|
||||
|
||||
export interface EdhRecCard {
|
||||
name: string
|
||||
type_line: string
|
||||
role: string
|
||||
roles: string[]
|
||||
inclusion_pct: number | null
|
||||
synergy_score: number | null
|
||||
owned: boolean
|
||||
owned_qty: number
|
||||
scryfall_id: string
|
||||
image_url: string | null
|
||||
}
|
||||
|
||||
export interface EdhRecsResponse {
|
||||
commander: string
|
||||
source: string
|
||||
total: number
|
||||
recommendations: EdhRecCard[]
|
||||
}
|
||||
|
||||
// ── Collection Recommendations ──────────────────────────────
|
||||
|
||||
export interface CollectionRecCard {
|
||||
id: number
|
||||
scryfall_id: string
|
||||
name: string
|
||||
type_line: string
|
||||
card_type: string
|
||||
cmc: number
|
||||
color_identity: string[]
|
||||
owned_qty: number
|
||||
roles: string[]
|
||||
score: number
|
||||
image_url: string | null
|
||||
}
|
||||
|
||||
export interface CollectionRecsResponse {
|
||||
shortfall_types: string[]
|
||||
role_filter: string[]
|
||||
grouped: Record<string, CollectionRecCard[]>
|
||||
total: number
|
||||
}
|
||||
|
||||
// ── V3 Deck Generator ────────────────────────────────────────
|
||||
|
||||
export interface DeckGenV3Request {
|
||||
commander_name: string
|
||||
strategy?: string
|
||||
target_bracket: number
|
||||
budget_usd?: number
|
||||
budget_mode?: 'total' | 'per_card'
|
||||
omit_cards?: string[]
|
||||
use_collection?: boolean
|
||||
run_substitution?: boolean
|
||||
model?: string
|
||||
deck_name?: string
|
||||
}
|
||||
|
||||
export interface DeckGenV3Status {
|
||||
initialized: boolean
|
||||
pplx_configured: boolean
|
||||
model: string | null
|
||||
embeddings_loaded: boolean
|
||||
embedding_cards: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface DeckGenV3Card {
|
||||
name: string
|
||||
quantity: number
|
||||
category: string
|
||||
type_line: string
|
||||
mana_cost: string
|
||||
cmc: number
|
||||
role: string
|
||||
reason: string
|
||||
tcg_price: number | null
|
||||
image_url: string
|
||||
owned: boolean
|
||||
owned_qty: number
|
||||
scryfall_id: string
|
||||
is_substitute: boolean
|
||||
original_name: string | null
|
||||
substitution_reason: string | null
|
||||
similarity_score: number | null
|
||||
}
|
||||
|
||||
export interface DeckGenV3Result {
|
||||
commander: {
|
||||
name: string
|
||||
image_url: string
|
||||
type_line: string
|
||||
color_identity: string[]
|
||||
mana_cost: string
|
||||
oracle_text: string
|
||||
}
|
||||
cards: DeckGenV3Card[]
|
||||
stats: DeckAnalysis
|
||||
strategy: string
|
||||
bracket: number
|
||||
model: string
|
||||
substitution?: {
|
||||
owned: number
|
||||
substituted: number
|
||||
missing: number
|
||||
}
|
||||
}
|
||||
|
||||
// ── Simulation ───────────────────────────────────────────────
|
||||
|
||||
export interface SimConfig {
|
||||
deck_names: string[]
|
||||
num_games: number
|
||||
threads: number
|
||||
use_deepseek?: boolean
|
||||
}
|
||||
|
||||
export interface SimStatus {
|
||||
running: boolean
|
||||
sim_id: string
|
||||
progress?: number
|
||||
games_completed?: number
|
||||
total_games?: number
|
||||
}
|
||||
|
||||
export interface SimResult {
|
||||
sim_id: string
|
||||
decks: SimDeckResult[]
|
||||
total_games: number
|
||||
duration_ms: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface SimDeckResult {
|
||||
name: string
|
||||
wins: number
|
||||
losses: number
|
||||
win_rate: number
|
||||
avg_turn: number
|
||||
}
|
||||
|
||||
// ── Batch Sim (Lab) ──────────────────────────────────────────
|
||||
|
||||
export interface LabDeck {
|
||||
name: string
|
||||
source: string
|
||||
path: string
|
||||
card_count: number
|
||||
}
|
||||
|
||||
export interface LabStartRequest {
|
||||
decks: string[]
|
||||
games: number
|
||||
threads?: number
|
||||
use_deepseek?: boolean
|
||||
deepseek_deck?: string
|
||||
}
|
||||
|
||||
export interface LabStatus {
|
||||
running: boolean
|
||||
run_id: string
|
||||
games_completed: number
|
||||
total_games: number
|
||||
current_decks: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface LabResult {
|
||||
run_id: string
|
||||
decks: LabDeckResult[]
|
||||
total_games: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface LabDeckResult {
|
||||
name: string
|
||||
wins: number
|
||||
games: number
|
||||
win_rate: number
|
||||
avg_turns: number
|
||||
avg_life_remaining: number
|
||||
}
|
||||
|
||||
export interface LabHistoryEntry {
|
||||
run_id: string
|
||||
timestamp: string
|
||||
decks: string[]
|
||||
total_games: number
|
||||
}
|
||||
|
||||
export interface PreconDeck {
|
||||
name: string
|
||||
filename: string
|
||||
release_date?: string
|
||||
set_name?: string
|
||||
installed: boolean
|
||||
}
|
||||
|
||||
// ── Coach ────────────────────────────────────────────────────
|
||||
|
||||
export interface CoachStatus {
|
||||
llmConnected: boolean
|
||||
llmModel: string | null
|
||||
llmModels: string[]
|
||||
embeddingsLoaded: boolean
|
||||
embeddingCards: number
|
||||
deckReportsAvailable: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface CoachDeck {
|
||||
deck_id: number
|
||||
deck_name: string
|
||||
commander: string
|
||||
card_count: number
|
||||
has_report: boolean
|
||||
report_count: number
|
||||
last_report_date: string | null
|
||||
}
|
||||
|
||||
export interface CoachGoals {
|
||||
targetPowerLevel?: number | null
|
||||
metaFocus?: string | null
|
||||
budget?: string | null
|
||||
focusAreas?: string[]
|
||||
}
|
||||
|
||||
export interface CoachMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface CoachChatResponse {
|
||||
content: string
|
||||
model: string
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
}
|
||||
|
||||
export interface CoachSession {
|
||||
sessionId: string
|
||||
deckId: string
|
||||
timestamp: string
|
||||
summary: string
|
||||
suggestedCuts: CoachSuggestedCut[]
|
||||
suggestedAdds: CoachSuggestedAdd[]
|
||||
heuristicHints: string[]
|
||||
manaBaseAdvice: string | null
|
||||
rawTextExplanation: string
|
||||
modelUsed: string
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
goals: CoachGoals | null
|
||||
}
|
||||
|
||||
export interface CoachSuggestedCut {
|
||||
cardName: string
|
||||
reason: string
|
||||
replacementOptions: string[]
|
||||
currentImpactScore: number
|
||||
}
|
||||
|
||||
export interface CoachSuggestedAdd {
|
||||
cardName: string
|
||||
role: string
|
||||
reason: string
|
||||
synergyWith: string[]
|
||||
estimatedManaValue: number | null
|
||||
}
|
||||
|
||||
export interface CoachSessionSummary {
|
||||
sessionId: string
|
||||
deckId: string
|
||||
timestamp: string
|
||||
summary: string
|
||||
cutsCount: number
|
||||
addsCount: number
|
||||
}
|
||||
|
||||
export interface CoachApplyResult {
|
||||
cuts: { name: string; status: string }[]
|
||||
adds: { name: string; scryfall_id?: string; status: string }[]
|
||||
errors: { name: string; error: string }[]
|
||||
total_cuts: number
|
||||
total_adds: number
|
||||
}
|
||||
|
||||
export interface CoachCardLikeResult {
|
||||
name: string
|
||||
similarity: number
|
||||
types: string
|
||||
mana_value: number
|
||||
mana_cost: string
|
||||
text: string
|
||||
owned_qty: number
|
||||
image_url: string | null
|
||||
tcg_price: number | null
|
||||
}
|
||||
|
||||
// ── ML Training ──────────────────────────────────────────────
|
||||
|
||||
/** GET /api/ml/status */
|
||||
export interface MLStatus {
|
||||
ml_logging_enabled: boolean
|
||||
training_files: { file: string; decisions: number; size_kb: number }[]
|
||||
total_decisions: number
|
||||
total_files: number
|
||||
}
|
||||
|
||||
/** GET /api/ml/data/status */
|
||||
export interface MLDataStatus {
|
||||
decisionFiles: { name: string; size: number; decisions: number }[]
|
||||
totalDecisions: number
|
||||
datasets: Record<string, { samples: number; features: number; size: number }>
|
||||
checkpoints: { name: string; size: number; modified: string }[]
|
||||
evalResults: Record<string, unknown> | null
|
||||
policyLoaded: boolean
|
||||
}
|
||||
|
||||
/** GET /api/ml/train/status */
|
||||
export interface MLTrainStatus {
|
||||
running: boolean
|
||||
progress: number
|
||||
total_epochs: number
|
||||
current_epoch: number
|
||||
phase: 'idle' | 'starting' | 'building' | 'training' | 'evaluating' | 'done' | 'error'
|
||||
message: string
|
||||
metrics: Record<string, unknown> | null
|
||||
result: {
|
||||
training: Record<string, unknown>
|
||||
evaluation: Record<string, unknown> | null
|
||||
checkpoint: string
|
||||
device: string
|
||||
} | null
|
||||
error: string | null
|
||||
started_at: string | null
|
||||
}
|
||||
|
||||
/** GET /api/ml/train/ppo/status */
|
||||
export interface PPOTrainStatus {
|
||||
running: boolean
|
||||
iteration: number
|
||||
total_iterations: number
|
||||
phase: 'idle' | 'starting' | 'training' | 'done' | 'error'
|
||||
message: string
|
||||
metrics: {
|
||||
avg_reward?: number
|
||||
win_rate?: number
|
||||
policy_loss?: number
|
||||
value_loss?: number
|
||||
entropy?: number
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
result: Record<string, unknown> | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** GET /api/ml/tournament/status */
|
||||
export interface TournamentStatus {
|
||||
running: boolean
|
||||
phase: 'idle' | 'starting' | 'running' | 'done' | 'error'
|
||||
message: string
|
||||
result: TournamentResults | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface TournamentResults {
|
||||
players: TournamentPlayer[]
|
||||
total_matches: number
|
||||
matchups?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TournamentPlayer {
|
||||
name: string
|
||||
wins: number
|
||||
games: number
|
||||
win_rate: number
|
||||
}
|
||||
|
||||
/** GET /api/ml/model */
|
||||
export interface MLModelInfo {
|
||||
loaded: boolean
|
||||
device?: string
|
||||
input_dim?: number
|
||||
num_actions?: number
|
||||
checkpoint_path?: string
|
||||
error?: string
|
||||
torch_available?: boolean
|
||||
}
|
||||
|
||||
// ── DeepSeek ─────────────────────────────────────────────────
|
||||
|
||||
export interface DeepSeekStatus {
|
||||
connected: boolean
|
||||
model: string | null
|
||||
base_url: string
|
||||
}
|
||||
|
||||
// ── Commander Search ─────────────────────────────────────────
|
||||
|
||||
export interface CommanderSearchResult {
|
||||
name: string
|
||||
type_line: string
|
||||
color_identity: string[]
|
||||
mana_cost: string
|
||||
image_url: string
|
||||
oracle_text: string
|
||||
scryfall_id: string
|
||||
in_collection: boolean
|
||||
}
|
||||
|
||||
// ── Perplexity ───────────────────────────────────────────────
|
||||
|
||||
export interface PplxStatus {
|
||||
configured: boolean
|
||||
key_prefix: string
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
recharts: ['recharts'],
|
||||
react: ['react', 'react-dom', 'react-router-dom'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend-tmp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-5077
File diff suppressed because it is too large.
Load diff
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "commander-ai-lab-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,184 +0,0 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import LobbyPage from './pages/LobbyPage';
|
||||
import RoomPage from './pages/RoomPage';
|
||||
import GamePage from './pages/GamePage';
|
||||
import SimulatorPage from './pages/SimulatorPage';
|
||||
|
||||
function Nav() {
|
||||
const location = useLocation();
|
||||
// Hide nav on active game pages
|
||||
if (location.pathname.startsWith('/game/')) return null;
|
||||
const links = [
|
||||
{ to: '/', label: 'Lobby' },
|
||||
{ to: '/simulator', label: 'Simulator' },
|
||||
];
|
||||
return (
|
||||
<nav className="bg-bg-bar border-b border-border-zone px-4 py-2 flex items-center gap-4">
|
||||
<span className="text-sm font-bold text-white mr-4">Commander AI Lab</span>
|
||||
{links.map((l) => (
|
||||
<Link
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
className={`text-sm px-2 py-1 rounded transition-colors ${
|
||||
location.pathname === l.to
|
||||
? 'text-white bg-accent-blue/20'
|
||||
: 'text-text-muted hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Nav />
|
||||
<Routes>
|
||||
<Route path="/" element={<LobbyPage />} />
|
||||
<Route path="/room/:roomId" element={<RoomPage />} />
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
<Route path="/simulator" element={<SimulatorPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
export default App;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import CardChip from '../components/CardChip';
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
function makeCard(overrides: Partial<Card> = {}): Card {
|
||||
return {
|
||||
name: 'Sol Ring',
|
||||
type: 'Artifact',
|
||||
tapped: false,
|
||||
is_commander: false,
|
||||
oracle: 'Tap: Add CC.',
|
||||
cmc: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CardChip', () => {
|
||||
it('applies opacity class when tapped', () => {
|
||||
render(<CardChip card={makeCard({ tapped: true })} />);
|
||||
const chip = screen.getByText(/Sol Ring/);
|
||||
expect(chip.closest('span')).toHaveClass('opacity-55');
|
||||
});
|
||||
|
||||
it('shows star for commander cards', () => {
|
||||
render(<CardChip card={makeCard({ is_commander: true })} />);
|
||||
expect(screen.getByText('★')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has hover border class', () => {
|
||||
render(<CardChip card={makeCard()} />);
|
||||
const chip = screen.getByText(/Sol Ring/).closest('span');
|
||||
expect(chip).toHaveClass('hover:border-[#ffdc32]');
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import GamePage from '../pages/GamePage';
|
||||
import type { GameState, Card } from '../types/game';
|
||||
|
||||
const mockGameState: GameState = {
|
||||
game_id: 'test-game',
|
||||
turn: 1,
|
||||
current_phase: 'main1',
|
||||
active_seat: 0,
|
||||
players: [
|
||||
{
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 1, name: 'Bob', life: 38, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 6,
|
||||
},
|
||||
{
|
||||
seat: 2, name: 'Charlie', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 3, name: 'Diana', life: 35, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 5,
|
||||
},
|
||||
],
|
||||
stack: [],
|
||||
game_over: false,
|
||||
winner: null,
|
||||
};
|
||||
|
||||
const mockHand: Card[] = [];
|
||||
const mockSendMove = vi.fn();
|
||||
const mockSendConcede = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useGameSocket', () => ({
|
||||
useGameSocket: () => ({
|
||||
gameState: mockGameState,
|
||||
narration: [],
|
||||
thinking: new Set<number>(),
|
||||
hand: mockHand,
|
||||
gameOver: null,
|
||||
status: 'Connected',
|
||||
sendMove: mockSendMove,
|
||||
sendConcede: mockSendConcede,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('GamePage integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all 4 PlayerZone components', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/game/test-game']}>
|
||||
<Routes>
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// P0: Alice appears in both PhaseBar and PlayerZone, so use getAllByText
|
||||
expect(screen.getAllByText(/P0: Alice/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P1: Bob/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P2: Charlie/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/P3: Diana/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders PhaseBar with current phase', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/game/test-game']}>
|
||||
<Routes>
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// main1 should be highlighted
|
||||
const main1Buttons = screen.getAllByText('main1');
|
||||
expect(main1Buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import MoveBar from '../components/MoveBar';
|
||||
|
||||
describe('MoveBar', () => {
|
||||
it('calls onPass when pass button is clicked', async () => {
|
||||
const onPass = vi.fn();
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={onPass}
|
||||
onConcede={vi.fn()}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Pass Priority'));
|
||||
expect(onPass).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows confirm dialog on concede', async () => {
|
||||
const onConcede = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={vi.fn()}
|
||||
onConcede={onConcede}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Concede'));
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to concede?');
|
||||
expect(onConcede).toHaveBeenCalledOnce();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not concede if confirm is cancelled', async () => {
|
||||
const onConcede = vi.fn();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<MoveBar
|
||||
hand={[]}
|
||||
humanSeat={0}
|
||||
onPass={vi.fn()}
|
||||
onConcede={onConcede}
|
||||
onPlayCard={vi.fn()}
|
||||
status=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Concede'));
|
||||
expect(onConcede).not.toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import NarrationPanel from '../components/NarrationPanel';
|
||||
import type { NarrationLine } from '../types/game';
|
||||
|
||||
describe('NarrationPanel', () => {
|
||||
it('renders only 120 lines when given more', () => {
|
||||
const lines: NarrationLine[] = Array.from({ length: 150 }, (_, i) => ({
|
||||
text: `Line ${i}`,
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
render(<NarrationPanel lines={lines} thinkingSeats={new Set()} />);
|
||||
|
||||
// Should not render the first 30 lines (0-29)
|
||||
expect(screen.queryByText('Line 0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Line 29')).not.toBeInTheDocument();
|
||||
|
||||
// Should render lines 30-149
|
||||
expect(screen.getByText('Line 30')).toBeInTheDocument();
|
||||
expect(screen.getByText('Line 149')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has a scroll container ref', () => {
|
||||
const lines: NarrationLine[] = [
|
||||
{ text: 'Hello world', isSystem: false, timestamp: Date.now() },
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<NarrationPanel lines={lines} thinkingSeats={new Set()} />,
|
||||
);
|
||||
|
||||
// The container div has overflow-y-auto
|
||||
const panel = container.firstChild as HTMLElement;
|
||||
expect(panel).toHaveClass('overflow-y-auto');
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import PhaseBar from '../components/PhaseBar';
|
||||
import type { Player } from '../types/game';
|
||||
|
||||
const players: Player[] = [
|
||||
{
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
{
|
||||
seat: 1, name: 'Bob', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
},
|
||||
];
|
||||
|
||||
describe('PhaseBar', () => {
|
||||
it('highlights the active phase tab', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
const main1 = screen.getByText('main1');
|
||||
expect(main1).toHaveClass('bg-accent-blue');
|
||||
});
|
||||
|
||||
it('does not highlight inactive phase tabs', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
const untap = screen.getByText('untap');
|
||||
expect(untap).not.toHaveClass('bg-accent-blue');
|
||||
expect(untap).toHaveClass('bg-[#252a42]');
|
||||
});
|
||||
|
||||
it('shows active player name', () => {
|
||||
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
|
||||
|
||||
expect(screen.getByText(/P0: Alice/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import PlayerZone from '../components/PlayerZone';
|
||||
import type { Player } from '../types/game';
|
||||
|
||||
function makePlayer(overrides: Partial<Player> = {}): Player {
|
||||
return {
|
||||
seat: 0, name: 'Alice', life: 40, eliminated: false,
|
||||
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlayerZone life colors', () => {
|
||||
it('uses life-high color for life >= 30', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 35 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('35');
|
||||
expect(life).toHaveClass('text-life-high');
|
||||
});
|
||||
|
||||
it('uses life-med color for life >= 15 and < 30', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 20 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('20');
|
||||
expect(life).toHaveClass('text-life-med');
|
||||
});
|
||||
|
||||
it('uses life-low color for life < 15', () => {
|
||||
render(<PlayerZone player={makePlayer({ life: 10 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
|
||||
const life = screen.getByText('10');
|
||||
expect(life).toHaveClass('text-life-low');
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { gameReducer, initialState } from '../store/gameReducer';
|
||||
import type { GameState, Card, NarrationLine } from '../types/game';
|
||||
|
||||
const mockGameState: GameState = {
|
||||
game_id: 'g1',
|
||||
turn: 1,
|
||||
current_phase: 'main1',
|
||||
active_seat: 0,
|
||||
players: [],
|
||||
stack: [],
|
||||
game_over: false,
|
||||
winner: null,
|
||||
};
|
||||
|
||||
const mockCard: Card = {
|
||||
name: 'Sol Ring',
|
||||
type: 'Artifact',
|
||||
tapped: false,
|
||||
is_commander: false,
|
||||
oracle: '',
|
||||
cmc: 1,
|
||||
};
|
||||
|
||||
const mockLine: NarrationLine = {
|
||||
text: 'Test narration',
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
describe('gameReducer', () => {
|
||||
it('SET_STATE sets the game state', () => {
|
||||
const result = gameReducer(initialState, { type: 'SET_STATE', payload: mockGameState });
|
||||
expect(result.gameState).toEqual(mockGameState);
|
||||
});
|
||||
|
||||
it('SET_HAND sets the hand cards', () => {
|
||||
const cards = [mockCard];
|
||||
const result = gameReducer(initialState, { type: 'SET_HAND', cards });
|
||||
expect(result.hand).toEqual(cards);
|
||||
});
|
||||
|
||||
it('ADD_NARRATION appends a line', () => {
|
||||
const result = gameReducer(initialState, { type: 'ADD_NARRATION', line: mockLine });
|
||||
expect(result.narration).toHaveLength(1);
|
||||
expect(result.narration[0].text).toBe('Test narration');
|
||||
});
|
||||
|
||||
it('ADD_NARRATION caps at 120 lines', () => {
|
||||
let state = initialState;
|
||||
for (let i = 0; i < 130; i++) {
|
||||
state = gameReducer(state, {
|
||||
type: 'ADD_NARRATION',
|
||||
line: { text: `line ${i}`, isSystem: false, timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
expect(state.narration).toHaveLength(120);
|
||||
expect(state.narration[0].text).toBe('line 10');
|
||||
});
|
||||
|
||||
it('SET_THINKING adds and removes seats', () => {
|
||||
let state = gameReducer(initialState, { type: 'SET_THINKING', seat: 2, active: true });
|
||||
expect(state.thinkingSeats.has(2)).toBe(true);
|
||||
|
||||
state = gameReducer(state, { type: 'SET_THINKING', seat: 2, active: false });
|
||||
expect(state.thinkingSeats.has(2)).toBe(false);
|
||||
});
|
||||
|
||||
it('GAME_OVER sets winner and reason', () => {
|
||||
const result = gameReducer(initialState, { type: 'GAME_OVER', winner: 1, reason: 'elimination' });
|
||||
expect(result.gameOver).toEqual({ winner: 1, reason: 'elimination' });
|
||||
});
|
||||
|
||||
it('SET_STATUS sets status text', () => {
|
||||
const result = gameReducer(initialState, { type: 'SET_STATUS', status: 'Connected' });
|
||||
expect(result.status).toBe('Connected');
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useGameSocket } from '../hooks/useGameSocket';
|
||||
|
||||
// Mock WebSocket
|
||||
class MockWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
static instances: MockWebSocket[] = [];
|
||||
url: string;
|
||||
readyState = 1; // OPEN
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
sent: string[] = [];
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
// Simulate connection open
|
||||
setTimeout(() => this.onopen?.(new Event('open')), 0);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
|
||||
simulateMessage(data: unknown) {
|
||||
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) }));
|
||||
}
|
||||
}
|
||||
|
||||
describe('useGameSocket', () => {
|
||||
beforeEach(() => {
|
||||
MockWebSocket.instances = [];
|
||||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('dispatches state on WS message', () => {
|
||||
const { result } = renderHook(() => useGameSocket('game-1'));
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
const mockState = {
|
||||
game_id: 'game-1', turn: 1, current_phase: 'main1', active_seat: 0,
|
||||
players: [], stack: [], game_over: false, winner: null,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
ws.simulateMessage({ type: 'state', payload: mockState });
|
||||
});
|
||||
|
||||
expect(result.current.gameState).toEqual(mockState);
|
||||
});
|
||||
|
||||
it('dispatches hand cards on WS message', () => {
|
||||
const { result } = renderHook(() => useGameSocket('game-1'));
|
||||
const ws = MockWebSocket.instances[0];
|
||||
|
||||
const cards = [{ name: 'Sol Ring', type: 'Artifact', tapped: false, is_commander: false, oracle: '', cmc: 1 }];
|
||||
|
||||
act(() => {
|
||||
ws.simulateMessage({ type: 'hand', cards });
|
||||
});
|
||||
|
||||
expect(result.current.hand).toEqual(cards);
|
||||
});
|
||||
|
||||
it('sends heartbeat ping every 15s', () => {
|
||||
renderHook(() => useGameSocket('game-1'));
|
||||
const ws = MockWebSocket.instances[0];
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
|
||||
expect(ws.sent).toContain(JSON.stringify({ type: 'ping' }));
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,54 +0,0 @@
|
||||
import type { GameState } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
import PlayerZone from './PlayerZone';
|
||||
import CardChip from './CardChip';
|
||||
|
||||
interface BoardProps {
|
||||
gameState: GameState;
|
||||
thinkingSeats: Set<number>;
|
||||
}
|
||||
|
||||
export default function Board({ gameState, thinkingSeats }: BoardProps) {
|
||||
const { players, active_seat, stack } = gameState;
|
||||
|
||||
const getPlayer = (seat: number) => players.find((p) => p.seat === seat);
|
||||
|
||||
const renderZone = (seat: number, className: string) => {
|
||||
const player = getPlayer(seat);
|
||||
if (!player) return <div className={className} />;
|
||||
return (
|
||||
<div className={className}>
|
||||
<PlayerZone
|
||||
player={player}
|
||||
isActive={active_seat === seat}
|
||||
isThinking={thinkingSeats.has(seat)}
|
||||
seatColour={SEAT_COLOURS[seat]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[180px_1fr_180px] grid-rows-3 gap-1 flex-1">
|
||||
{/* Row 1: P1 (top-left), stack center, P2 (top-right) */}
|
||||
{renderZone(1, 'row-start-1 col-start-1')}
|
||||
<div className="bg-bg-zone border border-accent-purple rounded-md min-h-[60px] row-span-3 col-start-2 p-2 overflow-y-auto">
|
||||
<span className="text-[10px] text-[#666] block mb-1">Stack</span>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{stack.map((card, i) => (
|
||||
<CardChip key={i} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{renderZone(2, 'row-start-1 col-start-3')}
|
||||
|
||||
{/* Row 2: empty side cells (stack spans all 3 rows in center) */}
|
||||
<div className="row-start-2 col-start-1" />
|
||||
<div className="row-start-2 col-start-3" />
|
||||
|
||||
{/* Row 3: P0 (bottom-left), P3 (bottom-right) */}
|
||||
{renderZone(0, 'row-start-3 col-start-1')}
|
||||
{renderZone(3, 'row-start-3 col-start-3')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
interface CardChipProps {
|
||||
card: Card;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function CardChip({ card, onClick }: CardChipProps) {
|
||||
return (
|
||||
<span
|
||||
onClick={onClick}
|
||||
className={`bg-card-bg border border-[#444] rounded text-[10px] max-w-[110px] overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer px-1.5 py-0.5 hover:border-[#ffdc32] ${
|
||||
card.tapped ? 'opacity-55 italic' : ''
|
||||
}`}
|
||||
>
|
||||
{card.name}
|
||||
{card.is_commander && <span className="text-[#ffd200]"> ★</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Player } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface GameOverOverlayProps {
|
||||
show: boolean;
|
||||
winner: Player | null;
|
||||
reason: string;
|
||||
players: Player[];
|
||||
}
|
||||
|
||||
export default function GameOverOverlay({ show, winner, reason, players }: GameOverOverlayProps) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center">
|
||||
<div className="bg-bg-zone border-2 border-[#4060c0] rounded-[10px] px-[60px] py-10 text-center">
|
||||
{winner && (
|
||||
<h2
|
||||
className="text-4xl font-bold"
|
||||
style={{ color: SEAT_COLOURS[winner.seat] }}
|
||||
>
|
||||
{winner.name} Wins!
|
||||
</h2>
|
||||
)}
|
||||
<p className="text-base text-[#aaa] mt-2">{reason}</p>
|
||||
<ul className="mt-6 space-y-1">
|
||||
{players.map((p) => (
|
||||
<li key={p.seat} className="text-sm">
|
||||
<span style={{ color: SEAT_COLOURS[p.seat] }}>P{p.seat}</span>{' '}
|
||||
{p.name} — {p.life} life
|
||||
{p.eliminated && (
|
||||
<span className="text-red-500 ml-1 font-bold">[ELIM]</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
interface HandCardProps {
|
||||
card: Card;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function HandCard({ card, isSelected, onClick }: HandCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`rounded text-[11px] px-2 py-1 cursor-pointer ${
|
||||
isSelected
|
||||
? 'border border-[#ffdc32] bg-card-selected'
|
||||
: 'bg-card-hand border border-[#5060a0]'
|
||||
}`}
|
||||
>
|
||||
{card.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { Card } from '../types/game';
|
||||
import HandCard from './HandCard';
|
||||
|
||||
interface MoveBarProps {
|
||||
hand: Card[];
|
||||
humanSeat: number;
|
||||
onPass: () => void;
|
||||
onConcede: () => void;
|
||||
onPlayCard: (index: number) => void;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function MoveBar({ hand, onPass, onConcede, onPlayCard, status }: MoveBarProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const handleCardClick = (index: number) => {
|
||||
if (selectedIndex === index) {
|
||||
onPlayCard(index);
|
||||
setSelectedIndex(null);
|
||||
} else {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-bg-bar border-t border-[#2a2e48] px-2.5 py-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPass}
|
||||
className="bg-[#2a5090] border border-[#4080d0] text-white rounded px-3 py-1 text-sm cursor-pointer hover:brightness-110"
|
||||
>
|
||||
Pass Priority
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Are you sure you want to concede?')) onConcede();
|
||||
}}
|
||||
className="bg-[#2a3868] border border-[#4060a0] text-white rounded px-3 py-1 text-sm cursor-pointer"
|
||||
>
|
||||
Concede
|
||||
</button>
|
||||
{hand.map((card, i) => (
|
||||
<HandCard
|
||||
key={i}
|
||||
card={card}
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => handleCardClick(i)}
|
||||
/>
|
||||
))}
|
||||
<span className="text-[11px] text-[#778] ml-auto">{status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import type { NarrationLine } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface NarrationPanelProps {
|
||||
lines: NarrationLine[];
|
||||
thinkingSeats: Set<number>;
|
||||
}
|
||||
|
||||
export default function NarrationPanel({ lines, thinkingSeats }: NarrationPanelProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dots, setDots] = useState('.');
|
||||
|
||||
// Auto-scroll on new lines
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines.length]);
|
||||
|
||||
// Cycling dots for thinking animation
|
||||
useEffect(() => {
|
||||
if (thinkingSeats.size === 0) return;
|
||||
const interval = setInterval(() => {
|
||||
setDots((prev) => (prev.length >= 3 ? '.' : prev + '.'));
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [thinkingSeats.size]);
|
||||
|
||||
const displayed = lines.slice(-120);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-[260px] bg-bg-panel border-l border-[#2a2e48] overflow-y-auto p-2 flex flex-col"
|
||||
>
|
||||
{displayed.map((line, i) => {
|
||||
let colorClass = 'text-[#e8e8e8]';
|
||||
let style: React.CSSProperties | undefined;
|
||||
|
||||
if (line.isSystem) {
|
||||
colorClass = 'text-[#556]';
|
||||
} else if (line.seat !== undefined) {
|
||||
colorClass = '';
|
||||
style = { color: SEAT_COLOURS[line.seat] };
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`text-[11px] leading-[1.4] break-words ${colorClass}`}
|
||||
style={style}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{thinkingSeats.size > 0 &&
|
||||
Array.from(thinkingSeats).map((seat) => (
|
||||
<div
|
||||
key={`thinking-${seat}`}
|
||||
className="text-[11px] leading-[1.4] break-words text-accent-think italic"
|
||||
>
|
||||
P{seat} thinking{dots}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Phase, Player } from '../types/game';
|
||||
import { PHASES, SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface PhaseBarProps {
|
||||
phase: Phase;
|
||||
activeSeat: number;
|
||||
players: Player[];
|
||||
}
|
||||
|
||||
export default function PhaseBar({ phase, activeSeat, players }: PhaseBarProps) {
|
||||
const activePlayer = players.find((p) => p.seat === activeSeat);
|
||||
|
||||
return (
|
||||
<div className="h-[34px] bg-[#1e2338] px-2 py-1 flex items-center gap-1">
|
||||
{PHASES.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`text-[11px] rounded px-2 py-0.5 ${
|
||||
p === phase
|
||||
? 'bg-accent-blue text-white'
|
||||
: 'bg-[#252a42] text-[#888]'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{activePlayer && (
|
||||
<span
|
||||
className="text-[11px] ml-auto font-bold"
|
||||
style={{ color: SEAT_COLOURS[activeSeat] }}
|
||||
>
|
||||
P{activeSeat}: {activePlayer.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Player } from '../types/game';
|
||||
import CardChip from './CardChip';
|
||||
|
||||
interface PlayerZoneProps {
|
||||
player: Player;
|
||||
isActive: boolean;
|
||||
isThinking: boolean;
|
||||
seatColour: string;
|
||||
}
|
||||
|
||||
function lifeColor(life: number): string {
|
||||
if (life >= 30) return 'text-life-high';
|
||||
if (life >= 15) return 'text-life-med';
|
||||
return 'text-life-low';
|
||||
}
|
||||
|
||||
export default function PlayerZone({ player, isActive, isThinking, seatColour }: PlayerZoneProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-bg-zone border border-[#3a4060] rounded-md p-1.5 ${
|
||||
isThinking ? 'animate-pulse' : ''
|
||||
} ${player.eliminated ? 'opacity-40' : ''}`}
|
||||
style={isActive ? { borderColor: seatColour } : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[11px] text-[#666]">
|
||||
P{player.seat}: {player.name}
|
||||
</span>
|
||||
<span className={`text-[28px] font-bold ${lifeColor(player.life)}`}>
|
||||
{player.life}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{player.battlefield.map((card, i) => (
|
||||
<CardChip key={i} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Phase } from './types/game';
|
||||
|
||||
export const SEAT_COLOURS = ['#4696ff', '#ff6450', '#50d278', '#dcaa32'] as const;
|
||||
|
||||
export const PHASES: Phase[] = [
|
||||
'untap', 'upkeep', 'draw', 'main1',
|
||||
'begin_combat', 'declare_attackers', 'declare_blockers', 'combat_damage',
|
||||
'main2', 'end_step', 'cleanup',
|
||||
];
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useReducer, useRef, useEffect, useCallback } from 'react';
|
||||
import { gameReducer, initialState } from '../store/gameReducer';
|
||||
import type { WsIncoming, NarrationLine } from '../types/game';
|
||||
|
||||
export function useGameSocket(gameId: string) {
|
||||
const [state, dispatch] = useReducer(gameReducer, initialState);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const url = `ws://${window.location.host}/ws/game/${gameId}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Connected' });
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg: WsIncoming = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'state':
|
||||
dispatch({ type: 'SET_STATE', payload: msg.payload });
|
||||
break;
|
||||
|
||||
case 'hand':
|
||||
dispatch({ type: 'SET_HAND', cards: msg.cards });
|
||||
break;
|
||||
|
||||
case 'event': {
|
||||
const line: NarrationLine = {
|
||||
text: msg.text,
|
||||
seat: msg.seat,
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch({ type: 'ADD_NARRATION', line });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'phase': {
|
||||
const line: NarrationLine = {
|
||||
text: `Phase: ${msg.phase} (P${msg.active_seat})`,
|
||||
isSystem: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch({ type: 'ADD_NARRATION', line });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'thinking':
|
||||
dispatch({ type: 'SET_THINKING', seat: msg.seat, active: msg.active });
|
||||
break;
|
||||
|
||||
case 'game_over':
|
||||
dispatch({ type: 'GAME_OVER', winner: msg.winner, reason: msg.reason });
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
dispatch({ type: 'SET_STATUS', status: `Error: ${msg.message}` });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!intentionalClose.current) {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Disconnected — reconnecting…' });
|
||||
reconnectTimeout.current = setTimeout(() => {
|
||||
connect();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Connection error' });
|
||||
};
|
||||
}, [gameId]);
|
||||
|
||||
// Connect on mount
|
||||
useEffect(() => {
|
||||
intentionalClose.current = false;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
intentionalClose.current = true;
|
||||
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// Heartbeat ping every 15s
|
||||
useEffect(() => {
|
||||
heartbeatInterval.current = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendMove = useCallback((move: string, data?: Record<string, unknown>) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'move', move, ...data }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendConcede = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'concede' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
gameState: state.gameState,
|
||||
narration: state.narration,
|
||||
thinking: state.thinkingSeats,
|
||||
hand: state.hand,
|
||||
gameOver: state.gameOver,
|
||||
status: state.status,
|
||||
sendMove,
|
||||
sendConcede,
|
||||
};
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { useReducer, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface Seat {
|
||||
seat: number;
|
||||
name: string;
|
||||
ready: boolean;
|
||||
isAi: boolean;
|
||||
}
|
||||
|
||||
export interface RoomState {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
host: string;
|
||||
seats: (Seat | null)[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'PLAYER' | 'AI' | 'SYSTEM';
|
||||
name: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type LobbyAction =
|
||||
| { type: 'SET_ROOM'; payload: RoomState }
|
||||
| { type: 'ADD_CHAT'; message: ChatMessage }
|
||||
| { type: 'SET_CHAT_HISTORY'; messages: ChatMessage[] }
|
||||
| { type: 'LAUNCH'; gameId: string };
|
||||
|
||||
interface LobbyState {
|
||||
roomState: RoomState | null;
|
||||
chat: ChatMessage[];
|
||||
launchedGameId: string | null;
|
||||
}
|
||||
|
||||
const initialState: LobbyState = {
|
||||
roomState: null,
|
||||
chat: [],
|
||||
launchedGameId: null,
|
||||
};
|
||||
|
||||
function lobbyReducer(state: LobbyState, action: LobbyAction): LobbyState {
|
||||
switch (action.type) {
|
||||
case 'SET_ROOM':
|
||||
return { ...state, roomState: action.payload };
|
||||
case 'ADD_CHAT':
|
||||
return { ...state, chat: [...state.chat, action.message].slice(-50) };
|
||||
case 'SET_CHAT_HISTORY':
|
||||
return { ...state, chat: action.messages.slice(-50) };
|
||||
case 'LAUNCH':
|
||||
return { ...state, launchedGameId: action.gameId };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useLobbySock(roomId: string, playerName: string) {
|
||||
const [state, dispatch] = useReducer(lobbyReducer, initialState);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const url = `ws://${window.location.host}/ws/lobby/${roomId}?name=${encodeURIComponent(playerName)}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'lobby_state':
|
||||
dispatch({ type: 'SET_ROOM', payload: msg.payload });
|
||||
break;
|
||||
case 'chat':
|
||||
dispatch({
|
||||
type: 'ADD_CHAT',
|
||||
message: {
|
||||
role: msg.role ?? 'PLAYER',
|
||||
name: msg.name ?? '',
|
||||
text: msg.text,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'chat_history':
|
||||
dispatch({
|
||||
type: 'SET_CHAT_HISTORY',
|
||||
messages: (msg.messages ?? []).map((m: { role?: string; name?: string; text: string }) => ({
|
||||
role: m.role ?? 'SYSTEM',
|
||||
name: m.name ?? '',
|
||||
text: m.text,
|
||||
timestamp: Date.now(),
|
||||
})),
|
||||
});
|
||||
break;
|
||||
case 'launch':
|
||||
dispatch({ type: 'LAUNCH', gameId: msg.game_id });
|
||||
break;
|
||||
case 'pong':
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!intentionalClose.current) {
|
||||
reconnectTimeout.current = setTimeout(() => connect(), 3000);
|
||||
}
|
||||
};
|
||||
}, [roomId, playerName]);
|
||||
|
||||
useEffect(() => {
|
||||
intentionalClose.current = false;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
intentionalClose.current = true;
|
||||
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// Heartbeat ping every 15s
|
||||
useEffect(() => {
|
||||
heartbeatInterval.current = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendReady = useCallback((seat: number) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ready', seat }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendChat = useCallback((text: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'chat', text }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendLaunch = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'launch' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
roomState: state.roomState,
|
||||
chat: state.chat,
|
||||
launchedGameId: state.launchedGameId,
|
||||
sendReady,
|
||||
sendChat,
|
||||
sendLaunch,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGameSocket } from '../hooks/useGameSocket';
|
||||
import PhaseBar from '../components/PhaseBar';
|
||||
import Board from '../components/Board';
|
||||
import NarrationPanel from '../components/NarrationPanel';
|
||||
import MoveBar from '../components/MoveBar';
|
||||
import GameOverOverlay from '../components/GameOverOverlay';
|
||||
|
||||
export default function GamePage() {
|
||||
const { gameId } = useParams<{ gameId: string }>();
|
||||
const { gameState, narration, thinking, hand, gameOver, status, sendMove, sendConcede } =
|
||||
useGameSocket(gameId!);
|
||||
|
||||
if (!gameState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
Connecting…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const winnerPlayer = gameOver
|
||||
? gameState.players.find((p) => p.seat === gameOver.winner) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-bg-base text-white">
|
||||
<PhaseBar
|
||||
phase={gameState.current_phase}
|
||||
activeSeat={gameState.active_seat}
|
||||
players={gameState.players}
|
||||
/>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Board gameState={gameState} thinkingSeats={thinking} />
|
||||
<NarrationPanel lines={narration} thinkingSeats={thinking} />
|
||||
</div>
|
||||
<MoveBar
|
||||
hand={hand}
|
||||
humanSeat={0}
|
||||
onPass={() => sendMove('pass')}
|
||||
onConcede={sendConcede}
|
||||
onPlayCard={(index) => sendMove('play_card', { index })}
|
||||
status={status}
|
||||
/>
|
||||
<GameOverOverlay
|
||||
show={gameOver !== null}
|
||||
winner={winnerPlayer}
|
||||
reason={gameOver?.reason ?? ''}
|
||||
players={gameState.players}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface RoomSummary {
|
||||
roomId: string;
|
||||
name: string;
|
||||
host: string;
|
||||
playerCount: number;
|
||||
}
|
||||
|
||||
export default function LobbyPage() {
|
||||
const navigate = useNavigate();
|
||||
const [rooms, setRooms] = useState<RoomSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Create room form state
|
||||
const [roomName, setRoomName] = useState('');
|
||||
const [hostName, setHostName] = useState('');
|
||||
const [aiSlots, setAiSlots] = useState(0);
|
||||
const [password, setPassword] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/lobby/rooms')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setRooms(data))
|
||||
.catch(() => setRooms([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/lobby/rooms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: roomName,
|
||||
host: hostName,
|
||||
ai_slots: aiSlots,
|
||||
password: password || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
navigate(`/room/${data.roomId}?name=${encodeURIComponent(hostName)}`);
|
||||
} catch {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-base text-white p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">Commander AI Lab — Lobby</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
|
||||
{/* Room List */}
|
||||
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Open Rooms</h2>
|
||||
{loading ? (
|
||||
<p className="text-text-muted text-sm">Loading...</p>
|
||||
) : rooms.length === 0 ? (
|
||||
<p className="text-text-muted text-sm">No rooms available. Create one!</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rooms.map((room) => (
|
||||
<li key={room.roomId}>
|
||||
<button
|
||||
onClick={() => navigate(`/room/${room.roomId}`)}
|
||||
className="w-full text-left bg-bg-panel border border-border-zone rounded px-3 py-2 hover:border-accent-blue transition-colors"
|
||||
>
|
||||
<span className="font-medium">{room.name}</span>
|
||||
<span className="text-text-muted text-sm ml-2">
|
||||
Host: {room.host} · {room.playerCount}/4 players
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Room Form */}
|
||||
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Create Room</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Room Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={roomName}
|
||||
onChange={(e) => setRoomName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Your Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={hostName}
|
||||
onChange={(e) => setHostName(e.target.value)}
|
||||
required
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">AI Players</label>
|
||||
<select
|
||||
value={aiSlots}
|
||||
onChange={(e) => setAiSlots(Number(e.target.value))}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value={0}>0 AI players</option>
|
||||
<option value={1}>1 AI player</option>
|
||||
<option value={2}>2 AI players</option>
|
||||
<option value={3}>3 AI players</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Password (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Room'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useLobbySock } from '../hooks/useLobbySock';
|
||||
import type { ChatMessage } from '../hooks/useLobbySock';
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
PLAYER: 'text-accent-blue',
|
||||
AI: 'text-accent-purple',
|
||||
SYSTEM: 'text-text-muted',
|
||||
};
|
||||
|
||||
function ChatBadge({ role }: { role: string }) {
|
||||
return (
|
||||
<span className={`text-[10px] font-bold mr-1 ${ROLE_COLORS[role] ?? 'text-text-muted'}`}>
|
||||
[{role}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoomPage() {
|
||||
const { roomId } = useParams<{ roomId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isSpectator = searchParams.get('spectate') === '1';
|
||||
const nameFromUrl = searchParams.get('name') ?? '';
|
||||
const [playerName, setPlayerName] = useState(nameFromUrl);
|
||||
const [nameConfirmed, setNameConfirmed] = useState(!!nameFromUrl);
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { roomState, chat, launchedGameId, sendReady, sendChat, sendLaunch } =
|
||||
useLobbySock(roomId!, nameConfirmed ? playerName : '');
|
||||
|
||||
// Redirect when game launches
|
||||
useEffect(() => {
|
||||
if (launchedGameId) {
|
||||
navigate(`/game/${launchedGameId}`);
|
||||
}
|
||||
}, [launchedGameId, navigate]);
|
||||
|
||||
// Auto-scroll chat
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [chat.length]);
|
||||
|
||||
// Determine own seat
|
||||
const mySeat = roomState?.seats.findIndex(
|
||||
(s) => s && !s.isAi && s.name === playerName,
|
||||
) ?? -1;
|
||||
const isHost = mySeat === 0;
|
||||
|
||||
if (!nameConfirmed) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
<div className="bg-bg-zone border border-border-zone rounded-lg p-6 w-80">
|
||||
<h2 className="text-lg font-semibold mb-3">Enter your name</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && playerName.trim()) setNameConfirmed(true);
|
||||
}}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white mb-3 focus:outline-none focus:border-accent-blue"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => playerName.trim() && setNameConfirmed(true)}
|
||||
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
|
||||
>
|
||||
Join Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!roomState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
Connecting to room...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSendChat = () => {
|
||||
if (chatInput.trim()) {
|
||||
sendChat(chatInput.trim());
|
||||
setChatInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg-base text-white">
|
||||
{/* Header */}
|
||||
<div className="bg-bg-bar border-b border-border-sep px-4 py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">{roomState.roomName}</h1>
|
||||
<span className="text-text-muted text-sm">Host: {roomState.host}</span>
|
||||
</div>
|
||||
{isSpectator && (
|
||||
<span className="text-text-muted text-xs bg-bg-panel px-2 py-1 rounded">
|
||||
SPECTATING
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Seat Grid */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="grid grid-cols-2 gap-3 max-w-lg">
|
||||
{[0, 1, 2, 3].map((seatIdx) => {
|
||||
const seat = roomState.seats[seatIdx];
|
||||
return (
|
||||
<div
|
||||
key={seatIdx}
|
||||
className="bg-bg-zone border border-border-zone rounded-lg p-4 min-h-[100px] flex flex-col items-center justify-center"
|
||||
>
|
||||
{seat ? (
|
||||
<>
|
||||
<span className="text-sm font-medium">
|
||||
{seat.isAi ? '🤖 AI' : seat.name}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted mt-1">
|
||||
Seat {seatIdx}
|
||||
</span>
|
||||
{!seat.isAi && (
|
||||
<span
|
||||
className={`text-xs mt-1 font-bold ${
|
||||
seat.ready ? 'text-life-high' : 'text-life-med'
|
||||
}`}
|
||||
>
|
||||
{seat.ready ? 'READY' : 'Not Ready'}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-text-muted text-sm">Waiting...</span>
|
||||
<span className="text-xs text-text-muted mt-1">
|
||||
Seat {seatIdx}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{!isSpectator && (
|
||||
<div className="mt-4 flex gap-3">
|
||||
{mySeat >= 0 && (
|
||||
<button
|
||||
onClick={() => sendReady(mySeat)}
|
||||
className="bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
|
||||
>
|
||||
Toggle Ready
|
||||
</button>
|
||||
)}
|
||||
{isHost && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/lobby/rooms/${roomId}/launch`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.gameId) {
|
||||
navigate(`/game/${data.gameId}`);
|
||||
}
|
||||
} catch {
|
||||
// launch via WS fallback
|
||||
sendLaunch();
|
||||
}
|
||||
}}
|
||||
className="bg-life-high border border-life-high text-white rounded px-4 py-2 text-sm font-bold hover:brightness-110"
|
||||
>
|
||||
Launch Game
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat Panel */}
|
||||
<div className="w-[280px] bg-bg-panel border-l border-border-sep flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border-sep text-sm font-semibold">
|
||||
Chat
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{chat.map((msg: ChatMessage, i: number) => (
|
||||
<div key={i} className="text-[11px] leading-[1.4] break-words">
|
||||
<ChatBadge role={msg.role} />
|
||||
{msg.name && <span className="font-medium mr-1">{msg.name}:</span>}
|
||||
<span className="text-text-body">{msg.text}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
{!isSpectator && (
|
||||
<div className="border-t border-border-sep p-2">
|
||||
<input
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSendChat();
|
||||
}}
|
||||
placeholder="Type a message..."
|
||||
className="w-full bg-bg-base border border-border-zone rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
import type { SimDeck, SimResult } from '../types/simulator';
|
||||
|
||||
const SEAT_LABELS = ['Seat 1 (You)', 'Seat 2', 'Seat 3', 'Seat 4'];
|
||||
|
||||
type SimStatus = 'idle' | 'running' | 'complete' | 'error';
|
||||
|
||||
export default function SimulatorPage() {
|
||||
// Deck list from API
|
||||
const [decks, setDecks] = useState<SimDeck[]>([]);
|
||||
const [loadingDecks, setLoadingDecks] = useState(true);
|
||||
|
||||
// Slot selections (4 slots, null = empty)
|
||||
const [slots, setSlots] = useState<(number | null)[]>([null, null, null, null]);
|
||||
|
||||
// Config
|
||||
const [numGames, setNumGames] = useState(10);
|
||||
const [engineType, setEngineType] = useState('heuristic');
|
||||
const [recordLogs, setRecordLogs] = useState(true);
|
||||
|
||||
// Sim state
|
||||
const [simId, setSimId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<SimStatus>('idle');
|
||||
const [completed, setCompleted] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [result, setResult] = useState<SimResult | null>(null);
|
||||
const [games, setGames] = useState<unknown[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Fetch decks on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/decks')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const list = (data.decks || []).map((d: Record<string, unknown>) => ({
|
||||
id: d.id as number,
|
||||
name: d.name as string,
|
||||
commander_name: (d.commander_name || '') as string,
|
||||
color_identity: (d.color_identity || []) as string[],
|
||||
}));
|
||||
setDecks(list);
|
||||
})
|
||||
.catch(() => setDecks([]))
|
||||
.finally(() => setLoadingDecks(false));
|
||||
}, []);
|
||||
|
||||
// Polling
|
||||
useEffect(() => {
|
||||
if (status !== 'running' || !simId) return;
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sim/status?simId=${simId}`);
|
||||
const data = await res.json();
|
||||
setCompleted(data.completed ?? 0);
|
||||
setTotal(data.total ?? 0);
|
||||
if (data.status === 'complete') {
|
||||
setStatus('complete');
|
||||
// Fetch results
|
||||
const rres = await fetch(`/api/sim/result?simId=${simId}`);
|
||||
const rdata = await rres.json();
|
||||
setResult(rdata.summary ?? null);
|
||||
setGames(rdata.games ?? []);
|
||||
} else if (data.status === 'error') {
|
||||
setStatus('error');
|
||||
setError(data.error || 'Simulation failed');
|
||||
}
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
}, 1000);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, [status, simId]);
|
||||
|
||||
const selectedCount = slots.filter((s) => s !== null).length;
|
||||
const canRun = selectedCount >= 2 && status !== 'running';
|
||||
|
||||
const handleRun = async () => {
|
||||
const deckIds = slots.filter((s): s is number => s !== null);
|
||||
setStatus('running');
|
||||
setCompleted(0);
|
||||
setTotal(numGames);
|
||||
setResult(null);
|
||||
setGames([]);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sim/run-n-player', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
deckIds,
|
||||
numGames,
|
||||
recordLogs,
|
||||
engineType,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setStatus('error');
|
||||
setError(data.error || 'Failed to start simulation');
|
||||
return;
|
||||
}
|
||||
setSimId(data.simId);
|
||||
setTotal(data.total);
|
||||
} catch {
|
||||
setStatus('error');
|
||||
setError('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
const setSlot = (idx: number, value: number | null) => {
|
||||
setSlots((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = value;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getDeckById = (id: number) => decks.find((d) => d.id === id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-base text-white p-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-2">Batch Simulator</h1>
|
||||
<p className="text-text-muted text-sm mb-6">
|
||||
Run N-player Monte Carlo simulations with your decks.
|
||||
</p>
|
||||
|
||||
{/* Deck Selection */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-3">Deck Slots</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{SEAT_LABELS.map((label, idx) => {
|
||||
const selected = slots[idx];
|
||||
const deck = selected !== null ? getDeckById(selected) : null;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-bg-zone rounded-md p-4"
|
||||
style={{
|
||||
border: `2px solid ${selected !== null ? SEAT_COLOURS[idx] : '#3a4060'}`,
|
||||
boxShadow: selected !== null ? `0 0 8px ${SEAT_COLOURS[idx]}40` : 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-medium mb-2"
|
||||
style={{ color: SEAT_COLOURS[idx] }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<select
|
||||
value={selected ?? ''}
|
||||
onChange={(e) => setSlot(idx, e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value="">— Empty —</option>
|
||||
{decks.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{deck && (
|
||||
<div className="mt-2 text-xs text-text-muted">
|
||||
{deck.commander_name && (
|
||||
<div className="truncate">{deck.commander_name}</div>
|
||||
)}
|
||||
{deck.color_identity.length > 0 && (
|
||||
<div>{deck.color_identity.join(' ')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{loadingDecks && (
|
||||
<p className="text-text-muted text-sm mt-2">Loading decks...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config Row */}
|
||||
<div className="flex flex-wrap items-end gap-4 mb-6 bg-bg-zone rounded-md border border-border-zone p-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Games</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={numGames}
|
||||
onChange={(e) => setNumGames(Math.max(1, Math.min(1000, Number(e.target.value))))}
|
||||
className="w-24 bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">Engine</label>
|
||||
<select
|
||||
value={engineType}
|
||||
onChange={(e) => setEngineType(e.target.value)}
|
||||
className="bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||
>
|
||||
<option value="heuristic">Heuristic AI</option>
|
||||
<option value="deepseek">DeepSeek AI</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="recordLogs"
|
||||
checked={recordLogs}
|
||||
onChange={(e) => setRecordLogs(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="recordLogs" className="text-sm text-text-muted">
|
||||
Record Logs
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={!canRun}
|
||||
className="bg-accent-blue text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === 'running' ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Running...
|
||||
</span>
|
||||
) : (
|
||||
'Run Simulation'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{status === 'running' && (
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-text-muted mb-1">
|
||||
<span>Progress</span>
|
||||
<span>
|
||||
{completed} / {total} games
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-bg-panel rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-blue rounded-full transition-all duration-300"
|
||||
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{status === 'error' && error && (
|
||||
<div className="mb-6 bg-red-900/30 border border-red-700 rounded-md p-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{status === 'complete' && result && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4 text-sm text-text-muted">
|
||||
<span>{result.totalGames} games</span>
|
||||
<span>{result.playerCount} players</span>
|
||||
<span>{result.elapsedSeconds}s elapsed</span>
|
||||
</div>
|
||||
|
||||
{/* Win Rate Bar Chart */}
|
||||
<div className="bg-bg-zone rounded-md border border-border-zone p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">Win Rates</h3>
|
||||
<div className="space-y-2">
|
||||
{result.players.map((p) => (
|
||||
<div key={p.seat} className="flex items-center gap-3">
|
||||
<div className="w-28 text-sm truncate" style={{ color: SEAT_COLOURS[p.seat] }}>
|
||||
{p.deckName}
|
||||
</div>
|
||||
<div className="flex-1 bg-bg-panel rounded-full h-5 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${p.winRate}%`,
|
||||
backgroundColor: SEAT_COLOURS[p.seat],
|
||||
minWidth: p.winRate > 0 ? '2px' : '0',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-16 text-right text-sm font-mono">
|
||||
{p.winRate}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Table */}
|
||||
<div className="bg-bg-zone rounded-md border border-border-zone overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-zone text-text-muted">
|
||||
<th className="text-left px-3 py-2">Seat</th>
|
||||
<th className="text-left px-3 py-2">Deck</th>
|
||||
<th className="text-right px-3 py-2">Wins</th>
|
||||
<th className="text-right px-3 py-2">Win%</th>
|
||||
<th className="text-right px-3 py-2">Avg Turns</th>
|
||||
<th className="text-right px-3 py-2">Avg Dmg</th>
|
||||
<th className="text-right px-3 py-2">Avg Spells</th>
|
||||
<th className="text-right px-3 py-2">Avg Creatures</th>
|
||||
<th className="text-right px-3 py-2">Avg Removal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.players.map((p) => {
|
||||
const isWinner = result.players.every((o) => o.wins <= p.wins);
|
||||
return (
|
||||
<tr
|
||||
key={p.seat}
|
||||
className={`border-b border-border-zone ${isWinner ? 'font-semibold' : ''}`}
|
||||
style={{
|
||||
backgroundColor: `${SEAT_COLOURS[p.seat]}${isWinner ? '20' : '0a'}`,
|
||||
}}
|
||||
>
|
||||
<td className="px-3 py-2" style={{ color: SEAT_COLOURS[p.seat] }}>
|
||||
P{p.seat + 1}
|
||||
</td>
|
||||
<td className="px-3 py-2">{p.deckName}</td>
|
||||
<td className="text-right px-3 py-2">{p.wins}</td>
|
||||
<td className="text-right px-3 py-2">{p.winRate}%</td>
|
||||
<td className="text-right px-3 py-2">{p.avgTurns}</td>
|
||||
<td className="text-right px-3 py-2">{p.avgDamageDealt}</td>
|
||||
<td className="text-right px-3 py-2">{p.avgSpellsCast}</td>
|
||||
<td className="text-right px-3 py-2">{p.avgCreaturesPlayed}</td>
|
||||
<td className="text-right px-3 py-2">{p.avgRemovalUsed}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Per-Game Logs (collapsible) */}
|
||||
{games.length > 0 && (
|
||||
<div className="bg-bg-zone rounded-md border border-border-zone">
|
||||
<button
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
className="w-full text-left px-4 py-3 text-sm font-medium flex justify-between items-center hover:bg-bg-panel transition-colors"
|
||||
>
|
||||
<span>Per-Game Results ({games.length} games)</span>
|
||||
<span className="text-text-muted">{showLogs ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{showLogs && (
|
||||
<div className="px-4 pb-3 max-h-96 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{games.map((g: unknown, i: number) => {
|
||||
const game = g as Record<string, unknown>;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 text-xs text-text-muted py-1 border-b border-border-zone"
|
||||
>
|
||||
<span className="w-12 font-mono">#{game.gameNumber as number}</span>
|
||||
<span>
|
||||
Winner:{' '}
|
||||
<span
|
||||
style={{
|
||||
color: SEAT_COLOURS[(game.winner ?? game.winningSeat ?? 0) as number],
|
||||
}}
|
||||
>
|
||||
P{((game.winner ?? game.winningSeat ?? 0) as number) + 1}
|
||||
</span>
|
||||
</span>
|
||||
<span>{game.turns as number} turns</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { GameState, Card, NarrationLine } from '../types/game';
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'SET_STATE'; payload: GameState }
|
||||
| { type: 'SET_HAND'; cards: Card[] }
|
||||
| { type: 'ADD_NARRATION'; line: NarrationLine }
|
||||
| { type: 'SET_THINKING'; seat: number; active: boolean }
|
||||
| { type: 'GAME_OVER'; winner: number; reason: string }
|
||||
| { type: 'SET_STATUS'; status: string };
|
||||
|
||||
export interface ReducerState {
|
||||
gameState: GameState | null;
|
||||
hand: Card[];
|
||||
narration: NarrationLine[];
|
||||
thinkingSeats: Set<number>;
|
||||
gameOver: { winner: number; reason: string } | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const initialState: ReducerState = {
|
||||
gameState: null,
|
||||
hand: [],
|
||||
narration: [],
|
||||
thinkingSeats: new Set(),
|
||||
gameOver: null,
|
||||
status: '',
|
||||
};
|
||||
|
||||
export function gameReducer(state: ReducerState, action: GameAction): ReducerState {
|
||||
switch (action.type) {
|
||||
case 'SET_STATE':
|
||||
return { ...state, gameState: action.payload };
|
||||
|
||||
case 'SET_HAND':
|
||||
return { ...state, hand: action.cards };
|
||||
|
||||
case 'ADD_NARRATION': {
|
||||
const narration = [...state.narration, action.line];
|
||||
return { ...state, narration: narration.slice(-120) };
|
||||
}
|
||||
|
||||
case 'SET_THINKING': {
|
||||
const thinkingSeats = new Set(state.thinkingSeats);
|
||||
if (action.active) {
|
||||
thinkingSeats.add(action.seat);
|
||||
} else {
|
||||
thinkingSeats.delete(action.seat);
|
||||
}
|
||||
return { ...state, thinkingSeats };
|
||||
}
|
||||
|
||||
case 'GAME_OVER':
|
||||
return { ...state, gameOver: { winner: action.winner, reason: action.reason } };
|
||||
|
||||
case 'SET_STATUS':
|
||||
return { ...state, status: action.status };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -1,55 +0,0 @@
|
||||
export type Phase =
|
||||
| 'untap' | 'upkeep' | 'draw' | 'main1'
|
||||
| 'begin_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage'
|
||||
| 'main2' | 'end_step' | 'cleanup';
|
||||
|
||||
export interface Card {
|
||||
name: string;
|
||||
type: string;
|
||||
tapped: boolean;
|
||||
is_commander: boolean;
|
||||
oracle: string;
|
||||
cmc: number;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
seat: number;
|
||||
name: string;
|
||||
life: number;
|
||||
eliminated: boolean;
|
||||
hand: Card[];
|
||||
battlefield: Card[];
|
||||
graveyard: Card[];
|
||||
exile: Card[];
|
||||
command_zone: Card[];
|
||||
hand_count: number;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
game_id: string;
|
||||
turn: number;
|
||||
current_phase: Phase;
|
||||
active_seat: number;
|
||||
players: Player[];
|
||||
stack: Card[];
|
||||
game_over: boolean;
|
||||
winner: Player | null;
|
||||
}
|
||||
|
||||
export type WsIncoming =
|
||||
| { type: 'state'; payload: GameState }
|
||||
| { type: 'event'; text: string; seat?: number }
|
||||
| { type: 'phase'; phase: Phase; active_seat: number }
|
||||
| { type: 'thinking'; seat: number; active: boolean }
|
||||
| { type: 'elimination'; seat: number }
|
||||
| { type: 'game_over'; winner: number; reason: string }
|
||||
| { type: 'hand'; cards: Card[] }
|
||||
| { type: 'pong' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
export interface NarrationLine {
|
||||
text: string;
|
||||
seat?: number;
|
||||
isSystem: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export interface SimDeck {
|
||||
id: number;
|
||||
name: string;
|
||||
commander_name: string;
|
||||
color_identity: string[];
|
||||
}
|
||||
|
||||
export interface SimPlayerResult {
|
||||
seat: number;
|
||||
deckName: string;
|
||||
wins: number;
|
||||
winRate: number;
|
||||
avgTurns: number;
|
||||
avgDamageDealt: number;
|
||||
avgSpellsCast: number;
|
||||
avgCreaturesPlayed: number;
|
||||
avgRemovalUsed: number;
|
||||
avgRampPlayed: number;
|
||||
avgCardsDrawn: number;
|
||||
avgMaxBoardSize: number;
|
||||
}
|
||||
|
||||
export interface SimResult {
|
||||
playerCount: number;
|
||||
totalGames: number;
|
||||
players: SimPlayerResult[];
|
||||
elapsedSeconds: number;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: {
|
||||
base: '#12141e',
|
||||
zone: '#1c2034',
|
||||
panel: '#161820',
|
||||
bar: '#1a1e30',
|
||||
phase: '#1e2338',
|
||||
hand: '#1a2030',
|
||||
},
|
||||
accent: {
|
||||
blue: '#3c82dc',
|
||||
think: '#64b4ff',
|
||||
purple: '#7060c0',
|
||||
},
|
||||
life: {
|
||||
high: '#50c87a',
|
||||
med: '#dcb432',
|
||||
low: '#dc3c3c',
|
||||
},
|
||||
seat: {
|
||||
0: '#4696ff',
|
||||
1: '#ff6450',
|
||||
2: '#50d278',
|
||||
3: '#dcaa32',
|
||||
},
|
||||
card: {
|
||||
bg: '#2a3050',
|
||||
hand: '#2a3868',
|
||||
selected: '#3a4878',
|
||||
},
|
||||
border: {
|
||||
zone: '#3a4060',
|
||||
sep: '#2a2e48',
|
||||
btn: '#4060a0',
|
||||
overlay: '#4060c0',
|
||||
'btn-hover': '#6080d0',
|
||||
'btn-primary': '#4080d0',
|
||||
'hand-card': '#5060a0',
|
||||
chip: '#444444',
|
||||
},
|
||||
phase: {
|
||||
tab: '#252a42',
|
||||
text: '#888888',
|
||||
},
|
||||
btn: {
|
||||
primary: '#2a5090',
|
||||
},
|
||||
text: {
|
||||
body: '#e8e8e8',
|
||||
muted: '#888888',
|
||||
system: '#555566',
|
||||
'zone-label': '#666666',
|
||||
status: '#777788',
|
||||
sub: '#aaaaaa',
|
||||
btn: '#cccccc',
|
||||
},
|
||||
highlight: {
|
||||
gold: '#ffdc32',
|
||||
commander: '#ffd200',
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
pulse: {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.7' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
pulse: 'pulse 1s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
// Dev-only proxy — in production FastAPI serves the built assets
|
||||
// and handles /api and /ws routes directly.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
},
|
||||
})
|
||||
+163
-22
@@ -8,6 +8,12 @@ import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass # dotenv is optional; env vars can be set directly
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -38,11 +44,16 @@ from routes.ml import router as ml_router
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
# ── CORS origins (env-configurable) ────────────────────────────
|
||||
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:8080,http://192.168.0.240:5173,http://192.168.0.240:8080"
|
||||
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
|
||||
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
||||
|
||||
# ── App ────────────────────────────────────────────────────────
|
||||
app = FastAPI(title="Commander AI Lab API", version="3.0.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=allowed_origins,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
@@ -60,17 +71,25 @@ app.include_router(coach_router)
|
||||
app.include_router(ml_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for load balancers and Unity client polling."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _on_startup():
|
||||
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
|
||||
import routes.shared as _shared
|
||||
# Ensure precon_dir is resolved even when started via uvicorn directly
|
||||
if not CFG.precon_dir:
|
||||
CFG.precon_dir = _resolve_precon_dir()
|
||||
_shared.init_collection_db()
|
||||
if not _shared.PRECON_INDEX:
|
||||
_shared.download_precon_database()
|
||||
if not _shared.COMMANDER_META:
|
||||
_shared.load_commander_meta()
|
||||
|
||||
|
||||
# ── Static UI ──────────────────────────────────────────────────
|
||||
_legacy_ui_dir = Path(__file__).parent / "ui"
|
||||
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||
@@ -81,7 +100,6 @@ elif _spa_dir.exists():
|
||||
_spa_assets = _spa_dir / "assets"
|
||||
if _spa_assets.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(_spa_assets)), name="spa-assets")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def _spa_catchall(full_path: str):
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -90,21 +108,20 @@ elif _spa_dir.exists():
|
||||
return FileResponse(str(requested_file))
|
||||
return FileResponse(str(_spa_dir / "index.html"))
|
||||
|
||||
|
||||
# ── Startup ────────────────────────────────────────────────────
|
||||
def _parse_args():
|
||||
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
|
||||
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
|
||||
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", ""))
|
||||
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
|
||||
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", ""))
|
||||
p.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", ""))
|
||||
p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""))
|
||||
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
|
||||
p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""))
|
||||
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
|
||||
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
|
||||
p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
|
||||
p.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", ""))
|
||||
p.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", ""))
|
||||
p.add_argument("--verbose", "-v", action="store_true")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def _resolve_forge_decks_dir() -> str:
|
||||
import sys
|
||||
if sys.platform == "win32":
|
||||
@@ -122,6 +139,97 @@ def _resolve_forge_decks_dir() -> str:
|
||||
return str(candidate)
|
||||
return ""
|
||||
|
||||
def _resolve_forge_dir() -> str:
|
||||
"""Auto-detect the Forge installation directory.
|
||||
|
||||
The Forge dir is the root of a Forge install — it typically contains
|
||||
a 'res' subdirectory with card data. We derive it from the JAR
|
||||
location (parent of 'target/') or look in common install paths.
|
||||
"""
|
||||
import sys
|
||||
|
||||
# 1. If forge_jar is already resolved, derive forge_dir from it.
|
||||
# JAR lives in <forge-root>/forge-gui-desktop/target/<jar>
|
||||
# so forge_dir = JAR's grandparent's parent.
|
||||
if CFG.forge_jar and os.path.isfile(CFG.forge_jar):
|
||||
jar_path = Path(CFG.forge_jar)
|
||||
# target/ -> forge-gui-desktop/ -> <forge-root>
|
||||
candidate = jar_path.parent.parent.parent
|
||||
if candidate.is_dir() and (candidate / "res").is_dir():
|
||||
return str(candidate)
|
||||
# Also try just one level up from target/
|
||||
candidate = jar_path.parent.parent
|
||||
# Check forge-gui sibling of forge-gui-desktop (res/ lives there)
|
||||
forge_gui_sibling = jar_path.parent.parent.parent / "forge-gui"
|
||||
if forge_gui_sibling.is_dir() and (forge_gui_sibling / "res").is_dir():
|
||||
return str(forge_gui_sibling)
|
||||
if candidate.is_dir() and (candidate / "res").is_dir():
|
||||
return str(candidate)
|
||||
# Fall back to the directory containing the JAR itself
|
||||
candidate = jar_path.parent
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 2. Check common sibling/child directories relative to project root
|
||||
project_root = Path(__file__).parent
|
||||
for candidate in [
|
||||
project_root / "forge",
|
||||
project_root.parent / "forge-repo",
|
||||
project_root.parent / "forge-repo" / "forge-gui",
|
||||
project_root.parent / "forge",
|
||||
project_root / "forge-gui-desktop",
|
||||
]:
|
||||
if candidate.is_dir():
|
||||
# Prefer directory that has 'res' subfolder
|
||||
if (candidate / "res").is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 3. Platform-specific user data directories
|
||||
if sys.platform == "win32":
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
if appdata:
|
||||
candidate = Path(appdata) / "Forge"
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
home = Path.home()
|
||||
for candidate in [
|
||||
home / ".forge",
|
||||
home / "Forge",
|
||||
]:
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
return ""
|
||||
|
||||
def _resolve_forge_jar() -> str:
|
||||
"""Auto-detect the Forge GUI desktop JAR in common locations."""
|
||||
project_root = Path(__file__).parent
|
||||
|
||||
# Candidate directories where the Forge JAR might live
|
||||
candidates = [
|
||||
project_root / "forge-gui-desktop" / "target",
|
||||
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
|
||||
project_root.parent / "forge" / "forge-gui-desktop" / "target",
|
||||
]
|
||||
|
||||
# Also check FORGE_DIR/../forge-gui-desktop/target if FORGE_DIR is set
|
||||
forge_dir_env = os.environ.get("FORGE_DIR", "")
|
||||
if forge_dir_env:
|
||||
fd = Path(forge_dir_env)
|
||||
candidates.append(fd.parent / "forge-gui-desktop" / "target")
|
||||
candidates.append(fd / "target")
|
||||
|
||||
for target_dir in candidates:
|
||||
if target_dir.is_dir():
|
||||
for pattern in [
|
||||
"forge-gui-desktop-*-jar-with-dependencies.jar",
|
||||
"forge-gui-desktop-*-shaded.jar",
|
||||
"forge-gui-desktop-*.jar",
|
||||
]:
|
||||
jars = sorted(target_dir.glob(pattern))
|
||||
jars = [j for j in jars if not j.name.startswith("original-")]
|
||||
if jars:
|
||||
return str(jars[0])
|
||||
return ""
|
||||
|
||||
def _resolve_lab_jar() -> str:
|
||||
target_dir = Path(__file__).parent / "target"
|
||||
@@ -137,29 +245,63 @@ def _resolve_lab_jar() -> str:
|
||||
return str(jars[0])
|
||||
return ""
|
||||
|
||||
def _resolve_precon_dir() -> str:
|
||||
"""Auto-detect the precon-decks directory.
|
||||
|
||||
The precon-decks dir lives in the project root and contains downloaded
|
||||
.dck files plus precon-index.json. We check relative to this file
|
||||
(lab_api.py) first, then common sibling paths.
|
||||
"""
|
||||
project_root = Path(__file__).parent
|
||||
|
||||
# 1. Standard location: <project-root>/precon-decks
|
||||
candidate = project_root / "precon-decks"
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 2. Check parent directory (in case running from a subdirectory)
|
||||
candidate = project_root.parent / "precon-decks"
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 3. Derive from forge_dir if available
|
||||
if CFG.forge_dir:
|
||||
candidate = Path(CFG.forge_dir) / "precon-decks"
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
# 4. Fall back: create the directory in the project root so downloads work
|
||||
fallback = project_root / "precon-decks"
|
||||
fallback.mkdir(parents=True, exist_ok=True)
|
||||
return str(fallback)
|
||||
|
||||
def main():
|
||||
args = _parse_args()
|
||||
setup_logging(logging.DEBUG if args.verbose else logging.INFO)
|
||||
|
||||
CFG.forge_jar = args.forge_jar
|
||||
CFG.forge_dir = args.forge_dir
|
||||
CFG.forge_jar = args.forge_jar or _resolve_forge_jar()
|
||||
CFG.forge_dir = args.forge_dir or _resolve_forge_dir()
|
||||
CFG.forge_decks_dir = args.forge_decks_dir or _resolve_forge_decks_dir()
|
||||
CFG.lab_jar = args.lab_jar or _resolve_lab_jar()
|
||||
CFG.port = args.port
|
||||
CFG.lab_jar = args.lab_jar or _resolve_lab_jar()
|
||||
CFG.precon_dir = args.precon_dir or _resolve_precon_dir()
|
||||
CFG.port = args.port
|
||||
CFG.ximilar_api_key = args.ximilar_key
|
||||
CFG.pplx_api_key = args.pplx_key
|
||||
CFG.pplx_api_key = args.pplx_key
|
||||
|
||||
from routes.shared import get_java17
|
||||
j17 = get_java17()
|
||||
|
||||
log.info("Commander AI Lab — API Server v3.0.0")
|
||||
log.info(f" Port: {CFG.port}")
|
||||
log.info(f" Forge JAR: {CFG.forge_jar or 'NOT SET'}")
|
||||
log.info(f" Decks Dir: {CFG.forge_decks_dir or 'NOT SET'}")
|
||||
log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET'}")
|
||||
log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET'}")
|
||||
log.info(f" Java 17: {j17}")
|
||||
log.info(f" Port: {CFG.port}")
|
||||
log.info(f" Forge JAR: {CFG.forge_jar or 'NOT SET'}")
|
||||
log.info(f" Forge Dir: {CFG.forge_dir or 'NOT SET'}")
|
||||
log.info(f" Decks Dir: {CFG.forge_decks_dir or 'NOT SET'}")
|
||||
log.info(f" Precon Dir: {CFG.precon_dir or 'NOT SET'}")
|
||||
log.info(f" Lab JAR: {CFG.lab_jar or 'NOT SET'}")
|
||||
log.info(f" Results: {CFG.results_dir}")
|
||||
log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET'}")
|
||||
log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET'}")
|
||||
log.info(f" Java 17: {j17}")
|
||||
|
||||
load_commander_meta()
|
||||
download_precon_database()
|
||||
@@ -169,6 +311,5 @@ def main():
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
-8872
File diff suppressed because it is too large.
Load diff
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user