Compare commits
236
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c34cb0966 | ||
|
|
c67ce422d8 | ||
|
|
cb4b48e362 | ||
|
|
4a8db4db6b | ||
|
|
9c0fa64a69 | ||
|
|
c338f16965 | ||
|
|
a7ab0e43a5 | ||
|
|
8b527d7c30 | ||
|
|
36760dd922 | ||
|
|
963a5db638 | ||
|
|
b1996a8deb | ||
|
|
c44d0b32bb | ||
|
|
a78df75014 | ||
|
|
9020d29745 | ||
|
|
b0a34ae9e2 | ||
|
|
4c4f5ce0ff | ||
|
|
1e7f4b4e31 | ||
|
|
488575f3df | ||
|
|
fd7f0258f3 | ||
|
|
7284b8617d | ||
|
|
678cc13fe0 | ||
|
|
19f83a546b | ||
|
|
c53e82bfeb | ||
|
|
f8b13703b8 | ||
|
|
eb59a2cd2e | ||
|
|
ff6040b53e | ||
|
|
db7bc62d7e | ||
|
|
fbffe81846 | ||
|
|
9369cf5450 | ||
|
|
e38401a740 | ||
|
|
111764615d | ||
|
|
2088da6fd8 | ||
|
|
a2fbc30b2b | ||
|
|
a62640668b | ||
|
|
b8e12dff1b | ||
|
|
ea0a785174 | ||
|
|
9f1277f233 | ||
|
|
66ce887fe3 | ||
|
|
80f63f192a | ||
|
|
46ed2f5606 | ||
|
|
93a421b90a | ||
|
|
ee95f26bc4 | ||
|
|
2ee574f678 | ||
|
|
473a2bdcc7 | ||
|
|
a6e50ee0f0 | ||
|
|
e2a6a75fd9 | ||
|
|
030bdf2b81 | ||
|
|
c83f7e0141 | ||
|
|
e5377ef4bc | ||
|
|
56ea2602b4 | ||
|
|
d86109c5c0 | ||
|
|
d9dd098ca3 | ||
|
|
69ce5b340e | ||
|
|
103a74a0e1 | ||
|
|
147121582b | ||
|
|
8be96ab47d | ||
|
|
e2653b3f72 | ||
|
|
7efac34279 | ||
|
|
b00df14f30 | ||
|
|
3542efd982 | ||
|
|
1f93b5510d | ||
|
|
5c31be6023 | ||
|
|
76139477be | ||
|
|
0e5d7ba7d3 | ||
|
|
4ec6ed57d5 | ||
|
|
08d81a4336 | ||
|
|
f6ed40a253 | ||
|
|
9fd39e42c7 | ||
|
|
72107df2ae | ||
|
|
2738bbdba4 | ||
|
|
0bbc78ef23 | ||
|
|
c2b470e251 | ||
|
|
6135feb644 | ||
|
|
b3b664872b | ||
|
|
a80e8908e1 | ||
|
|
a8ef10d5b7 | ||
|
|
86af4b6de5 | ||
|
|
332606d6b5 | ||
|
|
70dfa3d3e0 | ||
|
|
f970b6d571 | ||
|
|
5ade916207 | ||
|
|
cc53c58344 | ||
|
|
3e64aa67eb | ||
|
|
70a7c07b81 | ||
|
|
1b4e9f5a66 | ||
|
|
c962d1f16a | ||
|
|
dcdf15c56a | ||
|
|
b0214009f4 | ||
|
|
5fe0ac2bd6 | ||
|
|
fc09249ad3 | ||
|
|
d2c7783ce1 | ||
|
|
0ccf35f241 | ||
|
|
9a5105b0d6 | ||
|
|
6a688d1cd0 | ||
|
|
d9639a8414 | ||
|
|
633fd43b28 | ||
|
|
45c72aadc9 | ||
|
|
81ab7ac34a | ||
|
|
e0fe4144ae | ||
|
|
68aa6eac3f | ||
|
|
c870dd35fb | ||
|
|
35ce67ed6f | ||
|
|
232750a951 | ||
|
|
46c1c8cb01 | ||
|
|
64a4cd3c7c | ||
|
|
3f6b66fce3 | ||
|
|
2887c09550 | ||
|
|
84d84a211f | ||
|
|
12f90dd276 | ||
|
|
0b755c8428 | ||
|
|
50f36f284c | ||
|
|
369cc9243f | ||
|
|
22d3b64c2d | ||
|
|
beb3dac4d5 | ||
|
|
330801107c | ||
|
|
95c5a93584 | ||
|
|
377a704a90 | ||
|
|
cc481bd71b | ||
|
|
c6d02fd38c | ||
|
|
0f2d3583ac | ||
|
|
2b355ed9ba | ||
|
|
b4d8c2f50e | ||
|
|
9db1ed112f | ||
|
|
75568bfdb1 | ||
|
|
d834247885 | ||
|
|
310a408509 | ||
|
|
ef7bde841f | ||
|
|
87abfd801e | ||
|
|
8eba39cca7 | ||
|
|
bc9656a59f | ||
|
|
86b96c09f9 | ||
|
|
9a46b0f80c | ||
|
|
0d2da18783 | ||
|
|
6a84708c65 | ||
|
|
9b892e5289 | ||
|
|
f2a782f0bd | ||
|
|
da67879491 | ||
|
|
18beb9192e | ||
|
|
ce80b6d9c5 | ||
|
|
d8e355de2b | ||
|
|
9d2a043d3f | ||
|
|
85224f082e | ||
|
|
c6c3bfabf8 | ||
|
|
c6329d377a | ||
|
|
3f7f40d626 | ||
|
|
bc023ab6f6 | ||
|
|
83a8f288b7 | ||
|
|
e3f0787c73 | ||
|
|
3802bc3d78 | ||
|
|
2936fd37eb | ||
|
|
cad2f00ec7 | ||
|
|
6b50c6ee7d | ||
|
|
40a166188a | ||
|
|
a501a9755d | ||
|
|
2d8a55fdc6 | ||
|
|
b0d0b75dbb | ||
|
|
94b20f1347 | ||
|
|
c980e1b6d5 | ||
|
|
7c7be013c2 | ||
|
|
14eb72aaff | ||
|
|
81a1bc9022 | ||
|
|
6534380f67 | ||
|
|
1ee55d4428 | ||
|
|
432a16e87d | ||
|
|
c5a1f65e13 | ||
|
|
a95ae45e18 | ||
|
|
ed255c1a21 | ||
|
|
9899da9b98 | ||
|
|
86ae69112f | ||
|
|
1cc0b696d3 | ||
|
|
b248be867b | ||
|
|
2a9b043a24 | ||
|
|
9863f7590b | ||
|
|
9436276b69 | ||
|
|
d06618031a | ||
|
|
58c6908b85 | ||
|
|
682c4191f9 | ||
|
|
2add53d3c2 | ||
|
|
918d760dcb | ||
|
|
18ddd3027b | ||
|
|
a82a7d4dc6 | ||
|
|
d5fdc150f7 | ||
|
|
173a7f4f3b | ||
|
|
a988779bb4 | ||
|
|
7901fef495 | ||
|
|
d21928da53 | ||
|
|
9b1654a81e | ||
|
|
2435e67a6e | ||
|
|
69fe6cbffd | ||
|
|
3fe3ba0e62 | ||
|
|
643529e874 | ||
|
|
9746a41178 | ||
|
|
b0f48cde62 | ||
|
|
ec9719659a | ||
|
|
729283c3bd | ||
|
|
52dcf95c59 | ||
|
|
4f5135e85b | ||
|
|
ff3b134cb9 | ||
|
|
1c85733743 | ||
|
|
a71abfa7d0 | ||
|
|
abb1e08249 | ||
|
|
57b0598717 | ||
|
|
292801d445 | ||
|
|
71c90a588f | ||
|
|
5550c27744 | ||
|
|
986c501410 | ||
|
|
d3431ff246 | ||
|
|
36dba9e90a | ||
|
|
b9369af52f | ||
|
|
6dd1de894c | ||
|
|
3f1bcb4d12 | ||
|
|
52762ee0aa | ||
|
|
af8e59ed9a | ||
|
|
0923b75d79 | ||
|
|
20172c90b4 | ||
|
|
089c1abc8d | ||
|
|
646da59e2a | ||
|
|
10a7662d51 | ||
|
|
a6171b5180 | ||
|
|
b09fe4b48b | ||
|
|
cb8867a808 | ||
|
|
3d6365cfb4 | ||
|
|
397d02240e | ||
|
|
0c842b97fd | ||
|
|
7e94b29c6d | ||
|
|
fbc3bdb6b5 | ||
|
|
a5b9d70f04 | ||
|
|
8103a06992 | ||
|
|
85df46364a | ||
|
|
32f3c9dfa3 | ||
|
|
7bc4aac0aa | ||
|
|
70ad16fc79 | ||
|
|
6298f83abd | ||
|
|
d1422d6f64 | ||
|
|
4e8a999ebd | ||
|
|
aa5e381c59 |
No files matched your search
@@ -9,3 +9,7 @@ XIMILAR_API_KEY=your-ximilar-api-key-here
|
||||
# Perplexity API Key (AI deck research & generation)
|
||||
# Get yours at: https://www.perplexity.ai/settings/api
|
||||
PPLX_API_KEY=your-perplexity-api-key-here
|
||||
|
||||
# CORS allowed origins (comma-separated)
|
||||
# Default: http://localhost:5173,http://localhost:8080
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||
@@ -0,0 +1,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()
|
||||
|
||||
+11
-10
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — Coach Configuration
|
||||
═══════════════════════════════════════
|
||||
Central config for LM Studio, embeddings, and coach service paths.
|
||||
Central config for Ollama, embeddings, and coach service paths.
|
||||
All paths are relative to the commander-ai-lab root directory.
|
||||
"""
|
||||
|
||||
@@ -12,11 +12,11 @@ from pathlib import Path
|
||||
# Resolve relative to wherever the lab is installed
|
||||
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
|
||||
|
||||
# ── LM Studio (OpenAI-compatible API) ──────────────────────
|
||||
LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://192.168.0.122:1234/v1")
|
||||
LM_STUDIO_MODEL = os.environ.get("LM_STUDIO_MODEL", "local-model")
|
||||
LM_STUDIO_TIMEOUT = int(os.environ.get("LM_STUDIO_TIMEOUT", "120")) # seconds
|
||||
LM_STUDIO_MAX_RETRIES = 3
|
||||
# ── Ollama (OpenAI-compatible API) ──────────────────────
|
||||
LLM_URL = os.environ.get("LLM_URL", "http://192.168.0.240:11434/v1")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
|
||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "120")) # seconds
|
||||
LLM_MAX_RETRIES = 3
|
||||
|
||||
# ── Embeddings ─────────────────────────────────────────────
|
||||
EMBEDDINGS_DIR = LAB_ROOT / "data"
|
||||
@@ -33,7 +33,7 @@ MAX_CANDIDATES_PER_UNDERPERFORMER = 10
|
||||
MAX_UNDERPERFORMERS = 8
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
|
||||
|
||||
# ── LLM Generation Settings (Coach — LM Studio) ──────────────
|
||||
# ── LLM Generation Settings (Coach — Ollama) ──────────────
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
|
||||
# so we need a generous limit to avoid truncation
|
||||
@@ -41,9 +41,10 @@ DEFAULT_MAX_TOKENS = 8192
|
||||
|
||||
# ── Perplexity Deck Generation Settings ───────────────────────
|
||||
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "perplexity")
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
|
||||
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "sonar")
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
|
||||
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://192.168.0.240:11434/v1")
|
||||
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
|
||||
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
|
||||
|
||||
@@ -53,7 +54,7 @@ SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY"
|
||||
# Maximum number of alternatives to suggest per missing card
|
||||
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
|
||||
# Model for substitution fallback (sonar is fine — small focused queries)
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "sonar")
|
||||
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "gpt-oss:20b")
|
||||
# Enable Perplexity fallback for low-confidence embedding matches
|
||||
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
|
||||
|
||||
|
||||
+19
-19
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Commander AI Lab — LM Studio Client
|
||||
Commander AI Lab — Ollama Client
|
||||
════════════════════════════════════
|
||||
Async HTTP client targeting LM Studio's OpenAI-compatible
|
||||
Async HTTP client targeting Ollama's OpenAI-compatible
|
||||
/v1/chat/completions endpoint. Includes retry logic for
|
||||
non-JSON responses and configurable timeout.
|
||||
"""
|
||||
@@ -14,8 +14,8 @@ from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
from .config import (
|
||||
LM_STUDIO_URL, LM_STUDIO_MODEL,
|
||||
LM_STUDIO_TIMEOUT, LM_STUDIO_MAX_RETRIES,
|
||||
LLM_URL, LLM_MODEL,
|
||||
LLM_TIMEOUT, LLM_MAX_RETRIES,
|
||||
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
|
||||
)
|
||||
|
||||
@@ -34,12 +34,12 @@ class LLMResponse:
|
||||
self.model = model
|
||||
|
||||
|
||||
class LMStudioClient:
|
||||
class LLMClient:
|
||||
"""
|
||||
Client for LM Studio's OpenAI-compatible API.
|
||||
Client for Ollama's OpenAI-compatible API.
|
||||
|
||||
Usage:
|
||||
client = LMStudioClient()
|
||||
client = LLMClient()
|
||||
response = await client.chat(system_prompt, user_prompt)
|
||||
if response.parsed_json:
|
||||
suggestions = response.parsed_json
|
||||
@@ -47,20 +47,20 @@ class LMStudioClient:
|
||||
|
||||
def __init__(self, base_url: str = None, model: str = None,
|
||||
timeout: int = None):
|
||||
self.base_url = (base_url or LM_STUDIO_URL).rstrip("/")
|
||||
self.model = model or LM_STUDIO_MODEL
|
||||
self.timeout = timeout or LM_STUDIO_TIMEOUT
|
||||
self.base_url = (base_url or LLM_URL).rstrip("/")
|
||||
self.model = model or LLM_MODEL
|
||||
self.timeout = timeout or LLM_TIMEOUT
|
||||
self._resolved_model = None # actual model name from /models endpoint
|
||||
|
||||
def _resolve_model(self) -> str:
|
||||
"""Get the actual loaded model name from LM Studio /models endpoint."""
|
||||
"""Get the actual loaded model name from Ollama /models endpoint."""
|
||||
if self._resolved_model:
|
||||
return self._resolved_model
|
||||
try:
|
||||
status = self.check_connection()
|
||||
if status.get("connected") and status.get("active_model"):
|
||||
self._resolved_model = status["active_model"]
|
||||
logger.info("Resolved LM Studio model: %s", self._resolved_model)
|
||||
logger.info("Resolved Ollama model: %s", self._resolved_model)
|
||||
return self._resolved_model
|
||||
except Exception:
|
||||
pass
|
||||
@@ -196,8 +196,8 @@ class LMStudioClient:
|
||||
temperature: float = None, max_tokens: int = None,
|
||||
force_json: bool = True) -> LLMResponse:
|
||||
"""
|
||||
Synchronous chat call to LM Studio.
|
||||
Retries up to LM_STUDIO_MAX_RETRIES on failure or non-JSON response.
|
||||
Synchronous chat call to Ollama.
|
||||
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
|
||||
"""
|
||||
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
||||
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
||||
@@ -205,9 +205,9 @@ class LMStudioClient:
|
||||
temp, tokens, force_json)
|
||||
|
||||
last_error = None
|
||||
for attempt in range(1, LM_STUDIO_MAX_RETRIES + 1):
|
||||
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||
try:
|
||||
logger.info("LLM call attempt %d/%d", attempt, LM_STUDIO_MAX_RETRIES)
|
||||
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
|
||||
req = Request(
|
||||
f"{self.base_url}/chat/completions",
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
@@ -239,12 +239,12 @@ class LMStudioClient:
|
||||
body = self._build_request_body(
|
||||
system_prompt, user_prompt, temp, tokens, False)
|
||||
|
||||
if attempt < LM_STUDIO_MAX_RETRIES:
|
||||
if attempt < LLM_MAX_RETRIES:
|
||||
import time
|
||||
time.sleep(2 * attempt) # Simple backoff
|
||||
|
||||
raise ConnectionError(
|
||||
f"LLM call failed after {LM_STUDIO_MAX_RETRIES} attempts: {last_error}"
|
||||
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
|
||||
)
|
||||
|
||||
async def chat(self, system_prompt: str, user_prompt: str,
|
||||
@@ -263,7 +263,7 @@ class LMStudioClient:
|
||||
|
||||
def check_connection(self) -> dict:
|
||||
"""
|
||||
Check if LM Studio is reachable. Returns status dict.
|
||||
Check if Ollama is reachable. Returns status dict.
|
||||
"""
|
||||
try:
|
||||
req = Request(
|
||||
|
||||
@@ -25,6 +25,9 @@ class CardAlternative(BaseModel):
|
||||
|
||||
class DeckCardWithStatus(BaseModel):
|
||||
"""A deck card enriched with ownership and substitution data."""
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
name: str
|
||||
count: int = 1
|
||||
category: str = ""
|
||||
|
||||
@@ -247,7 +247,7 @@ class DeckGeneratorV3:
|
||||
truncated = card_count < 90 # Commander decks should have ~100 cards
|
||||
logger.info("Deck generated: %d cards, bracket %d%s",
|
||||
card_count,
|
||||
raw_deck.get("bracket", {}).get("level", 0),
|
||||
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
|
||||
" (TRUNCATED — response may have hit token limit)" if truncated else "")
|
||||
|
||||
# Step 5: Check ownership and build enriched card list
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
name,quantity,set_code
|
||||
Sol Ring,1,C21
|
||||
Command Tower,1,C21
|
||||
Arcane Signet,1,C21
|
||||
@@ -133,7 +133,7 @@ export function SimulatorPage() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Base URL (e.g. http://192.168.0.122:1234)"
|
||||
placeholder="Base URL (e.g. http://192.168.0.240:11434)"
|
||||
value={dsUrl}
|
||||
onChange={e => setDsUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
|
||||
|
||||
@@ -559,5 +559,4 @@ export interface CommanderSearchResult {
|
||||
|
||||
export interface PplxStatus {
|
||||
configured: boolean
|
||||
key_prefix: string
|
||||
}
|
||||
+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"
|
||||
_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
-4487
File diff suppressed because it is too large.
Load diff
@@ -154,17 +154,17 @@ def build_dataset(
|
||||
|
||||
# Print game outcome distribution
|
||||
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
|
||||
log.info(f"\n{'Outcome':<25} {'Count':>8}")
|
||||
log.info("─" * 35)
|
||||
logger.info(f"\n{'Outcome':<25} {'Count':>8}")
|
||||
logger.info("─" * 35)
|
||||
for o, c in sorted(zip(unique_outcomes, outcome_counts), key=lambda x: -x[1]):
|
||||
log.info(f" {str(o):<23} {c:>8}")
|
||||
logger.info(f" {str(o):<23} {c:>8}")
|
||||
|
||||
# Print playstyle distribution
|
||||
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
|
||||
log.info(f"\n{'Playstyle':<25} {'Count':>8}")
|
||||
log.info("─" * 35)
|
||||
logger.info(f"\n{'Playstyle':<25} {'Count':>8}")
|
||||
logger.info("─" * 35)
|
||||
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
|
||||
log.info(f" {str(s):<23} {c:>8}")
|
||||
logger.info(f" {str(s):<23} {c:>8}")
|
||||
|
||||
return {
|
||||
"states": states,
|
||||
@@ -288,7 +288,7 @@ def main():
|
||||
max_samples=args.max_samples,
|
||||
)
|
||||
if not dataset:
|
||||
log.info("\nNo data to save. Run simulations with --ml-log first.")
|
||||
logger.info("\nNo data to save. Run simulations with --ml-log first.")
|
||||
sys.exit(1)
|
||||
|
||||
if args.no_split:
|
||||
@@ -300,7 +300,7 @@ def main():
|
||||
save_dataset(val, f"{base}-val.npz")
|
||||
save_dataset(test, f"{base}-test.npz")
|
||||
|
||||
log.info("\nDataset build complete.")
|
||||
logger.info("\nDataset build complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+57
-1
@@ -9,7 +9,7 @@ class StartRequest(BaseModel):
|
||||
numGames: int = 100
|
||||
threads: int = 4
|
||||
seed: Optional[int] = None
|
||||
clock: int = 120
|
||||
clock: int = 300
|
||||
deckSources: Optional[list[Optional[dict]]] = None
|
||||
useLearnedPolicy: bool = False
|
||||
policyStyle: str = "midrange"
|
||||
@@ -114,6 +114,62 @@ class DeckGenV3Request(BaseModel):
|
||||
deck_name: Optional[str] = ""
|
||||
|
||||
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Deckgen requests (moved from routes/deckgen.py)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class DeckResearchRequest(BaseModel):
|
||||
deck_id: int # Deck to research
|
||||
goal: Optional[str] = "Identify weaknesses and suggest upgrades"
|
||||
budget_usd: Optional[float] = None
|
||||
omit_cards: Optional[list[str]] = []
|
||||
use_collection: bool = True
|
||||
|
||||
|
||||
class DeckGenerateAIRequest(BaseModel):
|
||||
commander: str
|
||||
budget_usd: Optional[float] = None
|
||||
budget_mode: str = "total" # "total" or "per_card"
|
||||
omit_cards: Optional[list[str]] = []
|
||||
use_collection: bool = True
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Coach requests (moved from routes/coach.py)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class CoachRequestBody(BaseModel):
|
||||
goals: Optional[dict] = None
|
||||
|
||||
|
||||
class CoachChatMessage(BaseModel):
|
||||
role: str # "user" or "assistant"
|
||||
content: str
|
||||
|
||||
|
||||
class CoachChatRequest(BaseModel):
|
||||
deck_id: str
|
||||
messages: list[dict] # conversation history [{role, content}]
|
||||
goals: Optional[dict] = None
|
||||
stream: Optional[bool] = False
|
||||
|
||||
|
||||
class CoachApplyRequest(BaseModel):
|
||||
session_id: str
|
||||
deck_id: int # numeric deck ID in the DB
|
||||
accepted_cuts: list[str] = [] # card names to remove
|
||||
accepted_adds: list[str] = [] # card names to add
|
||||
|
||||
|
||||
class CoachGoalsRequest(BaseModel):
|
||||
target_power_level: Optional[int] = None # 1-10
|
||||
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
|
||||
budget: Optional[str] = None # budget, medium, no-limit
|
||||
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
|
||||
class DeckGenV3SubstituteRequest(BaseModel):
|
||||
card_name: str
|
||||
substitute_name: str
|
||||
@@ -20,6 +20,7 @@ class Config:
|
||||
forge_dir: str = ""
|
||||
forge_decks_dir: str = ""
|
||||
lab_jar: str = ""
|
||||
precon_dir: str = ""
|
||||
results_dir: str = "results"
|
||||
port: int = 8080
|
||||
ximilar_api_key: str = ""
|
||||
|
||||
@@ -67,6 +67,13 @@
|
||||
<version>3.45.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- WebSocket server for Unity/Godot client -->
|
||||
<dependency>
|
||||
<groupId>org.java-websocket</groupId>
|
||||
<artifactId>Java-WebSocket</artifactId>
|
||||
<version>1.5.6</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Testing -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
|
||||
@@ -7,3 +7,9 @@ uvicorn[standard]>=0.23.0
|
||||
pydantic>=2.0.0
|
||||
httpx>=0.24.0
|
||||
websockets>=11.0
|
||||
|
||||
# Deck Builder dependencies
|
||||
requests>=2.31.0
|
||||
beautifulsoup4>=4.12.0
|
||||
ollama>=0.2.0
|
||||
scrython>=2.0.0
|
||||
+54
-81
@@ -25,12 +25,13 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from models.requests import CoachRequestBody, CoachChatMessage, CoachChatRequest, CoachApplyRequest, CoachGoalsRequest
|
||||
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
@@ -58,14 +59,14 @@ def init_coach_service():
|
||||
"""Initialize the coach service with LLM client and embeddings."""
|
||||
global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
|
||||
try:
|
||||
from coach.llm_client import LMStudioClient
|
||||
from coach.llm_client import LLMClient
|
||||
from coach.embeddings import MTGEmbeddingIndex
|
||||
from coach.coach_service import CoachService
|
||||
from coach.config import ensure_dirs
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
_coach_llm = LMStudioClient()
|
||||
_coach_llm = LLMClient()
|
||||
_coach_embeddings = MTGEmbeddingIndex()
|
||||
|
||||
# Try to load embeddings (non-blocking — download happens on first use)
|
||||
@@ -86,42 +87,28 @@ def init_coach_service():
|
||||
if llm_status.get("connected"):
|
||||
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
||||
else:
|
||||
log_coach.warning(f" Coach LLM: Not connected (start LM Studio on 192.168.0.122:1234)")
|
||||
log_coach.warning(f" Coach LLM: Not connected (start Ollama on 192.168.0.240:11434)")
|
||||
|
||||
log_coach.info(" Coach: Service initialized")
|
||||
|
||||
# Initialize V3 Deck Generator (Perplexity)
|
||||
# Initialize V3 Deck Generator (Ollama-based)
|
||||
_deck_gen_v3_error = None
|
||||
if CFG.pplx_api_key:
|
||||
try:
|
||||
from coach.clients.perplexity_client import PerplexityClient
|
||||
from coach.services.deck_generator import DeckGeneratorV3
|
||||
from coach.config import DECK_GEN_MODEL
|
||||
|
||||
pplx_client = PerplexityClient(
|
||||
api_key=CFG.pplx_api_key,
|
||||
model=DECK_GEN_MODEL,
|
||||
)
|
||||
_deck_gen_v3 = DeckGeneratorV3(
|
||||
pplx_client=pplx_client,
|
||||
db_conn_factory=_get_db_conn,
|
||||
embedding_index=_coach_embeddings,
|
||||
)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})")
|
||||
except ImportError as e:
|
||||
_deck_gen_v3_error = f"Missing dependency: {e}. Run: pip install openai"
|
||||
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||
_deck_gen_v3 = None
|
||||
except Exception as e:
|
||||
_deck_gen_v3_error = str(e)
|
||||
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
||||
_deck_gen_v3 = None
|
||||
else:
|
||||
_deck_gen_v3_error = "PPLX_API_KEY not set"
|
||||
log_deckgen.info(" Deck Gen V3: Skipped (no PPLX_API_KEY)")
|
||||
try:
|
||||
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
|
||||
_deck_gen_v3 = DeckBuilderAdapter(
|
||||
db_conn_factory=_get_db_conn,
|
||||
model="gpt-oss:20b",
|
||||
)
|
||||
log_deckgen.info(" Deck Gen V3: Initialized (Ollama DeckBuilderAdapter)")
|
||||
except ImportError as e:
|
||||
_deck_gen_v3_error = f"Missing dependency: {e}"
|
||||
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||
_deck_gen_v3 = None
|
||||
except Exception as e:
|
||||
_deck_gen_v3_error = str(e)
|
||||
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
||||
_deck_gen_v3 = None
|
||||
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Failed to initialize: {e}")
|
||||
log_coach.error(f" Coach: Failed to initialize: {e}\n{traceback.format_exc()}")
|
||||
_coach_service = None
|
||||
|
||||
|
||||
@@ -186,33 +173,6 @@ async def coach_get_report(deck_id: str):
|
||||
return report.model_dump()
|
||||
|
||||
|
||||
class CoachRequestBody(BaseModel):
|
||||
goals: Optional[dict] = None
|
||||
|
||||
|
||||
class CoachChatMessage(BaseModel):
|
||||
role: str # "user" or "assistant"
|
||||
content: str
|
||||
|
||||
class CoachChatRequest(BaseModel):
|
||||
deck_id: str
|
||||
messages: list[dict] # conversation history [{role, content}]
|
||||
goals: Optional[dict] = None
|
||||
stream: Optional[bool] = False
|
||||
|
||||
class CoachApplyRequest(BaseModel):
|
||||
session_id: str
|
||||
deck_id: int # numeric deck ID in the DB
|
||||
accepted_cuts: list[str] = [] # card names to remove
|
||||
accepted_adds: list[str] = [] # card names to add
|
||||
|
||||
class CoachGoalsRequest(BaseModel):
|
||||
target_power_level: Optional[int] = None # 1-10
|
||||
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
|
||||
budget: Optional[str] = None # budget, medium, no-limit
|
||||
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
|
||||
|
||||
|
||||
@router.post("/api/coach/decks/{deck_id}")
|
||||
async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
|
||||
"""Trigger a coaching session for a deck."""
|
||||
@@ -261,21 +221,35 @@ def _build_deck_report_from_db(deck_slug: str):
|
||||
from coach.models import DeckReport, CardPerformance, DeckStructure
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Find the deck by slug match against the deck name
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, commander_name, color_identity FROM decks ORDER BY id"
|
||||
).fetchall()
|
||||
# --- Input validation ---
|
||||
import re
|
||||
if not deck_slug or len(deck_slug) > 200:
|
||||
return None
|
||||
deck_slug = re.sub(r'[^a-zA-Z0-9 _-]', '', deck_slug)
|
||||
if not deck_slug:
|
||||
return None
|
||||
|
||||
matched_deck = None
|
||||
for r in rows:
|
||||
name = r["name"] or ""
|
||||
slug = name.lower().replace(" ", "-")
|
||||
# Also try a more thorough slugify
|
||||
import re
|
||||
clean_slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
|
||||
if slug == deck_slug.lower() or clean_slug == deck_slug.lower() or name.lower() == deck_slug.lower():
|
||||
matched_deck = r
|
||||
break
|
||||
# Find the deck by parameterized query instead of full-table scan
|
||||
slug_lower = deck_slug.lower()
|
||||
slug_hyphenated = slug_lower.replace(' ', '-')
|
||||
clean_slug = re.sub(r'[^a-z0-9]+', '-', slug_lower).strip('-')
|
||||
|
||||
if slug_lower.isdigit():
|
||||
# Numeric slug -- match by ID directly
|
||||
matched_deck = conn.execute(
|
||||
"SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
|
||||
(int(slug_lower),)
|
||||
).fetchone()
|
||||
else:
|
||||
# Try exact name match, hyphenated match, or clean-slug match
|
||||
matched_deck = conn.execute(
|
||||
"""SELECT id, name, commander_name, color_identity FROM decks
|
||||
WHERE LOWER(name) = ?
|
||||
OR LOWER(REPLACE(name, ' ', '-')) = ?
|
||||
OR LOWER(REPLACE(name, ' ', '-')) = ?
|
||||
LIMIT 1""",
|
||||
(slug_lower, slug_hyphenated, clean_slug)
|
||||
).fetchone()
|
||||
|
||||
if matched_deck is None:
|
||||
return None
|
||||
@@ -451,7 +425,7 @@ async def coach_chat(body: CoachChatRequest):
|
||||
try:
|
||||
import json
|
||||
from urllib.request import urlopen, Request as UrlRequest
|
||||
from coach.config import LM_STUDIO_URL, LM_STUDIO_TIMEOUT
|
||||
from coach.config import LLM_URL, LLM_TIMEOUT
|
||||
|
||||
model_name = _coach_llm._resolve_model()
|
||||
llm_body = {
|
||||
@@ -477,7 +451,7 @@ async def coach_chat(body: CoachChatRequest):
|
||||
import http.client
|
||||
import urllib.parse
|
||||
parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions")
|
||||
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LM_STUDIO_TIMEOUT)
|
||||
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LLM_TIMEOUT)
|
||||
conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
resp = conn.getresponse()
|
||||
@@ -526,7 +500,7 @@ async def coach_chat(body: CoachChatRequest):
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
def _call():
|
||||
with urlopen(req, timeout=LM_STUDIO_TIMEOUT) as resp:
|
||||
with urlopen(req, timeout=LLM_TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
raw = await loop.run_in_executor(None, _call)
|
||||
@@ -598,6 +572,7 @@ async def coach_apply_suggestions(body: CoachApplyRequest):
|
||||
# Try Scryfall API lookup
|
||||
try:
|
||||
import urllib.parse
|
||||
from urllib.request import urlopen, Request as UrlRequest
|
||||
encoded = urllib.parse.quote(card_name)
|
||||
scry_req = UrlRequest(
|
||||
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
|
||||
@@ -694,5 +669,3 @@ async def coach_generate_reports():
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Report generation failed: {e}")
|
||||
|
||||
|
||||
+1
-490
@@ -18,13 +18,8 @@ from __future__ import annotations
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -320,472 +315,6 @@ async def update_collection_card(cardId: int, body: dict):
|
||||
return _add_image_url(_row_to_dict(updated_row))
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Import Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Scryfall Response Cache ─────────────────────────────────
|
||||
# SQLite-backed cache for Scryfall API responses.
|
||||
# Keyed by (name, set_code, collector_number). Default TTL: 7 days.
|
||||
# Scryfall data changes infrequently (set releases ~every 3 months).
|
||||
# Eliminates redundant HTTP calls across imports and re-enrichments.
|
||||
|
||||
SCRYFALL_CACHE_DB_PATH = Path(__file__).parent / "scryfall_cache.db"
|
||||
SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600)) # 7 days
|
||||
|
||||
|
||||
class _ScryfallCache:
|
||||
"""
|
||||
Thread-safe SQLite cache for raw Scryfall JSON responses.
|
||||
|
||||
Schema:
|
||||
cache_key TEXT PRIMARY KEY — "name|set|cn" normalised lowercase
|
||||
json_blob TEXT — raw Scryfall API JSON response
|
||||
fetched_at TEXT — ISO-8601 UTC timestamp
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path = None, ttl_seconds: int = None):
|
||||
self._db_path = db_path or SCRYFALL_CACHE_DB_PATH
|
||||
self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS
|
||||
self._lock = threading.Lock()
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._init_db()
|
||||
|
||||
# ── internal ────────────────────────────────────────────
|
||||
|
||||
def _init_db(self):
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scryfall_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
json_blob TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _make_key(name: str, set_code: str, collector_number: str) -> str:
|
||||
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
|
||||
|
||||
# ── public API ──────────────────────────────────────────
|
||||
|
||||
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
|
||||
"""Return cached Scryfall JSON dict, or None if missing / expired."""
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT json_blob, fetched_at FROM scryfall_cache WHERE cache_key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
self._misses += 1
|
||||
return None
|
||||
# TTL check
|
||||
try:
|
||||
fetched = datetime.fromisoformat(row[1])
|
||||
age = (datetime.utcnow() - fetched).total_seconds()
|
||||
if age > self._ttl:
|
||||
self._misses += 1
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
self._misses += 1
|
||||
return None
|
||||
self._hits += 1
|
||||
try:
|
||||
return json.loads(row[0])
|
||||
except json.JSONDecodeError:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
def put(self, name: str, set_code: str, collector_number: str, card_data: dict):
|
||||
"""Store a Scryfall response in the cache."""
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
blob = json.dumps(card_data, separators=(",", ":"))
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob, fetched_at) "
|
||||
"VALUES (?, ?, datetime('now'))",
|
||||
(key, blob),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Return cache statistics."""
|
||||
with self._lock:
|
||||
total = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
oldest_row = self._conn.execute(
|
||||
"SELECT MIN(fetched_at) FROM scryfall_cache"
|
||||
).fetchone()
|
||||
newest_row = self._conn.execute(
|
||||
"SELECT MAX(fetched_at) FROM scryfall_cache"
|
||||
).fetchone()
|
||||
db_size_bytes = os.path.getsize(str(self._db_path)) if self._db_path.exists() else 0
|
||||
return {
|
||||
"total_entries": total,
|
||||
"hits": self._hits,
|
||||
"misses": self._misses,
|
||||
"hit_rate": round(self._hits / max(self._hits + self._misses, 1) * 100, 1),
|
||||
"ttl_seconds": self._ttl,
|
||||
"ttl_days": round(self._ttl / 86400, 1),
|
||||
"oldest_entry": oldest_row[0] if oldest_row else None,
|
||||
"newest_entry": newest_row[0] if newest_row else None,
|
||||
"db_size_kb": round(db_size_bytes / 1024, 1),
|
||||
"db_path": str(self._db_path),
|
||||
}
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Delete all cached entries. Returns count of deleted rows."""
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
self._conn.execute("DELETE FROM scryfall_cache")
|
||||
self._conn.commit()
|
||||
self._conn.execute("VACUUM") # must run outside transaction
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
return count
|
||||
|
||||
def evict_expired(self) -> int:
|
||||
"""Remove entries older than TTL. Returns count of evicted rows."""
|
||||
cutoff = datetime.utcnow().isoformat()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"DELETE FROM scryfall_cache WHERE "
|
||||
"(julianday(?) - julianday(fetched_at)) * 86400 > ?",
|
||||
(cutoff, self._ttl),
|
||||
)
|
||||
deleted = self._conn.total_changes
|
||||
self._conn.commit()
|
||||
return deleted
|
||||
|
||||
|
||||
_scryfall_cache = _ScryfallCache()
|
||||
|
||||
|
||||
# Scryfall rate-limit semaphore (max 10 req/sec)
|
||||
_scryfall_lock = threading.Lock()
|
||||
_scryfall_last_call = 0.0
|
||||
|
||||
|
||||
def _scryfall_rate_limit():
|
||||
"""Enforce at most 10 Scryfall requests per second via simple sleep."""
|
||||
global _scryfall_last_call
|
||||
with _scryfall_lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - _scryfall_last_call
|
||||
if elapsed < 0.1:
|
||||
time.sleep(0.1 - elapsed)
|
||||
_scryfall_last_call = time.monotonic()
|
||||
|
||||
|
||||
def _parse_finish(raw: str) -> str:
|
||||
"""Normalize finish string to NORMAL|FOIL|ETCHED."""
|
||||
if not raw:
|
||||
return "NORMAL"
|
||||
r = raw.strip().lower()
|
||||
if r in ("etched", "foil etched", "foil_etched", "etched foil"):
|
||||
return "ETCHED"
|
||||
if r in ("yes", "foil", "true", "1"):
|
||||
return "FOIL"
|
||||
return "NORMAL"
|
||||
|
||||
|
||||
def _parse_text_line(line: str) -> dict:
|
||||
"""
|
||||
Parse a text import line into {name, quantity, set_code, collector_number}.
|
||||
Supports formats:
|
||||
- "3 Sol Ring (CMR) 123"
|
||||
- "1x Sol Ring (CMR)"
|
||||
- "Sol Ring [CMR:123]"
|
||||
- "1 Sol Ring"
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("//"):
|
||||
return {}
|
||||
|
||||
result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""}
|
||||
|
||||
# Strip quantity prefix
|
||||
qty_match = re.match(r"^(\d+)x?\s+", line)
|
||||
if qty_match:
|
||||
result["quantity"] = int(qty_match.group(1))
|
||||
line = line[qty_match.end():]
|
||||
|
||||
# Format: "Name [SET:CollNum]"
|
||||
bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line)
|
||||
if bracket_match:
|
||||
result["set_code"] = bracket_match.group(1).lower()
|
||||
result["collector_number"] = bracket_match.group(2)
|
||||
line = line[:bracket_match.start()].strip()
|
||||
result["name"] = line
|
||||
return result
|
||||
|
||||
# Format: "Name (SET) CollNum"
|
||||
paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line)
|
||||
if paren_coll_match:
|
||||
result["set_code"] = paren_coll_match.group(1).lower()
|
||||
result["collector_number"] = paren_coll_match.group(2)
|
||||
line = line[:paren_coll_match.start()].strip()
|
||||
result["name"] = line
|
||||
return result
|
||||
|
||||
# Format: "Name (SET)"
|
||||
paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line)
|
||||
if paren_match:
|
||||
result["set_code"] = paren_match.group(1).lower()
|
||||
line = line[:paren_match.start()].strip()
|
||||
result["name"] = line
|
||||
return result
|
||||
|
||||
result["name"] = line.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _auto_infer_mapping(headers: list) -> dict:
|
||||
"""
|
||||
Auto-infer column → field mapping from header names.
|
||||
Returns dict of {header: field_key}.
|
||||
Field keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
|
||||
"""
|
||||
mapping = {}
|
||||
header_lower = {h: h.lower().strip() for h in headers}
|
||||
field_patterns = {
|
||||
"name": ["name", "card name", "card_name", "cardname", "title"],
|
||||
"quantity": ["quantity", "qty", "count", "amount", "number", "#"],
|
||||
"set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"],
|
||||
"collector_number": [
|
||||
"collector number", "collector_number", "collectornumber",
|
||||
"col #", "col#", "number", "card number",
|
||||
],
|
||||
"finish": ["finish", "foil", "printing", "treatment"],
|
||||
"condition": ["condition", "cond", "grade"],
|
||||
"language": ["language", "lang", "locale"],
|
||||
"notes": ["notes", "note", "comments", "comment"],
|
||||
"tags": ["tags", "tag", "labels", "label"],
|
||||
}
|
||||
for header, lower_h in header_lower.items():
|
||||
for field, patterns in field_patterns.items():
|
||||
if lower_h in patterns:
|
||||
mapping[header] = field
|
||||
break
|
||||
return mapping
|
||||
|
||||
|
||||
def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list:
|
||||
"""
|
||||
Parse CSV/text content into a list of RawImportRow dicts.
|
||||
Returns list of dicts with keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
|
||||
"""
|
||||
rows = []
|
||||
source_upper = (source or "").upper()
|
||||
|
||||
if source_upper == "TEXT":
|
||||
for line in content.splitlines():
|
||||
parsed = _parse_text_line(line)
|
||||
if parsed.get("name"):
|
||||
rows.append(parsed)
|
||||
return rows
|
||||
|
||||
# CSV-based sources
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
headers = reader.fieldnames or []
|
||||
|
||||
# Build effective mapping
|
||||
if mapping:
|
||||
col_map = {h: mapping.get(h, "") for h in headers}
|
||||
else:
|
||||
col_map = _auto_infer_mapping(headers)
|
||||
|
||||
# Normalize col_map field values to lowercase so both "NAME" (UI) and "name" (auto-infer) work
|
||||
col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()}
|
||||
|
||||
for csv_row in reader:
|
||||
row = {
|
||||
"name": "",
|
||||
"quantity": 1,
|
||||
"set_code": "",
|
||||
"collector_number": "",
|
||||
"finish": "NORMAL",
|
||||
"condition": "",
|
||||
"language": "",
|
||||
"notes": "",
|
||||
"tags": "",
|
||||
}
|
||||
for header, field in col_map.items():
|
||||
val = csv_row.get(header, "").strip()
|
||||
if not val or field == "ignore" or not field:
|
||||
continue
|
||||
if field == "name":
|
||||
# Strip finish suffix like "(Foil Etched)"
|
||||
val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip()
|
||||
row["name"] = val
|
||||
elif field == "quantity":
|
||||
try:
|
||||
row["quantity"] = int(float(val))
|
||||
except ValueError:
|
||||
row["quantity"] = 1
|
||||
elif field in ("set_code", "set_code_secondary"):
|
||||
row["set_code"] = val.lower()
|
||||
elif field in ("collector_number", "collector_number_secondary"):
|
||||
row["collector_number"] = val
|
||||
elif field == "finish":
|
||||
row["finish"] = _parse_finish(val)
|
||||
elif field == "condition":
|
||||
row["condition"] = val
|
||||
elif field == "language":
|
||||
row["language"] = val
|
||||
elif field == "notes":
|
||||
row["notes"] = val
|
||||
elif field == "tags":
|
||||
row["tags"] = val
|
||||
|
||||
if row["name"]:
|
||||
rows.append(row)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
"""
|
||||
Fetch raw card data from Scryfall API (with caching).
|
||||
Returns the raw Scryfall JSON dict, or a dict with '_error' on failure.
|
||||
Cache is checked first; live HTTP call only on miss.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
# ── Cache check ──────────────────────────────────────
|
||||
cached = _scryfall_cache.get(name, set_code, collector_number)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# ── Live fetch ───────────────────────────────────────
|
||||
card_data = None
|
||||
last_error = ""
|
||||
|
||||
# Try set + collector number first
|
||||
if set_code and collector_number:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}"
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
card_data = None
|
||||
|
||||
# Fall back to exact name lookup
|
||||
if not card_data:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
encoded_name = quote(name)
|
||||
url = f"https://api.scryfall.com/cards/named?exact={encoded_name}"
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
return {"_error": f"Scryfall lookup failed for '{name}': {last_error}"}
|
||||
|
||||
if not card_data or card_data.get("object") == "error":
|
||||
err_detail = card_data.get("details", "unknown error") if card_data else last_error
|
||||
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
|
||||
|
||||
# ── Cache the successful response ────────────────────
|
||||
_scryfall_cache.put(name, set_code, collector_number, card_data)
|
||||
# Also cache under the Scryfall-resolved name (handles spelling normalisation)
|
||||
resolved_name = card_data.get("name", "")
|
||||
if resolved_name and resolved_name.lower() != name.strip().lower():
|
||||
_scryfall_cache.put(resolved_name, set_code, collector_number, card_data)
|
||||
|
||||
return card_data
|
||||
|
||||
|
||||
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
"""
|
||||
Fetch card data from Scryfall (cached) and return enriched fields dict.
|
||||
Tries set+collectorNumber first, then exact name lookup.
|
||||
Returns empty dict on failure.
|
||||
"""
|
||||
card_data = _fetch_scryfall_api(name, set_code, collector_number)
|
||||
|
||||
if not card_data or "_error" in card_data:
|
||||
return card_data or {}
|
||||
|
||||
# Extract fields
|
||||
type_line = card_data.get("type_line", "")
|
||||
color_identity = card_data.get("color_identity", [])
|
||||
keywords = card_data.get("keywords", [])
|
||||
|
||||
# Parse subtypes (after em-dash or hyphen)
|
||||
subtypes = []
|
||||
for sep in [" \u2014 ", " - "]:
|
||||
if sep in type_line:
|
||||
parts = type_line.split(sep, 1)
|
||||
subtypes = [s.strip() for s in parts[1].split() if s.strip()]
|
||||
break
|
||||
|
||||
is_legendary = 1 if "Legendary" in type_line else 0
|
||||
is_basic = 1 if "Basic" in type_line else 0
|
||||
|
||||
# Get price
|
||||
prices = card_data.get("prices", {})
|
||||
tcg_price = 0.0
|
||||
try:
|
||||
price_val = prices.get("usd") or prices.get("usd_foil") or "0"
|
||||
tcg_price = float(price_val) if price_val else 0.0
|
||||
except (ValueError, TypeError):
|
||||
tcg_price = 0.0
|
||||
|
||||
# Handle double-faced cards: oracle_text may be on card_faces
|
||||
oracle_text = card_data.get("oracle_text", "")
|
||||
if not oracle_text and card_data.get("card_faces"):
|
||||
face_texts = [f.get("oracle_text", "") for f in card_data["card_faces"]]
|
||||
oracle_text = "\n//\n".join(t for t in face_texts if t)
|
||||
|
||||
# Power/toughness (may be on card_faces for DFCs)
|
||||
power = card_data.get("power", "")
|
||||
toughness = card_data.get("toughness", "")
|
||||
if not power and card_data.get("card_faces"):
|
||||
power = card_data["card_faces"][0].get("power", "")
|
||||
toughness = card_data["card_faces"][0].get("toughness", "")
|
||||
|
||||
# Mana cost (may be on card_faces for DFCs)
|
||||
mana_cost = card_data.get("mana_cost", "")
|
||||
if not mana_cost and card_data.get("card_faces"):
|
||||
mana_cost = card_data["card_faces"][0].get("mana_cost", "")
|
||||
|
||||
# Auto-detect functional categories
|
||||
auto_roles = _detect_card_roles(oracle_text, type_line, keywords)
|
||||
|
||||
return {
|
||||
"name": card_data.get("name", name),
|
||||
"type_line": type_line,
|
||||
"subtypes": json.dumps(subtypes),
|
||||
"is_legendary": is_legendary,
|
||||
"is_basic": is_basic,
|
||||
"color_identity": json.dumps(color_identity),
|
||||
"cmc": card_data.get("cmc", 0.0),
|
||||
"mana_cost": mana_cost,
|
||||
"oracle_text": oracle_text,
|
||||
"keywords": json.dumps(keywords),
|
||||
"power": power or "",
|
||||
"toughness": toughness or "",
|
||||
"rarity": card_data.get("rarity", ""),
|
||||
"set_name": card_data.get("set_name", ""),
|
||||
"edhrec_rank": card_data.get("edhrec_rank", 0) or 0,
|
||||
"tcg_price": tcg_price,
|
||||
"salt_score": 0.0,
|
||||
"is_game_changer": 0,
|
||||
"category": json.dumps(auto_roles),
|
||||
"scryfall_id": card_data.get("id", ""),
|
||||
"tcgplayer_id": str(card_data.get("tcgplayer_id", "")),
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Import Endpoint
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -956,21 +485,3 @@ async def scryfall_cache_evict_expired():
|
||||
return {"evicted": evicted, "message": f"Evicted {evicted} expired entries"}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Deck Builder Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# Type classification priority order
|
||||
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
|
||||
|
||||
# Target ranges: {type: [min, max]}
|
||||
_TYPE_TARGETS = {
|
||||
"Land": [36, 38],
|
||||
"Instant": [9, 11],
|
||||
"Sorcery": [7, 9],
|
||||
"Artifact": [9, 11],
|
||||
"Creature": [20, 30],
|
||||
"Enchantment": [5, 10],
|
||||
"Planeswalker": [0, 5],
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -1085,7 +1087,6 @@ def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
|
||||
Fuzzy-search Scryfall for a card name (used by the scanner pipeline).
|
||||
Returns the raw Scryfall JSON dict, or None on failure.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
encoded = quote(name)
|
||||
@@ -1098,4 +1099,3 @@ def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
+124
-164
@@ -29,10 +29,11 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen, Request
|
||||
import httpx
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from models.requests import DeckResearchRequest, DeckGenerateAIRequest
|
||||
|
||||
import routes.coach as _coach # for _deck_gen_v3 / _deck_gen_v3_error module-level access
|
||||
from routes.shared import (
|
||||
@@ -43,6 +44,7 @@ from routes.shared import (
|
||||
DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest,
|
||||
GeneratedDeckCard, DeckGenerationSourceConfig,
|
||||
)
|
||||
from services.deck_service import _write_dck_file, _build_dck_lines
|
||||
|
||||
router = APIRouter(tags=["deckgen"])
|
||||
|
||||
@@ -147,37 +149,48 @@ def _scryfall_to_commander(data: dict) -> dict:
|
||||
|
||||
|
||||
def _get_collection_for_colors(color_identity: list) -> list:
|
||||
"""
|
||||
Fetch all collection cards compatible with the given color identity.
|
||||
Returns list of dicts with card details.
|
||||
"""Fetch collection cards whose color identity is a subset of *color_identity*.
|
||||
|
||||
Uses SQLite's json_each() to push the colour-subset check into SQL so we
|
||||
only transfer the rows we actually need. The NOT EXISTS sub-query ensures
|
||||
every colour symbol in the card's identity appears in the commander's
|
||||
identity list. Cards with an empty colour identity (colourless / lands)
|
||||
always pass because the NOT EXISTS has no rows to violate.
|
||||
|
||||
Only the columns consumed downstream are projected (no SELECT *).
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_entries WHERE quantity > 0"
|
||||
).fetchall()
|
||||
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
|
||||
|
||||
rows = conn.execute("""
|
||||
SELECT scryfall_id, name, type_line, mana_cost, cmc,
|
||||
oracle_text, keywords, quantity, edhrec_rank, color_identity
|
||||
FROM collection_entries
|
||||
WHERE quantity > 0
|
||||
AND scryfall_id != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN collection_entries.color_identity IS NULL
|
||||
OR collection_entries.color_identity = ''
|
||||
THEN '[]'
|
||||
ELSE collection_entries.color_identity
|
||||
END
|
||||
) AS jc
|
||||
WHERE jc.value NOT IN (
|
||||
SELECT value FROM json_each(?)
|
||||
)
|
||||
)
|
||||
""", (allowed_json,)).fetchall()
|
||||
|
||||
valid = []
|
||||
for row in rows:
|
||||
card = _row_to_dict(row)
|
||||
card_ci = card.get("color_identity", [])
|
||||
if isinstance(card_ci, str):
|
||||
try:
|
||||
card_ci = json.loads(card_ci)
|
||||
except Exception:
|
||||
card_ci = []
|
||||
|
||||
# Color identity check: all card colors must be within commander's identity
|
||||
if color_identity and card_ci:
|
||||
if not all(c in color_identity for c in card_ci):
|
||||
continue
|
||||
|
||||
# Skip if no scryfall_id
|
||||
if not card.get("scryfall_id"):
|
||||
continue
|
||||
|
||||
card["color_identity"] = card_ci
|
||||
card = dict(row)
|
||||
ci = card.get("color_identity", "[]")
|
||||
try:
|
||||
card["color_identity"] = json.loads(ci) if isinstance(ci, str) else ci
|
||||
except Exception:
|
||||
card["color_identity"] = []
|
||||
valid.append(card)
|
||||
|
||||
return valid
|
||||
|
||||
|
||||
@@ -540,29 +553,8 @@ async def deck_generator_commit(req: DeckGenerationRequest):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Also export a .dck file to the Forge decks directory so it appears in the Sim Lab
|
||||
try:
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if decks_dir and os.path.isdir(decks_dir):
|
||||
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_')
|
||||
if not safe_name:
|
||||
safe_name = f"AutoDeck_{deck_id}"
|
||||
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
|
||||
dck_lines = []
|
||||
dck_lines.append("[metadata]")
|
||||
dck_lines.append(f"Name={deck_name}")
|
||||
dck_lines.append("")
|
||||
dck_lines.append("[Commander]")
|
||||
dck_lines.append(f"1 {commander['name']}")
|
||||
dck_lines.append("")
|
||||
dck_lines.append("[Main]")
|
||||
for card in cards:
|
||||
card_name = card.get("name", "")
|
||||
if card_name:
|
||||
dck_lines.append(f"{card.get('quantity', 1)} {card_name}")
|
||||
with open(dck_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(dck_lines))
|
||||
log_deckgen.info(f" Exported .dck file: {dck_path}")
|
||||
_write_dck_file(deck_name, commander['name'], cards, fallback_id=deck_id)
|
||||
except Exception as e:
|
||||
log_deckgen.warning(f" Warning: Failed to export .dck file: {e}")
|
||||
|
||||
@@ -655,56 +647,44 @@ async def deck_generator_commander_search(q: str = ""):
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class DeckResearchRequest(BaseModel):
|
||||
deck_id: int # Deck to research
|
||||
goal: Optional[str] = "Identify weaknesses and suggest upgrades"
|
||||
budget_usd: Optional[float] = None
|
||||
omit_cards: Optional[list[str]] = []
|
||||
use_collection: bool = True
|
||||
|
||||
|
||||
class DeckGenerateAIRequest(BaseModel):
|
||||
commander: str
|
||||
budget_usd: Optional[float] = None
|
||||
budget_mode: str = "total" # "total" or "per_card"
|
||||
omit_cards: Optional[list[str]] = []
|
||||
use_collection: bool = True
|
||||
|
||||
|
||||
def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
|
||||
"""Build a compact collection summary for the AI, optionally filtered by color identity."""
|
||||
"""Build a compact collection summary for the AI, optionally filtered by color identity.
|
||||
|
||||
Uses the same json_each NOT EXISTS pattern as _get_collection_for_colors()
|
||||
so that colour-identity filtering happens in SQL rather than Python.
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
|
||||
# Base query: all collection entries with oracle text
|
||||
where = "WHERE quantity > 0"
|
||||
params = []
|
||||
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
|
||||
|
||||
# Filter by color identity if provided
|
||||
if color_identity:
|
||||
# Cards must only contain colors in the commander's identity (+ colorless)
|
||||
ci_set = set(c.upper() for c in color_identity)
|
||||
# We'll do a post-filter since color_identity is JSON in the DB
|
||||
pass # filter in Python after fetch
|
||||
# SQL handles the colour-identity subset check via json_each
|
||||
ci_filter = """
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN collection_entries.color_identity IS NULL
|
||||
OR collection_entries.color_identity = ''
|
||||
THEN '[]'
|
||||
ELSE collection_entries.color_identity
|
||||
END
|
||||
) AS jc
|
||||
WHERE jc.value NOT IN (
|
||||
SELECT value FROM json_each(?)
|
||||
)
|
||||
)
|
||||
""" if color_identity else ""
|
||||
|
||||
params = (allowed_json,) if color_identity else ()
|
||||
|
||||
rows = conn.execute(f"""
|
||||
SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity,
|
||||
color_identity, category, is_game_changer, salt_score
|
||||
FROM collection_entries {where}
|
||||
FROM collection_entries
|
||||
WHERE quantity > 0
|
||||
{ci_filter}
|
||||
ORDER BY edhrec_rank ASC, tcg_price DESC
|
||||
""", params).fetchall()
|
||||
|
||||
# Color identity filter
|
||||
def card_fits_identity(card_ci_str, ci_set):
|
||||
if not ci_set:
|
||||
return True
|
||||
try:
|
||||
card_ci = json.loads(card_ci_str) if card_ci_str else []
|
||||
if not isinstance(card_ci, list):
|
||||
return True
|
||||
return all(c.upper() in ci_set for c in card_ci if c.upper() not in ('', 'C'))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
# Role detection from type_line, oracle_text, and category
|
||||
def detect_role(row):
|
||||
tl = (row['type_line'] or '').lower()
|
||||
@@ -746,12 +726,9 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
|
||||
'ramp': [], 'card_draw': [], 'removal': [], 'board_wipes': [],
|
||||
'lands': [], 'win_conditions': [], 'creatures': [], 'utility': []
|
||||
}
|
||||
filtered_count = 0
|
||||
filtered_count = len(rows) # SQL already filtered
|
||||
|
||||
for r in rows:
|
||||
if color_identity and not card_fits_identity(r['color_identity'], ci_set):
|
||||
continue
|
||||
filtered_count += 1
|
||||
role = detect_role(r)
|
||||
groups[role].append({
|
||||
'name': r['name'],
|
||||
@@ -789,40 +766,57 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
||||
"""Call Perplexity API chat/completions endpoint. Returns the assistant message content."""
|
||||
if not CFG.pplx_api_key:
|
||||
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
|
||||
async def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
||||
"""Call Perplexity API chat/completions endpoint (non-blocking).
|
||||
Uses httpx.AsyncClient so the uvicorn event loop is never stalled.
|
||||
Returns the assistant message content.
|
||||
"""
|
||||
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL
|
||||
|
||||
if DECK_GEN_PROVIDER == 'local':
|
||||
base_url = DECK_GEN_BASE_URL
|
||||
model = DECK_GEN_MODEL
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
else:
|
||||
if not CFG.pplx_api_key:
|
||||
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
|
||||
base_url = 'https://api.perplexity.ai'
|
||||
model = 'sonar'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {CFG.pplx_api_key}',
|
||||
}
|
||||
payload = {
|
||||
'model': 'sonar',
|
||||
'model': model,
|
||||
'messages': messages,
|
||||
'max_tokens': max_tokens,
|
||||
'temperature': temperature,
|
||||
'return_related_questions': False,
|
||||
}
|
||||
|
||||
req_data = json.dumps(payload).encode('utf-8')
|
||||
req = Request('https://api.perplexity.ai/chat/completions', data=req_data, method='POST')
|
||||
req.add_header('Content-Type', 'application/json')
|
||||
req.add_header('Authorization', f'Bearer {CFG.pplx_api_key}')
|
||||
|
||||
try:
|
||||
with urlopen(req, timeout=120) as resp:
|
||||
data = json.loads(resp.read())
|
||||
choices = data.get('choices', [])
|
||||
if not choices:
|
||||
raise ValueError('Empty response from Perplexity API')
|
||||
content = choices[0].get('message', {}).get('content', '')
|
||||
usage = data.get('usage', {})
|
||||
log_pplx.debug(f'tokens: prompt={usage.get("prompt_tokens","?")}, '
|
||||
f'completion={usage.get("completion_tokens","?")}, '
|
||||
f'model={data.get("model","?")}')
|
||||
return content
|
||||
except URLError as e:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
f'{base_url}/chat/completions',
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(502, f'Perplexity API returned {e.response.status_code}: {e.response.text[:200]}')
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(502, f'Perplexity API call failed: {e}')
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(502, f'Perplexity API returned invalid JSON: {e}')
|
||||
|
||||
choices = data.get('choices', [])
|
||||
if not choices:
|
||||
raise HTTPException(502, 'Empty response from Perplexity API')
|
||||
content = choices[0].get('message', {}).get('content', '')
|
||||
usage = data.get('usage', {})
|
||||
log_pplx.debug(
|
||||
f'tokens: prompt={usage.get("prompt_tokens", "?")}, '
|
||||
f'completion={usage.get("completion_tokens", "?")}, '
|
||||
f'model={data.get("model", "?")}'
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
def _extract_json_from_response(text: str) -> dict:
|
||||
@@ -1082,7 +1076,7 @@ DECKLIST ({len(card_rows)} cards):
|
||||
|
||||
Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4 per Commander Rules Committee), ALL synergy packages between cards, ALL win conditions, game plan by phase (early/mid/late), mana base health, every role gap. Suggest specific cuts with severity and specific adds with priority and synergy tags. For adds, prioritize cards from the COLLECTION SUMMARY when available."""
|
||||
|
||||
content = _call_pplx_api([
|
||||
content = await _call_pplx_api([
|
||||
{'role': 'system', 'content': system_msg},
|
||||
{'role': 'user', 'content': user_msg},
|
||||
], max_tokens=8192)
|
||||
@@ -1192,7 +1186,7 @@ COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'}
|
||||
|
||||
Build the deck as JSON. Prioritize collection cards when they fit the strategy."""
|
||||
|
||||
content = _call_pplx_api([
|
||||
content = await _call_pplx_api([
|
||||
{'role': 'system', 'content': system_msg},
|
||||
{'role': 'user', 'content': user_msg},
|
||||
], max_tokens=8192, temperature=0.3)
|
||||
@@ -1224,7 +1218,6 @@ async def pplx_status():
|
||||
"""Check if Perplexity API is configured."""
|
||||
return {
|
||||
'configured': bool(CFG.pplx_api_key),
|
||||
'key_prefix': CFG.pplx_api_key[:8] + '...' if CFG.pplx_api_key else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -1238,13 +1231,9 @@ async def deck_gen_v3_status():
|
||||
return {
|
||||
'initialized': _coach._deck_gen_v3 is not None,
|
||||
'pplx_configured': bool(CFG.pplx_api_key),
|
||||
'model': _coach._deck_gen_v3.pplx.model if _coach._deck_gen_v3 else None,
|
||||
'embeddings_loaded': (
|
||||
_coach_embeddings.loaded if _coach_embeddings else False
|
||||
),
|
||||
'embedding_cards': (
|
||||
_coach_embeddings.card_count if _coach_embeddings else 0
|
||||
),
|
||||
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'pplx', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
|
||||
'embeddings_loaded': False,
|
||||
'embedding_cards': 0,
|
||||
'error': _coach._deck_gen_v3_error,
|
||||
}
|
||||
|
||||
@@ -1263,7 +1252,7 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
|
||||
6. Return complete deck with substitution data
|
||||
"""
|
||||
if _coach._deck_gen_v3 is None:
|
||||
raise HTTPException(503, 'V3 Deck Generator not initialized. Check PPLX_API_KEY.')
|
||||
raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure Ollama is running with gpt-oss:20b model.')
|
||||
|
||||
if not req.commander_name or len(req.commander_name.strip()) < 2:
|
||||
raise HTTPException(400, 'Commander name is required (min 2 chars)')
|
||||
@@ -1305,9 +1294,7 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
|
||||
except ValueError as e:
|
||||
return JSONResponse({'error': str(e)}, status_code=422)
|
||||
except Exception as e:
|
||||
log_deckgen.error(f'Error: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
log_deckgen.error(f'V3 generate error: {e}', exc_info=True)
|
||||
raise HTTPException(500, f'Deck generation failed: {str(e)}')
|
||||
|
||||
|
||||
@@ -1394,25 +1381,8 @@ async def deck_gen_v3_commit(req: DeckGenV3Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Export .dck for Forge
|
||||
try:
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if decks_dir and os.path.isdir(decks_dir):
|
||||
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_')
|
||||
if not safe_name:
|
||||
safe_name = f"V3Deck_{deck_id}"
|
||||
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
|
||||
dck_lines = ["[metadata]", f"Name={deck_name}", "",
|
||||
"[Commander]", f"1 {commander['name']}", "", "[Main]"]
|
||||
for card in cards:
|
||||
cname = card.get('name', '')
|
||||
if card.get('status') == 'substituted' and card.get('selected_substitute'):
|
||||
cname = card['selected_substitute']
|
||||
if cname and cname != commander.get('name', ''):
|
||||
dck_lines.append(f"{card.get('count', 1)} {cname}")
|
||||
with open(dck_path, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(dck_lines))
|
||||
log_deckgen.info(f" Exported .dck: {dck_path}")
|
||||
_write_dck_file(deck_name, commander['name'], cards, fallback_id=deck_id, resolve_substitutes=True)
|
||||
except Exception as e:
|
||||
log_deckgen.error(f" .dck export failed: {e}")
|
||||
|
||||
@@ -1497,22 +1467,12 @@ async def deck_gen_v3_export_dck(req: DeckGenV3Request):
|
||||
cards = result.get('cards', [])
|
||||
commander = result.get('commander', {})
|
||||
|
||||
lines = [
|
||||
'[metadata]',
|
||||
f'Name={commander.get("name", "Deck")} - V3 Auto',
|
||||
'',
|
||||
'[Commander]',
|
||||
f'1 {commander.get("name", "")}',
|
||||
'',
|
||||
'[Main]',
|
||||
]
|
||||
for card in cards:
|
||||
cname = card.get('name', '')
|
||||
if card.get('status') == 'substituted' and card.get('selected_substitute'):
|
||||
cname = card['selected_substitute']
|
||||
if cname and cname != commander.get('name', ''):
|
||||
lines.append(f"{card.get('count', 1)} {cname}")
|
||||
|
||||
lines = _build_dck_lines(
|
||||
f"{commander.get('name', 'Deck')} - V3 Auto",
|
||||
commander.get('name', ''),
|
||||
cards,
|
||||
resolve_substitutes=True,
|
||||
)
|
||||
dck_content = '\n'.join(lines)
|
||||
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_')
|
||||
return StreamingResponse(
|
||||
|
||||
+106
-5
@@ -15,6 +15,7 @@ Python simulator & DeepSeek AI opponent endpoints:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
@@ -33,6 +34,8 @@ from routes.shared import (
|
||||
_get_deepseek_brain,
|
||||
_ml_logging_enabled,
|
||||
)
|
||||
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["deepseek"])
|
||||
|
||||
@@ -40,6 +43,26 @@ router = APIRouter(tags=["deepseek"])
|
||||
_sim_runs = {} # sim_id -> { status, result, error }
|
||||
_sim_lock = _threading.Lock()
|
||||
|
||||
# ── Validator singleton ──
|
||||
_validator_brain: ValidatorBrain | None = None
|
||||
_validator_lock = _threading.Lock()
|
||||
|
||||
|
||||
def _get_validator_brain() -> ValidatorBrain | None:
|
||||
global _validator_brain
|
||||
if os.environ.get("VALIDATOR_ENABLED", "false").lower() != "true":
|
||||
return None
|
||||
with _validator_lock:
|
||||
if _validator_brain is None:
|
||||
cfg = ValidatorConfig(
|
||||
api_base=os.environ.get("VALIDATOR_API_BASE", "http://localhost:11434"),
|
||||
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:14b"),
|
||||
max_tokens=int(os.environ.get("VALIDATOR_MAX_TOKENS", "2048")),
|
||||
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "60.0")),
|
||||
)
|
||||
_validator_brain = ValidatorBrain(cfg)
|
||||
return _validator_brain
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Shared Helpers (extracted to deduplicate sim threads — #42)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -113,6 +136,11 @@ def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
|
||||
name_b=opponent_name, **kw)
|
||||
game_data = result.to_dict()
|
||||
game_data['gameNumber'] = i + 1
|
||||
# Phase 8: Surface player_explanation from validation in turn log
|
||||
for turn_entry in game_data.get('log', []):
|
||||
v = turn_entry.get('validation')
|
||||
if isinstance(v, dict) and v.get('player_explanation'):
|
||||
turn_entry['player_explanation'] = v['player_explanation']
|
||||
game_results.append(game_data)
|
||||
|
||||
if result.winner == 0:
|
||||
@@ -182,7 +210,52 @@ def _finish_sim(sim_id, summary, game_results):
|
||||
_sim_runs[sim_id]['status'] = 'complete'
|
||||
_sim_runs[sim_id]['result'] = {'summary': summary, 'games': game_results}
|
||||
|
||||
# --- Persist batch-*.json for coach / training dashboard ---
|
||||
try:
|
||||
deck_name = summary.get('deckName', 'Unknown')
|
||||
opponent_name = summary.get('opponentName', 'Training Deck')
|
||||
batch_data = {
|
||||
'format': 'python-sim',
|
||||
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'simId': sim_id,
|
||||
'decks': [
|
||||
{
|
||||
'deckName': deck_name,
|
||||
'seatIndex': 0,
|
||||
'commanderName': summary.get('commanderName', ''),
|
||||
'colorIdentity': summary.get('colorIdentity', []),
|
||||
'games': game_results,
|
||||
},
|
||||
{
|
||||
'deckName': opponent_name,
|
||||
'seatIndex': 1,
|
||||
'commanderName': '',
|
||||
'colorIdentity': [],
|
||||
},
|
||||
],
|
||||
'summary': summary,
|
||||
}
|
||||
results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
|
||||
os.makedirs(results_dir, exist_ok=True)
|
||||
batch_path = os.path.join(results_dir, f'batch-sim-{sim_id}.json')
|
||||
with open(batch_path, 'w') as f:
|
||||
json.dump(batch_data, f, indent=2, default=str)
|
||||
logger.info(f'Saved batch results to {batch_path}')
|
||||
|
||||
# Auto-generate deck report for training dashboard / coach
|
||||
try:
|
||||
from coach.report_generator import generate_deck_reports
|
||||
reports_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'deck-reports')
|
||||
os.makedirs(reports_dir, exist_ok=True)
|
||||
generate_deck_reports(str(results_dir), str(reports_dir))
|
||||
logger.info(f'Generated deck reports after sim {sim_id}')
|
||||
except ImportError:
|
||||
logger.warning('reports module not available; skipping deck report generation')
|
||||
except Exception as e:
|
||||
logger.warning(f'Could not generate deck report: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to persist sim results: {e}')
|
||||
|
||||
def _fail_sim(sim_id, error):
|
||||
"""Mark a sim run as errored."""
|
||||
with _sim_lock:
|
||||
@@ -206,7 +279,7 @@ def _run_sim_thread(sim_id: str, decklist: list, num_games: int,
|
||||
|
||||
deck_a = build_deck(decklist)
|
||||
deck_b = _generate_training_deck()
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs)
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
|
||||
|
||||
game_results, stats, elapsed = _run_game_loop(
|
||||
engine, deck_a, deck_b, deck_name, 'Training Deck',
|
||||
@@ -233,7 +306,7 @@ def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int,
|
||||
|
||||
deck_a = _build_deck_from_card_data(card_data)
|
||||
deck_b = _generate_training_deck()
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs)
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
|
||||
|
||||
game_results, stats, elapsed = _run_game_loop(
|
||||
engine, deck_a, deck_b, deck_name, 'Training Deck',
|
||||
@@ -266,9 +339,11 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
|
||||
if brain and not brain._connected:
|
||||
brain.check_connection()
|
||||
|
||||
validator = _get_validator_brain() # None if disabled
|
||||
engine = DeepSeekGameEngine(
|
||||
brain=brain, ai_player_index=1,
|
||||
max_turns=25, record_log=record_logs)
|
||||
max_turns=25, record_log=record_logs, ml_log=True,
|
||||
validator=validator)
|
||||
|
||||
game_results, stats, elapsed = _run_game_loop(
|
||||
engine, deck_a, deck_b, deck_name, 'DeepSeek AI',
|
||||
@@ -321,9 +396,9 @@ def _run_sim_thread_n_player(sim_id: str, decks_data: list[list[dict]],
|
||||
brain.check_connection()
|
||||
engine = DeepSeekGameEngine(
|
||||
brain=brain, ai_player_index=1,
|
||||
max_turns=25, record_log=record_logs)
|
||||
max_turns=25, record_log=record_logs, ml_log=True)
|
||||
else:
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs)
|
||||
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
|
||||
|
||||
# Per-seat accumulators
|
||||
seat_wins = [0] * n_players
|
||||
@@ -697,3 +772,29 @@ async def deepseek_logs():
|
||||
'flushedPath': flushed_path,
|
||||
'logFiles': log_files[-20:],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/validator/status")
|
||||
async def validator_status():
|
||||
"""Get DeepSeek-R1-14B validator brain status."""
|
||||
v = _get_validator_brain()
|
||||
if v is None:
|
||||
return JSONResponse({"enabled": False,
|
||||
"reason": "VALIDATOR_ENABLED is not true"})
|
||||
return JSONResponse({"enabled": True, **v.get_stats()})
|
||||
|
||||
|
||||
@router.post("/api/validator/configure")
|
||||
async def validator_configure(request: FastAPIRequest):
|
||||
"""Update validator configuration at runtime."""
|
||||
body = await request.json()
|
||||
v = _get_validator_brain()
|
||||
if v is None:
|
||||
return JSONResponse({"error": "Validator not enabled"}, status_code=400)
|
||||
if "model" in body:
|
||||
v.config.model = body["model"]
|
||||
if "timeout" in body:
|
||||
v.config.request_timeout = float(body["timeout"])
|
||||
if "enabled" in body:
|
||||
v.config.enabled = bool(body["enabled"])
|
||||
return JSONResponse(v.get_stats())
|
||||
+21
-5
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
@@ -38,8 +39,6 @@ from routes.shared import (
|
||||
StartResponse,
|
||||
StatusResponse,
|
||||
AI_PROFILES,
|
||||
PRECON_DIR,
|
||||
PRECON_INDEX,
|
||||
build_java_command,
|
||||
run_batch_subprocess,
|
||||
_run_deepseek_batch_thread,
|
||||
@@ -54,6 +53,19 @@ from routes.shared import (
|
||||
router = APIRouter(tags=["lab"])
|
||||
|
||||
|
||||
def _sanitize_floats(obj):
|
||||
"""Replace NaN/Infinity with None so JSONResponse (allow_nan=False) won't crash."""
|
||||
if isinstance(obj, float):
|
||||
if math.isnan(obj) or math.isinf(obj):
|
||||
return None
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
return {k: _sanitize_floats(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_sanitize_floats(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
@router.post("/api/lab/start", response_model=StartResponse)
|
||||
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||
"""Start a new batch simulation run."""
|
||||
@@ -164,8 +176,12 @@ async def get_result(batchId: Optional[str] = None):
|
||||
raise HTTPException(500, state.error)
|
||||
if not state.result_path or not os.path.exists(state.result_path):
|
||||
raise HTTPException(500, "Result file not found")
|
||||
with open(state.result_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
try:
|
||||
with open(state.result_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
raise HTTPException(500, f"Failed to read result file: {e}")
|
||||
data = _sanitize_floats(data)
|
||||
return JSONResponse(content=data)
|
||||
|
||||
|
||||
@@ -441,5 +457,5 @@ async def start_batch_deepseek(request: FastAPIRequest, background_tasks: Backgr
|
||||
|
||||
|
||||
# Global ML logging toggle (can be enabled via API)
|
||||
_ml_logging_enabled = False
|
||||
_ml_logging_enabled = True
|
||||
|
||||
+216
-113
@@ -22,6 +22,7 @@ PPO training, and tournament endpoints.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -37,42 +38,60 @@ log_ml = logging.getLogger("commander_ai_lab.ml")
|
||||
|
||||
router = APIRouter(tags=["ml"])
|
||||
|
||||
# ---- module-level state (moved from lab_api.py) ----
|
||||
|
||||
# ── module-level singletons ──────────────────────────────────────────────────
|
||||
_policy_service = None
|
||||
_policy_service_init_attempted = False
|
||||
|
||||
_training_state = {
|
||||
"running": False,
|
||||
"progress": 0,
|
||||
"total_epochs": 0,
|
||||
"current_epoch": 0,
|
||||
"phase": "idle",
|
||||
"message": "",
|
||||
"metrics": None,
|
||||
"result": None,
|
||||
"error": None,
|
||||
"started_at": None,
|
||||
}
|
||||
# ── Thread-safe state objects ────────────────────────────────────────────────
|
||||
@dataclasses.dataclass
|
||||
class _TrainingState:
|
||||
running: bool = False
|
||||
progress: int = 0
|
||||
total_epochs: int = 0
|
||||
current_epoch: int = 0
|
||||
phase: str = "idle"
|
||||
message: str = ""
|
||||
metrics: object = None
|
||||
result: object = None
|
||||
error: object = None
|
||||
started_at: object = None
|
||||
|
||||
_ppo_state = {
|
||||
"running": False,
|
||||
"iteration": 0,
|
||||
"total_iterations": 0,
|
||||
"phase": "idle",
|
||||
"message": "",
|
||||
"metrics": None,
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
def snapshot(self) -> dict:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
_tournament_state = {
|
||||
"running": False,
|
||||
"phase": "idle",
|
||||
"message": "",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
@dataclasses.dataclass
|
||||
class _PPOState:
|
||||
running: bool = False
|
||||
iteration: int = 0
|
||||
total_iterations: int = 0
|
||||
phase: str = "idle"
|
||||
message: str = ""
|
||||
metrics: object = None
|
||||
result: object = None
|
||||
error: object = None
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _TournamentState:
|
||||
running: bool = False
|
||||
phase: str = "idle"
|
||||
message: str = ""
|
||||
result: object = None
|
||||
error: object = None
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
_training_state = _TrainingState()
|
||||
_training_lock = threading.Lock()
|
||||
|
||||
_ppo_state = _PPOState()
|
||||
_ppo_lock = threading.Lock()
|
||||
|
||||
_tournament_state = _TournamentState()
|
||||
_tournament_lock = threading.Lock()
|
||||
|
||||
# module-level toggle (local copy; the shared one is for batch runs)
|
||||
_ml_logging_enabled_local = _ml_logging_enabled
|
||||
@@ -252,16 +271,17 @@ def _run_training_pipeline(
|
||||
batch_size: int, patience: int, rebuild_dataset: bool,
|
||||
):
|
||||
"""Run the full ML training pipeline in a background thread."""
|
||||
global _training_state, _policy_service, _policy_service_init_attempted
|
||||
global _policy_service, _policy_service_init_attempted
|
||||
try:
|
||||
import sys
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
_training_state["running"] = True
|
||||
_training_state["started_at"] = datetime.now().isoformat()
|
||||
_training_state["error"] = None
|
||||
_training_state["result"] = None
|
||||
with _training_lock:
|
||||
_training_state.running = True
|
||||
_training_state.started_at = datetime.now().isoformat()
|
||||
_training_state.error = None
|
||||
_training_state.result = None
|
||||
data_dir = os.path.join(project_root, "ml", "models")
|
||||
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
@@ -269,8 +289,9 @@ def _run_training_pipeline(
|
||||
# Phase 1: Build Dataset
|
||||
train_path = os.path.join(data_dir, "train.npz")
|
||||
if rebuild_dataset or not os.path.exists(train_path):
|
||||
_training_state["phase"] = "building"
|
||||
_training_state["message"] = "Loading card embeddings & building dataset..."
|
||||
with _training_lock:
|
||||
_training_state.phase = "building"
|
||||
_training_state.message = "Loading card embeddings & building dataset..."
|
||||
log_ml.info("Building dataset (loading embeddings, may auto-download)...")
|
||||
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
|
||||
dataset = build_dataset(results_dir=results_dir)
|
||||
@@ -281,13 +302,15 @@ def _run_training_pipeline(
|
||||
save_dataset(val_ds, os.path.join(data_dir, "val.npz"))
|
||||
save_dataset(test_ds, os.path.join(data_dir, "test.npz"))
|
||||
total_samples = len(dataset["states"])
|
||||
_training_state["message"] = f"Dataset built: {total_samples} samples"
|
||||
with _training_lock:
|
||||
_training_state.message = f"Dataset built: {total_samples} samples"
|
||||
log_ml.info(f"Dataset built: {total_samples} samples")
|
||||
# Phase 2: Train
|
||||
_training_state["phase"] = "training"
|
||||
_training_state["total_epochs"] = epochs
|
||||
_training_state["current_epoch"] = 0
|
||||
_training_state["message"] = f"Training policy network ({epochs} epochs)..."
|
||||
with _training_lock:
|
||||
_training_state.phase = "training"
|
||||
_training_state.total_epochs = epochs
|
||||
_training_state.current_epoch = 0
|
||||
_training_state.message = f"Training policy network ({epochs} epochs)..."
|
||||
log_ml.info(f"Starting training: {epochs} epochs, lr={lr}, bs={batch_size}")
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -312,11 +335,13 @@ def _run_training_pipeline(
|
||||
checkpoint_dir=ckpt_dir,
|
||||
)
|
||||
summary = trainer.train(train_states, train_labels, val_states, val_labels)
|
||||
_training_state["current_epoch"] = summary.get("epochs_trained", epochs)
|
||||
_training_state["metrics"] = summary
|
||||
with _training_lock:
|
||||
_training_state.current_epoch = summary.get("epochs_trained", epochs)
|
||||
_training_state.metrics = summary
|
||||
# Phase 3: Evaluate
|
||||
_training_state["phase"] = "evaluating"
|
||||
_training_state["message"] = "Evaluating on test set..."
|
||||
with _training_lock:
|
||||
_training_state.phase = "evaluating"
|
||||
_training_state.message = "Evaluating on test set..."
|
||||
test_path = os.path.join(data_dir, "test.npz")
|
||||
eval_results = None
|
||||
if os.path.exists(test_path):
|
||||
@@ -328,15 +353,16 @@ def _run_training_pipeline(
|
||||
with open(eval_path, "w") as f:
|
||||
json.dump(eval_results, f, indent=2)
|
||||
# Done
|
||||
_training_state["phase"] = "done"
|
||||
_training_state["running"] = False
|
||||
_training_state["result"] = {
|
||||
"training": summary,
|
||||
"evaluation": eval_results,
|
||||
"checkpoint": summary.get("checkpoint_path", ""),
|
||||
"device": device,
|
||||
}
|
||||
_training_state["message"] = f"Training complete! Best val acc: {summary.get('best_val_accuracy', 0):.1%}"
|
||||
with _training_lock:
|
||||
_training_state.phase = "done"
|
||||
_training_state.running = False
|
||||
_training_state.result = {
|
||||
"training": summary,
|
||||
"evaluation": eval_results,
|
||||
"checkpoint": summary.get("checkpoint_path", ""),
|
||||
"device": device,
|
||||
}
|
||||
_training_state.message = f"Training complete! Best val acc: {summary.get('best_val_accuracy', 0):.1%}"
|
||||
log_ml.info(f"Complete: {summary.get('best_val_accuracy', 0):.1%} val accuracy")
|
||||
# Auto-reload policy server
|
||||
if _policy_service and _policy_service._loaded:
|
||||
@@ -346,10 +372,11 @@ def _run_training_pipeline(
|
||||
_policy_service_init_attempted = False
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_training_state["phase"] = "error"
|
||||
_training_state["running"] = False
|
||||
_training_state["error"] = str(e)
|
||||
_training_state["message"] = f"Training failed: {e}"
|
||||
with _training_lock:
|
||||
_training_state.phase = "error"
|
||||
_training_state.running = False
|
||||
_training_state.error = str(e)
|
||||
_training_state.message = f"Training failed: {e}"
|
||||
log_ml.error(f"ERROR: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -357,7 +384,7 @@ def _run_training_pipeline(
|
||||
@router.post("/api/ml/train")
|
||||
async def ml_start_training(request: FastAPIRequest):
|
||||
"""Trigger ML training pipeline from the web UI."""
|
||||
if _training_state["running"]:
|
||||
if _training_state.running:
|
||||
raise HTTPException(409, "Training already in progress")
|
||||
body = await request.json() if await request.body() else {}
|
||||
epochs = body.get("epochs", 50)
|
||||
@@ -366,12 +393,16 @@ async def ml_start_training(request: FastAPIRequest):
|
||||
patience = body.get("patience", 10)
|
||||
rebuild = body.get("rebuildDataset", True)
|
||||
results_dir = os.path.join(str(Path(__file__).resolve().parent.parent), "results")
|
||||
_training_state.update({
|
||||
"running": True, "progress": 0, "total_epochs": epochs,
|
||||
"current_epoch": 0, "phase": "starting",
|
||||
"message": "Initializing training pipeline...",
|
||||
"metrics": None, "result": None, "error": None,
|
||||
})
|
||||
with _training_lock:
|
||||
_training_state.running = True
|
||||
_training_state.progress = 0
|
||||
_training_state.total_epochs = epochs
|
||||
_training_state.current_epoch = 0
|
||||
_training_state.phase = "starting"
|
||||
_training_state.message = "Initializing training pipeline..."
|
||||
_training_state.metrics = None
|
||||
_training_state.result = None
|
||||
_training_state.error = None
|
||||
thread = threading.Thread(
|
||||
target=_run_training_pipeline,
|
||||
args=(results_dir, epochs, lr, batch_size, patience, rebuild),
|
||||
@@ -387,10 +418,18 @@ async def ml_start_training(request: FastAPIRequest):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/ml/train")
|
||||
async def ml_training_status_poll():
|
||||
"""Return current training status (polled by Unity client)."""
|
||||
with _training_lock:
|
||||
return _training_state.snapshot()
|
||||
|
||||
|
||||
@router.get("/api/ml/train/status")
|
||||
async def ml_training_status():
|
||||
"""Get current training pipeline status."""
|
||||
return dict(_training_state)
|
||||
with _training_lock:
|
||||
return _training_state.snapshot()
|
||||
|
||||
|
||||
@router.get("/api/ml/data/status")
|
||||
@@ -461,15 +500,15 @@ def _run_ppo_pipeline(
|
||||
load_supervised: str,
|
||||
):
|
||||
"""Run PPO training in a background thread."""
|
||||
global _ppo_state
|
||||
try:
|
||||
import sys
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
_ppo_state["running"] = True
|
||||
_ppo_state["error"] = None
|
||||
_ppo_state["result"] = None
|
||||
with _ppo_lock:
|
||||
_ppo_state.running = True
|
||||
_ppo_state.error = None
|
||||
_ppo_state.result = None
|
||||
from ml.training.ppo_trainer import PPOTrainer, PPOConfig
|
||||
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
||||
config = PPOConfig(
|
||||
@@ -482,30 +521,33 @@ def _run_ppo_pipeline(
|
||||
)
|
||||
trainer = PPOTrainer(config)
|
||||
def progress_cb(iteration, metrics):
|
||||
_ppo_state["iteration"] = iteration
|
||||
_ppo_state["phase"] = "training"
|
||||
_ppo_state["message"] = f"Iteration {iteration}/{iterations} | WR: {metrics.get('win_rate', 0):.0%}"
|
||||
_ppo_state["metrics"] = metrics
|
||||
with _ppo_lock:
|
||||
_ppo_state.iteration = iteration
|
||||
_ppo_state.phase = "training"
|
||||
_ppo_state.message = f"Iteration {iteration}/{iterations} | WR: {metrics.get('win_rate', 0):.0%}"
|
||||
_ppo_state.metrics = metrics
|
||||
summary = trainer.train(progress_callback=progress_cb)
|
||||
_ppo_state["phase"] = "done"
|
||||
_ppo_state["running"] = False
|
||||
_ppo_state["result"] = summary
|
||||
_ppo_state["message"] = f"PPO complete! Best win rate: {summary.get('best_win_rate', 0):.0%}"
|
||||
with _ppo_lock:
|
||||
_ppo_state.phase = "done"
|
||||
_ppo_state.running = False
|
||||
_ppo_state.result = summary
|
||||
_ppo_state.message = f"PPO complete! Best win rate: {summary.get('best_win_rate', 0):.0%}"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_ppo_state["phase"] = "error"
|
||||
_ppo_state["running"] = False
|
||||
_ppo_state["error"] = str(e)
|
||||
_ppo_state["message"] = f"PPO failed: {e}"
|
||||
with _ppo_lock:
|
||||
_ppo_state.phase = "error"
|
||||
_ppo_state.running = False
|
||||
_ppo_state.error = str(e)
|
||||
_ppo_state.message = f"PPO failed: {e}"
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@router.post("/api/ml/train/ppo")
|
||||
async def ml_start_ppo(request: FastAPIRequest):
|
||||
"""Start PPO training pipeline."""
|
||||
if _ppo_state["running"]:
|
||||
if _ppo_state.running:
|
||||
raise HTTPException(409, "PPO training already in progress")
|
||||
if _training_state["running"]:
|
||||
if _training_state.running:
|
||||
raise HTTPException(409, "Supervised training in progress -- wait for it to finish")
|
||||
body = await request.json() if await request.body() else {}
|
||||
iterations = body.get("iterations", 100)
|
||||
@@ -518,11 +560,15 @@ async def ml_start_ppo(request: FastAPIRequest):
|
||||
opponent = body.get("opponent", "heuristic")
|
||||
playstyle = body.get("playstyle", "midrange")
|
||||
load_sup = body.get("loadSupervised", "")
|
||||
_ppo_state.update({
|
||||
"running": True, "iteration": 0, "total_iterations": iterations,
|
||||
"phase": "starting", "message": "Initializing PPO...",
|
||||
"metrics": None, "result": None, "error": None,
|
||||
})
|
||||
with _ppo_lock:
|
||||
_ppo_state.running = True
|
||||
_ppo_state.iteration = 0
|
||||
_ppo_state.total_iterations = iterations
|
||||
_ppo_state.phase = "starting"
|
||||
_ppo_state.message = "Initializing PPO..."
|
||||
_ppo_state.metrics = None
|
||||
_ppo_state.result = None
|
||||
_ppo_state.error = None
|
||||
thread = threading.Thread(
|
||||
target=_run_ppo_pipeline,
|
||||
args=(iterations, episodes, ppo_epochs, batch_size, lr,
|
||||
@@ -536,22 +582,23 @@ async def ml_start_ppo(request: FastAPIRequest):
|
||||
@router.get("/api/ml/train/ppo/status")
|
||||
async def ml_ppo_status():
|
||||
"""Get PPO training status."""
|
||||
return dict(_ppo_state)
|
||||
with _ppo_lock:
|
||||
return _ppo_state.snapshot()
|
||||
|
||||
|
||||
def _run_tournament_pipeline(episodes: int, playstyle: str):
|
||||
"""Run tournament in a background thread."""
|
||||
global _tournament_state
|
||||
try:
|
||||
import sys
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
_tournament_state["running"] = True
|
||||
_tournament_state["error"] = None
|
||||
_tournament_state["result"] = None
|
||||
_tournament_state["phase"] = "running"
|
||||
_tournament_state["message"] = "Running tournament..."
|
||||
with _tournament_lock:
|
||||
_tournament_state.running = True
|
||||
_tournament_state.error = None
|
||||
_tournament_state.result = None
|
||||
_tournament_state.phase = "running"
|
||||
_tournament_state.message = "Running tournament..."
|
||||
from ml.eval.tournament import (
|
||||
run_tournament, HeuristicPolicy, RandomPolicy, LearnedPolicy,
|
||||
)
|
||||
@@ -574,32 +621,35 @@ def _run_tournament_pipeline(episodes: int, playstyle: str):
|
||||
output_path = os.path.join(ckpt_dir, "tournament_results.json")
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(result.to_dict(), f, indent=2)
|
||||
_tournament_state["phase"] = "done"
|
||||
_tournament_state["running"] = False
|
||||
_tournament_state["result"] = result.to_dict()
|
||||
_tournament_state["message"] = f"Tournament complete! {result.total_matches} matches"
|
||||
with _tournament_lock:
|
||||
_tournament_state.phase = "done"
|
||||
_tournament_state.running = False
|
||||
_tournament_state.result = result.to_dict()
|
||||
_tournament_state.message = f"Tournament complete! {result.total_matches} matches"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_tournament_state["phase"] = "error"
|
||||
_tournament_state["running"] = False
|
||||
_tournament_state["error"] = str(e)
|
||||
_tournament_state["message"] = f"Tournament failed: {e}"
|
||||
with _tournament_lock:
|
||||
_tournament_state.phase = "error"
|
||||
_tournament_state.running = False
|
||||
_tournament_state.error = str(e)
|
||||
_tournament_state.message = f"Tournament failed: {e}"
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@router.post("/api/ml/tournament")
|
||||
async def ml_start_tournament(request: FastAPIRequest):
|
||||
"""Start a tournament evaluation."""
|
||||
if _tournament_state["running"]:
|
||||
if _tournament_state.running:
|
||||
raise HTTPException(409, "Tournament already in progress")
|
||||
body = await request.json() if await request.body() else {}
|
||||
episodes = body.get("episodes", 50)
|
||||
playstyle = body.get("playstyle", "midrange")
|
||||
_tournament_state.update({
|
||||
"running": True, "phase": "starting",
|
||||
"message": "Initializing tournament...",
|
||||
"result": None, "error": None,
|
||||
})
|
||||
with _tournament_lock:
|
||||
_tournament_state.running = True
|
||||
_tournament_state.phase = "starting"
|
||||
_tournament_state.message = "Initializing tournament..."
|
||||
_tournament_state.result = None
|
||||
_tournament_state.error = None
|
||||
thread = threading.Thread(
|
||||
target=_run_tournament_pipeline,
|
||||
args=(episodes, playstyle),
|
||||
@@ -612,7 +662,8 @@ async def ml_start_tournament(request: FastAPIRequest):
|
||||
@router.get("/api/ml/tournament/status")
|
||||
async def ml_tournament_status():
|
||||
"""Get tournament status."""
|
||||
return dict(_tournament_state)
|
||||
with _tournament_lock:
|
||||
return _tournament_state.snapshot()
|
||||
|
||||
|
||||
@router.get("/api/ml/tournament/results")
|
||||
@@ -624,3 +675,55 @@ async def ml_tournament_results():
|
||||
with open(results_path, "r") as f:
|
||||
return json.load(f)
|
||||
return {"error": "No tournament results found. Run a tournament first."}
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# Model listing and stats endpoints
|
||||
# ==============================================================
|
||||
|
||||
@router.get("/api/ml/models")
|
||||
async def ml_list_models():
|
||||
"""List available policy model checkpoints."""
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
ckpt_dir = project_root / "ml" / "models" / "checkpoints"
|
||||
checkpoints = []
|
||||
if ckpt_dir.exists():
|
||||
for f in sorted(ckpt_dir.glob("*.pt")):
|
||||
checkpoints.append({
|
||||
"name": f.name,
|
||||
"path": str(f),
|
||||
"size_kb": round(f.stat().st_size / 1024, 1),
|
||||
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
||||
})
|
||||
svc = _get_policy_service()
|
||||
return {
|
||||
"models": checkpoints,
|
||||
"count": len(checkpoints),
|
||||
"active_model": svc.get_status() if svc else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/ml/stats")
|
||||
async def ml_stats():
|
||||
"""Return aggregate ML training statistics and model performance metrics."""
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
ckpt_dir = project_root / "ml" / "models" / "checkpoints"
|
||||
eval_results = None
|
||||
eval_path = ckpt_dir / "eval_results.json"
|
||||
if eval_path.exists():
|
||||
try:
|
||||
with open(eval_path, "r") as f:
|
||||
eval_results = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
with _training_lock:
|
||||
training_snapshot = _training_state.snapshot()
|
||||
with _ppo_lock:
|
||||
ppo_snapshot = _ppo_state.snapshot()
|
||||
svc = _get_policy_service()
|
||||
return {
|
||||
"training": training_snapshot,
|
||||
"ppo": ppo_snapshot,
|
||||
"eval_results": eval_results,
|
||||
"policy_loaded": svc._loaded if svc else False,
|
||||
}
|
||||
+8
-7
@@ -16,7 +16,8 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
import routes.shared as _shared # module reference, not value import
|
||||
from routes.shared import CFG, PRECON_DIR, download_precon_database, log
|
||||
from routes.shared import CFG, download_precon_database, log
|
||||
from services.precon_service import _get_precon_dir
|
||||
|
||||
router = APIRouter(tags=["precon"])
|
||||
|
||||
@@ -30,13 +31,13 @@ async def list_precons():
|
||||
@router.post("/api/lab/precons/install")
|
||||
async def install_precon(req: dict):
|
||||
"""Install a precon deck to the Forge decks directory.
|
||||
Body: {"fileName": "Elven_Empire.dck"}
|
||||
Body: {"fileName": "Elven_Empire.dck"} or {"name": "Elven_Empire.dck"}
|
||||
"""
|
||||
file_name = req.get("fileName", "")
|
||||
file_name = req.get("fileName", "") or req.get("name", "")
|
||||
if not file_name:
|
||||
raise HTTPException(400, "fileName is required")
|
||||
|
||||
src = PRECON_DIR / file_name
|
||||
src = _get_precon_dir() / file_name
|
||||
if not src.exists():
|
||||
raise HTTPException(404, f"Precon not found: {file_name}")
|
||||
|
||||
@@ -59,9 +60,9 @@ async def install_precon(req: dict):
|
||||
@router.post("/api/lab/precons/install-batch")
|
||||
async def install_precons_batch(req: dict):
|
||||
"""Install multiple precon decks at once.
|
||||
Body: {"fileNames": ["Elven_Empire.dck", "Necron_Dynasties.dck"]}
|
||||
Body: {"fileNames": ["Elven_Empire.dck", ...]} or {"names": ["Elven_Empire.dck", ...]}
|
||||
"""
|
||||
file_names = req.get("fileNames", [])
|
||||
file_names = req.get("fileNames", []) or req.get("names", [])
|
||||
if not file_names:
|
||||
raise HTTPException(400, "fileNames list is required")
|
||||
|
||||
@@ -71,7 +72,7 @@ async def install_precons_batch(req: dict):
|
||||
|
||||
results = []
|
||||
for file_name in file_names:
|
||||
src = PRECON_DIR / file_name
|
||||
src = _get_precon_dir() / file_name
|
||||
if not src.exists():
|
||||
results.append({"fileName": file_name, "installed": False, "error": "not found"})
|
||||
continue
|
||||
|
||||
+3
-2
@@ -186,6 +186,7 @@ from services.import_service import (
|
||||
from services.precon_service import (
|
||||
PRECON_DIR, PRECON_INDEX, GITHUB_PRECON_URL, PRECON_CACHE_HOURS,
|
||||
load_precon_index, _sanitize_filename, _deck_to_dck, download_precon_database,
|
||||
_get_precon_dir,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -195,9 +196,9 @@ from models.state import AI_PROFILES
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Java / Sim Helpers (extracted to services/forge_runner.py)
|
||||
from services.forge_runner import (
|
||||
_find_java17, get_java17, build_java_command, parse_dck_file,
|
||||
_find_java17, get_java17, build_java_command,
|
||||
run_batch_subprocess, _run_process_blocking,
|
||||
_run_deepseek_batch_thread, _get_deepseek_brain,
|
||||
)
|
||||
|
||||
_ml_logging_enabled = False
|
||||
_ml_logging_enabled = True
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
scripts/bench_deckgen.py
|
||||
========================
|
||||
Benchmark for _get_collection_for_colors() and _build_collection_summary().
|
||||
|
||||
Seeds an in-memory SQLite DB with randomised collection_entries rows,
|
||||
then times the SQL-filtered query for various commander colour identities.
|
||||
|
||||
Usage:
|
||||
python scripts/bench_deckgen.py [--rows 1000]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
ALL_COLORS = ["W", "U", "B", "R", "G"]
|
||||
|
||||
CARD_TYPE_LINES = [
|
||||
"Creature - Human Soldier",
|
||||
"Creature - Elf Druid",
|
||||
"Instant",
|
||||
"Sorcery",
|
||||
"Artifact",
|
||||
"Enchantment",
|
||||
"Land",
|
||||
"Artifact Creature - Golem",
|
||||
"Legendary Creature - Dragon",
|
||||
"Planeswalker - Jace",
|
||||
]
|
||||
|
||||
|
||||
def _random_color_identity() -> list:
|
||||
"""Return a random colour identity (including ~15% chance of colorless)."""
|
||||
if random.random() < 0.15:
|
||||
return []
|
||||
n = random.randint(1, 3)
|
||||
return sorted(random.sample(ALL_COLORS, n))
|
||||
|
||||
|
||||
def _seed_db(conn: sqlite3.Connection, num_rows: int):
|
||||
"""Create collection_entries table and insert random rows."""
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS collection_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type_line TEXT DEFAULT '',
|
||||
color_identity TEXT DEFAULT '[]',
|
||||
cmc REAL DEFAULT 0,
|
||||
mana_cost TEXT DEFAULT '',
|
||||
oracle_text TEXT DEFAULT '',
|
||||
keywords TEXT DEFAULT '[]',
|
||||
quantity INTEGER DEFAULT 1,
|
||||
edhrec_rank INTEGER DEFAULT 0,
|
||||
scryfall_id TEXT DEFAULT '',
|
||||
tcg_price REAL DEFAULT 0,
|
||||
salt_score REAL DEFAULT 0,
|
||||
is_game_changer INTEGER DEFAULT 0,
|
||||
category TEXT DEFAULT ''
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_quantity
|
||||
ON collection_entries (quantity) WHERE quantity > 0
|
||||
""")
|
||||
|
||||
rows = []
|
||||
for i in range(num_rows):
|
||||
ci = _random_color_identity()
|
||||
qty = random.choice([0, 1, 1, 1, 2, 3]) # ~17% zero quantity
|
||||
rows.append((
|
||||
f"Card_{i:05d}",
|
||||
random.choice(CARD_TYPE_LINES),
|
||||
json.dumps(ci),
|
||||
round(random.uniform(0, 8), 1),
|
||||
"",
|
||||
f"Sample oracle text for card {i}",
|
||||
json.dumps([]),
|
||||
qty,
|
||||
random.randint(1, 20000),
|
||||
f"scryfall-{i:05d}" if random.random() > 0.02 else "",
|
||||
round(random.uniform(0, 50), 2),
|
||||
round(random.uniform(0, 5), 2),
|
||||
1 if random.random() < 0.05 else 0,
|
||||
random.choice(["", "ramp", "draw", "removal", ""]),
|
||||
))
|
||||
|
||||
conn.executemany("""
|
||||
INSERT INTO collection_entries
|
||||
(name, type_line, color_identity, cmc, mana_cost, oracle_text,
|
||||
keywords, quantity, edhrec_rank, scryfall_id, tcg_price,
|
||||
salt_score, is_game_changer, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", rows)
|
||||
conn.commit()
|
||||
print(f" Seeded {num_rows} rows ({sum(1 for r in rows if r[7] > 0)} with qty > 0)")
|
||||
|
||||
|
||||
# ── Query under test (same as routes/deckgen.py) ────────────────────────────
|
||||
|
||||
def get_collection_for_colors(conn: sqlite3.Connection, color_identity: list) -> list:
|
||||
"""Mirrors the new SQL-filtered implementation."""
|
||||
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
|
||||
|
||||
rows = conn.execute("""
|
||||
SELECT scryfall_id, name, type_line, mana_cost, cmc,
|
||||
oracle_text, keywords, quantity, edhrec_rank, color_identity
|
||||
FROM collection_entries
|
||||
WHERE quantity > 0
|
||||
AND scryfall_id != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN collection_entries.color_identity IS NULL
|
||||
OR collection_entries.color_identity = ''
|
||||
THEN '[]'
|
||||
ELSE collection_entries.color_identity
|
||||
END
|
||||
) AS jc
|
||||
WHERE jc.value NOT IN (
|
||||
SELECT value FROM json_each(?)
|
||||
)
|
||||
)
|
||||
""", (allowed_json,)).fetchall()
|
||||
|
||||
valid = []
|
||||
for row in rows:
|
||||
card = dict(row)
|
||||
ci = card.get("color_identity", "[]")
|
||||
try:
|
||||
card["color_identity"] = json.loads(ci) if isinstance(ci, str) else ci
|
||||
except Exception:
|
||||
card["color_identity"] = []
|
||||
valid.append(card)
|
||||
return valid
|
||||
|
||||
|
||||
def get_collection_for_colors_old(conn: sqlite3.Connection, color_identity: list) -> list:
|
||||
"""Old implementation: SELECT * + Python-side filter."""
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_entries WHERE quantity > 0"
|
||||
).fetchall()
|
||||
valid = []
|
||||
for row in rows:
|
||||
card = dict(row)
|
||||
card_ci = card.get("color_identity", "[]")
|
||||
if isinstance(card_ci, str):
|
||||
try:
|
||||
card_ci = json.loads(card_ci)
|
||||
except Exception:
|
||||
card_ci = []
|
||||
if color_identity and card_ci:
|
||||
if not all(c in color_identity for c in card_ci):
|
||||
continue
|
||||
if not card.get("scryfall_id"):
|
||||
continue
|
||||
card["color_identity"] = card_ci
|
||||
valid.append(card)
|
||||
return valid
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
COMMANDERS = [
|
||||
("Mono-W", ["W"]),
|
||||
("WUB", ["W", "U", "B"]),
|
||||
("WUBRG", ["W", "U", "B", "R", "G"]),
|
||||
("Colorless", []),
|
||||
("Mono-G", ["G"]),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--rows", type=int, default=1000)
|
||||
args = parser.parse_args()
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
_seed_db(conn, args.rows)
|
||||
|
||||
print(f"\n{'Commander':<14} {'Old ms':>8} {'New ms':>8} {'Old#':>6} {'New#':>6} {'Match':>6}")
|
||||
print("-" * 60)
|
||||
|
||||
for label, ci in COMMANDERS:
|
||||
# Old
|
||||
t0 = time.perf_counter()
|
||||
old_result = get_collection_for_colors_old(conn, ci)
|
||||
old_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
# New
|
||||
t0 = time.perf_counter()
|
||||
new_result = get_collection_for_colors(conn, ci)
|
||||
new_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
match = len(old_result) == len(new_result)
|
||||
# NOTE: Colorless ([]) intentionally differs — old code had a bug that
|
||||
# skipped the colour check entirely when color_identity was empty,
|
||||
# returning ALL cards. The new SQL correctly restricts to colourless.
|
||||
if not ci: # colorless commander
|
||||
marker = "FIX" if len(new_result) < len(old_result) else "OK"
|
||||
else:
|
||||
marker = "OK" if match else "FAIL"
|
||||
print(f" {label:<12} {old_ms:>7.2f} {new_ms:>7.2f} {len(old_result):>6} {len(new_result):>6} {marker:>6}")
|
||||
|
||||
if not match and ci: # only flag non-colorless mismatches
|
||||
old_names = {c["name"] for c in old_result}
|
||||
new_names = {c["name"] for c in new_result}
|
||||
print(f" Only in old: {len(old_names - new_names)} cards")
|
||||
print(f" Only in new: {len(new_names - old_names)} cards")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,7 +15,7 @@ def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
|
||||
kw_raw = []
|
||||
kw = " ".join(str(k).lower() for k in kw_raw)
|
||||
|
||||
if ("add {" in ot or "add one mana" in ot
|
||||
if ("add {" in ot or "add one mana" in ot
|
||||
or ("search your library for a" in ot and "land" in ot)
|
||||
or "treasure token" in ot or "add mana" in ot):
|
||||
roles.append("Ramp")
|
||||
@@ -29,7 +29,7 @@ def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
|
||||
or re.search(r"deals? x damage to (target|any target)", ot)
|
||||
or "target creature gets -" in ot or "fights target" in ot):
|
||||
roles.append("Removal")
|
||||
if ("destroy all" in ot or "exile all" in ot
|
||||
if ("destroy all" in ot or "exile all" in ot
|
||||
or re.search(r"all creatures get -\d+/-\d+", ot)
|
||||
or re.search(r"each creature gets -\d+/-\d+", ot)
|
||||
or ("deals" in ot and "to each creature" in ot)):
|
||||
@@ -47,7 +47,7 @@ def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
|
||||
if ("target creature gains" in ot and ("hexproof" in ot or "indestructible" in ot or "shroud" in ot))\
|
||||
or ("protection" in kw):
|
||||
roles.append("Protection")
|
||||
if ("sacrifice" in ot and ("whenever" in ot or "you may" in ot)):
|
||||
if ("sacrifice" in ot and ("whenever" in ot or "you may" in ot)):
|
||||
roles.append("Sacrifice")
|
||||
if ("return" in ot and ("from your graveyard" in ot and "onto the battlefield" in ot)
|
||||
or ("put" in ot and "from a graveyard" in ot and "onto the battlefield" in ot)):
|
||||
@@ -61,7 +61,7 @@ def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
|
||||
or "each opponent loses" in ot and "life" in ot
|
||||
or "deals damage to each opponent" in ot):
|
||||
roles.append("Burn")
|
||||
if ("can't cast" in ot or "can't attack" in ot or "can't activate" in ot
|
||||
if ("can't cast" in ot or "can't attack" in ot or "can't activate" in ot
|
||||
or ("enters the battlefield tapped" in ot and "opponents" in ot)
|
||||
or "each player can't" in ot or "players can't" in ot
|
||||
or ("cost {" in ot and "more to cast" in ot)):
|
||||
|
||||
+66
-29
@@ -11,7 +11,6 @@ from typing import Optional
|
||||
|
||||
log_collect = logging.getLogger("commander_ai_lab.collection")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Database
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -20,6 +19,7 @@ _db_local = threading.local()
|
||||
|
||||
|
||||
def _get_db_conn() -> sqlite3.Connection:
|
||||
"""Return the thread-local SQLite connection, creating it on first use."""
|
||||
if not hasattr(_db_local, "conn") or _db_local.conn is None:
|
||||
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
@@ -27,11 +27,26 @@ def _get_db_conn() -> sqlite3.Connection:
|
||||
return _db_local.conn
|
||||
|
||||
|
||||
def init_collection_db():
|
||||
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.executescript("""
|
||||
def _close_db_conn() -> None:
|
||||
"""Close and discard the thread-local connection (call on thread teardown)."""
|
||||
conn = getattr(_db_local, "conn", None)
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_db_local.conn = None
|
||||
|
||||
|
||||
def init_collection_db() -> None:
|
||||
"""Create tables, indexes, and run column migrations.
|
||||
|
||||
Uses _get_db_conn() so the same thread-local connection that route
|
||||
handlers will use is warmed up and fully migrated before any request
|
||||
arrives. No separate connection is opened or closed here.
|
||||
"""
|
||||
conn = _get_db_conn()
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS card_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
@@ -95,6 +110,8 @@ def init_collection_db():
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_set_code ON collection_entries(set_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_collector_number ON collection_entries(collector_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_finish ON collection_entries(finish);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_quantity ON collection_entries(quantity)
|
||||
WHERE quantity > 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_cr_name ON card_records(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decks (
|
||||
@@ -124,29 +141,32 @@ def init_collection_db():
|
||||
CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id);
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# ── Column migrations (additive only) ─────────────────────────────────
|
||||
_migrate_columns = [
|
||||
("mana_cost", "TEXT DEFAULT ''"),
|
||||
("power", "TEXT DEFAULT ''"),
|
||||
("toughness", "TEXT DEFAULT ''"),
|
||||
("rarity", "TEXT DEFAULT ''"),
|
||||
("set_name", "TEXT DEFAULT ''"),
|
||||
("edhrec_rank", "INTEGER DEFAULT 0"),
|
||||
("mana_cost", "TEXT DEFAULT ''"),
|
||||
("power", "TEXT DEFAULT ''"),
|
||||
("toughness", "TEXT DEFAULT ''"),
|
||||
("rarity", "TEXT DEFAULT ''"),
|
||||
("set_name", "TEXT DEFAULT ''"),
|
||||
("edhrec_rank", "INTEGER DEFAULT 0"),
|
||||
]
|
||||
existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(collection_entries)").fetchall()}
|
||||
existing_cols = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(collection_entries)").fetchall()
|
||||
}
|
||||
for col_name, col_def in _migrate_columns:
|
||||
if col_name not in existing_cols:
|
||||
conn.execute(f"ALTER TABLE collection_entries ADD COLUMN {col_name} {col_def}")
|
||||
log_collect.info(f" Migration: added column '{col_name}' to collection_entries")
|
||||
log_collect.info(f" Migration: added column '{col_name}' to collection_entries")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
log_collect.info(f" Collection DB: {COLLECTION_DB_PATH}")
|
||||
|
||||
log_collect.info(f" Collection DB ready: {COLLECTION_DB_PATH}")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Utility Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
|
||||
|
||||
VALID_SORT_FIELDS = {
|
||||
"name", "cmc", "tcg_price", "salt_score", "category", "color_identity",
|
||||
"quantity", "type_line", "finish", "rarity", "set_code", "power",
|
||||
@@ -154,8 +174,6 @@ VALID_SORT_FIELDS = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def _row_to_dict(row) -> dict:
|
||||
if row is None:
|
||||
return None
|
||||
@@ -169,12 +187,12 @@ def _row_to_dict(row) -> dict:
|
||||
return d
|
||||
|
||||
|
||||
def _snake_to_camel(s: str) -> str:
|
||||
def _snake_to_camel(s: str) -> str:
|
||||
parts = s.split("_")
|
||||
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
||||
|
||||
|
||||
def _add_image_url(card: dict) -> dict:
|
||||
def _add_image_url(card: dict) -> dict:
|
||||
scryfall_id = card.get("scryfall_id", "")
|
||||
if scryfall_id:
|
||||
card["imageUrl"] = f"https://api.scryfall.com/cards/{scryfall_id}?format=image&version=normal"
|
||||
@@ -188,16 +206,35 @@ def _row_to_dict(row) -> dict:
|
||||
return card
|
||||
|
||||
|
||||
def _build_collection_filters(
|
||||
q=None, colors=None, types=None, isLegendary=None, isBasic=None,
|
||||
isGameChanger=None, highSalt=None, finish=None, cmcMin=None, cmcMax=None,
|
||||
priceMin=None, priceMax=None, category=None, rarity=None, setCode=None,
|
||||
powerMin=None, powerMax=None, toughMin=None, toughMax=None, keyword=None,
|
||||
edhrecMin=None, edhrecMax=None, qtyMin=None, qtyMax=None,
|
||||
def _build_collection_filters(
|
||||
q=None,
|
||||
colors=None,
|
||||
types=None,
|
||||
isLegendary=None,
|
||||
isBasic=None,
|
||||
isGameChanger=None,
|
||||
highSalt=None,
|
||||
finish=None,
|
||||
cmcMin=None,
|
||||
cmcMax=None,
|
||||
priceMin=None,
|
||||
priceMax=None,
|
||||
category=None,
|
||||
rarity=None,
|
||||
setCode=None,
|
||||
powerMin=None,
|
||||
powerMax=None,
|
||||
toughMin=None,
|
||||
toughMax=None,
|
||||
keyword=None,
|
||||
edhrecMin=None,
|
||||
edhrecMax=None,
|
||||
qtyMin=None,
|
||||
qtyMax=None,
|
||||
) -> tuple:
|
||||
conditions = []
|
||||
params = []
|
||||
if q:
|
||||
if q:
|
||||
like_q = f"%{q}%"
|
||||
conditions.append("(name LIKE ? OR type_line LIKE ? OR oracle_text LIKE ?)")
|
||||
params.extend([like_q, like_q, like_q])
|
||||
@@ -264,4 +301,4 @@ def _row_to_dict(row) -> dict:
|
||||
if qtyMax is not None:
|
||||
conditions.append("quantity <= ?"); params.append(qtyMax)
|
||||
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
return where_str, params
|
||||
return where_str, params
|
||||
+84
-13
@@ -34,7 +34,7 @@ def _classify_card_type(type_line: str) -> str:
|
||||
return "Other"
|
||||
|
||||
|
||||
def _get_deck_or_404(deck_id: int):
|
||||
def _get_deck_or_404(deck_id: int):
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute("SELECT * FROM decks WHERE id = ?", (deck_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -47,7 +47,7 @@ def _classify_card_type(type_line: str) -> str:
|
||||
return d
|
||||
|
||||
|
||||
def _compute_deck_analysis(deck_id: int) -> dict:
|
||||
def _compute_deck_analysis(deck_id: int) -> dict:
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute("""
|
||||
SELECT dc.id, dc.scryfall_id, dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
|
||||
@@ -59,7 +59,7 @@ def _classify_card_type(type_line: str) -> str:
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,)).fetchall()
|
||||
counts_by_type = {t: 0 for t in _TYPE_PRIORITY}
|
||||
counts_by_type = {t: 0 for t in _TYPE_PRIORITY}
|
||||
counts_by_type["Other"] = 0
|
||||
mana_curve = {"0": 0, "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6+": 0}
|
||||
color_pips = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
|
||||
@@ -96,21 +96,21 @@ def _classify_card_type(type_line: str) -> str:
|
||||
}
|
||||
|
||||
|
||||
def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool:
|
||||
def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool:
|
||||
analysis = _compute_deck_analysis(deck_id)
|
||||
current = analysis["counts_by_type"].get(card_type, 0)
|
||||
target_max = _TYPE_TARGETS.get(card_type, [0, 9999])[1]
|
||||
return (current + count_to_add) <= target_max
|
||||
|
||||
|
||||
def _to_edhrec_slug(name: str) -> str:
|
||||
def _to_edhrec_slug(name: str) -> str:
|
||||
slug = name.lower()
|
||||
slug = re.sub(r"[',.]" , "", slug)
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
||||
return slug.strip("-")
|
||||
|
||||
|
||||
def parse_dck_file(deck_path: str) -> dict:
|
||||
def parse_dck_file(deck_path: str) -> dict:
|
||||
"""Parse a Forge .dck file into {name, commanders, mainboard, colorIdentity, totalCards}."""
|
||||
path = Path(deck_path)
|
||||
if not path.exists():
|
||||
@@ -153,7 +153,7 @@ def _classify_card_type(type_line: str) -> str:
|
||||
return {"name": name, "commander": commander_name, "commanders": commanders, "mainboard": mainboard, "colorIdentity": [], "totalCards": total}
|
||||
|
||||
|
||||
def _save_profile_to_dck(profile: dict) -> Path:
|
||||
def _save_profile_to_dck(profile: dict) -> Path:
|
||||
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
|
||||
for name, qty in profile.get("commanders", {}).items():
|
||||
lines.append(f"{qty} {name}")
|
||||
@@ -174,21 +174,92 @@ def _classify_card_type(type_line: str) -> str:
|
||||
return out_path
|
||||
|
||||
|
||||
def _load_deck_cards_by_name(deck_name: str) -> list:
|
||||
def _load_deck_cards_by_name(deck_name: str) -> list:
|
||||
"""Load cards from a deck by name, returning list of card dicts."""
|
||||
conn = _get_db_conn()
|
||||
deck_row = conn.execute("SELECT id FROM decks WHERE name = ?", (deck_name,)).fetchone()
|
||||
if not deck_row:
|
||||
return []
|
||||
deck_id = deck_row[0]
|
||||
deck_id = deck_row[0]
|
||||
rows = conn.execute("""
|
||||
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
|
||||
ce.type_line, ce.oracle_text, ce.cmc, ce.color_identity, ce.mana_cost
|
||||
COALESCE(ce.type_line, cr.type_line, '') as type_line,
|
||||
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
|
||||
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
|
||||
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
|
||||
COALESCE(ce.mana_cost, '', '') as mana_cost,
|
||||
COALESCE(ce.power, '', '') as power,
|
||||
COALESCE(ce.toughness, '', '') as toughness,
|
||||
COALESCE(ce.keywords, cr.keywords, '[]') as keywords
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost
|
||||
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost, power, toughness, keywords
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
LEFT JOIN (
|
||||
SELECT name, type_line, oracle_text, cmc, color_identity, keywords
|
||||
FROM card_records GROUP BY name
|
||||
) cr ON cr.name = dc.card_name
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,)).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
|
||||
|
||||
|
||||
def _build_dck_lines(
|
||||
deck_name: str,
|
||||
commander_name: str,
|
||||
cards: list[dict],
|
||||
*,
|
||||
resolve_substitutes: bool = False,
|
||||
) -> list[str]:
|
||||
"""Build Forge .dck file lines from deck name, commander, and card list."""
|
||||
lines: list[str] = [
|
||||
"[metadata]",
|
||||
f"Name={deck_name}",
|
||||
"",
|
||||
"[Commander]",
|
||||
f"1 {commander_name}",
|
||||
"",
|
||||
"[Main]",
|
||||
]
|
||||
for card in cards:
|
||||
cname = card.get("name", "")
|
||||
if resolve_substitutes:
|
||||
if card.get("status") == "substituted" and card.get("selected_substitute"):
|
||||
cname = card["selected_substitute"]
|
||||
if cname and cname != commander_name:
|
||||
lines.append(f"{card.get('count', card.get('quantity', 1))} {cname}")
|
||||
return lines
|
||||
|
||||
|
||||
def _write_dck_file(
|
||||
deck_name: str,
|
||||
commander_name: str,
|
||||
cards: list[dict],
|
||||
decks_dir: str | None = None,
|
||||
*,
|
||||
fallback_id: int | str = 0,
|
||||
resolve_substitutes: bool = False,
|
||||
) -> str | None:
|
||||
"""Write a Forge .dck file to decks_dir. Returns path on success, None otherwise."""
|
||||
if not decks_dir:
|
||||
decks_dir = CFG.forge_decks_dir
|
||||
if not decks_dir or not os.path.isdir(decks_dir):
|
||||
return None
|
||||
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9_\-\s]", "", deck_name).strip().replace(" ", "_")
|
||||
if not safe_name:
|
||||
safe_name = f"Deck_{fallback_id}"
|
||||
|
||||
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
|
||||
lines = _build_dck_lines(
|
||||
deck_name, commander_name, cards,
|
||||
resolve_substitutes=resolve_substitutes,
|
||||
)
|
||||
with open(dck_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
log_deckgen.info(f" Exported .dck: {dck_path}")
|
||||
return dck_path
|
||||
+591
-141
@@ -1,141 +1,591 @@
|
||||
"""Forge batch simulation runner: Java discovery, command building, process management."""
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from models.state import CFG, BatchState
|
||||
from services.deck_service import parse_dck_file
|
||||
|
||||
log = logging.getLogger("lab.batch")
|
||||
|
||||
|
||||
def _find_java17() -> str:
|
||||
search_dirs = [r'C:\Program Files\Eclipse Adoptium', r'C:\Program Files\Java']
|
||||
for d in search_dirs:
|
||||
if os.path.isdir(d):
|
||||
for child in os.listdir(d):
|
||||
if child.startswith('jdk-17'):
|
||||
candidate = os.path.join(d, child, 'bin', 'java.exe')
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return 'java'
|
||||
|
||||
|
||||
_JAVA17_PATH = None
|
||||
|
||||
|
||||
def get_java17() -> str:
|
||||
global _JAVA17_PATH
|
||||
if _JAVA17_PATH is None:
|
||||
_JAVA17_PATH = _find_java17()
|
||||
return _JAVA17_PATH
|
||||
|
||||
|
||||
def build_java_command(
|
||||
decks: list, num_games: int, threads: int, seed: Optional[int],
|
||||
clock: int, output_path: str, use_learned_policy: bool = False,
|
||||
policy_server: str = "http://localhost:8080", policy_style: str = "midrange",
|
||||
policy_greedy: bool = False, ai_simplified: bool = False,
|
||||
ai_think_time_ms: int = -1, max_queue_depth: int = -1,
|
||||
) -> list:
|
||||
java17 = get_java17()
|
||||
forge_jar = CFG.forge_jar
|
||||
cmd = [
|
||||
java17, '-jar', forge_jar, '-sim',
|
||||
'-decks', ','.join(str(d) for d in decks),
|
||||
'-games', str(num_games),
|
||||
'-threads', str(threads),
|
||||
'-clock', str(clock),
|
||||
'-output', output_path,
|
||||
]
|
||||
if seed is not None:
|
||||
cmd += ['-seed', str(seed)]
|
||||
if use_learned_policy:
|
||||
cmd += ['-policy', policy_style, '-policyServer', policy_server]
|
||||
if policy_greedy:
|
||||
cmd += ['-policyGreedy']
|
||||
if ai_simplified:
|
||||
cmd += ['-aiSimplified']
|
||||
if ai_think_time_ms > 0:
|
||||
cmd += ['-aiThinkTimeMs', str(ai_think_time_ms)]
|
||||
if max_queue_depth > 0:
|
||||
cmd += ['-maxQueueDepth', str(max_queue_depth)]
|
||||
return cmd
|
||||
|
||||
|
||||
def _run_process_blocking(state: BatchState, cmd: list):
|
||||
"""Run Forge subprocess, parse progress, generate deck reports."""
|
||||
log.info(f"Running: {' '.join(cmd)}")
|
||||
state.started_at = datetime.datetime.now().isoformat()
|
||||
state.status = "running"
|
||||
env = os.environ.copy()
|
||||
java17 = get_java17()
|
||||
java_bin = os.path.dirname(java17)
|
||||
if java_bin:
|
||||
env["JAVA_HOME"] = os.path.dirname(java_bin)
|
||||
env["PATH"] = java_bin + os.pathsep + env.get("PATH", "")
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1, env=env,
|
||||
)
|
||||
except Exception as e:
|
||||
state.status = "error"
|
||||
state.error = str(e)
|
||||
log.error(f"Failed to start subprocess: {e}")
|
||||
return
|
||||
last_activity = [time.monotonic()]
|
||||
stall_limit = 300 # 5 min
|
||||
def watchdog():
|
||||
while proc.poll() is None:
|
||||
if (time.monotonic() - last_activity[0]) > stall_limit:
|
||||
log.warning("Stall detected, killing process")
|
||||
proc.kill()
|
||||
state.status = "error"
|
||||
state.error = "Stall detected (5 min no output)"
|
||||
return
|
||||
time.sleep(5)
|
||||
wd = threading.Thread(target=watchdog, daemon=True)
|
||||
wd.start()
|
||||
output_lines = []
|
||||
for line in proc.stdout:
|
||||
last_activity[0] = time.monotonic()
|
||||
line = line.rstrip()
|
||||
output_lines.append(line)
|
||||
gm = re.match(r'\[Game (\d+)/(\d+)\]', line)
|
||||
if gm:
|
||||
state.games_done = int(gm.group(1))
|
||||
state.games_total = int(gm.group(2))
|
||||
pm = re.match(r'\[PROGRESS\].*?(\d+\.?\d*)\s*sims/sec', line)
|
||||
if pm:
|
||||
state.throughput = float(pm.group(1))
|
||||
proc.wait()
|
||||
state.log = '\n'.join(output_lines)
|
||||
if proc.returncode != 0:
|
||||
state.status = "error"
|
||||
state.error = f"Exit code {proc.returncode}"
|
||||
else:
|
||||
state.status = "done"
|
||||
state.games_done = state.games_total
|
||||
state.ended_at = datetime.datetime.now().isoformat()
|
||||
|
||||
|
||||
async def run_batch_subprocess(
|
||||
state: BatchState, decks: list, num_games: int, threads: int,
|
||||
seed: Optional[int] = None, clock: int = 6000, output_path: str = "results",
|
||||
use_learned_policy: bool = False, **kwargs,
|
||||
):
|
||||
"""Async wrapper that runs Forge subprocess in executor."""
|
||||
cmd = build_java_command(
|
||||
decks, num_games, threads, seed, clock, output_path,
|
||||
use_learned_policy=use_learned_policy, **kwargs,
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _run_process_blocking, state, cmd)
|
||||
"""Forge batch simulation runner: Java discovery, command building, process management."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime as _datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from models.state import CFG, BatchState
|
||||
from services.deck_service import parse_dck_file, _load_deck_cards_by_name
|
||||
|
||||
log = logging.getLogger("lab.batch")
|
||||
log_sim = logging.getLogger("commander_ai_lab.sim")
|
||||
|
||||
# Global DeepSeek brain instance (lazy-initialized)
|
||||
_deepseek_brain = None
|
||||
_deepseek_lock = threading.Lock()
|
||||
|
||||
# ── Global watchdog: single thread monitors all active batches ──
|
||||
_watchdog_registry: dict[str, dict] = {} # batch_id -> {state, proc, last_activity}
|
||||
_watchdog_lock = threading.Lock()
|
||||
_watchdog_started = False
|
||||
_STALL_LIMIT = 600 # 10 min
|
||||
|
||||
def _global_watchdog_loop():
|
||||
"""Single daemon thread that checks all active batches for stalls."""
|
||||
while True:
|
||||
time.sleep(5)
|
||||
with _watchdog_lock:
|
||||
finished = []
|
||||
for bid, entry in _watchdog_registry.items():
|
||||
proc = entry["proc"]
|
||||
if proc.poll() is not None:
|
||||
finished.append(bid)
|
||||
continue
|
||||
if (time.monotonic() - entry["last_activity"]) > _STALL_LIMIT:
|
||||
log.warning(f"Stall detected for batch {bid}, killing process")
|
||||
entry["state"].log_lines.append("[WATCHDOG] Process stalled. Killed.")
|
||||
proc.kill()
|
||||
entry["state"].running = False
|
||||
entry["state"].error = "Stall detected (10 min no output)"
|
||||
finished.append(bid)
|
||||
for bid in finished:
|
||||
del _watchdog_registry[bid]
|
||||
|
||||
def _ensure_watchdog_started():
|
||||
global _watchdog_started
|
||||
if not _watchdog_started:
|
||||
with _watchdog_lock:
|
||||
if not _watchdog_started:
|
||||
t = threading.Thread(target=_global_watchdog_loop, daemon=True)
|
||||
t.start()
|
||||
_watchdog_started = True
|
||||
|
||||
def _register_batch(batch_id: str, state, proc):
|
||||
_ensure_watchdog_started()
|
||||
with _watchdog_lock:
|
||||
_watchdog_registry[batch_id] = {
|
||||
"state": state, "proc": proc, "last_activity": time.monotonic()
|
||||
}
|
||||
|
||||
def _touch_batch(batch_id: str):
|
||||
with _watchdog_lock:
|
||||
if batch_id in _watchdog_registry:
|
||||
_watchdog_registry[batch_id]["last_activity"] = time.monotonic()
|
||||
|
||||
def _unregister_batch(batch_id: str):
|
||||
with _watchdog_lock:
|
||||
_watchdog_registry.pop(batch_id, None)
|
||||
|
||||
|
||||
# ── Java 17 Discovery ───────────────────────────────────────────────
|
||||
|
||||
# Ordered list of directories to scan for JDK 17 installations.
|
||||
# Covers: Eclipse Temurin, Oracle, Amazon Corretto, Microsoft, Azul Zulu, SAP.
|
||||
_JAVA17_SEARCH_DIRS = [
|
||||
r'C:\Program Files\Eclipse Adoptium',
|
||||
r'C:\Program Files\Java',
|
||||
r'C:\Program Files\Amazon Corretto',
|
||||
r'C:\Program Files\Microsoft',
|
||||
r'C:\Program Files\Zulu',
|
||||
r'C:\Program Files\BellSoft',
|
||||
r'C:\Program Files\SapMachine',
|
||||
]
|
||||
|
||||
|
||||
def _find_java17() -> str:
|
||||
"""
|
||||
Search well-known JDK install directories for a Java 17 binary.
|
||||
Falls back to JAVA17_HOME env var, then JAVA_HOME, then warns and returns 'java'.
|
||||
Returns the absolute path to java.exe if found, otherwise 'java'.
|
||||
"""
|
||||
# 1. Explicit override via env var
|
||||
env_override = os.environ.get('JAVA17_HOME', '').strip()
|
||||
if env_override:
|
||||
candidate = os.path.join(env_override, 'bin', 'java.exe')
|
||||
if os.path.isfile(candidate):
|
||||
log.info(f"Java 17 found via JAVA17_HOME: {candidate}")
|
||||
return candidate
|
||||
log.warning(f"JAVA17_HOME set to '{env_override}' but java.exe not found there.")
|
||||
|
||||
# 2. Scan vendor directories
|
||||
for d in _JAVA17_SEARCH_DIRS:
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
try:
|
||||
children = os.listdir(d)
|
||||
except OSError:
|
||||
continue
|
||||
for child in sorted(children): # sorted = deterministic, picks lowest 17.x
|
||||
if re.match(r'jdk-?17', child, re.IGNORECASE) or re.match(r'temurin-17', child, re.IGNORECASE):
|
||||
candidate = os.path.join(d, child, 'bin', 'java.exe')
|
||||
if os.path.isfile(candidate):
|
||||
log.info(f"Java 17 found: {candidate}")
|
||||
return candidate
|
||||
|
||||
# 3. JAVA_HOME fallback — only accept if it IS version 17
|
||||
java_home = os.environ.get('JAVA_HOME', '').strip()
|
||||
if java_home:
|
||||
candidate = os.path.join(java_home, 'bin', 'java.exe')
|
||||
if os.path.isfile(candidate):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[candidate, '-version'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
version_output = result.stderr or result.stdout
|
||||
if 'version "17' in version_output:
|
||||
log.info(f"Java 17 found via JAVA_HOME: {candidate}")
|
||||
return candidate
|
||||
else:
|
||||
log.warning(
|
||||
f"JAVA_HOME points to '{java_home}' but it is not Java 17. "
|
||||
f"Version output: {version_output.strip()!r}"
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Could not probe JAVA_HOME java version: {e}")
|
||||
|
||||
# 4. Nothing found — warn loudly so the log surfaces the issue
|
||||
log.error(
|
||||
"Java 17 not found! Forge simulation requires Java 17. "
|
||||
"Install Eclipse Temurin 17 (winget install EclipseAdoptium.Temurin.17.JDK) "
|
||||
"or set the JAVA17_HOME environment variable to your JDK 17 install directory. "
|
||||
"Falling back to system 'java' which may be the wrong version."
|
||||
)
|
||||
return 'java'
|
||||
|
||||
|
||||
_JAVA17_PATH = None
|
||||
|
||||
|
||||
def get_java17() -> str:
|
||||
global _JAVA17_PATH
|
||||
if _JAVA17_PATH is None:
|
||||
_JAVA17_PATH = _find_java17()
|
||||
return _JAVA17_PATH
|
||||
|
||||
|
||||
def reset_java17_cache():
|
||||
"""Force re-discovery of Java 17 path (useful after installing JDK at runtime)."""
|
||||
global _JAVA17_PATH
|
||||
_JAVA17_PATH = None
|
||||
|
||||
|
||||
def build_java_command(
|
||||
decks: list, num_games: int, threads: int, seed: Optional[int],
|
||||
clock: int, output_path: str, use_learned_policy: bool = False,
|
||||
policy_server: str = "http://localhost:8080", policy_style: str = "midrange",
|
||||
policy_greedy: bool = False, ai_simplified: bool = False,
|
||||
ai_think_time_ms: int = -1, max_queue_depth: int = -1,
|
||||
) -> list:
|
||||
java17 = get_java17()
|
||||
lab_jar = CFG.lab_jar
|
||||
forge_jar = CFG.forge_jar
|
||||
forge_dir = CFG.forge_dir
|
||||
if not lab_jar or not os.path.isfile(lab_jar):
|
||||
log.error(f"Lab JAR not found at '{lab_jar}'. Run 'mvn package' first.")
|
||||
raise FileNotFoundError(f"Lab JAR not found: {lab_jar}")
|
||||
cmd = [
|
||||
java17, '-jar', lab_jar,
|
||||
'--forge-jar', forge_jar,
|
||||
'--forge-dir', forge_dir,
|
||||
'--games', str(num_games),
|
||||
'--threads', str(threads),
|
||||
'--clock', str(clock),
|
||||
'--output', output_path,
|
||||
]
|
||||
# Map deck list to --deck1 .. --deck4
|
||||
for i, d in enumerate(decks[:4], start=1):
|
||||
cmd += [f'--deck{i}', str(d)]
|
||||
if seed is not None:
|
||||
cmd += ['--seed', str(seed)]
|
||||
if use_learned_policy:
|
||||
cmd += ['--policy', policy_style, '--policyServer', policy_server]
|
||||
if policy_greedy:
|
||||
cmd += ['--policyGreedy']
|
||||
if ai_simplified:
|
||||
cmd += ['--aiSimplified']
|
||||
if ai_think_time_ms > 0:
|
||||
cmd += ['--aiThinkTimeMs', str(ai_think_time_ms)]
|
||||
if max_queue_depth > 0:
|
||||
cmd += ['--maxQueueDepth', str(max_queue_depth)]
|
||||
return cmd
|
||||
|
||||
|
||||
def _run_process_blocking(state: BatchState, cmd: list):
|
||||
"""Run Forge subprocess, parse progress, generate deck reports."""
|
||||
log.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
# Ensure Forge subprocesses use Java 17
|
||||
env = os.environ.copy()
|
||||
java17 = get_java17()
|
||||
if java17 != 'java':
|
||||
java17_bin = os.path.dirname(java17)
|
||||
java17_home = os.path.dirname(java17_bin)
|
||||
env['JAVA_HOME'] = java17_home
|
||||
env['PATH'] = java17_bin + os.pathsep + env.get('PATH', '')
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1, env=env,
|
||||
)
|
||||
state.process = proc
|
||||
except Exception as e:
|
||||
state.running = False
|
||||
state.error = str(e)
|
||||
log.error(f"Failed to start subprocess: {e}")
|
||||
return
|
||||
|
||||
_register_batch(state.batch_id, state, proc)
|
||||
|
||||
for line in proc.stdout:
|
||||
_touch_batch(state.batch_id)
|
||||
line = line.rstrip()
|
||||
state.log_lines.append(line)
|
||||
gm = re.match(r'\[Game (\d+)/(\d+)\]', line)
|
||||
if gm:
|
||||
state.completed_games = int(gm.group(1))
|
||||
state.total_games = int(gm.group(2))
|
||||
pm = re.match(r'\[PROGRESS\].*?(\d+\.?\d*)\s*sims/sec', line)
|
||||
if pm:
|
||||
state.sims_per_sec = float(pm.group(1))
|
||||
|
||||
proc.wait()
|
||||
_unregister_batch(state.batch_id)
|
||||
elapsed = (_datetime.now() - state.start_time).total_seconds() * 1000
|
||||
state.elapsed_ms = int(elapsed)
|
||||
if proc.returncode != 0:
|
||||
state.running = False
|
||||
# Include last few log lines in error for easier diagnosis
|
||||
tail = ' | '.join(state.log_lines[-5:]) if state.log_lines else '(no output)'
|
||||
state.error = f"Exit code {proc.returncode} — last output: {tail}"
|
||||
else:
|
||||
state.running = False
|
||||
state.completed_games = state.total_games
|
||||
|
||||
|
||||
async def run_batch_subprocess(
|
||||
state: BatchState,
|
||||
decks: list,
|
||||
num_games: int,
|
||||
threads: int,
|
||||
seed: Optional[int] = None,
|
||||
clock: int = 6000,
|
||||
output_path: str = "results",
|
||||
use_learned_policy: bool = False,
|
||||
policy_style: str = "midrange",
|
||||
policy_greedy: bool = False,
|
||||
ai_simplified: bool = False,
|
||||
ai_think_time_ms: int = -1,
|
||||
max_queue_depth: int = -1,
|
||||
):
|
||||
"""Async wrapper that runs Forge subprocess in executor."""
|
||||
try:
|
||||
policy_server = f"http://localhost:{CFG.port}"
|
||||
cmd = build_java_command(
|
||||
decks, num_games, threads, seed, clock, output_path,
|
||||
use_learned_policy=use_learned_policy,
|
||||
policy_server=policy_server,
|
||||
policy_style=policy_style,
|
||||
policy_greedy=policy_greedy,
|
||||
ai_simplified=ai_simplified,
|
||||
ai_think_time_ms=ai_think_time_ms,
|
||||
max_queue_depth=max_queue_depth,
|
||||
)
|
||||
log.info(f"Starting batch {state.batch_id}: {' '.join(cmd[:6])}...")
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _run_process_blocking, state, cmd)
|
||||
except Exception as e:
|
||||
state.error = str(e)
|
||||
state.running = False
|
||||
log.error(f"Batch {state.batch_id} ERROR: {e}")
|
||||
|
||||
|
||||
def _get_deepseek_brain():
|
||||
"""Get or create the global DeepSeek brain instance."""
|
||||
global _deepseek_brain
|
||||
if _deepseek_brain is None:
|
||||
with _deepseek_lock:
|
||||
if _deepseek_brain is None:
|
||||
try:
|
||||
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
|
||||
src_dir = os.path.normpath(src_dir)
|
||||
if src_dir not in sys.path:
|
||||
sys.path.insert(0, src_dir)
|
||||
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
|
||||
cfg = DeepSeekConfig(
|
||||
api_base=os.environ.get("BRAIN_API_BASE", "http://localhost:11434"),
|
||||
model=os.environ.get("BRAIN_MODEL", "gpt-oss:20b"),
|
||||
temperature=float(os.environ.get("BRAIN_TEMPERATURE", "0.3")),
|
||||
max_tokens=int(os.environ.get("BRAIN_MAX_TOKENS", "1024")),
|
||||
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "30.0")),
|
||||
)
|
||||
cfg.log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'logs', 'decisions')
|
||||
cfg.log_dir = os.path.normpath(cfg.log_dir)
|
||||
_deepseek_brain = DeepSeekBrain(cfg)
|
||||
except Exception as e:
|
||||
log_sim.error(f'Failed to initialize brain: {e}')
|
||||
return None
|
||||
return _deepseek_brain
|
||||
|
||||
|
||||
def _run_deepseek_batch_thread(
|
||||
state: BatchState,
|
||||
deck_names: list,
|
||||
num_games: int,
|
||||
output_path: str,
|
||||
):
|
||||
"""Run batch simulation using Python sim engine + DeepSeek AI opponent."""
|
||||
try:
|
||||
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
|
||||
src_dir = os.path.normpath(src_dir)
|
||||
if src_dir not in sys.path:
|
||||
sys.path.insert(0, src_dir)
|
||||
|
||||
from commander_ai_lab.sim.models import Card
|
||||
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
|
||||
from commander_ai_lab.sim.rules import enrich_card
|
||||
|
||||
state.log_lines.append('[DeepSeek Batch] Initializing DeepSeek brain...')
|
||||
brain = _get_deepseek_brain()
|
||||
if brain and not brain._connected:
|
||||
brain.check_connection()
|
||||
if not brain or not brain._connected:
|
||||
state.error = 'DeepSeek LLM not connected. Go to Simulator > DeepSeek and connect first.'
|
||||
state.running = False
|
||||
return
|
||||
|
||||
state.log_lines.append(f'[DeepSeek Batch] Connected to {brain.config.model}')
|
||||
|
||||
deck_win_rates = {}
|
||||
try:
|
||||
results_dir = CFG.results_dir
|
||||
if os.path.isdir(results_dir):
|
||||
for fname in sorted(os.listdir(results_dir), reverse=True):
|
||||
if fname.startswith('batch-') and fname.endswith('.json'):
|
||||
try:
|
||||
with open(os.path.join(results_dir, fname), 'r') as rf:
|
||||
past = json.loads(rf.read())
|
||||
for dd in past.get('decks', []):
|
||||
dname = dd.get('deckName', '')
|
||||
wr = dd.get('winRate')
|
||||
if dname and wr is not None and dname not in deck_win_rates:
|
||||
deck_win_rates[dname] = float(wr)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
loaded_decks = {}
|
||||
deck_meta = {}
|
||||
for dn in deck_names:
|
||||
raw_cards = _load_deck_cards_by_name(dn)
|
||||
if not raw_cards:
|
||||
state.log_lines.append(f'[DeepSeek Batch] WARNING: Could not load deck "{dn}", skipping.')
|
||||
continue
|
||||
|
||||
commander_name = ''
|
||||
color_identity_set = set()
|
||||
deck_objs = []
|
||||
for cd in raw_cards:
|
||||
c = Card(name=(cd.get('card_name') or cd.get('name', '')))
|
||||
if cd.get('type_line'): c.type_line = cd['type_line']
|
||||
if cd.get('cmc'): c.cmc = float(cd['cmc'])
|
||||
if cd.get('power') and cd.get('toughness'):
|
||||
c.power = str(cd['power'])
|
||||
c.toughness = str(cd['toughness'])
|
||||
c.pt = c.power + '/' + c.toughness
|
||||
if cd.get('oracle_text'): c.oracle_text = cd['oracle_text']
|
||||
if cd.get('mana_cost'): c.mana_cost = cd['mana_cost']
|
||||
if cd.get('keywords'):
|
||||
kw = cd['keywords']
|
||||
if isinstance(kw, str):
|
||||
try: kw = json.loads(kw)
|
||||
except Exception: kw = []
|
||||
if isinstance(kw, list): c.keywords = kw
|
||||
if cd.get('is_commander'):
|
||||
c.is_commander = True
|
||||
commander_name = (cd.get('card_name') or cd.get('name', ''))
|
||||
ci_str = cd.get('color_identity', '')
|
||||
if ci_str:
|
||||
try:
|
||||
ci_parsed = json.loads(ci_str) if isinstance(ci_str, str) else ci_str
|
||||
if isinstance(ci_parsed, list):
|
||||
for color in ci_parsed:
|
||||
color_identity_set.add(color)
|
||||
except Exception:
|
||||
pass
|
||||
enrich_card(c)
|
||||
deck_objs.append(c)
|
||||
|
||||
creature_count = sum(1 for c in deck_objs if c.is_creature())
|
||||
removal_count = sum(1 for c in deck_objs if c.is_removal)
|
||||
ramp_count = sum(1 for c in deck_objs if c.is_ramp)
|
||||
avg_cmc = sum(c.cmc or 0 for c in deck_objs if not c.is_land()) / max(sum(1 for c in deck_objs if not c.is_land()), 1)
|
||||
oracle_all = ' '.join((c.oracle_text or '').lower() for c in deck_objs)
|
||||
has_combo_text = any(kw in oracle_all for kw in ['you win the game', 'infinite', 'extra turn'])
|
||||
|
||||
if has_combo_text:
|
||||
archetype = 'combo'
|
||||
elif creature_count >= 30 and avg_cmc <= 2.8:
|
||||
archetype = 'aggro'
|
||||
elif removal_count >= 10 or (creature_count <= 18 and avg_cmc >= 3.2):
|
||||
archetype = 'control'
|
||||
else:
|
||||
archetype = 'midrange'
|
||||
|
||||
color_identity_list = sorted(list(color_identity_set))
|
||||
deck_meta[dn] = {
|
||||
'commander_name': commander_name,
|
||||
'color_identity': color_identity_list,
|
||||
'archetype': archetype,
|
||||
'win_rate': deck_win_rates.get(dn),
|
||||
}
|
||||
|
||||
loaded_decks[dn] = deck_objs
|
||||
cmdr_info = f' (Commander: {commander_name})' if commander_name else ''
|
||||
wr_info = f' [History: {deck_win_rates[dn]:.0f}% WR]' if dn in deck_win_rates else ''
|
||||
state.log_lines.append(f'[DeepSeek Batch] Loaded deck "{dn}" ({len(deck_objs)} cards, {archetype}){cmdr_info}{wr_info}')
|
||||
|
||||
if not loaded_decks:
|
||||
state.error = 'No decks could be loaded.'
|
||||
state.running = False
|
||||
return
|
||||
|
||||
deck_list = list(loaded_decks.keys())
|
||||
games_per_deck = max(1, num_games // len(deck_list))
|
||||
total_games = games_per_deck * len(deck_list)
|
||||
state.total_games = total_games
|
||||
|
||||
state.log_lines.append(f'[DeepSeek Batch] Running {games_per_deck} games per deck \u00d7 {len(deck_list)} decks = {total_games} total')
|
||||
|
||||
engine = DeepSeekGameEngine(
|
||||
brain=brain,
|
||||
ai_player_index=0,
|
||||
max_turns=25,
|
||||
record_log=True,
|
||||
ml_log=True,
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
all_deck_results = []
|
||||
completed = 0
|
||||
|
||||
for deck_name in deck_list:
|
||||
deck_a = loaded_decks[deck_name]
|
||||
from commander_ai_lab.lab.experiments import _generate_training_deck
|
||||
|
||||
deck_stats = {
|
||||
'deckName': deck_name,
|
||||
'wins': 0, 'losses': 0, 'totalGames': games_per_deck,
|
||||
'totalTurns': 0, 'totalDamageDealt': 0, 'totalDamageReceived': 0,
|
||||
'totalSpellsCast': 0, 'totalCreaturesPlayed': 0,
|
||||
'games': [],
|
||||
}
|
||||
|
||||
meta = deck_meta.get(deck_name, {})
|
||||
dk_archetype = meta.get('archetype', 'midrange')
|
||||
dk_commander = meta.get('commander_name', '')
|
||||
dk_colors = meta.get('color_identity', [])
|
||||
dk_win_rate = meta.get('win_rate')
|
||||
|
||||
for g in range(games_per_deck):
|
||||
try:
|
||||
deck_b = _generate_training_deck()
|
||||
game_id = f'ds-{state.batch_id}-{deck_name[:12]}-g{g+1}'
|
||||
result = engine.run(
|
||||
deck_a, deck_b,
|
||||
name_a=deck_name + ' (AI)',
|
||||
name_b='Training Opponent',
|
||||
game_id=game_id, archetype=dk_archetype,
|
||||
commander_name=dk_commander,
|
||||
color_identity=dk_colors,
|
||||
win_rate=dk_win_rate,
|
||||
)
|
||||
gd = result.to_dict()
|
||||
gd['gameNumber'] = g + 1
|
||||
deck_stats['games'].append(gd)
|
||||
|
||||
if result.winner == 0:
|
||||
deck_stats['wins'] += 1
|
||||
else:
|
||||
deck_stats['losses'] += 1
|
||||
deck_stats['totalTurns'] += result.turns
|
||||
if result.player_a_stats:
|
||||
deck_stats['totalDamageDealt'] += result.player_a_stats.damage_dealt
|
||||
deck_stats['totalDamageReceived'] += result.player_a_stats.damage_received
|
||||
deck_stats['totalSpellsCast'] += result.player_a_stats.spells_cast
|
||||
deck_stats['totalCreaturesPlayed'] += result.player_a_stats.creatures_played
|
||||
|
||||
state.log_lines.append(
|
||||
f'[Game {completed + 1}/{total_games}] {deck_name} (AI-piloted) \u2192 '
|
||||
f'{"WIN" if result.winner == 0 else "LOSS"} (turn {result.turns})'
|
||||
)
|
||||
except Exception as ge:
|
||||
state.log_lines.append(f'[Game {completed + 1}/{total_games}] ERROR: {ge}')
|
||||
deck_stats['games'].append({'error': str(ge), 'gameNumber': g + 1})
|
||||
|
||||
completed += 1
|
||||
state.completed_games = completed
|
||||
|
||||
n = deck_stats['totalGames']
|
||||
deck_stats['winRate'] = round(deck_stats['wins'] / n * 100, 1) if n > 0 else 0.0
|
||||
deck_stats['avgTurns'] = round(deck_stats['totalTurns'] / n, 1) if n > 0 else 0.0
|
||||
deck_stats['avgDamageDealt'] = round(deck_stats['totalDamageDealt'] / n, 1) if n > 0 else 0.0
|
||||
deck_stats['avgDamageReceived'] = round(deck_stats['totalDamageReceived'] / n, 1) if n > 0 else 0.0
|
||||
deck_stats['avgSpellsCast'] = round(deck_stats['totalSpellsCast'] / n, 1) if n > 0 else 0.0
|
||||
deck_stats['avgCreaturesPlayed'] = round(deck_stats['totalCreaturesPlayed'] / n, 1) if n > 0 else 0.0
|
||||
deck_stats['archetype'] = dk_archetype
|
||||
deck_stats['commander'] = dk_commander
|
||||
deck_stats['colorIdentity'] = dk_colors
|
||||
if dk_win_rate is not None:
|
||||
deck_stats['priorWinRate'] = dk_win_rate
|
||||
all_deck_results.append(deck_stats)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
ds_stats = brain.get_stats() if brain else {}
|
||||
|
||||
batch_result = {
|
||||
'metadata': {
|
||||
'batchId': state.batch_id,
|
||||
'timestamp': _datetime.now().isoformat(),
|
||||
'completedGames': completed,
|
||||
'threads': 1,
|
||||
'elapsedMs': int(elapsed * 1000),
|
||||
'engine': 'deepseek',
|
||||
'model': brain.config.model if brain else 'unknown',
|
||||
},
|
||||
'decks': all_deck_results,
|
||||
'deepseekStats': ds_stats,
|
||||
}
|
||||
|
||||
ml_decisions = engine.flush_ml_decisions()
|
||||
if ml_decisions:
|
||||
ml_jsonl_path = os.path.join(CFG.results_dir, f'ml-decisions-ds-{state.batch_id}.jsonl')
|
||||
os.makedirs(os.path.dirname(ml_jsonl_path) or '.', exist_ok=True)
|
||||
with open(ml_jsonl_path, 'w', encoding='utf-8') as mf:
|
||||
for dec in ml_decisions:
|
||||
mf.write(json.dumps(dec) + '\n')
|
||||
state.log_lines.append(f'[ML Data] Wrote {len(ml_decisions)} decision snapshots to {os.path.basename(ml_jsonl_path)}')
|
||||
else:
|
||||
state.log_lines.append('[ML Data] No decision snapshots captured (0 decisions)')
|
||||
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(batch_result, f, indent=2, default=str)
|
||||
|
||||
state.result_path = output_path
|
||||
state.elapsed_ms = int(elapsed * 1000)
|
||||
state.running = False
|
||||
state.completed_games = total_games
|
||||
|
||||
state.log_lines.append(f'[DeepSeek Batch] Complete: {completed} games in {elapsed:.1f}s')
|
||||
for ds in all_deck_results:
|
||||
state.log_lines.append(f' {ds["deckName"]}: {ds["wins"]}W-{ds["losses"]}L ({ds["winRate"]}% WR)')
|
||||
|
||||
log_sim.info(f'Batch {state.batch_id} complete: {completed} games in {elapsed:.1f}s')
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
state.error = str(e)
|
||||
state.running = False
|
||||
state.log_lines.append(f'[DeepSeek Batch] FATAL: {e}')
|
||||
traceback.print_exc()
|
||||
+230
-125
@@ -1,125 +1,230 @@
|
||||
"""Precon deck download, caching, and conversion logic."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
PRECON_DIR = Path(__file__).parent.parent / "precon-decks"
|
||||
PRECON_INDEX: list[dict] = []
|
||||
GITHUB_PRECON_URL = (
|
||||
"https://raw.githubusercontent.com/taw/magic-preconstructed-decks-data/"
|
||||
"master/decks_v2.json"
|
||||
)
|
||||
PRECON_CACHE_HOURS = 168
|
||||
|
||||
|
||||
def load_precon_index():
|
||||
global PRECON_INDEX
|
||||
idx_path = PRECON_DIR / "precon-index.json"
|
||||
if idx_path.exists():
|
||||
with open(idx_path, "r", encoding="utf-8") as f:
|
||||
PRECON_INDEX = json.load(f)
|
||||
log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
|
||||
else:
|
||||
PRECON_INDEX = []
|
||||
log.info(f" Precons: index not found at {idx_path}")
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
safe = re.sub(r'[<>:"/\\|?*]', '', name)
|
||||
safe = safe.replace(' ', '_').replace("'", '').replace('!', '')
|
||||
return re.sub(r'_+', '_', safe).strip('_')
|
||||
|
||||
|
||||
def _deck_to_dck(deck_data: dict) -> str:
|
||||
lines = ["[metadata]", f"Name={deck_data['name']}"]
|
||||
commanders = deck_data.get('commander', [])
|
||||
if commanders:
|
||||
lines.append("[Commander]")
|
||||
for card in commanders:
|
||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||
cards = deck_data.get('cards', [])
|
||||
if cards:
|
||||
lines.append("[Main]")
|
||||
for card in cards:
|
||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||
sideboard = deck_data.get('sideboard', [])
|
||||
if sideboard:
|
||||
lines.append("[Sideboard]")
|
||||
for card in sideboard:
|
||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def download_precon_database(force: bool = False) -> dict:
|
||||
global PRECON_INDEX
|
||||
idx_path = PRECON_DIR / "precon-index.json"
|
||||
if not force and idx_path.exists():
|
||||
try:
|
||||
with open(idx_path, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
if len(existing) > 50:
|
||||
age_hours = (time.time() - idx_path.stat().st_mtime) / 3600
|
||||
if age_hours < PRECON_CACHE_HOURS:
|
||||
log.info(f" Precons: {len(existing)} decks cached ({age_hours:.0f}h old)")
|
||||
PRECON_INDEX = existing
|
||||
return {"downloaded": 0, "skipped": True, "total": len(existing), "error": None}
|
||||
except Exception:
|
||||
pass
|
||||
log.info(" Precons: Downloading full precon database from GitHub...")
|
||||
try:
|
||||
req = Request(GITHUB_PRECON_URL, headers={"User-Agent": "CommanderAILab/3.0"})
|
||||
with urlopen(req, timeout=120) as resp:
|
||||
all_decks = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
msg = f"Failed to download precon database: {e}"
|
||||
log.error(f" Precons: ERROR - {msg}")
|
||||
if idx_path.exists():
|
||||
load_precon_index()
|
||||
return {"downloaded": 0, "skipped": False, "error": msg}
|
||||
commander_decks = [
|
||||
d for d in all_decks
|
||||
if d.get('type') == 'Commander Deck'
|
||||
and (d.get('format') or '').lower() == 'commander'
|
||||
]
|
||||
log.info(f" Precons: Found {len(commander_decks)} Commander precon decks")
|
||||
PRECON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name_counts = Counter(_sanitize_filename(d['name']) for d in commander_decks)
|
||||
dup_names = {n for n, c in name_counts.items() if c > 1}
|
||||
index = []
|
||||
written = 0
|
||||
for deck in sorted(commander_decks, key=lambda d: (d.get('release_date', ''), d.get('name', ''))):
|
||||
safe_name = _sanitize_filename(deck['name'])
|
||||
if safe_name in dup_names:
|
||||
sc = (deck.get('set_code') or 'unk').upper()
|
||||
safe_name = f"{safe_name}_{sc}"
|
||||
file_name = f"{safe_name}.dck"
|
||||
dck_path = PRECON_DIR / file_name
|
||||
commanders = deck.get('commander', [])
|
||||
cmdr_names = [c['name'] for c in commanders] if commanders else []
|
||||
total_cards = sum(c.get('count', 1) for c in deck.get('cards', [])) + sum(c.get('count', 1) for c in commanders)
|
||||
with open(dck_path, "w", encoding="utf-8") as f:
|
||||
f.write(_deck_to_dck(deck))
|
||||
written += 1
|
||||
release = deck.get('release_date', '')
|
||||
year = int(release[:4]) if release and len(release) >= 4 else 0
|
||||
index.append({
|
||||
"name": deck['name'], "commander": cmdr_names[0] if cmdr_names else "Unknown",
|
||||
"commanders": cmdr_names, "colors": [],
|
||||
"set": deck.get('set_name', ''), "setCode": deck.get('set_code', ''),
|
||||
"year": year, "releaseDate": release, "theme": "",
|
||||
"fileName": file_name, "cardCount": total_cards,
|
||||
})
|
||||
with open(idx_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=2, ensure_ascii=False)
|
||||
PRECON_INDEX = index
|
||||
log.info(f" Precons: {written} .dck files written, index saved")
|
||||
return {"downloaded": written, "skipped": False, "total": written, "error": None}
|
||||
"""Precon deck download, caching, and conversion logic."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
# Fallback default — used only when CFG.precon_dir has not been resolved yet.
|
||||
_DEFAULT_PRECON_DIR = Path(__file__).parent.parent / "precon-decks"
|
||||
|
||||
PRECON_INDEX: list[dict] = []
|
||||
GITHUB_PRECON_URL = (
|
||||
"https://raw.githubusercontent.com/taw/magic-preconstructed-decks-data/"
|
||||
"master/decks_v2.json"
|
||||
)
|
||||
PRECON_CACHE_HOURS = 168
|
||||
_SCRYFALL_COLLECTION_URL = "https://api.scryfall.com/cards/collection"
|
||||
|
||||
|
||||
def _get_precon_dir() -> Path:
|
||||
"""Return the active precon-decks directory from CFG, with fallback."""
|
||||
from models.state import CFG
|
||||
if CFG.precon_dir:
|
||||
return Path(CFG.precon_dir)
|
||||
return _DEFAULT_PRECON_DIR
|
||||
|
||||
|
||||
# Re-export for backwards compat — callers that import PRECON_DIR at module
|
||||
# level will get the default. Runtime code should call _get_precon_dir().
|
||||
PRECON_DIR = _DEFAULT_PRECON_DIR
|
||||
|
||||
|
||||
def load_precon_index():
|
||||
"""Load precon index from disk into the shared PRECON_INDEX list.
|
||||
|
||||
We mutate the list in-place (clear + extend) so that any module that
|
||||
captured a reference via ``from precon_service import PRECON_INDEX``
|
||||
will see the updated contents.
|
||||
"""
|
||||
precon_dir = _get_precon_dir()
|
||||
idx_path = precon_dir / "precon-index.json"
|
||||
if idx_path.exists():
|
||||
with open(idx_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
PRECON_INDEX.clear()
|
||||
PRECON_INDEX.extend(data)
|
||||
log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
|
||||
else:
|
||||
PRECON_INDEX.clear()
|
||||
log.info(f" Precons: index not found at {idx_path}")
|
||||
|
||||
|
||||
def _sanitize_filename(name: str) -> str:
|
||||
safe = re.sub(r'[<>:"/\\|?*]', '', name)
|
||||
safe = safe.replace(' ', '_').replace("'", '').replace('!', '')
|
||||
return re.sub(r'_+', '_', safe).strip('_')
|
||||
|
||||
|
||||
def _deck_to_dck(deck_data: dict) -> str:
|
||||
lines = ["[metadata]", f"Name={deck_data['name']}"]
|
||||
commanders = deck_data.get('commander', [])
|
||||
if commanders:
|
||||
lines.append("[Commander]")
|
||||
for card in commanders:
|
||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||
cards = deck_data.get('cards', [])
|
||||
if cards:
|
||||
lines.append("[Main]")
|
||||
for card in cards:
|
||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||
sideboard = deck_data.get('sideboard', [])
|
||||
if sideboard:
|
||||
lines.append("[Sideboard]")
|
||||
for card in sideboard:
|
||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def download_precon_database(force: bool = False) -> dict:
|
||||
"""Download precon database from GitHub and populate PRECON_INDEX.
|
||||
|
||||
We mutate the list in-place (clear + extend) so that any module that
|
||||
captured a reference via ``from precon_service import PRECON_INDEX``
|
||||
will see the updated contents.
|
||||
"""
|
||||
precon_dir = _get_precon_dir()
|
||||
idx_path = precon_dir / "precon-index.json"
|
||||
if not force and idx_path.exists():
|
||||
try:
|
||||
with open(idx_path, "r", encoding="utf-8") as f:
|
||||
existing = json.load(f)
|
||||
if len(existing) > 50:
|
||||
# Invalidate cache if missing enrichment fields (imageUrl, colors)
|
||||
has_enrichment = existing[0].get("imageUrl") or existing[0].get("colors")
|
||||
age_hours = (time.time() - idx_path.stat().st_mtime) / 3600
|
||||
if has_enrichment and age_hours < PRECON_CACHE_HOURS:
|
||||
log.info(f" Precons: {len(existing)} decks cached ({age_hours:.0f}h old)")
|
||||
PRECON_INDEX.clear()
|
||||
PRECON_INDEX.extend(existing)
|
||||
return {"downloaded": 0, "skipped": True, "total": len(existing), "error": None}
|
||||
if not has_enrichment:
|
||||
log.info(" Precons: Cache missing enrichment data, re-downloading...")
|
||||
except Exception:
|
||||
pass
|
||||
log.info(" Precons: Downloading full precon database from GitHub...")
|
||||
try:
|
||||
req = Request(GITHUB_PRECON_URL, headers={"User-Agent": "CommanderAILab/3.0"})
|
||||
with urlopen(req, timeout=120) as resp:
|
||||
all_decks = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
msg = f"Failed to download precon database: {e}"
|
||||
log.error(f" Precons: ERROR - {msg}")
|
||||
if idx_path.exists():
|
||||
load_precon_index()
|
||||
return {"downloaded": 0, "skipped": False, "error": msg}
|
||||
commander_decks = [
|
||||
d for d in all_decks
|
||||
if d.get('type') == 'Commander Deck'
|
||||
and (d.get('format') or '').lower() == 'commander'
|
||||
]
|
||||
log.info(f" Precons: Found {len(commander_decks)} Commander precon decks")
|
||||
precon_dir.mkdir(parents=True, exist_ok=True)
|
||||
name_counts = Counter(_sanitize_filename(d['name']) for d in commander_decks)
|
||||
dup_names = {n for n, c in name_counts.items() if c > 1}
|
||||
index = []
|
||||
written = 0
|
||||
for deck in sorted(commander_decks, key=lambda d: (d.get('release_date', ''), d.get('name', ''))):
|
||||
safe_name = _sanitize_filename(deck['name'])
|
||||
if safe_name in dup_names:
|
||||
sc = (deck.get('set_code') or 'unk').upper()
|
||||
safe_name = f"{safe_name}_{sc}"
|
||||
file_name = f"{safe_name}.dck"
|
||||
dck_path = precon_dir / file_name
|
||||
commanders = deck.get('commander', [])
|
||||
cmdr_names = [c['name'] for c in commanders] if commanders else []
|
||||
# Build Scryfall art_crop image URL from first commander's set/number
|
||||
image_url = ""
|
||||
if commanders:
|
||||
c0 = commanders[0]
|
||||
sc = (c0.get('set_code') or '').lower()
|
||||
num = c0.get('number', '')
|
||||
if sc and num:
|
||||
image_url = f"https://api.scryfall.com/cards/{sc}/{num}?format=image&version=art_crop"
|
||||
total_cards = sum(c.get('count', 1) for c in deck.get('cards', [])) + sum(c.get('count', 1) for c in commanders)
|
||||
with open(dck_path, "w", encoding="utf-8") as f:
|
||||
f.write(_deck_to_dck(deck))
|
||||
written += 1
|
||||
release = deck.get('release_date', '')
|
||||
year = int(release[:4]) if release and len(release) >= 4 else 0
|
||||
index.append({
|
||||
"name": deck['name'], "commander": cmdr_names[0] if cmdr_names else "Unknown",
|
||||
"commanders": cmdr_names, "colors": [],
|
||||
"set": deck.get('set_name', ''), "setCode": deck.get('set_code', ''),
|
||||
"year": year, "releaseDate": release, "theme": "",
|
||||
"fileName": file_name, "cardCount": total_cards,
|
||||
"imageUrl": image_url,
|
||||
})
|
||||
# Enrich with color identity from Scryfall (best-effort)
|
||||
_enrich_colors(index)
|
||||
with open(idx_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=2, ensure_ascii=False)
|
||||
PRECON_INDEX.clear()
|
||||
PRECON_INDEX.extend(index)
|
||||
log.info(f" Precons: {written} .dck files written, index saved")
|
||||
return {"downloaded": written, "skipped": False, "total": written, "error": None}
|
||||
|
||||
|
||||
def _enrich_colors(index: list[dict]) -> None:
|
||||
"""Batch-fetch color_identity for commanders via Scryfall /cards/collection.
|
||||
|
||||
Scryfall accepts up to 75 identifiers per request. We look up each unique
|
||||
commander name once, then propagate the result to every index entry.
|
||||
"""
|
||||
# Build unique commander -> identifiers map
|
||||
cmdr_lookup: dict[str, dict] = {} # name -> {"name": ...}
|
||||
for entry in index:
|
||||
name = entry.get("commander", "")
|
||||
if name and name != "Unknown" and name not in cmdr_lookup:
|
||||
cmdr_lookup[name] = {"name": name}
|
||||
if not cmdr_lookup:
|
||||
return
|
||||
|
||||
names = list(cmdr_lookup.keys())
|
||||
color_map: dict[str, list[str]] = {} # commander name -> ["W","U",...]
|
||||
|
||||
# Batch in chunks of 75
|
||||
for i in range(0, len(names), 75):
|
||||
batch = [{"name": n} for n in names[i:i + 75]]
|
||||
try:
|
||||
payload = json.dumps({"identifiers": batch}).encode("utf-8")
|
||||
req = Request(
|
||||
_SCRYFALL_COLLECTION_URL,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "CommanderAILab/3.0",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read())
|
||||
for card in result.get("data", []):
|
||||
cname = card.get("name", "")
|
||||
ci = card.get("color_identity", [])
|
||||
if cname and ci:
|
||||
color_map[cname] = ci
|
||||
# Handle double-faced names: "A // B" -> also store "A"
|
||||
if " // " in cname:
|
||||
front = cname.split(" // ")[0]
|
||||
color_map[front] = ci
|
||||
# Respect Scryfall rate limit (100ms between requests)
|
||||
time.sleep(0.15)
|
||||
except Exception as e:
|
||||
log.warning(f" Precons: Scryfall color lookup failed for batch {i}: {e}")
|
||||
|
||||
# Apply colors to index entries
|
||||
enriched = 0
|
||||
for entry in index:
|
||||
name = entry.get("commander", "")
|
||||
if name in color_map:
|
||||
entry["colors"] = color_map[name]
|
||||
enriched += 1
|
||||
log.info(f" Precons: Enriched {enriched}/{len(index)} decks with color identity")
|
||||
+13
-13
@@ -11,7 +11,7 @@ from urllib.request import Request, urlopen
|
||||
|
||||
# Lazy import to avoid circular dependency
|
||||
def _detect_card_roles(oracle_text, type_line, keywords):
|
||||
from services.card_analysis import _detect_card_roles as _dcr
|
||||
from services.card_analysis import _detect_card_roles as _dcr
|
||||
return _dcr(oracle_text, type_line, keywords)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class _ScryfallCache:
|
||||
self._misses = 0
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
def _init_db(self):
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("""
|
||||
@@ -43,11 +43,11 @@ class _ScryfallCache:
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
@staticmethod
|
||||
@staticmethod
|
||||
def _make_key(name: str, set_code: str, collector_number: str) -> str:
|
||||
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
|
||||
|
||||
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
|
||||
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
@@ -59,7 +59,7 @@ class _ScryfallCache:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
def put(self, name: str, set_code: str, collector_number: str, data: dict):
|
||||
def put(self, name: str, set_code: str, collector_number: str, data: dict):
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
blob = json.dumps(data)
|
||||
with self._lock:
|
||||
@@ -69,12 +69,12 @@ class _ScryfallCache:
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def stats(self) -> dict:
|
||||
def stats(self) -> dict:
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
return {"cached": count, "hits": self._hits, "misses": self._misses}
|
||||
|
||||
def clear(self) -> int:
|
||||
def clear(self) -> int:
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
self._conn.execute("DELETE FROM scryfall_cache")
|
||||
@@ -95,7 +95,7 @@ class _ScryfallCache:
|
||||
return deleted
|
||||
|
||||
|
||||
_scryfall_cache = _ScryfallCache()
|
||||
_scryfall_cache = _ScryfallCache()
|
||||
_scryfall_lock = threading.Lock()
|
||||
_scryfall_last_call = 0.0
|
||||
|
||||
@@ -110,7 +110,7 @@ def _scryfall_rate_limit():
|
||||
_scryfall_last_call = time.monotonic()
|
||||
|
||||
|
||||
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
from urllib.parse import quote
|
||||
cached = _scryfall_cache.get(name, set_code, collector_number)
|
||||
if cached is not None:
|
||||
@@ -133,7 +133,7 @@ def _scryfall_rate_limit():
|
||||
card_data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
if not card_data or card_data.get("object") == "error":
|
||||
if not card_data or card_data.get("object") == "error":
|
||||
err_detail = card_data.get("details", "unknown error") if card_data else last_error
|
||||
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
|
||||
_scryfall_cache.put(name, set_code, collector_number, card_data)
|
||||
@@ -143,7 +143,7 @@ def _scryfall_rate_limit():
|
||||
return card_data
|
||||
|
||||
|
||||
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
card_data = _fetch_scryfall_api(name, set_code, collector_number)
|
||||
if not card_data or "_error" in card_data:
|
||||
return card_data or {}
|
||||
@@ -165,7 +165,7 @@ def _scryfall_rate_limit():
|
||||
oracle_text = card_data.get("oracle_text", "")
|
||||
if not oracle_text and card_data.get("card_faces"):
|
||||
oracle_text = "\n//\n".join(f.get("oracle_text", "") for f in card_data["card_faces"] if f.get("oracle_text"))
|
||||
power = card_data.get("power", "")
|
||||
power = card_data.get("power", "")
|
||||
toughness = card_data.get("toughness", "")
|
||||
if not power and card_data.get("card_faces"):
|
||||
power = card_data["card_faces"][0].get("power", "")
|
||||
@@ -199,7 +199,7 @@ def _scryfall_rate_limit():
|
||||
}
|
||||
|
||||
|
||||
def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
|
||||
def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
|
||||
"""Fuzzy-search Scryfall for a card name. Returns raw Scryfall JSON dict, or None on failure."""
|
||||
from urllib.parse import quote
|
||||
_scryfall_rate_limit()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Commander AI Deck Builder
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Top-level package for the deck builder module.
|
||||
|
||||
Usage::
|
||||
|
||||
from commander_ai_lab.deck_builder import build_deck, BuildRequest
|
||||
|
||||
result = build_deck(BuildRequest(commander_name="Atraxa, Praetors' Voice"))
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from .pipeline import build_deck
|
||||
from .core.models import BuildRequest, BuildResult
|
||||
|
||||
__all__ = ["build_deck", "BuildRequest", "BuildResult", "__version__"]
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Adapter: bridges the new Ollama deck_builder pipeline to the legacy
|
||||
DeckGeneratorV3 dict interface expected by lab_api.py endpoints.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections import Counter
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from .pipeline.build_deck import build_deck
|
||||
from .core.models import BuildRequest, BuildResult, DeckRatios
|
||||
|
||||
logger = logging.getLogger("commander_ai_lab.deckgen")
|
||||
|
||||
|
||||
class DeckBuilderAdapter:
|
||||
"""
|
||||
Drop-in replacement for DeckGeneratorV3.
|
||||
Wraps the Ollama-based build_deck pipeline and returns
|
||||
the same dict shape the existing FastAPI endpoints expect.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_conn_factory: Callable[[], sqlite3.Connection] = None,
|
||||
model: str = "gpt-oss:20b",
|
||||
):
|
||||
self.db_conn_factory = db_conn_factory
|
||||
self.model = model
|
||||
|
||||
# -- public API (matches DeckGeneratorV3.generate_deck signature) --
|
||||
def generate_deck(
|
||||
self,
|
||||
commander_name: str,
|
||||
strategy: str = "",
|
||||
target_bracket: int = 3,
|
||||
budget_usd: Optional[float] = None,
|
||||
budget_mode: str = "total",
|
||||
omit_cards: Optional[List[str]] = None,
|
||||
use_collection: bool = True,
|
||||
model: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run the 7-step Ollama pipeline and return a dict that matches
|
||||
the shape expected by /api/deck/v3/generate.
|
||||
"""
|
||||
request = BuildRequest(
|
||||
commander_name=commander_name,
|
||||
strategy_notes=strategy or None,
|
||||
collection_only=use_collection,
|
||||
budget_limit=budget_usd,
|
||||
ratios=DeckRatios(),
|
||||
)
|
||||
|
||||
result: BuildResult = build_deck(request)
|
||||
deck = result.deck
|
||||
commander = deck.commander
|
||||
color_identity = sorted(commander.color_identity)
|
||||
|
||||
cards_with_status = self._check_ownership(
|
||||
[self._card_to_dict(c) for c in deck.cards]
|
||||
)
|
||||
cards_with_status = self._fill_basic_lands(
|
||||
cards_with_status, color_identity, target_total=100
|
||||
)
|
||||
stats = self._compute_stats(cards_with_status)
|
||||
|
||||
raw_deck = {
|
||||
"commander": commander.name,
|
||||
"strategy_summary": strategy or f"{commander.name} synergy deck",
|
||||
"archetype": strategy or "synergy",
|
||||
"bracket": {"level": target_bracket},
|
||||
"reasoning": {
|
||||
"strategy": f"Built with local Ollama ({self.model})",
|
||||
"sources": result.sources_consulted,
|
||||
"warnings": result.warnings,
|
||||
},
|
||||
"estimated_total_usd": 0,
|
||||
"cards": [self._card_to_raw(c) for c in deck.cards],
|
||||
}
|
||||
|
||||
return {
|
||||
"commander": {
|
||||
"name": commander.name,
|
||||
"type_line": commander.type_line or "Legendary Creature",
|
||||
"color_identity": color_identity,
|
||||
"mana_cost": commander.mana_cost or "",
|
||||
"cmc": commander.cmc,
|
||||
"scryfall_id": commander.scryfall_id,
|
||||
},
|
||||
"color_identity": color_identity,
|
||||
"raw_deck": raw_deck,
|
||||
"cards": cards_with_status,
|
||||
"stats": stats,
|
||||
"strategy_summary": raw_deck["strategy_summary"],
|
||||
"bracket": raw_deck["bracket"],
|
||||
"archetype": raw_deck["archetype"],
|
||||
"reasoning": raw_deck["reasoning"],
|
||||
"estimated_total_usd": 0,
|
||||
"tokens_used": {"prompt": 0, "completion": 0},
|
||||
"model": self.model,
|
||||
"citations": [],
|
||||
"truncated": False,
|
||||
"build_time_seconds": result.build_time_seconds,
|
||||
}
|
||||
|
||||
# -- substitution stub --
|
||||
def run_substitution(self, cards, commander: str = "", strategy: str = "", **kw):
|
||||
"""
|
||||
No-op substitution -- Ollama pipeline handles collection internally.
|
||||
Returns a SimpleNamespace matching the shape DeckGeneratorV3 returns.
|
||||
"""
|
||||
owned = sum(
|
||||
1 for c in cards
|
||||
if getattr(c, "owned", False)
|
||||
or (isinstance(c, dict) and c.get("owned"))
|
||||
)
|
||||
return SimpleNamespace(
|
||||
cards=cards,
|
||||
owned_count=owned,
|
||||
substituted_count=0,
|
||||
missing_count=len(cards) - owned,
|
||||
)
|
||||
|
||||
# -- helpers --
|
||||
@staticmethod
|
||||
def _card_to_dict(card) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": card.name,
|
||||
"count": card.quantity,
|
||||
"category": card.category,
|
||||
"mana_cost": card.mana_cost or "",
|
||||
"cmc": card.cmc,
|
||||
"type_line": card.type_line or "",
|
||||
"color_identity": sorted(card.color_identity),
|
||||
"scryfall_id": card.scryfall_id,
|
||||
"edhrec_rank": card.edhrec_rank,
|
||||
"source": card.source or "ollama",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _card_to_raw(card) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": card.name,
|
||||
"count": card.quantity,
|
||||
"category": card.category,
|
||||
"reasoning": "",
|
||||
}
|
||||
|
||||
def _check_ownership(self, cards: List[Dict]) -> List[Dict]:
|
||||
if not self.db_conn_factory:
|
||||
for c in cards:
|
||||
c["owned"] = False
|
||||
c["owned_qty"] = 0
|
||||
c["from_collection"] = False
|
||||
c.setdefault("estimated_price_usd", 0)
|
||||
return cards
|
||||
try:
|
||||
conn = self.db_conn_factory()
|
||||
for card in cards:
|
||||
name = card.get("name", "")
|
||||
if not name:
|
||||
continue
|
||||
row = conn.execute(
|
||||
"SELECT name, scryfall_id, quantity, tcg_price "
|
||||
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
|
||||
(name,),
|
||||
).fetchone()
|
||||
if row:
|
||||
card["owned"] = True
|
||||
card["owned_qty"] = row[2] if row else 0
|
||||
card["from_collection"] = True
|
||||
card["scryfall_id"] = card.get("scryfall_id") or row[1]
|
||||
card["estimated_price_usd"] = float(row[3]) if row[3] else 0
|
||||
else:
|
||||
card["owned"] = False
|
||||
card["owned_qty"] = 0
|
||||
card["from_collection"] = False
|
||||
card.setdefault("estimated_price_usd", 0)
|
||||
except Exception as e:
|
||||
logger.warning("Collection check failed: %s", e)
|
||||
for c in cards:
|
||||
c.setdefault("owned", False)
|
||||
c.setdefault("owned_qty", 0)
|
||||
c.setdefault("from_collection", False)
|
||||
c.setdefault("estimated_price_usd", 0)
|
||||
return cards
|
||||
|
||||
@staticmethod
|
||||
def _fill_basic_lands(
|
||||
cards: List[Dict], color_identity: List[str], target_total: int = 100
|
||||
) -> List[Dict]:
|
||||
current = sum(c.get("count", 1) for c in cards)
|
||||
needed = target_total - current
|
||||
if needed <= 0:
|
||||
return cards
|
||||
ci_to_basic = {"W": "Plains", "U": "Island", "B": "Swamp", "R": "Mountain", "G": "Forest"}
|
||||
basics = [ci_to_basic[c] for c in color_identity if c in ci_to_basic] or ["Wastes"]
|
||||
for i in range(needed):
|
||||
land_name = basics[i % len(basics)]
|
||||
existing = next((c for c in cards if c["name"] == land_name), None)
|
||||
if existing:
|
||||
existing["count"] = existing.get("count", 1) + 1
|
||||
else:
|
||||
cards.append({
|
||||
"name": land_name, "count": 1, "category": "lands",
|
||||
"mana_cost": "", "cmc": 0, "type_line": "Basic Land",
|
||||
"color_identity": [], "owned": False, "owned_qty": 0,
|
||||
"from_collection": False, "estimated_price_usd": 0,
|
||||
"source": "auto-fill",
|
||||
})
|
||||
return cards
|
||||
|
||||
@staticmethod
|
||||
def _compute_stats(cards: List[Dict]) -> Dict[str, Any]:
|
||||
total = sum(c.get("count", 1) for c in cards)
|
||||
categories = Counter()
|
||||
cmc_total = 0.0
|
||||
nonland_count = 0
|
||||
for c in cards:
|
||||
count = c.get("count", 1)
|
||||
cat = c.get("category", "uncategorized")
|
||||
categories[cat] += count
|
||||
if cat != "lands":
|
||||
cmc_total += c.get("cmc", 0) * count
|
||||
nonland_count += count
|
||||
avg_cmc = round(cmc_total / nonland_count, 2) if nonland_count else 0
|
||||
return {
|
||||
"total_cards": total,
|
||||
"category_breakdown": dict(categories),
|
||||
"average_cmc": avg_cmc,
|
||||
"land_count": categories.get("lands", 0),
|
||||
"nonland_count": nonland_count,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""API subpackage – external service clients (Scryfall, EDHRec, Ollama)."""
|
||||
|
||||
from . import scryfall
|
||||
from . import edhrec
|
||||
from . import ollama_client
|
||||
from .community_scraper import CommunityScraper
|
||||
|
||||
__all__ = [
|
||||
"scryfall",
|
||||
"edhrec",
|
||||
"ollama_client",
|
||||
"CommunityScraper",
|
||||
]
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Community scraper – pull popular decklists from Moxfield, Archidekt, and Reddit.
|
||||
|
||||
This is a Phase-3 expansion module. Currently provides stub implementations
|
||||
that return empty results. Wire up real scraping logic as the project matures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MOXFIELD_API = "https://api2.moxfield.com/v3/decks/search"
|
||||
ARCHIDEKT_API = "https://archidekt.com/api/decks/"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommunityDeck:
|
||||
"""Minimal representation of a community-sourced decklist."""
|
||||
|
||||
source: str # "moxfield" | "archidekt" | "reddit"
|
||||
title: str
|
||||
url: str
|
||||
card_names: List[str] = field(default_factory=list)
|
||||
author: Optional[str] = None
|
||||
|
||||
|
||||
class CommunityScraper:
|
||||
"""Aggregate community decklists for a given commander."""
|
||||
|
||||
def __init__(self, timeout: int = 15):
|
||||
self.timeout = timeout
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({"User-Agent": "CommanderAILab/0.1"})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Moxfield
|
||||
# ------------------------------------------------------------------
|
||||
def search_moxfield(
|
||||
self, commander_name: str, *, max_results: int = 10
|
||||
) -> List[CommunityDeck]:
|
||||
"""Search Moxfield for public decks featuring *commander_name*."""
|
||||
try:
|
||||
resp = self._session.get(
|
||||
MOXFIELD_API,
|
||||
params={
|
||||
"q": commander_name,
|
||||
"fmt": "commander",
|
||||
"sort": "views",
|
||||
"sortDirection": "descending",
|
||||
"pageSize": max_results,
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
decks: List[CommunityDeck] = []
|
||||
for item in data.get("data", []):
|
||||
pub_id = item.get("publicId", "")
|
||||
decks.append(
|
||||
CommunityDeck(
|
||||
source="moxfield",
|
||||
title=item.get("name", ""),
|
||||
url=f"https://www.moxfield.com/decks/{pub_id}",
|
||||
author=item.get("createdByUser", {}).get("userName"),
|
||||
)
|
||||
)
|
||||
logger.info("Moxfield returned %d decks for %s", len(decks), commander_name)
|
||||
return decks
|
||||
except Exception:
|
||||
logger.warning("Moxfield search failed for %s", commander_name, exc_info=True)
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Archidekt
|
||||
# ------------------------------------------------------------------
|
||||
def search_archidekt(
|
||||
self, commander_name: str, *, max_results: int = 10
|
||||
) -> List[CommunityDeck]:
|
||||
"""Search Archidekt for public decks featuring *commander_name*."""
|
||||
try:
|
||||
resp = self._session.get(
|
||||
ARCHIDEKT_API,
|
||||
params={
|
||||
"commanders": commander_name,
|
||||
"formats": 3, # Commander
|
||||
"orderBy": "-viewCount",
|
||||
"pageSize": max_results,
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
decks: List[CommunityDeck] = []
|
||||
for item in data.get("results", []):
|
||||
deck_id = item.get("id", "")
|
||||
decks.append(
|
||||
CommunityDeck(
|
||||
source="archidekt",
|
||||
title=item.get("name", ""),
|
||||
url=f"https://archidekt.com/decks/{deck_id}",
|
||||
author=item.get("owner", {}).get("username"),
|
||||
)
|
||||
)
|
||||
logger.info("Archidekt returned %d decks for %s", len(decks), commander_name)
|
||||
return decks
|
||||
except Exception:
|
||||
logger.warning("Archidekt search failed for %s", commander_name, exc_info=True)
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Aggregate
|
||||
# ------------------------------------------------------------------
|
||||
def search_all(
|
||||
self, commander_name: str, *, max_per_source: int = 10
|
||||
) -> List[CommunityDeck]:
|
||||
"""Search all community sources and return combined results."""
|
||||
results: List[CommunityDeck] = []
|
||||
results.extend(self.search_moxfield(commander_name, max_results=max_per_source))
|
||||
results.extend(self.search_archidekt(commander_name, max_results=max_per_source))
|
||||
return results
|
||||
|
||||
def extract_popular_cards(
|
||||
self, decks: List[CommunityDeck], *, min_frequency: int = 2
|
||||
) -> Dict[str, int]:
|
||||
"""Count card frequency across decks; return cards seen >= min_frequency."""
|
||||
counts: Dict[str, int] = {}
|
||||
for deck in decks:
|
||||
for name in deck.card_names:
|
||||
counts[name] = counts.get(name, 0) + 1
|
||||
return {k: v for k, v in counts.items() if v >= min_frequency}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
EDHrec JSON client for the Commander AI Deck Builder.
|
||||
|
||||
Fetches commander recommendations, top cards, and synergy data
|
||||
from EDHrec's public JSON API (json.edhrec.com).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from ..core.models import CardEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_URL = "https://json.edhrec.com/pages"
|
||||
HEADERS = {
|
||||
"User-Agent": "CommanderAI-DeckBuilder/0.1 (github.com/KoalaTrapLord/commander-ai-lab)",
|
||||
}
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
"""Convert a commander name to an EDHrec URL slug."""
|
||||
slug = name.lower().strip()
|
||||
slug = re.sub(r"[',.]", "", slug)
|
||||
slug = re.sub(r"\s+", "-", slug)
|
||||
return slug
|
||||
|
||||
|
||||
def _get_json(path: str) -> Optional[Dict]:
|
||||
"""Fetch JSON from EDHrec, returning None on failure."""
|
||||
url = f"{BASE_URL}/{path}.json"
|
||||
try:
|
||||
resp = requests.get(url, headers=HEADERS, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except (requests.HTTPError, requests.ConnectionError, ValueError) as e:
|
||||
logger.warning(f"EDHrec fetch failed for {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ── commander page ───────────────────────────────────────────────
|
||||
def get_commander_data(commander_name: str) -> Optional[Dict]:
|
||||
"""
|
||||
Fetch the full EDHrec commander page JSON.
|
||||
|
||||
Returns the raw dict with keys like 'cardlists', 'container', etc.
|
||||
"""
|
||||
slug = _slugify(commander_name)
|
||||
return _get_json(f"commanders/{slug}")
|
||||
|
||||
|
||||
def get_recommended_cards(commander_name: str) -> List[CardEntry]:
|
||||
"""
|
||||
Get EDHrec's recommended cards for a commander.
|
||||
|
||||
Returns CardEntry objects with edhrec_rank populated from
|
||||
the 'num_decks' / inclusion percentage.
|
||||
"""
|
||||
data = get_commander_data(commander_name)
|
||||
if not data:
|
||||
return []
|
||||
|
||||
cards: List[CardEntry] = []
|
||||
cardlists = data.get("container", {}).get("json_dict", {}).get("cardlists", [])
|
||||
|
||||
for cardlist in cardlists:
|
||||
header = cardlist.get("header", "uncategorized").lower()
|
||||
category = _map_edhrec_category(header)
|
||||
|
||||
for card_data in cardlist.get("cardviews", []):
|
||||
name = card_data.get("name", "")
|
||||
if not name:
|
||||
continue
|
||||
|
||||
cards.append(
|
||||
CardEntry(
|
||||
name=name,
|
||||
category=category,
|
||||
color_identity=set(card_data.get("color_identity", [])),
|
||||
cmc=card_data.get("cmc", 0.0),
|
||||
type_line=card_data.get("type_line"),
|
||||
edhrec_rank=card_data.get("num_decks"),
|
||||
source="edhrec",
|
||||
)
|
||||
)
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
# ── top cards by color identity ──────────────────────────────────
|
||||
def get_top_cards_by_colors(colors: str) -> List[CardEntry]:
|
||||
"""
|
||||
Fetch top EDHrec cards for a color combination.
|
||||
|
||||
Args:
|
||||
colors: e.g. "wub", "rg", "wubrg", "colorless"
|
||||
"""
|
||||
data = _get_json(f"commanders/{colors.lower()}")
|
||||
if not data:
|
||||
return []
|
||||
|
||||
cards: List[CardEntry] = []
|
||||
card_list = data.get("container", {}).get("json_dict", {}).get("cardlists", [])
|
||||
for cardlist in card_list:
|
||||
for card_data in cardlist.get("cardviews", []):
|
||||
name = card_data.get("name", "")
|
||||
if name:
|
||||
cards.append(
|
||||
CardEntry(
|
||||
name=name,
|
||||
color_identity=set(card_data.get("color_identity", [])),
|
||||
edhrec_rank=card_data.get("num_decks"),
|
||||
source="edhrec",
|
||||
)
|
||||
)
|
||||
return cards
|
||||
|
||||
|
||||
# ── average deck ─────────────────────────────────────────────────
|
||||
def get_average_deck(commander_name: str) -> List[CardEntry]:
|
||||
"""
|
||||
Fetch the EDHrec 'average deck' for a commander.
|
||||
|
||||
This is the most common 99 cards people play with this commander.
|
||||
"""
|
||||
slug = _slugify(commander_name)
|
||||
data = _get_json(f"average-decks/{slug}")
|
||||
if not data:
|
||||
return []
|
||||
|
||||
cards: List[CardEntry] = []
|
||||
deck_data = data.get("container", {}).get("json_dict", {})
|
||||
|
||||
for card_data in deck_data.get("cardlists", [{}]):
|
||||
for cv in card_data.get("cardviews", []):
|
||||
name = cv.get("name", "")
|
||||
if name:
|
||||
cards.append(
|
||||
CardEntry(
|
||||
name=name,
|
||||
color_identity=set(cv.get("color_identity", [])),
|
||||
edhrec_rank=cv.get("num_decks"),
|
||||
source="edhrec",
|
||||
)
|
||||
)
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
# ── category mapper ──────────────────────────────────────────────
|
||||
def _map_edhrec_category(header: str) -> str:
|
||||
"""Map EDHrec section headers to our deck categories."""
|
||||
header = header.lower()
|
||||
mapping = {
|
||||
"ramp": "ramp",
|
||||
"mana": "ramp",
|
||||
"land": "lands",
|
||||
"lands": "lands",
|
||||
"draw": "card_draw",
|
||||
"card draw": "card_draw",
|
||||
"card advantage": "card_draw",
|
||||
"removal": "removal",
|
||||
"interaction": "removal",
|
||||
"board wipe": "removal",
|
||||
"wipe": "removal",
|
||||
"protection": "protection",
|
||||
"counter": "protection",
|
||||
"win": "wincon",
|
||||
"finisher": "wincon",
|
||||
"combo": "wincon",
|
||||
}
|
||||
for keyword, category in mapping.items():
|
||||
if keyword in header:
|
||||
return category
|
||||
return "synergy"
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Ollama gpt-oss:20b client for the Commander AI Deck Builder.
|
||||
|
||||
Uses the OpenAI-compatible API exposed by Ollama at localhost:11434.
|
||||
Provides 6 core functions:
|
||||
1. analyze_synergies - Rank card candidates by synergy with commander
|
||||
2. suggest_cards - Suggest cards for a specific category
|
||||
3. filter_color_identity - Validate cards against commander colors
|
||||
4. enforce_deck_ratios - Adjust card counts to hit ratio targets
|
||||
5. assemble_deck_json - Produce final structured 99-card JSON
|
||||
6. chat - Raw chat completion for ad-hoc queries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ollama exposes an OpenAI-compatible endpoint
|
||||
DEFAULT_BASE_URL = "http://localhost:11434/v1"
|
||||
DEFAULT_MODEL = "gpt-oss:20b"
|
||||
|
||||
|
||||
def _get_client(base_url: str = DEFAULT_BASE_URL) -> OpenAI:
|
||||
"""Create an OpenAI client pointing at the local Ollama server."""
|
||||
return OpenAI(base_url=base_url, api_key="ollama")
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Any:
|
||||
"""Best-effort extraction of JSON from possibly messy LLM output.
|
||||
|
||||
Handles:
|
||||
- Markdown code fences (```json ... ```)
|
||||
- Leading/trailing prose around JSON
|
||||
- Trailing commas before } or ]
|
||||
"""
|
||||
text = raw.strip()
|
||||
|
||||
# Strip markdown fences
|
||||
if "```" in text:
|
||||
match = re.search(r"```(?:json)?\s*\n?(.*?)```", text, re.DOTALL)
|
||||
if match:
|
||||
text = match.group(1).strip()
|
||||
|
||||
# Try direct parse first
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Remove trailing commas (common LLM error)
|
||||
cleaned = re.sub(r",\s*([}\]])", r"\1", text)
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Try to find the first { or [ and last } or ]
|
||||
for start_char, end_char in [("{", "}"), ("[", "]")]:
|
||||
start = text.find(start_char)
|
||||
end = text.rfind(end_char)
|
||||
if start != -1 and end != -1 and end > start:
|
||||
substring = text[start : end + 1]
|
||||
substring = re.sub(r",\s*([}\]])", r"\1", substring)
|
||||
try:
|
||||
return json.loads(substring)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
raise json.JSONDecodeError("No valid JSON found in response", text, 0)
|
||||
|
||||
|
||||
def chat(
|
||||
messages: List[Dict[str, str]],
|
||||
model: str = DEFAULT_MODEL,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 4096,
|
||||
json_mode: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Send a chat completion request to Ollama.
|
||||
|
||||
Returns the assistant's response as a string.
|
||||
"""
|
||||
client = _get_client()
|
||||
kwargs: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if json_mode:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
response = client.chat.completions.create(**kwargs)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
|
||||
# ── 1. Analyze synergies ─────────────────────────────────────────
|
||||
|
||||
def analyze_synergies(
|
||||
commander_name: str,
|
||||
commander_text: str,
|
||||
candidates: List[str],
|
||||
strategy_notes: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Rank candidate cards by synergy with the commander.
|
||||
Returns a list of dicts: [{"name": str, "score": int, "reason": str}, ...]
|
||||
Score is 1-10.
|
||||
"""
|
||||
strategy = f"\nStrategy notes: {strategy_notes}" if strategy_notes else ""
|
||||
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
|
||||
|
||||
Commander: {commander_name}
|
||||
Commander text: {commander_text}
|
||||
{strategy}
|
||||
|
||||
Rate each of these cards on a 1-10 synergy scale with this commander.
|
||||
Return ONLY a JSON array of objects with keys: "name", "score", "reason".
|
||||
|
||||
Cards to evaluate:
|
||||
{json.dumps(candidates)}"""
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an expert MTG Commander deck builder. Always respond with valid JSON only. No explanation text."},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
raw = chat(messages, json_mode=True)
|
||||
try:
|
||||
result = _extract_json(raw)
|
||||
if isinstance(result, dict) and "cards" in result:
|
||||
return result["cards"]
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
return []
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Failed to parse synergy response: %s", raw[:500])
|
||||
return []
|
||||
|
||||
|
||||
# ── 2. Suggest cards for a category ──────────────────────────────
|
||||
|
||||
def suggest_cards(
|
||||
commander_name: str,
|
||||
color_identity: List[str],
|
||||
category: str,
|
||||
count: int = 10,
|
||||
exclude: Optional[List[str]] = None,
|
||||
strategy_notes: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Ask the model to suggest cards for a specific deck category.
|
||||
Returns a list of card names.
|
||||
"""
|
||||
exclude_str = f"\nDo NOT suggest these cards: {json.dumps(exclude)}" if exclude else ""
|
||||
strategy = f"\nStrategy notes: {strategy_notes}" if strategy_notes else ""
|
||||
|
||||
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
|
||||
|
||||
Commander: {commander_name}
|
||||
Color identity: {', '.join(color_identity)}
|
||||
Category: {category}
|
||||
{strategy}
|
||||
{exclude_str}
|
||||
|
||||
Suggest exactly {count} cards for the {category} category that work well with this commander.
|
||||
Only suggest cards that are legal in Commander and match the color identity.
|
||||
|
||||
Return ONLY a JSON array of card name strings, like: ["Card A", "Card B", ...]"""
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an expert MTG Commander deck builder. Respond with ONLY a JSON array of strings. No other text."},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
raw = chat(messages, json_mode=True)
|
||||
logger.debug("suggest_cards raw response: %s", raw[:500])
|
||||
try:
|
||||
result = _extract_json(raw)
|
||||
if isinstance(result, dict):
|
||||
# Handle {"cards": [...]} or {"suggestions": [...]} etc.
|
||||
for key in ("cards", "suggestions", "card_names", "names"):
|
||||
if key in result and isinstance(result[key], list):
|
||||
return [str(c) for c in result[key]]
|
||||
# If dict has a single list value, use it
|
||||
for v in result.values():
|
||||
if isinstance(v, list):
|
||||
return [str(c) for c in v]
|
||||
if isinstance(result, list):
|
||||
return [str(c) for c in result]
|
||||
return []
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Failed to parse suggestion response: %s", raw[:500])
|
||||
return []
|
||||
|
||||
|
||||
# ── 3. Filter by color identity ──────────────────────────────────
|
||||
|
||||
def filter_color_identity(
|
||||
card_names: List[str],
|
||||
commander_colors: List[str],
|
||||
) -> List[str]:
|
||||
"""
|
||||
Ask the model to filter out cards that violate color identity.
|
||||
Returns only the cards that are legal for the commander.
|
||||
"""
|
||||
prompt = f"""You are a Magic: The Gathering rules expert.
|
||||
|
||||
Commander color identity: {', '.join(commander_colors)}
|
||||
|
||||
From this list, return ONLY the cards whose color identity is a subset of the commander's.
|
||||
Remove any card that has mana symbols or color identity outside these colors.
|
||||
Return a JSON array of valid card name strings.
|
||||
|
||||
Cards:
|
||||
{json.dumps(card_names)}"""
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an MTG rules expert. Respond with ONLY a JSON array. No other text."},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
raw = chat(messages, json_mode=True)
|
||||
try:
|
||||
result = _extract_json(raw)
|
||||
if isinstance(result, dict):
|
||||
for key in ("cards", "valid_cards", "legal_cards"):
|
||||
if key in result and isinstance(result[key], list):
|
||||
return [str(c) for c in result[key]]
|
||||
for v in result.values():
|
||||
if isinstance(v, list):
|
||||
return [str(c) for c in v]
|
||||
if isinstance(result, list):
|
||||
return [str(c) for c in result]
|
||||
return card_names # fallback: return all
|
||||
except json.JSONDecodeError:
|
||||
return card_names
|
||||
|
||||
|
||||
# ── 4. Enforce deck ratios ───────────────────────────────────────
|
||||
|
||||
def enforce_deck_ratios(
|
||||
cards_by_category: Dict[str, List[str]],
|
||||
target_ratios: Dict[str, int],
|
||||
commander_name: str,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Ask the model to trim/expand categories to hit target counts.
|
||||
Returns adjusted dict of category -> card names.
|
||||
"""
|
||||
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
|
||||
|
||||
Commander: {commander_name}
|
||||
|
||||
Current cards by category:
|
||||
{json.dumps(cards_by_category, indent=2)}
|
||||
|
||||
Target card counts per category:
|
||||
{json.dumps(target_ratios, indent=2)}
|
||||
|
||||
Adjust each category to match the target count. Remove the weakest cards from
|
||||
over-filled categories. For under-filled categories, suggest additional cards.
|
||||
The total across all categories must be exactly 99.
|
||||
|
||||
Return a JSON object with the same category keys, each mapping to an array of card name strings."""
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an expert MTG Commander deck builder. Respond with ONLY a JSON object. No other text."},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
raw = chat(messages, json_mode=True, max_tokens=8192)
|
||||
try:
|
||||
result = _extract_json(raw)
|
||||
if isinstance(result, dict):
|
||||
return {k: [str(c) for c in v] for k, v in result.items() if isinstance(v, list)}
|
||||
return cards_by_category
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Failed to parse ratio response: %s", raw[:500])
|
||||
return cards_by_category
|
||||
|
||||
|
||||
# ── 5. Assemble final deck JSON ──────────────────────────────────
|
||||
|
||||
def assemble_deck_json(
|
||||
commander_name: str,
|
||||
cards_by_category: Dict[str, List[str]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Ask the model to produce the final structured deck JSON.
|
||||
Returns a dict matching the CommanderDeck schema.
|
||||
"""
|
||||
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
|
||||
|
||||
Commander: {commander_name}
|
||||
|
||||
Final card selections by category:
|
||||
{json.dumps(cards_by_category, indent=2)}
|
||||
|
||||
Produce the final deck as a JSON object with this structure:
|
||||
{{
|
||||
"commander": {{"name": "...", "category": "commander"}},
|
||||
"cards": [
|
||||
{{"name": "...", "category": "...", "quantity": 1}},
|
||||
...
|
||||
]
|
||||
}}
|
||||
|
||||
The "cards" array must have exactly 99 entries (one per card, quantity 1 each).
|
||||
Use the category from the input grouping for each card.
|
||||
Return ONLY the JSON object."""
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are an expert MTG Commander deck builder. Respond with ONLY a JSON object. No other text."},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
raw = chat(messages, json_mode=True, max_tokens=16384)
|
||||
try:
|
||||
return _extract_json(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Failed to parse deck JSON: %s", raw[:500])
|
||||
return {}
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Scryfall API client for the Commander AI Deck Builder.
|
||||
|
||||
Provides card lookup, commander search, and color-identity-filtered
|
||||
card queries using the free Scryfall REST API.
|
||||
Rate-limited to 50ms between requests per Scryfall's guidelines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
import requests
|
||||
|
||||
from ..core.models import CardEntry
|
||||
|
||||
BASE_URL = "https://api.scryfall.com"
|
||||
_last_request_time: float = 0.0
|
||||
REQUEST_DELAY = 0.1 # 100ms between requests (Scryfall asks for 50-100ms)
|
||||
|
||||
|
||||
def _throttle() -> None:
|
||||
"""Respect Scryfall rate limits."""
|
||||
global _last_request_time
|
||||
elapsed = time.time() - _last_request_time
|
||||
if elapsed < REQUEST_DELAY:
|
||||
time.sleep(REQUEST_DELAY - elapsed)
|
||||
_last_request_time = time.time()
|
||||
|
||||
|
||||
def _get(endpoint: str, params: Optional[Dict] = None) -> Dict:
|
||||
"""Make a throttled GET request to Scryfall."""
|
||||
_throttle()
|
||||
resp = requests.get(f"{BASE_URL}{endpoint}", params=params, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ── card lookup ──────────────────────────────────────────────────
|
||||
def get_card_by_name(name: str) -> Optional[CardEntry]:
|
||||
"""Look up a single card by exact or fuzzy name."""
|
||||
try:
|
||||
data = _get("/cards/named", {"fuzzy": name})
|
||||
return _parse_card(data)
|
||||
except requests.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
def get_card_by_id(scryfall_id: str) -> Optional[CardEntry]:
|
||||
"""Look up a single card by Scryfall UUID."""
|
||||
try:
|
||||
data = _get(f"/cards/{scryfall_id}")
|
||||
return _parse_card(data)
|
||||
except requests.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
# ── commander search ─────────────────────────────────────────────
|
||||
def search_commander(name: str) -> Optional[CardEntry]:
|
||||
"""Find a legendary creature / commander by name."""
|
||||
try:
|
||||
data = _get("/cards/named", {"fuzzy": name})
|
||||
type_line = data.get("type_line", "")
|
||||
if "Legendary" not in type_line:
|
||||
return None
|
||||
return _parse_card(data)
|
||||
except requests.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
# ── search cards by color identity ───────────────────────────────
|
||||
def search_cards_by_identity(
|
||||
color_identity: Set[str],
|
||||
query_extra: str = "",
|
||||
max_results: int = 100,
|
||||
) -> List[CardEntry]:
|
||||
"""
|
||||
Search Scryfall for cards within a commander's color identity.
|
||||
|
||||
Uses the `id<=` operator so results only include cards whose
|
||||
color identity is a subset of the commander's.
|
||||
|
||||
Args:
|
||||
color_identity: e.g. {"W", "U", "B"}
|
||||
query_extra: additional Scryfall search syntax (e.g. 't:creature')
|
||||
max_results: cap on total cards returned
|
||||
"""
|
||||
ci_str = "".join(sorted(color_identity)) if color_identity else "C"
|
||||
q = f"id<={ci_str} f:commander"
|
||||
if query_extra:
|
||||
q += f" {query_extra}"
|
||||
|
||||
cards: List[CardEntry] = []
|
||||
params = {"q": q, "order": "edhrec", "dir": "asc"}
|
||||
|
||||
try:
|
||||
data = _get("/cards/search", params)
|
||||
except requests.HTTPError:
|
||||
return cards
|
||||
|
||||
while True:
|
||||
for item in data.get("data", []):
|
||||
cards.append(_parse_card(item))
|
||||
if len(cards) >= max_results:
|
||||
return cards
|
||||
|
||||
if not data.get("has_more"):
|
||||
break
|
||||
|
||||
# Scryfall provides next_page URL directly
|
||||
next_url = data.get("next_page")
|
||||
if not next_url:
|
||||
break
|
||||
_throttle()
|
||||
resp = requests.get(next_url, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
return cards
|
||||
|
||||
|
||||
# ── search by category (typed helpers) ───────────────────────────
|
||||
def search_ramp(color_identity: Set[str], max_results: int = 30) -> List[CardEntry]:
|
||||
"""Find mana ramp cards within color identity."""
|
||||
return search_cards_by_identity(
|
||||
color_identity,
|
||||
query_extra='(t:artifact o:"add" o:mana) or (t:creature o:"add" o:mana) or (t:enchantment o:"add" o:mana) or (t:sorcery o:land o:"search")',
|
||||
max_results=max_results,
|
||||
)
|
||||
|
||||
|
||||
def search_removal(color_identity: Set[str], max_results: int = 30) -> List[CardEntry]:
|
||||
"""Find removal cards within color identity."""
|
||||
return search_cards_by_identity(
|
||||
color_identity,
|
||||
query_extra='(o:destroy or o:exile or o:"-/-") -t:land',
|
||||
max_results=max_results,
|
||||
)
|
||||
|
||||
|
||||
def search_card_draw(color_identity: Set[str], max_results: int = 30) -> List[CardEntry]:
|
||||
"""Find card draw / card advantage cards."""
|
||||
return search_cards_by_identity(
|
||||
color_identity,
|
||||
query_extra='(o:"draw a card" or o:"draw cards" or o:"draw two" or o:"draw three")',
|
||||
max_results=max_results,
|
||||
)
|
||||
|
||||
|
||||
def search_lands(color_identity: Set[str], max_results: int = 45) -> List[CardEntry]:
|
||||
"""Find lands within color identity."""
|
||||
return search_cards_by_identity(
|
||||
color_identity,
|
||||
query_extra="t:land",
|
||||
max_results=max_results,
|
||||
)
|
||||
|
||||
|
||||
# ── bulk name lookup (collection matching) ───────────────────────
|
||||
def get_cards_by_names(names: List[str]) -> List[CardEntry]:
|
||||
"""
|
||||
Batch-lookup cards by name using Scryfall's /cards/collection endpoint.
|
||||
Accepts up to 75 names per request.
|
||||
"""
|
||||
cards: List[CardEntry] = []
|
||||
for i in range(0, len(names), 75):
|
||||
batch = names[i : i + 75]
|
||||
identifiers = [{"name": n} for n in batch]
|
||||
_throttle()
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/cards/collection",
|
||||
json={"identifiers": identifiers},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
for item in data.get("data", []):
|
||||
cards.append(_parse_card(item))
|
||||
return cards
|
||||
|
||||
|
||||
# ── parser ───────────────────────────────────────────────────────
|
||||
def _parse_card(data: Dict) -> CardEntry:
|
||||
"""Convert a Scryfall JSON card object into a CardEntry."""
|
||||
return CardEntry(
|
||||
name=data.get("name", "Unknown"),
|
||||
color_identity=set(data.get("color_identity", [])),
|
||||
mana_cost=data.get("mana_cost"),
|
||||
cmc=data.get("cmc", 0.0),
|
||||
type_line=data.get("type_line"),
|
||||
scryfall_id=data.get("id"),
|
||||
edhrec_rank=data.get("edhrec_rank"),
|
||||
source="scryfall",
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Core subpackage – models, rules, and collection utilities."""
|
||||
|
||||
from .models import (
|
||||
BuildRequest,
|
||||
BuildResult,
|
||||
CardEntry,
|
||||
CommanderDeck,
|
||||
DeckRatios,
|
||||
)
|
||||
from .rules_engine import (
|
||||
check_ban_list,
|
||||
filter_by_color_identity,
|
||||
validate_deck,
|
||||
)
|
||||
from .collection_filter import filter_names_by_collection
|
||||
|
||||
__all__ = [
|
||||
"BuildRequest",
|
||||
"BuildResult",
|
||||
"CardEntry",
|
||||
"CommanderDeck",
|
||||
"DeckRatios",
|
||||
"check_ban_list",
|
||||
"filter_by_color_identity",
|
||||
"validate_deck",
|
||||
"filter_names_by_collection",
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Collection filter for the Commander AI Deck Builder.
|
||||
|
||||
Loads a user's card collection from CSV and filters candidate
|
||||
cards to only those the user owns (collection-only mode).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from .models import CardEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_collection(path: str) -> Dict[str, int]:
|
||||
"""
|
||||
Load a card collection from CSV.
|
||||
|
||||
Expected CSV columns: name, quantity, [set_code]
|
||||
Returns a dict of card_name -> quantity.
|
||||
"""
|
||||
collection: Dict[str, int] = {}
|
||||
csv_path = Path(path)
|
||||
|
||||
if not csv_path.exists():
|
||||
logger.error(f"Collection file not found: {path}")
|
||||
return collection
|
||||
|
||||
with open(csv_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
name = row.get("name", "").strip()
|
||||
if not name:
|
||||
continue
|
||||
qty = int(row.get("quantity", 1))
|
||||
# Accumulate quantities (user may have multiple entries)
|
||||
collection[name] = collection.get(name, 0) + qty
|
||||
|
||||
logger.info(f"Loaded collection: {len(collection)} unique cards from {path}")
|
||||
return collection
|
||||
|
||||
|
||||
def get_collection_names(path: str) -> Set[str]:
|
||||
"""Load collection and return just the set of card names."""
|
||||
return set(load_collection(path).keys())
|
||||
|
||||
|
||||
def filter_by_collection(
|
||||
cards: List[CardEntry],
|
||||
collection_path: str,
|
||||
) -> List[CardEntry]:
|
||||
"""
|
||||
Filter a list of CardEntry objects to only those in the collection.
|
||||
|
||||
Cards not found in the collection are removed.
|
||||
"""
|
||||
collection = load_collection(collection_path)
|
||||
if not collection:
|
||||
logger.warning("Empty collection — returning all cards unfiltered")
|
||||
return cards
|
||||
|
||||
filtered = []
|
||||
for card in cards:
|
||||
if card.name in collection:
|
||||
filtered.append(card)
|
||||
else:
|
||||
logger.debug(f"Filtered out (not in collection): {card.name}")
|
||||
|
||||
logger.info(
|
||||
f"Collection filter: {len(filtered)}/{len(cards)} cards matched"
|
||||
)
|
||||
return filtered
|
||||
|
||||
|
||||
def filter_names_by_collection(
|
||||
names: List[str],
|
||||
collection_path: str,
|
||||
) -> List[str]:
|
||||
"""Filter a list of card name strings to only those in the collection."""
|
||||
owned = get_collection_names(collection_path)
|
||||
return [n for n in names if n in owned]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Deck assembler – converts Ollama JSON output into validated CardEntry lists.
|
||||
|
||||
Responsible for parsing the structured JSON that Ollama returns after the
|
||||
ratio-enforcement step and mapping card names back to Scryfall CardEntry
|
||||
objects with correct categories.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from .models import CardEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_ollama_deck_json(raw: str) -> Dict[str, List[str]]:
|
||||
"""Parse the JSON blob Ollama returns into {category: [card_names]}.
|
||||
|
||||
Ollama is instructed to return JSON like::
|
||||
|
||||
{
|
||||
"Creatures": ["Sol Ring", ...],
|
||||
"Instants": [...],
|
||||
...
|
||||
}
|
||||
|
||||
This function tolerates minor formatting issues (trailing commas,
|
||||
markdown fences, etc.).
|
||||
"""
|
||||
# Strip markdown code fences if present
|
||||
cleaned = raw.strip()
|
||||
if cleaned.startswith("```"):
|
||||
# Remove opening fence (possibly ```json)
|
||||
first_nl = cleaned.index("\n")
|
||||
cleaned = cleaned[first_nl + 1 :]
|
||||
if cleaned.endswith("```"):
|
||||
cleaned = cleaned[: -3]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
# Remove trailing commas before closing braces/brackets (common LLM error)
|
||||
import re
|
||||
cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned)
|
||||
|
||||
try:
|
||||
data = json.loads(cleaned)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("Failed to parse Ollama deck JSON: %s", exc)
|
||||
logger.debug("Raw output:\n%s", raw[:500])
|
||||
raise ValueError(f"Ollama returned invalid JSON: {exc}") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Expected dict from Ollama, got {type(data).__name__}")
|
||||
|
||||
result: Dict[str, List[str]] = {}
|
||||
for key, val in data.items():
|
||||
if isinstance(val, list):
|
||||
result[key] = [str(v) for v in val]
|
||||
else:
|
||||
logger.warning("Skipping non-list category %s", key)
|
||||
return result
|
||||
|
||||
|
||||
def flatten_categories(categorised: Dict[str, List[str]]) -> List[str]:
|
||||
"""Return a flat, deduplicated list of card names from categorised dict."""
|
||||
seen: set[str] = set()
|
||||
names: List[str] = []
|
||||
for cards in categorised.values():
|
||||
for name in cards:
|
||||
lower = name.lower()
|
||||
if lower not in seen:
|
||||
seen.add(lower)
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def assign_categories(
|
||||
entries: List[CardEntry],
|
||||
categorised: Dict[str, List[str]],
|
||||
) -> List[CardEntry]:
|
||||
"""Set each CardEntry's category based on the Ollama categorisation."""
|
||||
name_to_cat: Dict[str, str] = {}
|
||||
for cat, names in categorised.items():
|
||||
for name in names:
|
||||
name_to_cat[name.lower()] = cat
|
||||
|
||||
for entry in entries:
|
||||
cat = name_to_cat.get(entry.name.lower())
|
||||
if cat:
|
||||
entry.category = cat
|
||||
return entries
|
||||
|
||||
|
||||
def deduplicate_entries(entries: List[CardEntry]) -> List[CardEntry]:
|
||||
"""Remove duplicate card entries, keeping the first occurrence."""
|
||||
seen: set[str] = set()
|
||||
unique: List[CardEntry] = []
|
||||
for entry in entries:
|
||||
key = entry.name.lower()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(entry)
|
||||
return unique
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Pydantic models for the Commander AI Deck Builder.
|
||||
|
||||
Defines CardEntry, DeckCategory, CommanderDeck, and BuildRequest
|
||||
with full color identity validation and 99-card singleton enforcement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# ── colour identity ──────────────────────────────────────────────
|
||||
class Color(str, Enum):
|
||||
WHITE = "W"
|
||||
BLUE = "U"
|
||||
BLACK = "B"
|
||||
RED = "R"
|
||||
GREEN = "G"
|
||||
|
||||
|
||||
WUBRG: Set[str] = {c.value for c in Color}
|
||||
|
||||
|
||||
# ── card entry ───────────────────────────────────────────────────
|
||||
class CardEntry(BaseModel):
|
||||
"""A single card in a deck list."""
|
||||
|
||||
name: str
|
||||
quantity: int = Field(default=1, ge=1)
|
||||
category: str = Field(
|
||||
default="uncategorized",
|
||||
description="ramp | removal | card_draw | lands | synergy | protection | wincon | uncategorized",
|
||||
)
|
||||
color_identity: Set[str] = Field(default_factory=set)
|
||||
mana_cost: Optional[str] = None
|
||||
cmc: float = 0.0
|
||||
type_line: Optional[str] = None
|
||||
scryfall_id: Optional[str] = None
|
||||
edhrec_rank: Optional[int] = None
|
||||
source: Optional[str] = None # edhrec | scryfall | moxfield | archidekt | reddit | ollama
|
||||
|
||||
@field_validator("color_identity", mode="before")
|
||||
@classmethod
|
||||
def normalize_colors(cls, v):
|
||||
if isinstance(v, str):
|
||||
return {c.upper() for c in v if c.upper() in WUBRG}
|
||||
if isinstance(v, (list, set)):
|
||||
return {c.upper() for c in v if c.upper() in WUBRG}
|
||||
return set()
|
||||
|
||||
|
||||
# ── deck category targets ────────────────────────────────────────
|
||||
class DeckRatios(BaseModel):
|
||||
"""Target card counts per category (must sum to 99)."""
|
||||
|
||||
lands: int = Field(default=36, ge=30, le=42)
|
||||
ramp: int = Field(default=10, ge=6, le=15)
|
||||
card_draw: int = Field(default=10, ge=6, le=15)
|
||||
removal: int = Field(default=8, ge=4, le=14)
|
||||
protection: int = Field(default=5, ge=2, le=10)
|
||||
synergy: int = Field(default=25, ge=15, le=35)
|
||||
wincon: int = Field(default=3, ge=1, le=8)
|
||||
uncategorized: int = Field(default=2, ge=0, le=10)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_total(self) -> "DeckRatios":
|
||||
total = (
|
||||
self.lands
|
||||
+ self.ramp
|
||||
+ self.card_draw
|
||||
+ self.removal
|
||||
+ self.protection
|
||||
+ self.synergy
|
||||
+ self.wincon
|
||||
+ self.uncategorized
|
||||
)
|
||||
if total != 99:
|
||||
raise ValueError(f"Card type targets must sum to 99, got {total}")
|
||||
return self
|
||||
|
||||
|
||||
# ── commander deck ───────────────────────────────────────────────
|
||||
class CommanderDeck(BaseModel):
|
||||
"""Full 99-card Commander deck plus the commander card."""
|
||||
|
||||
commander: CardEntry
|
||||
companion: Optional[CardEntry] = None
|
||||
cards: List[CardEntry] = Field(default_factory=list)
|
||||
ratios: DeckRatios = Field(default_factory=DeckRatios)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_deck(self) -> "CommanderDeck":
|
||||
# ── singleton check (basic lands exempt) ──
|
||||
basic_lands = {
|
||||
"Plains", "Island", "Swamp", "Mountain", "Forest",
|
||||
"Wastes", "Snow-Covered Plains", "Snow-Covered Island",
|
||||
"Snow-Covered Swamp", "Snow-Covered Mountain", "Snow-Covered Forest",
|
||||
}
|
||||
seen: dict[str, int] = {}
|
||||
for card in self.cards:
|
||||
if card.name not in basic_lands:
|
||||
seen[card.name] = seen.get(card.name, 0) + card.quantity
|
||||
if seen[card.name] > 1:
|
||||
raise ValueError(f"Singleton violation: '{card.name}' appears {seen[card.name]} times")
|
||||
|
||||
# ── 99-card check ──
|
||||
total = sum(c.quantity for c in self.cards)
|
||||
if total != 99:
|
||||
raise ValueError(f"Deck must contain exactly 99 cards, got {total}")
|
||||
|
||||
# ── color identity check ──
|
||||
commander_ci = self.commander.color_identity
|
||||
for card in self.cards:
|
||||
if card.color_identity and not card.color_identity.issubset(commander_ci):
|
||||
violation = card.color_identity - commander_ci
|
||||
raise ValueError(
|
||||
f"'{card.name}' has colors {violation} outside commander identity {commander_ci}"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
# ── build request (API input) ────────────────────────────────────
|
||||
class BuildRequest(BaseModel):
|
||||
"""Input payload for the deck build pipeline."""
|
||||
|
||||
commander_name: str
|
||||
collection_only: bool = False
|
||||
collection_path: Optional[str] = None # path to CSV
|
||||
budget_limit: Optional[float] = None
|
||||
ratios: DeckRatios = Field(default_factory=DeckRatios)
|
||||
strategy_notes: Optional[str] = None # free-text guidance for Ollama
|
||||
|
||||
|
||||
# ── build result (API output) ────────────────────────────────────
|
||||
class BuildResult(BaseModel):
|
||||
"""Output payload from the deck build pipeline."""
|
||||
|
||||
deck: CommanderDeck
|
||||
warnings: List[str] = Field(default_factory=list)
|
||||
sources_consulted: List[str] = Field(default_factory=list)
|
||||
build_time_seconds: float = 0.0
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Rules engine for the Commander AI Deck Builder.
|
||||
|
||||
Validates decks against Commander format rules:
|
||||
- Color identity compliance
|
||||
- Singleton rule (basic lands exempt)
|
||||
- Exactly 99 cards + 1 commander
|
||||
- Minimum card type ratios
|
||||
- Ban list checking
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Set, Tuple
|
||||
|
||||
from .models import CardEntry, CommanderDeck, DeckRatios
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Commander ban list (partial — keep updated)
|
||||
BANNED_CARDS: Set[str] = {
|
||||
"Ancestral Recall", "Balance", "Biorhythm", "Black Lotus",
|
||||
"Braids, Cabal Minion", "Channel", "Chaos Orb", "Coalition Victory",
|
||||
"Emrakul, the Aeons Torn", "Erayo, Soratami Ascendant",
|
||||
"Falling Star", "Fastbond", "Flash", "Gifts Ungiven",
|
||||
"Golos, Tireless Pilgrim", "Griselbrand", "Hullbreacher",
|
||||
"Iona, Shield of Emeria", "Karakas", "Leovold, Emissary of Trest",
|
||||
"Library of Alexandria", "Limited Resources", "Lutri, the Spellchaser",
|
||||
"Mox Emerald", "Mox Jet", "Mox Pearl", "Mox Ruby", "Mox Sapphire",
|
||||
"Panoptic Mirror", "Paradox Engine", "Primeval Titan",
|
||||
"Prophet of Kruphix", "Recurring Nightmare", "Rofellos, Llanowar Emissary",
|
||||
"Shahrazad", "Sundering Titan", "Sway of the Stars",
|
||||
"Sylvan Primordial", "Time Vault", "Time Walk", "Tinker",
|
||||
"Tolarian Academy", "Trade Secrets", "Upheaval", "Worldfire",
|
||||
}
|
||||
|
||||
BASIC_LANDS: Set[str] = {
|
||||
"Plains", "Island", "Swamp", "Mountain", "Forest",
|
||||
"Wastes", "Snow-Covered Plains", "Snow-Covered Island",
|
||||
"Snow-Covered Swamp", "Snow-Covered Mountain", "Snow-Covered Forest",
|
||||
}
|
||||
|
||||
|
||||
# ── color identity validation ────────────────────────────────────
|
||||
def validate_color_identity(
|
||||
card: CardEntry,
|
||||
commander_identity: Set[str],
|
||||
) -> bool:
|
||||
"""Check if a card's color identity is within the commander's."""
|
||||
if not card.color_identity:
|
||||
return True # colorless cards are always legal
|
||||
return card.color_identity.issubset(commander_identity)
|
||||
|
||||
|
||||
def filter_by_color_identity(
|
||||
cards: List[CardEntry],
|
||||
commander_identity: Set[str],
|
||||
) -> Tuple[List[CardEntry], List[CardEntry]]:
|
||||
"""
|
||||
Split cards into valid and invalid based on color identity.
|
||||
|
||||
Returns (valid_cards, rejected_cards).
|
||||
"""
|
||||
valid = []
|
||||
rejected = []
|
||||
for card in cards:
|
||||
if validate_color_identity(card, commander_identity):
|
||||
valid.append(card)
|
||||
else:
|
||||
rejected.append(card)
|
||||
return valid, rejected
|
||||
|
||||
|
||||
# ── singleton validation ─────────────────────────────────────────
|
||||
def validate_singleton(cards: List[CardEntry]) -> List[str]:
|
||||
"""
|
||||
Check for singleton violations.
|
||||
|
||||
Returns a list of card names that appear more than once
|
||||
(excluding basic lands).
|
||||
"""
|
||||
seen: Dict[str, int] = {}
|
||||
violations: List[str] = []
|
||||
|
||||
for card in cards:
|
||||
if card.name in BASIC_LANDS:
|
||||
continue
|
||||
seen[card.name] = seen.get(card.name, 0) + card.quantity
|
||||
if seen[card.name] > 1 and card.name not in violations:
|
||||
violations.append(card.name)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
# ── ban list check ───────────────────────────────────────────────
|
||||
def check_ban_list(cards: List[CardEntry]) -> List[str]:
|
||||
"""Return names of any banned cards found in the list."""
|
||||
return [c.name for c in cards if c.name in BANNED_CARDS]
|
||||
|
||||
|
||||
# ── card count validation ────────────────────────────────────────
|
||||
def validate_card_count(cards: List[CardEntry], expected: int = 99) -> int:
|
||||
"""Return the difference from expected count (0 = valid)."""
|
||||
total = sum(c.quantity for c in cards)
|
||||
return total - expected
|
||||
|
||||
|
||||
# ── ratio validation ─────────────────────────────────────────────
|
||||
def validate_ratios(
|
||||
cards: List[CardEntry],
|
||||
ratios: DeckRatios,
|
||||
) -> Dict[str, Dict[str, int]]:
|
||||
"""
|
||||
Check if card categories meet minimum targets.
|
||||
|
||||
Returns a dict of category -> {"target": int, "actual": int, "diff": int}.
|
||||
"""
|
||||
counts: Dict[str, int] = {}
|
||||
for card in cards:
|
||||
cat = card.category
|
||||
counts[cat] = counts.get(cat, 0) + card.quantity
|
||||
|
||||
result: Dict[str, Dict[str, int]] = {}
|
||||
targets = {
|
||||
"lands": ratios.lands,
|
||||
"ramp": ratios.ramp,
|
||||
"card_draw": ratios.card_draw,
|
||||
"removal": ratios.removal,
|
||||
"protection": ratios.protection,
|
||||
"synergy": ratios.synergy,
|
||||
"wincon": ratios.wincon,
|
||||
"uncategorized": ratios.uncategorized,
|
||||
}
|
||||
|
||||
for cat, target in targets.items():
|
||||
actual = counts.get(cat, 0)
|
||||
result[cat] = {
|
||||
"target": target,
|
||||
"actual": actual,
|
||||
"diff": actual - target,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── full deck validation ─────────────────────────────────────────
|
||||
def validate_deck(
|
||||
commander: CardEntry,
|
||||
cards: List[CardEntry],
|
||||
ratios: DeckRatios,
|
||||
) -> Dict[str, any]:
|
||||
"""
|
||||
Run all validation checks on a deck.
|
||||
|
||||
Returns a dict with:
|
||||
- "valid": bool
|
||||
- "errors": list of fatal errors
|
||||
- "warnings": list of non-fatal warnings
|
||||
- "ratio_report": category ratio details
|
||||
"""
|
||||
errors: List[str] = []
|
||||
warnings: List[str] = []
|
||||
|
||||
# Color identity
|
||||
commander_ci = commander.color_identity
|
||||
_, rejected = filter_by_color_identity(cards, commander_ci)
|
||||
if rejected:
|
||||
names = [c.name for c in rejected]
|
||||
errors.append(f"Color identity violations: {', '.join(names)}")
|
||||
|
||||
# Singleton
|
||||
dupes = validate_singleton(cards)
|
||||
if dupes:
|
||||
errors.append(f"Singleton violations: {', '.join(dupes)}")
|
||||
|
||||
# Ban list
|
||||
banned = check_ban_list(cards)
|
||||
if banned:
|
||||
errors.append(f"Banned cards: {', '.join(banned)}")
|
||||
|
||||
# Card count
|
||||
diff = validate_card_count(cards)
|
||||
if diff != 0:
|
||||
direction = "too many" if diff > 0 else "too few"
|
||||
errors.append(f"Card count: {abs(diff)} {direction} (need exactly 99)")
|
||||
|
||||
# Ratios
|
||||
ratio_report = validate_ratios(cards, ratios)
|
||||
for cat, info in ratio_report.items():
|
||||
if info["diff"] < 0:
|
||||
warnings.append(
|
||||
f"{cat}: {info['actual']}/{info['target']} "
|
||||
f"({abs(info['diff'])} short)"
|
||||
)
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"ratio_report": ratio_report,
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
FastAPI + CLI entrypoint for the Commander AI Deck Builder.
|
||||
|
||||
Run with:
|
||||
uvicorn commander_ai_lab.deck_builder.main:app --reload
|
||||
|
||||
Or via CLI:
|
||||
python -m commander_ai_lab.deck_builder.main --commander "Atraxa, Praetors' Voice"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .core.models import BuildRequest, BuildResult
|
||||
from .pipeline.build_deck import build_deck
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI app
|
||||
# ---------------------------------------------------------------------------
|
||||
app = FastAPI(
|
||||
title="Commander AI Deck Builder",
|
||||
version="0.1.0",
|
||||
description="Generate optimised 99-card Commander decks using local Ollama AI.",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Liveness probe."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/build", response_model=BuildResult)
|
||||
async def api_build_deck(request: BuildRequest):
|
||||
"""Build a Commander deck from the given request."""
|
||||
try:
|
||||
result = build_deck(request)
|
||||
return result
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("Unhandled error during deck build")
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
def cli() -> None:
|
||||
"""Minimal CLI wrapper around build_deck."""
|
||||
parser = argparse.ArgumentParser(description="Commander AI Deck Builder CLI")
|
||||
parser.add_argument("--commander", required=True, help="Commander card name")
|
||||
parser.add_argument(
|
||||
"--collection",
|
||||
default=None,
|
||||
help="Path to collection CSV (optional)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--collection-only",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only use cards from collection",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="gpt-oss:20b",
|
||||
help="Ollama model name (default: gpt-oss:20b)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default=None,
|
||||
help="Output JSON file path (default: stdout)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load collection if provided
|
||||
collection_names: list[str] | None = None
|
||||
if args.collection:
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(args.collection)
|
||||
if not path.exists():
|
||||
print(f"Error: collection file not found: {path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(path, newline="", encoding="utf-8") as fh:
|
||||
reader = csv.DictReader(fh)
|
||||
name_col = "Name" if "Name" in (reader.fieldnames or []) else "name"
|
||||
collection_names = [row[name_col] for row in reader if row.get(name_col)]
|
||||
|
||||
request = BuildRequest(
|
||||
commander_name=args.commander,
|
||||
collection_names=collection_names,
|
||||
collection_only=args.collection_only,
|
||||
)
|
||||
|
||||
logger.info("Building deck for %s …", args.commander)
|
||||
result = build_deck(request)
|
||||
|
||||
output = result.model_dump_json(indent=2)
|
||||
if args.output:
|
||||
from pathlib import Path as P
|
||||
|
||||
P(args.output).write_text(output, encoding="utf-8")
|
||||
logger.info("Deck written to %s", args.output)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
if result.warnings:
|
||||
for w in result.warnings:
|
||||
logger.warning("⚠ %s", w)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Pipeline subpackage – exposes the public build_deck entry point."""
|
||||
|
||||
from .build_deck import build_deck
|
||||
|
||||
__all__ = ["build_deck"]
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
7-step deck build pipeline for the Commander AI Deck Builder.
|
||||
|
||||
Orchestrates: EDHrec data -> Scryfall -> Ollama suggestions ->
|
||||
color filter -> synergy rank -> ratio enforce -> final assembly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ..api import edhrec, scryfall
|
||||
from ..api import ollama_client as ollama
|
||||
from ..core.collection_filter import filter_names_by_collection
|
||||
from ..core.models import BuildRequest, BuildResult, CardEntry, CommanderDeck, DeckRatios
|
||||
from ..core.rules_engine import check_ban_list, filter_by_color_identity, validate_deck
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_deck(request: BuildRequest) -> BuildResult:
|
||||
"""
|
||||
Execute the full 7-step deck build pipeline.
|
||||
|
||||
Steps:
|
||||
1. Resolve commander via Scryfall
|
||||
2. Fetch EDHrec recommendations
|
||||
3. Fetch Scryfall candidates by category
|
||||
4. Ollama: suggest additional cards + fill gaps
|
||||
5. Filter by color identity + collection + ban list
|
||||
6. Ollama: enforce deck ratios (trim/expand to 99)
|
||||
7. Ollama: assemble final structured deck JSON
|
||||
"""
|
||||
start = time.time()
|
||||
warnings: List[str] = []
|
||||
sources: List[str] = []
|
||||
|
||||
# ── Step 1: Resolve commander ────────────────────────────────
|
||||
logger.info(f"Step 1: Resolving commander '{request.commander_name}'")
|
||||
commander = scryfall.search_commander(request.commander_name)
|
||||
if not commander:
|
||||
commander = scryfall.get_card_by_name(request.commander_name)
|
||||
if not commander:
|
||||
raise ValueError(f"Commander not found: {request.commander_name}")
|
||||
|
||||
commander_ci = commander.color_identity
|
||||
ci_list = sorted(commander_ci)
|
||||
logger.info(f"Commander: {commander.name}, CI: {ci_list}")
|
||||
sources.append("scryfall")
|
||||
|
||||
# ── Step 2: Fetch EDHrec recommendations ─────────────────────
|
||||
logger.info(f"Step 2: Fetching EDHrec data for '{commander.name}'")
|
||||
edhrec_cards = edhrec.get_recommended_cards(commander.name)
|
||||
if edhrec_cards:
|
||||
sources.append("edhrec")
|
||||
logger.info(f"EDHrec returned {len(edhrec_cards)} cards")
|
||||
else:
|
||||
warnings.append("EDHrec returned no data — relying on Scryfall + Ollama")
|
||||
|
||||
# ── Step 3: Fetch Scryfall candidates by category ────────────
|
||||
logger.info("Step 3: Fetching Scryfall candidates by category")
|
||||
scryfall_candidates: Dict[str, List[CardEntry]] = {
|
||||
"ramp": scryfall.search_ramp(commander_ci),
|
||||
"removal": scryfall.search_removal(commander_ci),
|
||||
"card_draw": scryfall.search_card_draw(commander_ci),
|
||||
"lands": scryfall.search_lands(commander_ci),
|
||||
}
|
||||
for cat, cards in scryfall_candidates.items():
|
||||
logger.info(f" Scryfall {cat}: {len(cards)} candidates")
|
||||
|
||||
# ── Step 4: Ollama suggestions to fill gaps ──────────────────
|
||||
logger.info("Step 4: Ollama suggesting additional cards")
|
||||
|
||||
# Collect all names we already have
|
||||
all_names: List[str] = [c.name for c in edhrec_cards]
|
||||
for cards in scryfall_candidates.values():
|
||||
all_names.extend(c.name for c in cards)
|
||||
all_names = list(set(all_names))
|
||||
|
||||
# Ask Ollama to suggest cards for categories that need more
|
||||
categories_to_fill = ["synergy", "protection", "wincon"]
|
||||
ollama_suggestions: Dict[str, List[str]] = {}
|
||||
|
||||
for category in categories_to_fill:
|
||||
suggested = ollama.suggest_cards(
|
||||
commander_name=commander.name,
|
||||
color_identity=ci_list,
|
||||
category=category,
|
||||
count=15,
|
||||
exclude=all_names,
|
||||
strategy_notes=request.strategy_notes,
|
||||
)
|
||||
ollama_suggestions[category] = suggested
|
||||
all_names.extend(suggested)
|
||||
logger.info(f" Ollama {category}: {len(suggested)} suggestions")
|
||||
|
||||
sources.append("ollama")
|
||||
|
||||
# ── Step 5: Filter (color identity, collection, ban list) ────
|
||||
logger.info("Step 5: Filtering candidates")
|
||||
|
||||
# Build a unified name pool per category
|
||||
cards_by_category: Dict[str, List[str]] = {}
|
||||
|
||||
# EDHrec cards (already categorized)
|
||||
for card in edhrec_cards:
|
||||
cat = card.category
|
||||
cards_by_category.setdefault(cat, []).append(card.name)
|
||||
|
||||
# Scryfall candidates
|
||||
for cat, cards in scryfall_candidates.items():
|
||||
cards_by_category.setdefault(cat, []).extend(c.name for c in cards)
|
||||
|
||||
# Ollama suggestions
|
||||
for cat, names in ollama_suggestions.items():
|
||||
cards_by_category.setdefault(cat, []).extend(names)
|
||||
|
||||
# Deduplicate within each category
|
||||
for cat in cards_by_category:
|
||||
cards_by_category[cat] = list(dict.fromkeys(cards_by_category[cat]))
|
||||
|
||||
# Collection filter (if enabled)
|
||||
if request.collection_only and request.collection_path:
|
||||
logger.info(" Applying collection filter")
|
||||
for cat in cards_by_category:
|
||||
before = len(cards_by_category[cat])
|
||||
cards_by_category[cat] = filter_names_by_collection(
|
||||
cards_by_category[cat], request.collection_path
|
||||
)
|
||||
after = len(cards_by_category[cat])
|
||||
if before != after:
|
||||
logger.info(f" {cat}: {before} -> {after} after collection filter")
|
||||
|
||||
# Ban list filter
|
||||
for cat in cards_by_category:
|
||||
from ..core.rules_engine import BANNED_CARDS
|
||||
cards_by_category[cat] = [
|
||||
n for n in cards_by_category[cat] if n not in BANNED_CARDS
|
||||
]
|
||||
|
||||
# ── Step 6: Ollama enforce ratios ────────────────────────────
|
||||
logger.info("Step 6: Ollama enforcing deck ratios")
|
||||
target_ratios = {
|
||||
"lands": request.ratios.lands,
|
||||
"ramp": request.ratios.ramp,
|
||||
"card_draw": request.ratios.card_draw,
|
||||
"removal": request.ratios.removal,
|
||||
"protection": request.ratios.protection,
|
||||
"synergy": request.ratios.synergy,
|
||||
"wincon": request.ratios.wincon,
|
||||
"uncategorized": request.ratios.uncategorized,
|
||||
}
|
||||
|
||||
adjusted = ollama.enforce_deck_ratios(
|
||||
cards_by_category=cards_by_category,
|
||||
target_ratios=target_ratios,
|
||||
commander_name=commander.name,
|
||||
)
|
||||
|
||||
# Verify total is 99
|
||||
total = sum(len(v) for v in adjusted.values())
|
||||
if total != 99:
|
||||
warnings.append(f"Ollama returned {total} cards instead of 99 — adjusting")
|
||||
logger.warning(f"Ratio enforcement returned {total} cards")
|
||||
|
||||
# ── Step 7: Assemble final deck ──────────────────────────────
|
||||
logger.info("Step 7: Assembling final deck")
|
||||
|
||||
# Convert to CardEntry list via Scryfall batch lookup
|
||||
all_card_names = []
|
||||
for names in adjusted.values():
|
||||
all_card_names.extend(names)
|
||||
|
||||
card_entries = scryfall.get_cards_by_names(all_card_names)
|
||||
|
||||
# Map back categories
|
||||
name_to_cat: Dict[str, str] = {}
|
||||
for cat, names in adjusted.items():
|
||||
for name in names:
|
||||
name_to_cat[name] = cat
|
||||
|
||||
for entry in card_entries:
|
||||
if entry.name in name_to_cat:
|
||||
entry.category = name_to_cat[entry.name]
|
||||
|
||||
# Build the deck
|
||||
elapsed = time.time() - start
|
||||
logger.info(f"Deck built in {elapsed:.1f}s")
|
||||
|
||||
try:
|
||||
deck = CommanderDeck(
|
||||
commander=commander,
|
||||
cards=card_entries,
|
||||
ratios=request.ratios,
|
||||
)
|
||||
except ValueError as e:
|
||||
warnings.append(f"Deck validation issue: {e}")
|
||||
# Return a "best effort" deck without strict validation
|
||||
deck = CommanderDeck.model_construct(
|
||||
commander=commander,
|
||||
cards=card_entries,
|
||||
ratios=request.ratios,
|
||||
)
|
||||
|
||||
return BuildResult(
|
||||
deck=deck,
|
||||
warnings=warnings,
|
||||
sources_consulted=sources,
|
||||
build_time_seconds=elapsed,
|
||||
)
|
||||
@@ -9,7 +9,7 @@ The LLM receives a structured game-state snapshot and returns a JSON
|
||||
action describing what to do this turn (play land, cast spell, attack,
|
||||
hold/pass, use removal, use board wipe, cast ramp, cast commander).
|
||||
|
||||
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at localhost:11434
|
||||
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at 192.168.0.240:11434
|
||||
ollama run gpt-oss:20b
|
||||
|
||||
Also supports:
|
||||
@@ -58,17 +58,17 @@ class DeepSeekConfig:
|
||||
|
||||
Example — LM Studio::
|
||||
config = DeepSeekConfig(
|
||||
api_base="http://192.168.0.122:1234",
|
||||
api_base="http://192.168.0.240:11434",
|
||||
model="deepseek-r1-distill-qwen-8b",
|
||||
max_tokens=2048, # R1 needs room for <think> blocks
|
||||
)
|
||||
|
||||
Example — Ollama (default)::
|
||||
config = DeepSeekConfig() # points at localhost:11434 / gpt-oss:20b
|
||||
config = DeepSeekConfig() # points at 192.168.0.240:11434 / gpt-oss:20b
|
||||
"""
|
||||
|
||||
# API endpoint — Ollama OpenAI-compatible base (NO /v1 suffix)
|
||||
api_base: str = "http://localhost:11434"
|
||||
api_base: str = "http://192.168.0.240:11434"
|
||||
# Model name as registered in Ollama
|
||||
model: str = "gpt-oss:20b"
|
||||
|
||||
@@ -784,7 +784,8 @@ class DeepSeekBrain:
|
||||
"source": result.get("source"),
|
||||
"latency_ms": result.get("latency_ms"),
|
||||
},
|
||||
"game_result": None,
|
||||
"validation": result.get("validation"),
|
||||
"game_result": None,
|
||||
}
|
||||
self._decision_log.append(entry)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Usage::
|
||||
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
|
||||
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
|
||||
|
||||
brain = DeepSeekBrain(DeepSeekConfig(api_base="http://192.168.0.122:1234"))
|
||||
brain = DeepSeekBrain(DeepSeekConfig(api_base="http://192.168.0.240:11434"))
|
||||
brain.check_connection()
|
||||
engine = DeepSeekGameEngine(brain=brain, ai_player_index=1)
|
||||
result = engine.run(deck_a, deck_b, name_a="My Deck", name_b="DeepSeek AI")
|
||||
@@ -39,6 +39,9 @@ from typing import Optional
|
||||
from commander_ai_lab.sim.models import Card, GameResult, Player, PlayerResult, PlayerStats, SimState
|
||||
from commander_ai_lab.sim.rules import AI_DEFAULT_WEIGHTS, enrich_card, score_card
|
||||
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig, build_deck_context
|
||||
import os
|
||||
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
|
||||
from commander_ai_lab.sim.deepseek_brain import build_game_state_snapshot
|
||||
|
||||
logger = logging.getLogger("deepseek_engine")
|
||||
|
||||
@@ -54,12 +57,13 @@ class DeepSeekGameEngine:
|
||||
def __init__(
|
||||
self,
|
||||
brain: DeepSeekBrain | None = None,
|
||||
ai_player_index: int = 1, # Which player is the LLM AI (0 or 1)
|
||||
ai_player_index: int = 1,
|
||||
max_turns: int = 25,
|
||||
starting_life: int = 40,
|
||||
weights: Optional[dict] = None,
|
||||
record_log: bool = False,
|
||||
ml_log: bool = False, # Capture ML decision snapshots
|
||||
ml_log: bool = False,
|
||||
validator: ValidatorBrain | None = None, # NEW
|
||||
):
|
||||
self.brain = brain or DeepSeekBrain()
|
||||
self.ai_player_index = ai_player_index
|
||||
@@ -68,7 +72,16 @@ class DeepSeekGameEngine:
|
||||
self.weights = weights or AI_DEFAULT_WEIGHTS
|
||||
self.record_log = record_log
|
||||
self.ml_log = ml_log
|
||||
self.ml_decisions: list[dict] = [] # Collected ML decision snapshots
|
||||
self.ml_decisions: list[dict] = []
|
||||
|
||||
# Validator: use injected instance, or auto-create from env
|
||||
if validator is not None:
|
||||
self._validator = validator
|
||||
elif os.environ.get("VALIDATOR_ENABLED", "false").lower() == "true":
|
||||
cfg = ValidatorConfig.from_env()
|
||||
self._validator = ValidatorBrain(cfg)
|
||||
else:
|
||||
self._validator = None
|
||||
|
||||
def run(
|
||||
self,
|
||||
@@ -247,6 +260,24 @@ class DeepSeekGameEngine:
|
||||
|
||||
# Ask DeepSeek what to do — with full deck intelligence
|
||||
decision = self.brain.choose_action(sim, pi, turn, available_mana, deck_context=deck_context)
|
||||
|
||||
# -- Optional validator call (DeepSeek-R1-14B) --
|
||||
if self._validator is not None:
|
||||
try:
|
||||
snapshot = build_game_state_snapshot(
|
||||
sim, pi, turn,
|
||||
deck_context=self._deck_context,
|
||||
)
|
||||
except Exception:
|
||||
snapshot = {}
|
||||
validation = self._validator.validate(snapshot, decision)
|
||||
if validation:
|
||||
decision["validation"] = validation
|
||||
if validation.get("legality") == "illegal":
|
||||
logger.warning(
|
||||
"Validator flagged illegal action on turn %d: %s",
|
||||
turn, validation.get("legality_issues"),
|
||||
)
|
||||
action = decision.get("action", "hold")
|
||||
target_card_name = decision.get("target_card", "")
|
||||
reasoning = decision.get("reasoning", "")
|
||||
|
||||
@@ -12,6 +12,9 @@ scale with pod size (e.g. Propaganda is worth more vs 3 opponents).
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
@@ -72,6 +75,35 @@ AI_DEFAULT_WEIGHTS: dict[str, float] = {
|
||||
"commander_bonus": 5.0,
|
||||
}
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Default path for learned weights (written by ml/scripts/update_weights.py)
|
||||
_LEARNED_WEIGHTS_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), 'learned_weights.json'
|
||||
)
|
||||
|
||||
|
||||
def load_weights(path: Optional[str] = None) -> dict[str, float]:
|
||||
"""Load AI weights from a JSON file, falling back to AI_DEFAULT_WEIGHTS.
|
||||
|
||||
If *path* is None the default ``learned_weights.json`` next to this
|
||||
module is checked. If the file does not exist or is invalid the
|
||||
built-in defaults are returned silently.
|
||||
"""
|
||||
fpath = path or _LEARNED_WEIGHTS_PATH
|
||||
if os.path.isfile(fpath):
|
||||
try:
|
||||
with open(fpath, 'r', encoding='utf-8') as f:
|
||||
learned = json.load(f)
|
||||
if isinstance(learned, dict):
|
||||
merged = dict(AI_DEFAULT_WEIGHTS)
|
||||
merged.update(learned)
|
||||
_log.info('Loaded learned weights from %s (%d keys)', fpath, len(learned))
|
||||
return merged
|
||||
except Exception as exc:
|
||||
_log.warning('Failed to load learned weights from %s: %s', fpath, exc)
|
||||
return dict(AI_DEFAULT_WEIGHTS)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Card Enrichment — guess type/cost for cards without Scryfall data
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
src/commander_ai_lab/sim/validator_brain.py
|
||||
===========================================
|
||||
DeepSeek-R1-Distill-Qwen-14B validator / explainer brain.
|
||||
|
||||
Called AFTER GPT-OSS 20B chooses an action. Receives the game-state
|
||||
snapshot and the chosen action, then returns:
|
||||
- legality check (legal / illegal / unclear)
|
||||
- tactical quality assessment
|
||||
- suggested improvement (or null)
|
||||
- player_explanation: 2-5 sentences suitable for display in the UI
|
||||
|
||||
Strips <think> blocks automatically (R1-style reasoning models always
|
||||
emit them). Falls back silently on timeout so it never blocks the
|
||||
game loop.
|
||||
|
||||
Environment variables (all optional -- see ValidatorConfig defaults):
|
||||
VALIDATOR_ENABLED true/false (default: false)
|
||||
VALIDATOR_API_BASE Ollama/LM-Studio base URL (default: http://localhost:11434)
|
||||
VALIDATOR_MODEL model name (default: deepseek-r1:14b)
|
||||
VALIDATOR_TEMPERATURE generation temperature (default: 0.0)
|
||||
VALIDATOR_MAX_TOKENS max output tokens (default: 2048)
|
||||
VALIDATOR_TIMEOUT request timeout seconds (default: 60.0)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
logger = logging.getLogger("validator_brain")
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# Configuration
|
||||
# ==============================================================
|
||||
|
||||
@dataclass
|
||||
class ValidatorConfig:
|
||||
"""
|
||||
Configuration for the R1-14B validator brain.
|
||||
|
||||
All fields can be overridden via environment variables at runtime
|
||||
(see module docstring). Defaults target deepseek-r1:14b served by
|
||||
Ollama on localhost.
|
||||
|
||||
Example -- LM Studio::
|
||||
cfg = ValidatorConfig(
|
||||
api_base="http://192.168.0.240:1234",
|
||||
model="deepseek-r1-distill-qwen-14b",
|
||||
)
|
||||
"""
|
||||
api_base: str = "http://localhost:11434"
|
||||
model: str = "deepseek-r1:14b"
|
||||
temperature: float = 0.0
|
||||
max_tokens: int = 2048
|
||||
request_timeout: float = 60.0
|
||||
enabled: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "ValidatorConfig":
|
||||
"""Build a ValidatorConfig from environment variables."""
|
||||
enabled_str = os.environ.get("VALIDATOR_ENABLED", "false").lower()
|
||||
return cls(
|
||||
api_base=os.environ.get("VALIDATOR_API_BASE", "http://localhost:11434"),
|
||||
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:14b"),
|
||||
temperature=float(os.environ.get("VALIDATOR_TEMPERATURE", "0.0")),
|
||||
max_tokens=int(os.environ.get("VALIDATOR_MAX_TOKENS", "2048")),
|
||||
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "60.0")),
|
||||
enabled=(enabled_str == "true"),
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# Prompts
|
||||
# ==============================================================
|
||||
|
||||
VALIDATOR_SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander rules judge and strategy coach.
|
||||
|
||||
You will receive:
|
||||
1. A JSON game-state snapshot.
|
||||
2. A proposed action chosen by an AI opponent.
|
||||
|
||||
Your tasks:
|
||||
1. LEGALITY CHECK -- verify the proposed action is rules-legal given the game state.
|
||||
Check: does the player have the mana to cast the target card? Is the target card
|
||||
actually in hand? Is the action type appropriate (e.g., no attacking on turn 1
|
||||
with a creature played this turn without haste)?
|
||||
2. TACTICAL QUALITY -- evaluate how strong the action is for the active player given
|
||||
their archetype, threat level, and hand.
|
||||
3. EXPLANATION -- write a clear 2-5 sentence explanation suitable for showing to
|
||||
a human player watching the simulation.
|
||||
|
||||
IMPORTANT:
|
||||
- Think through your reasoning internally using <think> blocks.
|
||||
- Then output ONLY the JSON object below -- no other text outside the JSON.
|
||||
|
||||
Output this exact JSON structure:
|
||||
{
|
||||
"legality": "legal",
|
||||
"legality_issues": [],
|
||||
"tactical_assessment": "short paragraph describing tactical quality",
|
||||
"suggested_improvement": null,
|
||||
"player_explanation": "2-5 sentence explanation of what the AI did and why"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Set "legality" to "legal", "illegal", or "unclear".
|
||||
- Set "legality_issues" to a list of issue strings if illegal/unclear, otherwise [].
|
||||
- Set "suggested_improvement" to a one-sentence alternative if a clearly better line
|
||||
exists, otherwise null.
|
||||
- Keep "player_explanation" concise, friendly, and informative for a human audience.
|
||||
"""
|
||||
|
||||
VALIDATOR_USER_TEMPLATE = """Game state:
|
||||
{game_state_json}
|
||||
|
||||
Chosen action:
|
||||
{action_json}
|
||||
|
||||
Validate this action and provide your analysis. Respond with ONLY valid JSON."""
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# ValidatorBrain
|
||||
# ==============================================================
|
||||
|
||||
class ValidatorBrain:
|
||||
"""
|
||||
Thin LLM client for the DeepSeek-R1-14B validator role.
|
||||
|
||||
Designed to be called after GPT-OSS 20B chooses an action.
|
||||
Never raises -- returns None on timeout or parse failure so the
|
||||
game loop is never blocked.
|
||||
|
||||
Usage::
|
||||
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
|
||||
|
||||
cfg = ValidatorConfig.from_env() # reads VALIDATOR_* env vars
|
||||
validator = ValidatorBrain(cfg)
|
||||
result = validator.validate(game_state_snapshot, chosen_action)
|
||||
if result:
|
||||
print(result["player_explanation"])
|
||||
"""
|
||||
|
||||
def __init__(self, config: ValidatorConfig | None = None):
|
||||
self.config = config or ValidatorConfig.from_env()
|
||||
self._total_calls: int = 0
|
||||
self._total_failures: int = 0
|
||||
self._total_latency_ms: float = 0.0
|
||||
|
||||
# -- Public API ----------------------------------------
|
||||
|
||||
def validate(
|
||||
self,
|
||||
game_state_snapshot: dict,
|
||||
chosen_action: dict,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Validate a chosen action against the current game state.
|
||||
|
||||
Returns a dict with keys:
|
||||
legality -- "legal" | "illegal" | "unclear"
|
||||
legality_issues -- list[str]
|
||||
tactical_assessment -- str
|
||||
suggested_improvement -- str | None
|
||||
player_explanation -- str
|
||||
|
||||
Returns None on timeout, connection error, or JSON parse failure.
|
||||
The caller should silently continue with the original action.
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return None
|
||||
|
||||
self._total_calls += 1
|
||||
t_start = time.time()
|
||||
|
||||
game_state_json = json.dumps(game_state_snapshot, indent=2)
|
||||
action_json = json.dumps(chosen_action, indent=2)
|
||||
user_msg = VALIDATOR_USER_TEMPLATE.format(
|
||||
game_state_json=game_state_json,
|
||||
action_json=action_json,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": self.config.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": VALIDATOR_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
"temperature": self.config.temperature,
|
||||
"max_tokens": self.config.max_tokens,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
try:
|
||||
url = self.config.api_base.rstrip("/") + "/v1/chat/completions"
|
||||
req = Request(
|
||||
url,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
method="POST",
|
||||
)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
with urlopen(req, timeout=self.config.request_timeout) as resp:
|
||||
data = json.loads(resp.read())
|
||||
|
||||
raw = data["choices"][0]["message"]["content"]
|
||||
result = self._parse_response(raw)
|
||||
latency = round((time.time() - t_start) * 1000, 1)
|
||||
self._total_latency_ms += latency
|
||||
logger.debug(
|
||||
"Validator latency: %sms legality=%s",
|
||||
latency, result.get("legality"),
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
self._total_failures += 1
|
||||
logger.warning("Validator call failed (proceeding without validation): %s", exc)
|
||||
return None
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
"""Test whether the validator endpoint is reachable."""
|
||||
try:
|
||||
url = self.config.api_base.rstrip("/") + "/v1/models"
|
||||
req = Request(url, method="GET")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read())
|
||||
models = data.get("data", [])
|
||||
if models:
|
||||
logger.info(
|
||||
"Validator connected to %s model=%s",
|
||||
self.config.api_base, self.config.model,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Validator connection check failed: %s", exc)
|
||||
return False
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Return performance statistics for monitoring/API endpoints."""
|
||||
successful = self._total_calls - self._total_failures
|
||||
avg_latency = round(
|
||||
self._total_latency_ms / max(successful, 1), 1
|
||||
)
|
||||
return {
|
||||
"total_calls": self._total_calls,
|
||||
"failures": self._total_failures,
|
||||
"successful": successful,
|
||||
"avg_latency_ms": avg_latency,
|
||||
"model": self.config.model,
|
||||
"api_base": self.config.api_base,
|
||||
"enabled": self.config.enabled,
|
||||
}
|
||||
|
||||
# -- Internal helpers ----------------------------------
|
||||
|
||||
def _parse_response(self, raw_text: str) -> dict:
|
||||
"""Strip <think> blocks and markdown fences, then parse JSON."""
|
||||
text = raw_text.strip()
|
||||
|
||||
# Strip R1-style <think>...</think> reasoning blocks
|
||||
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
||||
# Strip any unclosed <think> block trailing the response
|
||||
text = re.sub(r"<think>.*", "", text, flags=re.DOTALL).strip()
|
||||
|
||||
# Strip markdown code fences
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text).strip()
|
||||
|
||||
# Extract outermost JSON object if any surrounding text leaked through
|
||||
match = re.search(r"\{.*\}", text, re.DOTALL)
|
||||
if match:
|
||||
text = match.group(0)
|
||||
|
||||
parsed = json.loads(text)
|
||||
|
||||
return {
|
||||
"legality": parsed.get("legality", "unclear"),
|
||||
"legality_issues": parsed.get("legality_issues", []),
|
||||
"tactical_assessment": parsed.get("tactical_assessment", ""),
|
||||
"suggested_improvement": parsed.get("suggested_improvement"),
|
||||
"player_explanation": parsed.get("player_explanation", ""),
|
||||
}
|
||||
@@ -75,11 +75,11 @@ public class BatchRunner {
|
||||
|
||||
// "Game Outcome: Ai(1)-Edgar Markov has won because all opponents have lost"
|
||||
private static final Pattern WINNER_PATTERN =
|
||||
Pattern.compile("Game Outcome:\\s*Ai\\((\\d+)\\)-(.+?)\\s+has won because (.+)");
|
||||
Pattern.compile("Game Outcome:\\s*Ai\\(?(\\d+)\\)?-(.+?)\\s+has won because (.+)");
|
||||
|
||||
// "Game Outcome: Ai(2)-Grimgrin has lost because life total reached 0"
|
||||
private static final Pattern LOSER_PATTERN =
|
||||
Pattern.compile("Game Outcome:\\s*Ai\\((\\d+)\\)-(.+?)\\s+has lost because (.+)");
|
||||
Pattern.compile("Game Outcome:\\s*Ai\\(?(\\d+)\\)?-(.+?)\\s+has lost because (.+)");
|
||||
|
||||
// "Game Result: Game 1 ended in 9201 ms. Ai(1)-Edgar Markov has won!"
|
||||
private static final Pattern GAME_RESULT_PATTERN =
|
||||
@@ -97,19 +97,19 @@ public class BatchRunner {
|
||||
// Forge verbose log format: "Life: Life: Ai(1)-Name 40 > 42" (old life > new life)
|
||||
// Also support older format: "Ai(1)-Name's life is now 27."
|
||||
private static final Pattern LIFE_CHANGE_PATTERN =
|
||||
Pattern.compile("Life:\\s*Life:\\s*Ai\\((\\d+)\\)-.+?\\s+(\\d+)\\s*>\\s*(-?\\d+)");
|
||||
Pattern.compile("Life:\\s*Life:\\s*Ai\\(?(\\d+)\\)?-.+?\\s+(\\d+)\\s*>\\s*(-?\\d+)");
|
||||
|
||||
// Fallback: "Ai(1)-Name's life is now 27." or "Ai(1)-Name's life total is now 27"
|
||||
private static final Pattern LIFE_TOTAL_PATTERN =
|
||||
Pattern.compile("Ai\\((\\d+)\\)-.+?'s\\s+life(?:\\s+total)?\\s+is\\s+now\\s+(-?\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
Pattern.compile("Ai\\(?(\\d+)\\)?-.+?'s\\s+life(?:\\s+total)?\\s+is\\s+now\\s+(-?\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
// "Ai(1)-Name loses N life" — life loss event
|
||||
private static final Pattern LIFE_LOSS_PATTERN =
|
||||
Pattern.compile("Ai\\((\\d+)\\)-.+?\\s+loses\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE);
|
||||
Pattern.compile("Ai\\(?(\\d+)\\)?-.+?\\s+loses\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
// "Ai(1)-Name gains N life" — life gain event
|
||||
private static final Pattern LIFE_GAIN_PATTERN =
|
||||
Pattern.compile("Ai\\((\\d+)\\)-.+?\\s+gains\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE);
|
||||
Pattern.compile("Ai\\(?(\\d+)\\)?-.+?\\s+gains\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
// ── Verbose game-log patterns for extracting combat stats ────────────
|
||||
|
||||
@@ -117,12 +117,12 @@ public class BatchRunner {
|
||||
// Format: "Add To Stack: Ai(N)-DeckName cast CardName"
|
||||
// Note: Forge uses past tense "cast" not "casts"
|
||||
private static final Pattern CAST_PATTERN =
|
||||
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+cast\\s+(.+?)(?:\\s+targeting.*)?$", Pattern.CASE_INSENSITIVE);
|
||||
Pattern.compile("(?:Add To Stack:\\s*)?Ai\\(?(\\d+)\\)?-[^\\s].*?\\s+casts?\\s+(.+?)(?:\\s+targeting.*)?$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
// "Land: Ai(1)-Name played LandName (SET)" or "Ai(1)-Name plays LandName."
|
||||
// Forge verbose log uses "played" (past tense) with set info in parens, no trailing period
|
||||
private static final Pattern LAND_PLAY_PATTERN =
|
||||
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+play(?:s|ed)\\s+(.+?)(?:\\s+\\(\\d+\\))?(?:\\.|$)", Pattern.CASE_INSENSITIVE);
|
||||
Pattern.compile("(?:Land:\\s*)?Ai\\(?(\\d+)\\)?-[^\\s].*?\\s+play(?:s|ed)\\s+(.+?)(?:\\s+\\(\\d+\\))?(?:\\.|$)", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
// "Zone Change: CardName (N) was put into Graveyard from Battlefield." — creature/permanent death
|
||||
// Also: "is destroyed" / "dies" from older format
|
||||
@@ -133,11 +133,15 @@ public class BatchRunner {
|
||||
|
||||
// "deals N combat damage" or "deals N damage to Ai(X)"
|
||||
private static final Pattern DAMAGE_PATTERN =
|
||||
Pattern.compile("deals\\s+(\\d+)\\s+(?:combat\\s+)?damage\\s+to\\s+Ai\\((\\d+)\\)", Pattern.CASE_INSENSITIVE);
|
||||
Pattern.compile("deals\\s+(\\d+)\\s+(?:combat\\s+)?damage\\s+to\\s+Ai\\(?(\\d+)\\)?", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
|
||||
// "Damage: CardName deals 3 combat damage to Ai(2)-DeckName." — captures source card for commander damage tracking
|
||||
private static final Pattern PLAYER_DAMAGE_SOURCE_PATTERN =
|
||||
Pattern.compile("(?:Damage:\\s*)?(.+?)\\s+deals\\s+(\\d+)\\s+(?:combat\\s+)?damage\\s+to\\s+Ai\\(?(\\d+)\\)?", Pattern.CASE_INSENSITIVE);
|
||||
// "commander damage" — specifically commander damage dealt
|
||||
private static final Pattern CMDR_DAMAGE_PATTERN =
|
||||
Pattern.compile("Ai\\((\\d+)\\).*?commander.*?damage.*?(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
Pattern.compile("Ai\\(?(\\d+)\\)?.*?commander.*?damage.*?(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
// ── Per-card tracking patterns (verbose log) ─────────────────────────
|
||||
|
||||
@@ -145,12 +149,12 @@ public class BatchRunner {
|
||||
// This pattern is kept as a fallback for other Forge output modes.
|
||||
// In verbose mode, draws can only be inferred from "Draw step" phase entries.
|
||||
private static final Pattern DRAW_PATTERN =
|
||||
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+draws\\s+(.+?)(?:\\.|$)", Pattern.CASE_INSENSITIVE);
|
||||
Pattern.compile("Ai\\(?(\\d+)\\)?-[^\\s].*?\\s+draws\\s+(.+?)(?:\\.|$)", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
// Turn boundary: "Turn 5 (Ai(1)-Name)" or "Turn 5 (Ai(2)-Name)"
|
||||
// Some Forge versions use: "== Turn X (Ai(N)-Name) =="
|
||||
private static final Pattern VERBOSE_TURN_PATTERN =
|
||||
Pattern.compile("Turn\\s+(\\d+)\\s+\\(Ai\\((\\d+)\\)", Pattern.CASE_INSENSITIVE);
|
||||
Pattern.compile("Turn\\s+(\\d+)\\s+\\(Ai\\(?(\\d+)\\)?", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
// "CardName deals N damage" — damage by a specific card
|
||||
private static final Pattern CARD_DAMAGE_PATTERN =
|
||||
@@ -582,9 +586,11 @@ public class BatchRunner {
|
||||
Set<Integer> loserSeats = new HashSet<>();
|
||||
Set<Integer> winnerCandidates = new HashSet<>(); // Track all "has won" seats
|
||||
int maxVerboseTurn = 0; // Fallback turn count from verbose log
|
||||
|
||||
for (String line : lines) {
|
||||
line = line.trim();
|
||||
if (line.startsWith("Game Outcome:") || line.startsWith("Game Result:") || line.startsWith("Match Result:")) {
|
||||
System.out.println("[OUTCOME-LINE] " + line);
|
||||
}
|
||||
|
||||
// Parse turn count: "Game Outcome: Turn 11"
|
||||
Matcher turnMatcher = TURN_PATTERN.matcher(line);
|
||||
@@ -713,7 +719,7 @@ public class BatchRunner {
|
||||
|
||||
// Count mulligans from non-quiet output
|
||||
if (line.toLowerCase().contains("mulligan")) {
|
||||
Matcher mullMatcher = Pattern.compile("Ai\\((\\d+)\\)").matcher(line);
|
||||
Matcher mullMatcher = Pattern.compile("Ai\\(?(\\d+)\\)?").matcher(line);
|
||||
if (mullMatcher.find()) {
|
||||
int aiNum = Integer.parseInt(mullMatcher.group(1));
|
||||
int seat = aiNum - 1;
|
||||
@@ -791,8 +797,32 @@ public class BatchRunner {
|
||||
for (int ci = 0; ci < decks.size(); ci++) {
|
||||
commanderNames[ci] = decks.get(ci).commanderName;
|
||||
}
|
||||
// Auto-detect commander names from .dck files if not explicitly set
|
||||
for (int ci2 = 0; ci2 < decks.size(); ci2++) {
|
||||
if (commanderNames[ci2] == null || commanderNames[ci2].isEmpty()
|
||||
|| commanderNames[ci2].equals(decks.get(ci2).deckName)
|
||||
|| commanderNames[ci2].equals(decks.get(ci2).deckFile)) {
|
||||
String detected = readCommanderCardName(decks.get(ci2).deckFile);
|
||||
if (detected != null && !detected.isEmpty()) {
|
||||
commanderNames[ci2] = detected;
|
||||
System.out.printf("[CMDR] Seat %d: auto-detected commander '%s' from .dck file%n", ci2, detected);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Log commander names for diagnostics
|
||||
for (int ci3 = 0; ci3 < decks.size(); ci3++) {
|
||||
System.out.printf("[CMDR-INFO] Seat %d: commander='%s', deckName='%s'%n",
|
||||
ci3, commanderNames[ci3], decks.get(ci3).deckName);
|
||||
}
|
||||
|
||||
for (String line : lines) {
|
||||
|
||||
|
||||
// Diagnostic: log lines containing combat-related keywords (first 10 only)
|
||||
String lower = line.trim().toLowerCase();
|
||||
if (lower.contains("cast") || lower.contains("play") || lower.contains("damage") || lower.contains("add to stack") || lower.contains("land:")) {
|
||||
System.out.println("[COMBAT-LINE] " + line.trim());
|
||||
}
|
||||
line = line.trim();
|
||||
if (line.isEmpty()) continue;
|
||||
|
||||
@@ -824,18 +854,30 @@ public class BatchRunner {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match damage dealt: "deals N damage to Ai(X)"
|
||||
Matcher dmgMatcher = DAMAGE_PATTERN.matcher(line);
|
||||
while (dmgMatcher.find()) {
|
||||
// Note: this captures damage TO a player, not FROM.
|
||||
// We track it as creature/combat damage events
|
||||
// Match damage dealt to players and track commander damage
|
||||
Matcher playerDmgMatcher = PLAYER_DAMAGE_SOURCE_PATTERN.matcher(line);
|
||||
if (playerDmgMatcher.find()) {
|
||||
String sourceCard = playerDmgMatcher.group(1).trim();
|
||||
int dmgAmount = Integer.parseInt(playerDmgMatcher.group(2));
|
||||
int targetAiNum = Integer.parseInt(playerDmgMatcher.group(3));
|
||||
int targetSeat = targetAiNum - 1;
|
||||
// Check if the source card is any player's commander
|
||||
for (int ci = 0; ci < decks.size(); ci++) {
|
||||
if (commanderNames[ci] != null && sourceCard.equalsIgnoreCase(commanderNames[ci])) {
|
||||
if (ci >= 0 && ci < result.playerResults.size()) {
|
||||
result.playerResults.get(ci).commanderDamageDealt += dmgAmount;
|
||||
System.out.printf("[CMDR-DMG] %s (seat %d) dealt %d cmdr damage to seat %d%n",
|
||||
sourceCard, ci, dmgAmount, targetSeat);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match creature deaths: "is destroyed" / "dies"
|
||||
if (CREATURE_DESTROYED_PATTERN.matcher(line).find()) {
|
||||
// Try to figure out which player's creature died
|
||||
// Look for Ai(N) reference before the destruction text
|
||||
Matcher aiRef = Pattern.compile("Ai\\((\\d+)\\)").matcher(line);
|
||||
Matcher aiRef = Pattern.compile("Ai\\(?(\\d+)\\)?").matcher(line);
|
||||
// We attribute destruction to the OPPOSING player (the one who caused it)
|
||||
// But since we can't reliably determine the attacker, count it globally
|
||||
// For each seat, count creatures they destroyed (opponent's creatures that died)
|
||||
@@ -860,7 +902,7 @@ public class BatchRunner {
|
||||
if (cdmg.find()) {
|
||||
int amount = Integer.parseInt(cdmg.group(1));
|
||||
// Try to find the source player
|
||||
Matcher srcPlayer = Pattern.compile("Ai\\((\\d+)\\)").matcher(line);
|
||||
Matcher srcPlayer = Pattern.compile("Ai\\(?(\\d+)\\)?").matcher(line);
|
||||
if (srcPlayer.find()) {
|
||||
int seat = Integer.parseInt(srcPlayer.group(1)) - 1;
|
||||
if (seat >= 0 && seat < decks.size()) {
|
||||
@@ -879,6 +921,14 @@ public class BatchRunner {
|
||||
// Leave at 0 rather than fabricating data
|
||||
}
|
||||
|
||||
System.out.printf("[PARSE-SUMMARY] Game %d: total lines=%d%n", gameIndex, lines.length);
|
||||
// ── Diagnostic: log parsed combat stats ──────────────────────
|
||||
for (int seat = 0; seat < result.playerResults.size(); seat++) {
|
||||
PlayerResult pr = result.playerResults.get(seat);
|
||||
System.out.printf("[PARSE] Game %d Seat %d: spells=%d, lands=%d, cmdrCasts=%d, cmdrDmg=%d, life=%d%n",
|
||||
gameIndex, seat, pr.spellsCast, pr.landsPlayed, pr.commanderCasts, pr.commanderDamageDealt, pr.finalLife);
|
||||
}
|
||||
|
||||
// ── Build per-card stats from verbose output (for coach analytics) ──
|
||||
if (!quiet) {
|
||||
try {
|
||||
@@ -1267,4 +1317,49 @@ public class BatchRunner {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the commander card name from a Forge .dck file.
|
||||
* Looks for the [Commander] section and extracts the card name.
|
||||
* Format: "1 Card Name|SET" under [Commander]
|
||||
*/
|
||||
private String readCommanderCardName(String deckFilePath) {
|
||||
try {
|
||||
Path path = Path.of(deckFilePath);
|
||||
if (!Files.exists(path)) {
|
||||
Path withExt = Path.of(deckFilePath + ".dck");
|
||||
if (Files.exists(withExt)) {
|
||||
path = withExt;
|
||||
} else {
|
||||
Path resolved = resolveForgeDecksPath(deckFilePath);
|
||||
if (resolved != null && Files.exists(resolved)) {
|
||||
path = resolved;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> lines = Files.readAllLines(path);
|
||||
boolean inCommanderSection = false;
|
||||
Pattern cardLine = Pattern.compile("^(\\d+)\\s+(.+?)(?:\\|(.+))?$");
|
||||
for (String line : lines) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty() || line.startsWith("//")) continue;
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
inCommanderSection = line.equalsIgnoreCase("[Commander]");
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("Name=")) continue;
|
||||
if (inCommanderSection) {
|
||||
Matcher m = cardLine.matcher(line);
|
||||
if (m.matches()) {
|
||||
return m.group(2).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("[CMDR] Failed to read commander from .dck: " + deckFilePath + " - " + e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package commanderailab.bridge;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* GameSession — holds state for one live interactive Commander game.
|
||||
*
|
||||
* PHASE 1 STUB: generates synthetic game state so you can verify the full
|
||||
* WebSocket contract round-trips correctly before wiring in real Forge IPC.
|
||||
*
|
||||
* Replace run() in Phase 2 once the contract is verified end-to-end.
|
||||
*/
|
||||
public class GameSession {
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private final List<String> deckNames;
|
||||
private final Long seed;
|
||||
private final String forgeJarPath;
|
||||
private final String forgeWorkDir;
|
||||
private final BlockingQueue<String> humanActionQueue;
|
||||
|
||||
private volatile boolean running = false;
|
||||
private volatile int turnNumber = 1;
|
||||
|
||||
// Mutable game state
|
||||
private String phase = "UNTAP";
|
||||
private int activePlayer = 0;
|
||||
private int priorityPlayer = 0;
|
||||
private final int[] life;
|
||||
private final int[] poison;
|
||||
private final List<String>[] hands;
|
||||
private final List<String>[] battlefields;
|
||||
private final List<String>[] graveyards;
|
||||
private final List<String>[] commandZones;
|
||||
private final int[] commanderTax;
|
||||
private boolean awaitingHumanInput = false;
|
||||
|
||||
private static final String[] PHASES = {
|
||||
"UNTAP", "UPKEEP", "DRAW",
|
||||
"MAIN1", "BEGIN_COMBAT", "DECLARE_ATTACKERS",
|
||||
"DECLARE_BLOCKERS", "DAMAGE", "END_COMBAT",
|
||||
"MAIN2", "END", "CLEANUP"
|
||||
};
|
||||
private int phaseIndex = 0;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public GameSession(List<String> deckNames, Long seed,
|
||||
String forgeJarPath, String forgeWorkDir,
|
||||
BlockingQueue<String> humanActionQueue) {
|
||||
this.deckNames = deckNames;
|
||||
this.seed = seed;
|
||||
this.forgeJarPath = forgeJarPath;
|
||||
this.forgeWorkDir = forgeWorkDir;
|
||||
this.humanActionQueue = humanActionQueue;
|
||||
|
||||
int seats = Math.min(deckNames.size(), 4);
|
||||
life = new int[seats];
|
||||
poison = new int[seats];
|
||||
hands = new List[seats];
|
||||
battlefields = new List[seats];
|
||||
graveyards = new List[seats];
|
||||
commandZones = new List[seats];
|
||||
commanderTax = new int[seats];
|
||||
|
||||
for (int i = 0; i < seats; i++) {
|
||||
life[i] = 40;
|
||||
hands[i] = new ArrayList<>(List.of(
|
||||
"card_stub_1", "card_stub_2", "card_stub_3",
|
||||
"card_stub_4", "card_stub_5", "card_stub_6", "card_stub_7"
|
||||
));
|
||||
battlefields[i] = new ArrayList<>();
|
||||
graveyards[i] = new ArrayList<>();
|
||||
commandZones[i] = new ArrayList<>(List.of(deckNames.get(i) + "_commander"));
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isRunning() { return running; }
|
||||
public int getTurnNumber() { return turnNumber; }
|
||||
|
||||
public void stop() {
|
||||
running = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the game loop. Calls onStateChange after every action resolves.
|
||||
* Blocks until the game ends or stop() is called.
|
||||
*
|
||||
* PHASE 1 STUB: advances through phases automatically; on MAIN1 of seat 0's
|
||||
* turn, pauses and waits for the human client to send an action.
|
||||
*/
|
||||
public void run(Consumer<Object> onStateChange) throws InterruptedException {
|
||||
running = true;
|
||||
Random rng = seed != null ? new Random(seed) : new Random();
|
||||
System.out.println("[Session] Game started. Decks: " + deckNames);
|
||||
|
||||
while (running && !isGameOver()) {
|
||||
String currentPhase = PHASES[phaseIndex];
|
||||
phase = currentPhase;
|
||||
activePlayer = (turnNumber - 1) % deckNames.size();
|
||||
|
||||
boolean isHumanTurn = (activePlayer == 0);
|
||||
boolean isInteractivePhase = currentPhase.equals("MAIN1") || currentPhase.equals("MAIN2");
|
||||
|
||||
if (isHumanTurn && isInteractivePhase) {
|
||||
awaitingHumanInput = true;
|
||||
priorityPlayer = 0;
|
||||
onStateChange.accept(buildStateSnapshot());
|
||||
|
||||
System.out.println("[Session] Awaiting human action for " + currentPhase + "...");
|
||||
String actionJson = humanActionQueue.poll(120, TimeUnit.SECONDS);
|
||||
|
||||
if (actionJson == null) {
|
||||
System.out.println("[Session] Human timed out — auto passing.");
|
||||
} else {
|
||||
applyAction(actionJson);
|
||||
System.out.println("[Session] Human action applied: " + actionJson);
|
||||
}
|
||||
awaitingHumanInput = false;
|
||||
} else {
|
||||
// AI turn — small delay for readability
|
||||
Thread.sleep(300);
|
||||
// Simulate AI occasionally dealing damage to human
|
||||
if (!isHumanTurn && currentPhase.equals("DAMAGE") && life[0] > 5) {
|
||||
life[0] -= rng.nextInt(4);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance phase
|
||||
phaseIndex++;
|
||||
if (phaseIndex >= PHASES.length) {
|
||||
phaseIndex = 0;
|
||||
turnNumber++;
|
||||
}
|
||||
|
||||
onStateChange.accept(buildStateSnapshot());
|
||||
}
|
||||
|
||||
running = false;
|
||||
System.out.println("[Session] Game ended after " + turnNumber + " turns.");
|
||||
}
|
||||
|
||||
private void applyAction(String actionJson) {
|
||||
try {
|
||||
JsonObject action = JsonParser.parseString(actionJson).getAsJsonObject();
|
||||
String type = action.has("type") ? action.get("type").getAsString() : "PASS_PRIORITY";
|
||||
|
||||
switch (type) {
|
||||
case "PLAY_LAND", "CAST_SPELL" -> {
|
||||
String cardId = action.has("cardId") ? action.get("cardId").getAsString() : null;
|
||||
if (cardId != null && hands[0].contains(cardId)) {
|
||||
hands[0].remove(cardId);
|
||||
battlefields[0].add(cardId);
|
||||
}
|
||||
}
|
||||
case "PASS_PRIORITY" -> { /* no-op, phase advances naturally */ }
|
||||
default -> System.out.println("[Session] Unknown action type: " + type);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("[Session] Failed to apply action: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isGameOver() {
|
||||
int alive = 0;
|
||||
for (int lp : life) if (lp > 0) alive++;
|
||||
// Phase 1 stub: end after 50 turns or only 1 player alive
|
||||
return alive <= 1 || turnNumber > 50;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// State serialization
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/** Returns a full { type: "STATE", state: {...} } JSON string. */
|
||||
public String buildStateMessage() {
|
||||
Map<String, Object> msg = new LinkedHashMap<>();
|
||||
msg.put("type", "STATE");
|
||||
msg.put("state", buildStateSnapshot());
|
||||
return GSON.toJson(msg);
|
||||
}
|
||||
|
||||
/** Returns the raw state snapshot map (serialized by caller). */
|
||||
public Map<String, Object> buildStateSnapshot() {
|
||||
Map<String, Object> state = new LinkedHashMap<>();
|
||||
state.put("schema", "1.0.0");
|
||||
state.put("phase", phase);
|
||||
state.put("turnNumber", turnNumber);
|
||||
state.put("activePlayer", activePlayer);
|
||||
state.put("priorityPlayer", priorityPlayer);
|
||||
state.put("awaitingInput", awaitingHumanInput);
|
||||
state.put("inputPrompt", awaitingHumanInput ? "Choose an action or pass priority" : null);
|
||||
|
||||
List<Map<String, Object>> players = new ArrayList<>();
|
||||
for (int i = 0; i < deckNames.size(); i++) {
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("seat", i);
|
||||
p.put("name", i == 0 ? "Human" : "AI-" + i);
|
||||
p.put("isAI", i != 0);
|
||||
p.put("deckName", deckNames.get(i));
|
||||
p.put("life", life[i]);
|
||||
p.put("poison", poison[i]);
|
||||
p.put("commanderTax", commanderTax[i]);
|
||||
p.put("handCount", hands[i].size());
|
||||
p.put("hand", i == 0 ? hands[i] : List.of()); // hide opponent hands
|
||||
p.put("battlefield", battlefields[i]);
|
||||
p.put("graveyard", graveyards[i]);
|
||||
p.put("commandZone", commandZones[i]);
|
||||
p.put("manaPool", Map.of("W",0,"U",0,"B",0,"R",0,"G",0,"C",0));
|
||||
players.add(p);
|
||||
}
|
||||
state.put("players", players);
|
||||
state.put("stack", List.of());
|
||||
|
||||
// Legal actions — only populated when it's the human's turn
|
||||
List<Map<String, Object>> legalActions = new ArrayList<>();
|
||||
if (awaitingHumanInput) {
|
||||
legalActions.add(Map.of("type", "PASS_PRIORITY", "label", "Pass Priority"));
|
||||
for (String card : hands[0]) {
|
||||
legalActions.add(Map.of("type", "CAST_SPELL", "cardId", card, "label", "Cast " + card));
|
||||
}
|
||||
}
|
||||
state.put("legalActions", legalActions);
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package commanderailab.bridge;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.java_websocket.WebSocket;
|
||||
import org.java_websocket.handshake.ClientHandshake;
|
||||
import org.java_websocket.server.WebSocketServer;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/**
|
||||
* GameWsServer — WebSocket server that exposes the Forge engine to a Unity/Godot client.
|
||||
*
|
||||
* Connect at: ws://localhost:7654
|
||||
*
|
||||
* Protocol:
|
||||
* ENGINE -> CLIENT: GameStateSnapshot JSON (pushed after every action resolves)
|
||||
* CLIENT -> ENGINE: PlayerAction JSON
|
||||
*
|
||||
* Message types (CLIENT -> ENGINE):
|
||||
* { "type": "START_GAME", "decks": ["DeckA","DeckB","DeckC","DeckD"], "seed": 12345 }
|
||||
* { "type": "PLAYER_ACTION", "action": { ... } }
|
||||
* { "type": "PING" }
|
||||
*
|
||||
* Message types (ENGINE -> CLIENT):
|
||||
* { "type": "STATE", "state": { ... GameStateSnapshot ... } }
|
||||
* { "type": "ERROR", "message": "..." }
|
||||
* { "type": "PONG" }
|
||||
*/
|
||||
public class GameWsServer extends WebSocketServer {
|
||||
|
||||
private static final int DEFAULT_PORT = 7654;
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private final String forgeJarPath;
|
||||
private final String forgeWorkDir;
|
||||
|
||||
// Active game session (one game at a time for Phase 1)
|
||||
private volatile GameSession activeSession;
|
||||
|
||||
// Waiting-for-human-input: the session parks here until Unity sends an action
|
||||
private final BlockingQueue<String> humanActionQueue = new LinkedBlockingQueue<>();
|
||||
|
||||
public GameWsServer(String forgeJarPath, String forgeWorkDir) {
|
||||
super(new InetSocketAddress(DEFAULT_PORT));
|
||||
this.forgeJarPath = forgeJarPath;
|
||||
this.forgeWorkDir = forgeWorkDir;
|
||||
setReuseAddr(true);
|
||||
}
|
||||
|
||||
public GameWsServer(int port, String forgeJarPath, String forgeWorkDir) {
|
||||
super(new InetSocketAddress(port));
|
||||
this.forgeJarPath = forgeJarPath;
|
||||
this.forgeWorkDir = forgeWorkDir;
|
||||
setReuseAddr(true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// WebSocket lifecycle
|
||||
// ---------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocket conn, ClientHandshake handshake) {
|
||||
System.out.println("[WS] Client connected: " + conn.getRemoteSocketAddress());
|
||||
// Send current state if a game is in progress
|
||||
if (activeSession != null) {
|
||||
conn.send(activeSession.buildStateMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
|
||||
System.out.println("[WS] Client disconnected: " + conn.getRemoteSocketAddress());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocket conn, String message) {
|
||||
try {
|
||||
JsonObject msg = JsonParser.parseString(message).getAsJsonObject();
|
||||
String type = msg.get("type").getAsString();
|
||||
|
||||
switch (type) {
|
||||
case "PING" -> conn.send(buildPong());
|
||||
case "START_GAME" -> handleStartGame(conn, msg);
|
||||
case "PLAYER_ACTION" -> handlePlayerAction(conn, msg);
|
||||
case "STOP_GAME" -> {
|
||||
if (activeSession != null) activeSession.stop();
|
||||
activeSession = null;
|
||||
broadcast(buildInfo("Game stopped."));
|
||||
}
|
||||
default -> sendError(conn, "Unknown message type: " + type);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
sendError(conn, "Parse error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(WebSocket conn, Exception ex) {
|
||||
System.err.println("[WS] Error: " + ex.getMessage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
System.out.println("[WS] GameWsServer started on port " + getPort());
|
||||
System.out.println("[WS] Connect Unity/Godot to: ws://localhost:" + getPort());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Message handlers
|
||||
// ---------------------------------------------------------
|
||||
|
||||
private void handleStartGame(WebSocket conn, JsonObject msg) {
|
||||
if (activeSession != null && activeSession.isRunning()) {
|
||||
sendError(conn, "A game is already in progress. Send { type: 'STOP_GAME' } first.");
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> decks = new ArrayList<>();
|
||||
if (msg.has("decks")) {
|
||||
msg.getAsJsonArray("decks").forEach(d -> decks.add(d.getAsString()));
|
||||
}
|
||||
|
||||
if (decks.size() < 3) {
|
||||
sendError(conn, "Need at least 3 deck names in 'decks' array.");
|
||||
return;
|
||||
}
|
||||
|
||||
Long seed = msg.has("seed") ? msg.get("seed").getAsLong() : null;
|
||||
|
||||
System.out.println("[WS] Starting game: " + decks + " seed=" + seed);
|
||||
|
||||
// Clear any stale actions from previous game
|
||||
humanActionQueue.clear();
|
||||
|
||||
activeSession = new GameSession(decks, seed, forgeJarPath, forgeWorkDir, humanActionQueue);
|
||||
|
||||
// Push initial state immediately
|
||||
broadcast(activeSession.buildStateMessage());
|
||||
|
||||
// Run the game loop on a background thread so WebSocket thread stays free
|
||||
Thread gameThread = new Thread(() -> {
|
||||
try {
|
||||
activeSession.run(state -> broadcast(GSON.toJson(state)));
|
||||
broadcast(buildInfo("Game over after " + activeSession.getTurnNumber() + " turns."));
|
||||
} catch (Exception e) {
|
||||
broadcastError("Game error: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}, "game-loop");
|
||||
gameThread.setDaemon(true);
|
||||
gameThread.start();
|
||||
}
|
||||
|
||||
private void handlePlayerAction(WebSocket conn, JsonObject msg) {
|
||||
if (activeSession == null || !activeSession.isRunning()) {
|
||||
sendError(conn, "No active game. Send START_GAME first.");
|
||||
return;
|
||||
}
|
||||
if (!msg.has("action")) {
|
||||
sendError(conn, "Missing 'action' field.");
|
||||
return;
|
||||
}
|
||||
// Put the raw action JSON string onto the queue -- GameSession polls this
|
||||
humanActionQueue.offer(msg.get("action").toString());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Broadcast helpers
|
||||
// ---------------------------------------------------------
|
||||
|
||||
private void broadcastError(String message) {
|
||||
broadcast(buildError(message));
|
||||
}
|
||||
|
||||
private void sendError(WebSocket conn, String message) {
|
||||
conn.send(buildError(message));
|
||||
}
|
||||
|
||||
private static String buildError(String message) {
|
||||
JsonObject obj = new JsonObject();
|
||||
obj.addProperty("type", "ERROR");
|
||||
obj.addProperty("message", message);
|
||||
return obj.toString();
|
||||
}
|
||||
|
||||
private static String buildInfo(String message) {
|
||||
JsonObject obj = new JsonObject();
|
||||
obj.addProperty("type", "INFO");
|
||||
obj.addProperty("message", message);
|
||||
return obj.toString();
|
||||
}
|
||||
|
||||
private static String buildPong() {
|
||||
JsonObject obj = new JsonObject();
|
||||
obj.addProperty("type", "PONG");
|
||||
obj.addProperty("ts", System.currentTimeMillis());
|
||||
return obj.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package commanderailab.cli;
|
||||
|
||||
import commanderailab.bridge.GameWsServer;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
/**
|
||||
* WsServerCommand — picocli subcommand that starts the interactive game WebSocket server.
|
||||
*
|
||||
* Usage:
|
||||
* java -jar commander-ai-lab.jar ws-server \
|
||||
* --forge-jar path/to/forge.jar \
|
||||
* --forge-dir path/to/forge-gui \
|
||||
* --port 7654
|
||||
*
|
||||
* Then connect Unity/Godot to ws://localhost:7654 and send:
|
||||
* { "type": "START_GAME", "decks": ["DeckA","DeckB","DeckC","DeckD"], "seed": 42 }
|
||||
*/
|
||||
@Command(
|
||||
name = "ws-server",
|
||||
mixinStandardHelpOptions = true,
|
||||
description = "Start the interactive game WebSocket server for Unity/Godot clients."
|
||||
)
|
||||
public class WsServerCommand implements Callable<Integer> {
|
||||
|
||||
@Option(names = {"--forge-jar", "-F"}, required = true,
|
||||
description = "Path to Forge desktop JAR (forge-gui-desktop-XXX-jar-with-dependencies.jar)")
|
||||
private String forgeJarPath;
|
||||
|
||||
@Option(names = {"--forge-dir", "-W"}, required = true,
|
||||
description = "Forge working directory (folder containing res/)")
|
||||
private String forgeWorkDir;
|
||||
|
||||
@Option(names = {"--port", "-p"}, defaultValue = "7654",
|
||||
description = "WebSocket port (default: 7654)")
|
||||
private int port;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
System.out.println("╔══════════════════════════════════════════════════╗");
|
||||
System.out.println("║ Commander AI Lab — Interactive WS Server ║");
|
||||
System.out.println("╚══════════════════════════════════════════════════╝");
|
||||
System.out.println();
|
||||
System.out.println(" Forge JAR : " + forgeJarPath);
|
||||
System.out.println(" Forge Dir : " + forgeWorkDir);
|
||||
System.out.println(" WS Port : " + port);
|
||||
System.out.println();
|
||||
|
||||
GameWsServer server = new GameWsServer(port, forgeJarPath, forgeWorkDir);
|
||||
server.start();
|
||||
|
||||
System.out.println(" Ready — connect Unity/Godot to ws://localhost:" + port);
|
||||
System.out.println(" Press Ctrl+C to stop.");
|
||||
System.out.println();
|
||||
|
||||
// Block until killed
|
||||
Thread.currentThread().join();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,13 @@ public class BatchResult {
|
||||
public double winRate;
|
||||
public Double avgTurnsToWin; // null if zero wins
|
||||
public double avgMulligans;
|
||||
|
||||
// v5: Aggregate combat stats from per-game PlayerResult data
|
||||
public double avgSpellsCast;
|
||||
public double avgLandsPlayed;
|
||||
public double avgCommanderCasts;
|
||||
public double avgCommanderDamageDealt;
|
||||
public double avgCreaturesDestroyed;
|
||||
public double avgFinalLife;
|
||||
public WinConditionBreakdown winConditionBreakdown;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ public class StatsAggregator {
|
||||
long[] totalTurnsOnWin = new long[podSize];
|
||||
long[] totalMulligans = new long[podSize];
|
||||
long[] totalFinalLife = new long[podSize];
|
||||
long[] totalSpellsCast = new long[podSize];
|
||||
long[] totalLandsPlayed = new long[podSize];
|
||||
long[] totalCommanderCasts = new long[podSize];
|
||||
long[] totalCommanderDamageDealt = new long[podSize];
|
||||
long[] totalCreaturesDestroyed = new long[podSize];
|
||||
int[] gameCount = new int[podSize];
|
||||
WinConditionBreakdown[] breakdowns = new WinConditionBreakdown[podSize];
|
||||
for (int i = 0; i < podSize; i++) {
|
||||
@@ -46,6 +51,11 @@ public class StatsAggregator {
|
||||
gameCount[seat]++;
|
||||
totalMulligans[seat] += pr.mulligans;
|
||||
totalFinalLife[seat] += pr.finalLife;
|
||||
totalSpellsCast[seat] += pr.spellsCast;
|
||||
totalLandsPlayed[seat] += pr.landsPlayed;
|
||||
totalCommanderCasts[seat] += pr.commanderCasts;
|
||||
totalCommanderDamageDealt[seat] += pr.commanderDamageDealt;
|
||||
totalCreaturesDestroyed[seat] += pr.creaturesDestroyed;
|
||||
|
||||
if (pr.isWinner) {
|
||||
wins[seat]++;
|
||||
@@ -70,6 +80,11 @@ public class StatsAggregator {
|
||||
ds.draws = draws[i];
|
||||
ds.winRate = completedGames > 0 ? (double) wins[i] / completedGames : 0.0;
|
||||
ds.avgTurnsToWin = wins[i] > 0 ? (double) totalTurnsOnWin[i] / wins[i] : null;
|
||||
ds.avgSpellsCast = gameCount[i] > 0 ? (double) totalSpellsCast[i] / gameCount[i] : 0.0;
|
||||
ds.avgLandsPlayed = gameCount[i] > 0 ? (double) totalLandsPlayed[i] / gameCount[i] : 0.0;
|
||||
ds.avgCommanderCasts = gameCount[i] > 0 ? (double) totalCommanderCasts[i] / gameCount[i] : 0.0;
|
||||
ds.avgCommanderDamageDealt = gameCount[i] > 0 ? (double) totalCommanderDamageDealt[i] / gameCount[i] : 0.0;
|
||||
ds.avgCreaturesDestroyed = gameCount[i] > 0 ? (double) totalCreaturesDestroyed[i] / gameCount[i] : 0.0;
|
||||
ds.avgMulligans = gameCount[i] > 0 ? (double) totalMulligans[i] / gameCount[i] : 0.0;
|
||||
ds.avgFinalLife = gameCount[i] > 0 ? (double) totalFinalLife[i] / gameCount[i] : 0.0;
|
||||
ds.winConditionBreakdown = breakdowns[i];
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# deck_builder tests
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Tests for DeckBuilderAdapter — the bridge between Ollama pipeline and legacy API."""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
|
||||
from src.commander_ai_lab.deck_builder.core.models import (
|
||||
BuildResult,
|
||||
CardEntry,
|
||||
CommanderDeck,
|
||||
DeckRatios,
|
||||
)
|
||||
|
||||
|
||||
def _fake_build_result():
|
||||
"""Return a minimal BuildResult for mocking build_deck."""
|
||||
cmdr = CardEntry(name="Atraxa", color_identity=["W", "U", "B", "G"])
|
||||
basics = ["Plains", "Island", "Swamp", "Mountain", "Forest"]
|
||||
cards = [
|
||||
CardEntry(name=basics[i % 5], quantity=1, category="lands", color_identity={"W", "U", "B", "G"})
|
||||
for i in range(99)
|
||||
]
|
||||
deck = CommanderDeck(commander=cmdr, cards=cards)
|
||||
return BuildResult(
|
||||
deck=deck,
|
||||
warnings=["test warning"],
|
||||
sources_consulted=["scryfall", "edhrec"],
|
||||
build_time_seconds=1.23,
|
||||
)
|
||||
|
||||
|
||||
class TestDeckBuilderAdapter:
|
||||
def test_init_defaults(self):
|
||||
adapter = DeckBuilderAdapter()
|
||||
assert adapter.model == "gpt-oss:20b"
|
||||
assert adapter.db_conn_factory is None
|
||||
|
||||
@patch("src.commander_ai_lab.deck_builder.adapter.build_deck")
|
||||
def test_generate_deck_returns_expected_shape(self, mock_build):
|
||||
mock_build.return_value = _fake_build_result()
|
||||
adapter = DeckBuilderAdapter()
|
||||
result = adapter.generate_deck("Atraxa", strategy="superfriends")
|
||||
|
||||
assert "commander" in result
|
||||
assert result["commander"]["name"] == "Atraxa"
|
||||
assert "cards" in result
|
||||
assert "stats" in result
|
||||
assert result["model"] == "gpt-oss:20b"
|
||||
assert result["build_time_seconds"] == 1.23
|
||||
assert "scryfall" in result["reasoning"]["sources"]
|
||||
|
||||
@patch("src.commander_ai_lab.deck_builder.adapter.build_deck")
|
||||
def test_generate_deck_fills_to_100(self, mock_build):
|
||||
mock_build.return_value = _fake_build_result()
|
||||
adapter = DeckBuilderAdapter()
|
||||
result = adapter.generate_deck("Atraxa")
|
||||
total = sum(c.get("count", 1) for c in result["cards"])
|
||||
assert total == 100 # 99 cards + commander fills to 100
|
||||
|
||||
@patch("src.commander_ai_lab.deck_builder.adapter.build_deck")
|
||||
def test_ownership_without_db(self, mock_build):
|
||||
mock_build.return_value = _fake_build_result()
|
||||
adapter = DeckBuilderAdapter(db_conn_factory=None)
|
||||
result = adapter.generate_deck("Atraxa")
|
||||
for card in result["cards"]:
|
||||
assert card["owned"] is False
|
||||
assert card["from_collection"] is False
|
||||
|
||||
|
||||
class TestRunSubstitution:
|
||||
def test_no_op_returns_namespace(self):
|
||||
adapter = DeckBuilderAdapter()
|
||||
cards = [
|
||||
{"name": "Sol Ring", "owned": True},
|
||||
{"name": "Mana Crypt", "owned": False},
|
||||
]
|
||||
result = adapter.run_substitution(cards, commander="Atraxa")
|
||||
assert isinstance(result, SimpleNamespace)
|
||||
assert result.owned_count == 1
|
||||
assert result.substituted_count == 0
|
||||
assert result.missing_count == 1
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_fill_basic_lands_adds_missing(self):
|
||||
cards = [{"name": "Sol Ring", "count": 1, "category": "ramp"}]
|
||||
filled = DeckBuilderAdapter._fill_basic_lands(cards, ["W", "U"], target_total=5)
|
||||
total = sum(c.get("count", 1) for c in filled)
|
||||
assert total == 5
|
||||
|
||||
def test_fill_basic_lands_no_excess(self):
|
||||
cards = [{"name": f"Card{i}", "count": 1} for i in range(100)]
|
||||
filled = DeckBuilderAdapter._fill_basic_lands(cards, ["W"], target_total=100)
|
||||
assert len(filled) == 100
|
||||
|
||||
def test_compute_stats(self):
|
||||
cards = [
|
||||
{"name": "Sol Ring", "count": 1, "category": "ramp", "cmc": 1},
|
||||
{"name": "Plains", "count": 36, "category": "lands", "cmc": 0},
|
||||
]
|
||||
stats = DeckBuilderAdapter._compute_stats(cards)
|
||||
assert stats["total_cards"] == 37
|
||||
assert stats["land_count"] == 36
|
||||
assert stats["average_cmc"] == 1.0
|
||||
@@ -0,0 +1 @@
|
||||
# Tests for collection_filter — Phase 5
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Tests for the Pydantic models used by the Ollama deck builder."""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.commander_ai_lab.deck_builder.core.models import (
|
||||
BuildRequest,
|
||||
BuildResult,
|
||||
CardEntry,
|
||||
Color,
|
||||
CommanderDeck,
|
||||
DeckRatios,
|
||||
WUBRG,
|
||||
)
|
||||
|
||||
|
||||
# ── Color enum ───────────────────────────────────────────────────
|
||||
|
||||
class TestColor:
|
||||
def test_wubrg_set_has_five_colors(self):
|
||||
assert WUBRG == {"W", "U", "B", "R", "G"}
|
||||
|
||||
def test_enum_values(self):
|
||||
assert Color.WHITE.value == "W"
|
||||
assert Color.BLUE.value == "U"
|
||||
assert Color.GREEN.value == "G"
|
||||
|
||||
|
||||
# ── CardEntry ────────────────────────────────────────────────────
|
||||
|
||||
class TestCardEntry:
|
||||
def test_minimal_card(self):
|
||||
card = CardEntry(name="Sol Ring")
|
||||
assert card.name == "Sol Ring"
|
||||
assert card.quantity == 1
|
||||
assert card.category == "uncategorized"
|
||||
assert card.cmc == 0.0
|
||||
|
||||
def test_color_identity_from_string(self):
|
||||
card = CardEntry(name="Counterspell", color_identity="UB")
|
||||
assert card.color_identity == {"U", "B"}
|
||||
|
||||
def test_color_identity_from_list(self):
|
||||
card = CardEntry(name="Swords to Plowshares", color_identity=["W"])
|
||||
assert card.color_identity == {"W"}
|
||||
|
||||
def test_invalid_colors_stripped(self):
|
||||
card = CardEntry(name="Test", color_identity=["W", "X", "Z"])
|
||||
assert card.color_identity == {"W"}
|
||||
|
||||
def test_full_card(self):
|
||||
card = CardEntry(
|
||||
name="Rhystic Study",
|
||||
quantity=1,
|
||||
category="card_draw",
|
||||
color_identity=["U"],
|
||||
mana_cost="{2}{U}",
|
||||
cmc=3.0,
|
||||
type_line="Enchantment",
|
||||
scryfall_id="abc-123",
|
||||
edhrec_rank=5,
|
||||
source="edhrec",
|
||||
)
|
||||
assert card.source == "edhrec"
|
||||
assert card.edhrec_rank == 5
|
||||
|
||||
|
||||
# ── DeckRatios ───────────────────────────────────────────────────
|
||||
|
||||
class TestDeckRatios:
|
||||
def test_default_sums_to_99(self):
|
||||
ratios = DeckRatios()
|
||||
total = (
|
||||
ratios.lands + ratios.ramp + ratios.card_draw
|
||||
+ ratios.removal + ratios.protection + ratios.synergy
|
||||
+ ratios.wincon + ratios.uncategorized
|
||||
)
|
||||
assert total == 99
|
||||
|
||||
def test_bad_total_raises(self):
|
||||
with pytest.raises(ValidationError, match="must sum to 99"):
|
||||
DeckRatios(lands=50)
|
||||
|
||||
def test_out_of_range_raises(self):
|
||||
with pytest.raises(ValidationError):
|
||||
DeckRatios(lands=5) # ge=30
|
||||
|
||||
|
||||
# ── CommanderDeck ────────────────────────────────────────────────
|
||||
|
||||
def _make_99_cards(ci=None):
|
||||
"""Helper: build a valid 99-card list of basic lands."""
|
||||
ci = ci or set()
|
||||
cards = []
|
||||
basics = ["Plains", "Island", "Swamp", "Mountain", "Forest"]
|
||||
for i in range(99):
|
||||
cards.append(CardEntry(
|
||||
name=basics[i % 5],
|
||||
quantity=1,
|
||||
category="lands",
|
||||
color_identity=ci,
|
||||
))
|
||||
return cards
|
||||
|
||||
|
||||
class TestCommanderDeck:
|
||||
def test_valid_deck(self):
|
||||
cmdr = CardEntry(name="Atraxa", color_identity=["W", "U", "B", "G"])
|
||||
deck = CommanderDeck(
|
||||
commander=cmdr,
|
||||
cards=_make_99_cards({"W", "U", "B", "G"}),
|
||||
)
|
||||
assert len(deck.cards) == 99
|
||||
|
||||
def test_wrong_card_count_raises(self):
|
||||
cmdr = CardEntry(name="Atraxa", color_identity=["W", "U", "B", "G"])
|
||||
with pytest.raises(ValidationError, match="exactly 99 cards"):
|
||||
CommanderDeck(commander=cmdr, cards=_make_99_cards()[:50])
|
||||
|
||||
def test_singleton_violation_raises(self):
|
||||
cmdr = CardEntry(name="Atraxa", color_identity=list(WUBRG))
|
||||
cards = _make_99_cards(WUBRG)[:97]
|
||||
cards.append(CardEntry(name="Sol Ring", color_identity=set()))
|
||||
cards.append(CardEntry(name="Sol Ring", color_identity=set()))
|
||||
with pytest.raises(ValidationError, match="Singleton violation"):
|
||||
CommanderDeck(commander=cmdr, cards=cards)
|
||||
|
||||
def test_color_identity_violation_raises(self):
|
||||
cmdr = CardEntry(name="Mono White", color_identity=["W"])
|
||||
cards = _make_99_cards({"W"})[:98]
|
||||
cards.append(CardEntry(name="Counterspell", color_identity=["U"]))
|
||||
with pytest.raises(ValidationError, match="outside commander identity"):
|
||||
CommanderDeck(commander=cmdr, cards=cards)
|
||||
|
||||
|
||||
# ── BuildRequest / BuildResult ───────────────────────────────────
|
||||
|
||||
class TestBuildRequest:
|
||||
def test_minimal(self):
|
||||
req = BuildRequest(commander_name="Atraxa")
|
||||
assert req.commander_name == "Atraxa"
|
||||
assert req.collection_only is False
|
||||
assert req.budget_limit is None
|
||||
|
||||
def test_with_strategy(self):
|
||||
req = BuildRequest(
|
||||
commander_name="Atraxa",
|
||||
strategy_notes="superfriends planeswalker tribal",
|
||||
)
|
||||
assert "superfriends" in req.strategy_notes
|
||||
|
||||
|
||||
class TestBuildResult:
|
||||
def test_minimal(self):
|
||||
cmdr = CardEntry(name="Atraxa", color_identity=["W", "U", "B", "G"])
|
||||
deck = CommanderDeck(
|
||||
commander=cmdr,
|
||||
cards=_make_99_cards({"W", "U", "B", "G"}),
|
||||
)
|
||||
result = BuildResult(deck=deck)
|
||||
assert result.build_time_seconds == 0.0
|
||||
assert result.warnings == []
|
||||
assert result.sources_consulted == []
|
||||
@@ -0,0 +1 @@
|
||||
# Tests for rules_engine — Phase 5
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
tests/test_deckgen_color_filter.py
|
||||
===================================
|
||||
Regression tests for the SQL-based colour-identity filtering in
|
||||
_get_collection_for_colors() and _build_collection_summary().
|
||||
|
||||
These tests use an in-memory SQLite database seeded with known data so they
|
||||
run without touching the real collection DB.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_conn() -> sqlite3.Connection:
|
||||
"""Return a seeded in-memory DB matching the collection_entries schema."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE collection_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type_line TEXT DEFAULT '',
|
||||
subtypes TEXT DEFAULT '',
|
||||
is_legendary INTEGER DEFAULT 0,
|
||||
is_basic INTEGER DEFAULT 0,
|
||||
color_identity TEXT DEFAULT '[]',
|
||||
cmc REAL DEFAULT 0,
|
||||
mana_cost TEXT DEFAULT '',
|
||||
oracle_text TEXT DEFAULT '',
|
||||
keywords TEXT DEFAULT '[]',
|
||||
power TEXT DEFAULT '',
|
||||
toughness TEXT DEFAULT '',
|
||||
rarity TEXT DEFAULT '',
|
||||
set_name TEXT DEFAULT '',
|
||||
edhrec_rank INTEGER DEFAULT 0,
|
||||
tcg_price REAL DEFAULT 0,
|
||||
salt_score REAL DEFAULT 0,
|
||||
is_game_changer INTEGER DEFAULT 0,
|
||||
category TEXT DEFAULT '',
|
||||
scryfall_id TEXT DEFAULT '',
|
||||
tcgplayer_id TEXT DEFAULT '',
|
||||
quantity INTEGER DEFAULT 1,
|
||||
finish TEXT DEFAULT 'NORMAL',
|
||||
condition TEXT DEFAULT '',
|
||||
language TEXT DEFAULT '',
|
||||
notes TEXT DEFAULT '',
|
||||
tags TEXT DEFAULT '',
|
||||
set_code TEXT DEFAULT '',
|
||||
collector_number TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_quantity
|
||||
ON collection_entries (quantity) WHERE quantity > 0
|
||||
""")
|
||||
# Seed known cards
|
||||
cards = [
|
||||
# (name, color_identity, quantity, scryfall_id, type_line, oracle_text, cmc, mana_cost, keywords, edhrec_rank, tcg_price, category)
|
||||
("Sol Ring", '[]', 2, "sol-001", "Artifact", "Tap: Add {C}{C}.", 0, "{1}", '[]', 1, 5.0, "ramp"),
|
||||
("Plains", '[]', 4, "plains-001", "Basic Land - Plains", "({T}: Add {W}.)", 0, "", '[]', 0, 0.1, ""),
|
||||
("Swords to Plow", '["W"]', 1, "stp-001", "Instant", "Exile target creature.", 1, "{W}", '[]', 100, 3.0, "removal"),
|
||||
("Counterspell", '["U"]', 1, "cs-001", "Instant", "Counter target spell.", 2, "{U}{U}", '[]', 200, 2.0, "removal"),
|
||||
("Damnation", '["B"]', 1, "damn-001", "Sorcery", "Destroy all creatures.", 4, "{2}{B}{B}", '[]', 300, 15.0, "board wipe"),
|
||||
("Lightning Bolt", '["R"]', 1, "bolt-001", "Instant", "Deals 3 damage.", 1, "{R}", '[]', 150, 1.0, "removal"),
|
||||
("Llanowar Elves", '["G"]', 3, "llano-001", "Creature - Elf Druid", "Tap: Add {G}.", 1, "{G}", '[]', 50, 0.5, "ramp"),
|
||||
("Atraxa", '["W","U","B","G"]', 1, "atrax-001", "Legendary Creature", "Proliferate.", 4, "{W}{U}{B}{G}", '["Proliferate"]', 10, 25.0, ""),
|
||||
("Kozilek", '[]', 1, "kozi-001", "Legendary Creature - Eldrazi", "Draw 4 cards.", 10, "{10}", '[]', 20, 30.0, ""),
|
||||
("Zero-Qty Card", '["W"]', 0, "zero-001", "Creature", "Some text.", 2, "{1}{W}", '[]', 5000, 0.5, ""),
|
||||
("No-ID Card", '["R"]', 1, "", "Creature", "Some text.", 3, "{2}{R}", '[]', 9999, 0.1, ""),
|
||||
("Orzhov Signet", '["W","B"]', 2, "orzh-001", "Artifact", "Add {W} or {B}.", 2, "{2}", '[]', 80, 1.0, "ramp"),
|
||||
]
|
||||
conn.executemany("""
|
||||
INSERT INTO collection_entries
|
||||
(name, color_identity, quantity, scryfall_id, type_line, oracle_text,
|
||||
cmc, mana_cost, keywords, edhrec_rank, tcg_price, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", cards)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _query_for_colors(conn: sqlite3.Connection, color_identity: list) -> list:
|
||||
"""Run the same SQL as _get_collection_for_colors() against the given conn."""
|
||||
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
|
||||
rows = conn.execute("""
|
||||
SELECT scryfall_id, name, type_line, mana_cost, cmc,
|
||||
oracle_text, keywords, quantity, edhrec_rank, color_identity
|
||||
FROM collection_entries
|
||||
WHERE quantity > 0
|
||||
AND scryfall_id != ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN collection_entries.color_identity IS NULL
|
||||
OR collection_entries.color_identity = ''
|
||||
THEN '[]'
|
||||
ELSE collection_entries.color_identity
|
||||
END
|
||||
) AS jc
|
||||
WHERE jc.value NOT IN (
|
||||
SELECT value FROM json_each(?)
|
||||
)
|
||||
)
|
||||
""", (allowed_json,)).fetchall()
|
||||
valid = []
|
||||
for row in rows:
|
||||
card = dict(row)
|
||||
ci = card.get("color_identity", "[]")
|
||||
try:
|
||||
card["color_identity"] = json.loads(ci) if isinstance(ci, str) else ci
|
||||
except Exception:
|
||||
card["color_identity"] = []
|
||||
valid.append(card)
|
||||
return valid
|
||||
|
||||
|
||||
def _summary_for_colors(conn: sqlite3.Connection, color_identity: list | None) -> list:
|
||||
"""Run the same SQL as _build_collection_summary() and return matched rows."""
|
||||
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
|
||||
ci_filter = """
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM json_each(
|
||||
CASE WHEN collection_entries.color_identity IS NULL
|
||||
OR collection_entries.color_identity = ''
|
||||
THEN '[]'
|
||||
ELSE collection_entries.color_identity
|
||||
END
|
||||
) AS jc
|
||||
WHERE jc.value NOT IN (
|
||||
SELECT value FROM json_each(?)
|
||||
)
|
||||
)
|
||||
""" if color_identity else ""
|
||||
params = (allowed_json,) if color_identity else ()
|
||||
rows = conn.execute(f"""
|
||||
SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity,
|
||||
color_identity, category, is_game_changer, salt_score
|
||||
FROM collection_entries
|
||||
WHERE quantity > 0
|
||||
{ci_filter}
|
||||
ORDER BY edhrec_rank ASC, tcg_price DESC
|
||||
""", params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestGetCollectionForColors:
|
||||
|
||||
def setup_method(self):
|
||||
self.conn = _make_conn()
|
||||
|
||||
# Test 1: Mono-W — white cards, colourless cards, no off-colour
|
||||
def test_mono_white(self):
|
||||
result = _query_for_colors(self.conn, ["W"])
|
||||
names = {c["name"] for c in result}
|
||||
assert "Sol Ring" in names # colourless
|
||||
assert "Plains" in names # colourless land
|
||||
assert "Swords to Plow" in names # white
|
||||
assert "Kozilek" in names # colourless creature
|
||||
assert "Counterspell" not in names # blue
|
||||
assert "Damnation" not in names # black
|
||||
assert "Lightning Bolt" not in names # red
|
||||
assert "Llanowar Elves" not in names # green
|
||||
assert "Atraxa" not in names # WUBG (needs more than W)
|
||||
assert "Orzhov Signet" not in names # WB (B not in W)
|
||||
|
||||
# Test 2: WUBRG — returns all cards with qty > 0 and scryfall_id
|
||||
def test_five_color(self):
|
||||
result = _query_for_colors(self.conn, ["W", "U", "B", "R", "G"])
|
||||
names = {c["name"] for c in result}
|
||||
# Every card with qty > 0 and scryfall_id should be here
|
||||
assert "Sol Ring" in names
|
||||
assert "Swords to Plow" in names
|
||||
assert "Counterspell" in names
|
||||
assert "Damnation" in names
|
||||
assert "Lightning Bolt" in names
|
||||
assert "Llanowar Elves" in names
|
||||
assert "Atraxa" in names
|
||||
assert "Orzhov Signet" in names
|
||||
# Excluded: zero qty or missing scryfall_id
|
||||
assert "Zero-Qty Card" not in names
|
||||
assert "No-ID Card" not in names
|
||||
|
||||
# Test 3: Colourless — only colourless cards
|
||||
def test_colorless(self):
|
||||
result = _query_for_colors(self.conn, [])
|
||||
names = {c["name"] for c in result}
|
||||
assert "Sol Ring" in names
|
||||
assert "Plains" in names
|
||||
assert "Kozilek" in names
|
||||
# Coloured cards excluded
|
||||
assert "Swords to Plow" not in names
|
||||
assert "Counterspell" not in names
|
||||
assert "Llanowar Elves" not in names
|
||||
|
||||
# Test 4: Zero-quantity cards excluded
|
||||
def test_zero_quantity_excluded(self):
|
||||
result = _query_for_colors(self.conn, ["W", "U", "B", "R", "G"])
|
||||
names = {c["name"] for c in result}
|
||||
assert "Zero-Qty Card" not in names
|
||||
|
||||
# Test 5: Malformed color_identity values
|
||||
def test_malformed_color_identity(self):
|
||||
conn = _make_conn()
|
||||
conn.execute("""
|
||||
INSERT INTO collection_entries (name, color_identity, quantity, scryfall_id, type_line)
|
||||
VALUES ('Null CI', NULL, 1, 'null-001', 'Artifact')
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO collection_entries (name, color_identity, quantity, scryfall_id, type_line)
|
||||
VALUES ('Empty Str CI', '', 1, 'empty-001', 'Artifact')
|
||||
""")
|
||||
conn.commit()
|
||||
# Should not crash — NULL/empty treated as colorless (empty array)
|
||||
result = _query_for_colors(conn, ["W"])
|
||||
names = {c["name"] for c in result}
|
||||
assert "Null CI" in names # NULL COALESCE'd to '[]' -> colorless -> passes
|
||||
# Empty string '' is not valid JSON but COALESCE handles NULL;
|
||||
# empty string through json_each may error, check graceful handling
|
||||
# The COALESCE only guards NULL, '' will go to json_each which may fail
|
||||
# This tests the edge case
|
||||
|
||||
# Test 6: _build_collection_summary returns same filtered count
|
||||
def test_summary_matches_filter_count(self):
|
||||
for ci in [["W"], ["W", "U", "B"], ["W", "U", "B", "R", "G"], ["G"]]:
|
||||
filter_result = _query_for_colors(self.conn, ci)
|
||||
summary_result = _summary_for_colors(self.conn, ci)
|
||||
# summary includes cards without scryfall_id, filter excludes them
|
||||
# So summary count >= filter count; but for our test data only "No-ID Card"
|
||||
# differs. Compare the intersection.
|
||||
filter_names = {c["name"] for c in filter_result}
|
||||
summary_names = {r["name"] for r in summary_result}
|
||||
# Everything in filter should be in summary (summary is superset minus scryfall_id check)
|
||||
assert filter_names <= summary_names, (
|
||||
f"CI={ci}: filter has cards not in summary: {filter_names - summary_names}"
|
||||
)
|
||||
|
||||
# Test: Multi-color subset check (WB commander should include W, B, WB, colorless)
|
||||
def test_multi_color_subset(self):
|
||||
result = _query_for_colors(self.conn, ["W", "B"])
|
||||
names = {c["name"] for c in result}
|
||||
assert "Sol Ring" in names # colorless
|
||||
assert "Swords to Plow" in names # W subset of WB
|
||||
assert "Damnation" in names # B subset of WB
|
||||
assert "Orzhov Signet" in names # WB subset of WB
|
||||
assert "Counterspell" not in names # U not in WB
|
||||
assert "Lightning Bolt" not in names # R not in WB
|
||||
assert "Atraxa" not in names # WUBG not subset of WB
|
||||
+169
@@ -465,6 +465,175 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Precon Picker
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
.lab-modal-content.lab-modal-precon {
|
||||
max-width: 900px;
|
||||
width: 94%;
|
||||
max-height: 82vh;
|
||||
}
|
||||
|
||||
.lab-precon-controls {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-4);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lab-precon-controls input {
|
||||
flex: 1;
|
||||
background: var(--surface-1);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.lab-precon-controls input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||
}
|
||||
|
||||
.lab-precon-install-all-btn,
|
||||
.lab-precon-refresh-btn {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all var(--duration) var(--ease);
|
||||
}
|
||||
|
||||
.lab-precon-install-all-btn:hover,
|
||||
.lab-precon-refresh-btn:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.lab-precon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
max-height: 56vh;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--sp-1);
|
||||
}
|
||||
|
||||
.lab-precon-card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration) var(--ease);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lab-precon-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-glow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.lab-precon-card-installed {
|
||||
border-color: var(--success);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.lab-precon-card-installed:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.precon-card-art {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.precon-card-body {
|
||||
padding: var(--sp-2) var(--sp-3) var(--sp-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.precon-card-colors {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.precon-card-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.precon-card-commander {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.precon-card-set {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.precon-card-theme {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.lab-precon-install-btn {
|
||||
margin-top: auto;
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all var(--duration) var(--ease);
|
||||
}
|
||||
|
||||
.lab-precon-install-btn:hover {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.lab-precon-card-installed .lab-precon-install-btn {
|
||||
background: var(--success-dim);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.lab-arch-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 7px;
|
||||
|
||||
+2540
-1
File diff suppressed because it is too large.
Load diff
@@ -1256,3 +1256,45 @@
|
||||
.dg-stat-substituted .dg-stat-num { color: #60a5fa; }
|
||||
.dg-stat-missing .dg-stat-num { color: #f59e0b; }
|
||||
.dg-stat-price .dg-stat-num { color: #a78bfa; }
|
||||
|
||||
|
||||
/* -- Conversation Log ---------------------------------------- */
|
||||
.dg-conversation-log {
|
||||
margin-top: var(--sp-3);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--sp-3);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.dg-log-entry {
|
||||
padding: 3px 0;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.dg-log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dg-log-ts {
|
||||
color: var(--text-muted);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.dg-log-info {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dg-log-success {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.dg-log-error {
|
||||
color: #f59e0b;
|
||||
}
|
||||
@@ -132,8 +132,7 @@
|
||||
<div class="dg-model-row">
|
||||
<label class="dg-label">AI Model</label>
|
||||
<select class="dg-select" id="dg-model">
|
||||
<option value="sonar">Sonar (fast, ~$0.004)</option>
|
||||
<option value="sonar-pro">Sonar Pro (deep, ~$0.04)</option>
|
||||
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,6 +145,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conversation Log -->
|
||||
<div id="dg-conversation-log" class="dg-conversation-log">
|
||||
<div class="dg-log-entry dg-log-info"><span class="dg-log-ts">[Ready]</span> Waiting for deck generation...</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- RIGHT: Preview Results -->
|
||||
@@ -160,7 +164,7 @@
|
||||
<!-- Loading State -->
|
||||
<div class="dg-loading" id="dg-loading" style="display:none">
|
||||
<div class="dg-spinner"></div>
|
||||
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Perplexity AI...</div>
|
||||
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
|
||||
+37
-12
@@ -5,7 +5,7 @@
|
||||
* V3 Endpoints:
|
||||
* GET /api/deck/v3/status — check V3 generator status
|
||||
* GET /api/deck-generator/commander-search — autocomplete commanders
|
||||
* POST /api/deck/v3/generate — generate deck (Perplexity structured output)
|
||||
* POST /api/deck/v3/generate — generate deck (local AI structured output)
|
||||
* POST /api/deck/v3/commit — generate + save to Deck Builder
|
||||
* POST /api/deck/v3/export/csv — export as CSV
|
||||
* POST /api/deck/v3/export/dck — export as Forge .dck
|
||||
@@ -68,7 +68,9 @@ const DeckGenerator = (() => {
|
||||
const data = await resp.json();
|
||||
state.v3Ready = data.initialized;
|
||||
if (!state.v3Ready) {
|
||||
toast('V3 Deck Generator not available — check Perplexity API key', 'error');
|
||||
// Warn but do not block — local/Ollama mode may still work
|
||||
console.warn('V3 status: not initialized. Error:', data.error || 'unknown');
|
||||
toast('V3 generator not ready — check that the local AI model (gpt-oss:20b) is running via Ollama', 'warning');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('V3 status check failed:', e);
|
||||
@@ -223,7 +225,6 @@ const DeckGenerator = (() => {
|
||||
|
||||
const img = $('dg-commander-img');
|
||||
if (cmdr.image_url) {
|
||||
// Use normal size for readable oracle text; ensure we don't have small version
|
||||
var imgUrl = cmdr.image_url;
|
||||
if (imgUrl.indexOf('version=small') !== -1) {
|
||||
imgUrl = imgUrl.replace('version=small', 'version=normal');
|
||||
@@ -253,14 +254,39 @@ const DeckGenerator = (() => {
|
||||
$('dg-commander-dropdown').classList.remove('open');
|
||||
}
|
||||
|
||||
// -- Conversation Log Helper --
|
||||
function logMessage(msg, type = 'info') {
|
||||
const log = $('dg-conversation-log');
|
||||
if (!log) return;
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'dg-log-entry dg-log-' + type;
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
entry.innerHTML = '<span class="dg-log-ts">[' + ts + ']</span> ' + msg;
|
||||
log.appendChild(entry);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
const log = $('dg-conversation-log');
|
||||
if (!log) return;
|
||||
log.innerHTML = '';
|
||||
}
|
||||
|
||||
// ── Generate Deck ───────────────────────────────────────
|
||||
async function generateDeck() {
|
||||
clearLog();
|
||||
logMessage('Connecting to local AI model (gpt-oss:20b)...', 'info');
|
||||
if (!state.commander) { toast('Select a commander first', 'error'); return; }
|
||||
if (!state.v3Ready) { toast('V3 generator not ready — check Perplexity API key', 'error'); return; }
|
||||
// Non-blocking warning only — local Ollama mode does not require v3Ready
|
||||
if (!state.v3Ready) {
|
||||
logMessage('Warning: V3 status check failed. Attempting generation anyway via local AI...', 'warning');
|
||||
}
|
||||
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
|
||||
|
||||
setLoading(true, 'Generating deck via Perplexity AI...');
|
||||
setLoading(true, 'Generating deck via Local AI...');
|
||||
|
||||
const body = buildRequestBody();
|
||||
logMessage('Sending request to AI model... this may take a moment.', 'info');
|
||||
state.lastRequestBody = body;
|
||||
|
||||
try {
|
||||
@@ -276,12 +302,17 @@ const DeckGenerator = (() => {
|
||||
}
|
||||
|
||||
state.previewResult = await resp.json();
|
||||
renderResults(state.previewResult);
|
||||
logMessage('AI response received! Processing deck data...', 'success');
|
||||
|
||||
const stats = state.previewResult.stats || {};
|
||||
const cardCount = (stats.total_cards || 0);
|
||||
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
|
||||
renderResults(state.previewResult);
|
||||
|
||||
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
|
||||
} catch (e) {
|
||||
toast(e.message || 'Generation failed', 'error');
|
||||
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -365,7 +396,6 @@ const DeckGenerator = (() => {
|
||||
const cards = result.cards || [];
|
||||
const commander = result.commander;
|
||||
|
||||
// Group by category
|
||||
const groups = {};
|
||||
for (const card of cards) {
|
||||
const cat = card.category || 'Other';
|
||||
@@ -380,7 +410,6 @@ const DeckGenerator = (() => {
|
||||
|
||||
let html = '';
|
||||
|
||||
// Commander section
|
||||
if (commander) {
|
||||
html += '<div class="dg-card-group">'
|
||||
+ '<div class="dg-group-header">'
|
||||
@@ -395,7 +424,6 @@ const DeckGenerator = (() => {
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
// Card groups
|
||||
for (const type of sortedTypes) {
|
||||
const groupCards = groups[type];
|
||||
if (!groupCards || groupCards.length === 0) continue;
|
||||
@@ -419,7 +447,6 @@ const DeckGenerator = (() => {
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Collapse toggles
|
||||
container.querySelectorAll('.dg-group-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const type = header.dataset.type;
|
||||
@@ -438,7 +465,6 @@ const DeckGenerator = (() => {
|
||||
.map(r => '<span class="dg-role-chip">' + escHtml(r) + '</span>')
|
||||
.join('');
|
||||
|
||||
// Substitution indicator
|
||||
let subHtml = '';
|
||||
if (card.status === 'substituted' && card.selected_substitute) {
|
||||
subHtml = '<div class="dg-card-sub-line">'
|
||||
@@ -577,7 +603,6 @@ const DeckGenerator = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build and copy text
|
||||
const lines = ['Shopping List — ' + (data.commander || 'Deck'), ''];
|
||||
for (const item of list) {
|
||||
lines.push(item.count + 'x ' + item.name + ' ($' + (item.estimated_price_usd || 0).toFixed(2) + ') — ' + item.category);
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="global.css" />
|
||||
<link rel="stylesheet" href="ai-lab.css" />
|
||||
<link rel="stylesheet" href="ai-lab.css?v=4" />
|
||||
<style>
|
||||
/* ── Dashboard Feature Grid ──────────────────── */
|
||||
.feature-grid {
|
||||
@@ -196,6 +196,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="ai-lab.js"></script>
|
||||
<script src="ai-lab.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -62,7 +62,7 @@
|
||||
<div class="deepseek-config-row">
|
||||
<div class="form-group sim-field-grow">
|
||||
<label class="form-label" for="ds-api-base">API Endpoint</label>
|
||||
<input type="text" id="ds-api-base" class="form-input" value="http://192.168.0.122:1234" placeholder="http://192.168.0.122:1234" />
|
||||
<input type="text" id="ds-api-base" class="form-input" value="http://192.168.0.240:11434" placeholder="http://192.168.0.240:11434" />
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="ds-model">Model</label>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Unity generated
|
||||
/[Ll]ibrary/
|
||||
/[Tt]emp/
|
||||
/[Oo]bj/
|
||||
/[Bb]uild/
|
||||
/[Bb]uilds/
|
||||
/[Ll]ogs/
|
||||
/[Uu]ser[Ss]ettings/
|
||||
|
||||
# MemoryCaptures
|
||||
/[Mm]emoryCaptures/
|
||||
|
||||
# Asset meta data should only be ignored when the corresponding asset is also ignored
|
||||
!/[Aa]ssets/**/*.meta
|
||||
|
||||
# Autogenerated Coverage
|
||||
/[Aa]ssets/[Cc]overage[Rr]esults/
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
*.csproj
|
||||
*.sln
|
||||
*.suo
|
||||
*.tmp
|
||||
*.user
|
||||
*.userprefs
|
||||
*.pidb
|
||||
*.booproj
|
||||
*.svd
|
||||
*.pdb
|
||||
*.mdb
|
||||
*.opendb
|
||||
*.VC.db
|
||||
|
||||
# Gradle cache (Android)
|
||||
.gradle/
|
||||
|
||||
# TextMesh Pro
|
||||
/[Aa]ssets/TextMesh Pro/
|
||||
|
||||
# Rider
|
||||
.idea/
|
||||
|
||||
# Builds
|
||||
*.apk
|
||||
*.aab
|
||||
*.unitypackage
|
||||
*.app
|
||||
|
||||
# Crashlytics
|
||||
crashlytics-build.properties
|
||||
|
||||
# Addressables
|
||||
/[Aa]ssets/[Aa]ddressable[Aa]ssets[Dd]ata/*/*.bin*
|
||||
/[Aa]ssets/[Ss]treamingAssets/aa.meta
|
||||
/[Aa]ssets/[Ss]treamingAssets/aa/*
|
||||
@@ -0,0 +1,32 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
|
||||
public static class BuildAllScenes
|
||||
{
|
||||
[MenuItem("Tools/Build All Scenes")]
|
||||
public static void BuildAll()
|
||||
{
|
||||
// Clear build settings first
|
||||
EditorBuildSettings.scenes = new EditorBuildSettingsScene[0];
|
||||
|
||||
// Create all scenes in order (MainMenu must be index 0)
|
||||
CreateMainMenuScene.Create();
|
||||
CreateCollectionScene.Create();
|
||||
CreateDeckBuilderScene.Create();
|
||||
CreateSimulatorScene.Create();
|
||||
CreateScannerScene.Create();
|
||||
CreateTrainingScene.Create();
|
||||
|
||||
// Log the final build settings
|
||||
Debug.Log($"[BuildAllScenes] Registered {EditorBuildSettings.scenes.Length} scenes in Build Settings:");
|
||||
foreach (var s in EditorBuildSettings.scenes)
|
||||
Debug.Log($" {s.path} (enabled={s.enabled})");
|
||||
|
||||
// Reopen MainMenu as the active scene
|
||||
EditorSceneManager.OpenScene("Assets/Scenes/MainMenu.unity");
|
||||
Debug.Log("[BuildAllScenes] Done! MainMenu scene is now active.");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,354 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using TMPro;
|
||||
using System.Reflection;
|
||||
using System.Linq;
|
||||
|
||||
public static class CreateCollectionScene
|
||||
{
|
||||
[MenuItem("Tools/Create Collection Scene")]
|
||||
public static void Create()
|
||||
{
|
||||
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
|
||||
|
||||
// -- Services --
|
||||
var services = new GameObject("[Services]");
|
||||
services.AddComponent<CommanderAILab.Services.ApiClient>();
|
||||
services.AddComponent<CommanderAILab.Services.ImageCache>();
|
||||
|
||||
// -- Canvas --
|
||||
var canvasGo = new GameObject("Canvas");
|
||||
var canvas = canvasGo.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
var scaler = canvasGo.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
canvasGo.AddComponent<GraphicRaycaster>();
|
||||
|
||||
// -- EventSystem --
|
||||
if (Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>() == null)
|
||||
{
|
||||
var es = new GameObject("EventSystem");
|
||||
es.AddComponent<UnityEngine.EventSystems.EventSystem>();
|
||||
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
|
||||
}
|
||||
|
||||
// -- Background --
|
||||
var bg = CreatePanel(canvasGo.transform, "Background", new Color(0.08f, 0.08f, 0.12f), stretch: true);
|
||||
|
||||
// -- Header bar --
|
||||
var header = CreatePanel(canvasGo.transform, "Header", new Color(0.12f, 0.12f, 0.18f, 0.95f));
|
||||
SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(1f, 1f));
|
||||
|
||||
var backBtn = CreateTMPButton(header.transform, "BackButton", "< Back", new Color(0.2f, 0.2f, 0.3f));
|
||||
SetAnchors(backBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.07f, 0.9f));
|
||||
|
||||
var titleText = CreateTMPText(header.transform, "TitleText", "Collection", 28, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(titleText.GetComponent<RectTransform>(), new Vector2(0.3f, 0.1f), new Vector2(0.7f, 0.9f));
|
||||
|
||||
// -- Search bar --
|
||||
var searchBar = CreatePanel(canvasGo.transform, "SearchBar", new Color(0.1f, 0.1f, 0.16f, 0.98f));
|
||||
SetAnchors(searchBar.GetComponent<RectTransform>(), new Vector2(0.18f, 0.86f), new Vector2(1f, 0.93f));
|
||||
|
||||
var searchInput = CreateTMPInputField(searchBar.transform, "SearchInput", "Search cards...");
|
||||
SetAnchors(searchInput.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.72f, 0.9f));
|
||||
|
||||
var searchBtn = CreateTMPButton(searchBar.transform, "SearchButton", "Search", new Color(0.2f, 0.5f, 0.8f));
|
||||
SetAnchors(searchBtn.GetComponent<RectTransform>(), new Vector2(0.73f, 0.1f), new Vector2(0.86f, 0.9f));
|
||||
|
||||
var sortDd = CreateTMPDropdown(searchBar.transform, "SortDropdown",
|
||||
new[] { "Name", "CMC", "Color", "Type", "Price" });
|
||||
SetAnchors(sortDd.GetComponent<RectTransform>(), new Vector2(0.87f, 0.05f), new Vector2(0.99f, 0.95f));
|
||||
|
||||
// -- Filter toggle button (mobile) --
|
||||
var filterToggleBtn = CreateTMPButton(canvasGo.transform, "FilterToggleButton", "Filters", new Color(0.18f, 0.22f, 0.35f));
|
||||
SetAnchors(filterToggleBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.86f), new Vector2(0.17f, 0.93f));
|
||||
|
||||
// -- Filter sidebar --
|
||||
var filterPanel = CreatePanel(canvasGo.transform, "FilterPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
|
||||
SetAnchors(filterPanel.GetComponent<RectTransform>(), new Vector2(0f, 0.06f), new Vector2(0.18f, 0.86f));
|
||||
var filterVlg = filterPanel.AddComponent<VerticalLayoutGroup>();
|
||||
filterVlg.padding = new RectOffset(8, 8, 8, 8);
|
||||
filterVlg.spacing = 6;
|
||||
filterVlg.childForceExpandWidth = true;
|
||||
filterVlg.childForceExpandHeight = false;
|
||||
|
||||
CreateTMPText(filterPanel.transform, "FilterTitle", "Filters", 18, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
var colorDd = CreateTMPDropdown(filterPanel.transform, "ColorFilterDropdown", new[] { "All Colors", "W", "U", "B", "R", "G", "Colorless" });
|
||||
var typeDd = CreateTMPDropdown(filterPanel.transform, "TypeFilterDropdown", new[] { "All Types", "Creature", "Instant", "Sorcery", "Enchantment", "Artifact", "Planeswalker", "Land" });
|
||||
var setDd = CreateTMPDropdown(filterPanel.transform, "SetFilterDropdown", new[] { "All Sets" });
|
||||
var rarityDd = CreateTMPDropdown(filterPanel.transform, "RarityFilterDropdown", new[] { "All Rarities", "Common", "Uncommon", "Rare", "Mythic" });
|
||||
var ownedDd = CreateTMPDropdown(filterPanel.transform, "OwnedFilterDropdown", new[] { "All", "Owned", "Missing" });
|
||||
var applyBtn = CreateTMPButton(filterPanel.transform, "ApplyFilterButton", "Apply", new Color(0.2f, 0.55f, 0.2f));
|
||||
var clearBtn = CreateTMPButton(filterPanel.transform, "ClearFilterButton", "Clear", new Color(0.45f, 0.2f, 0.2f));
|
||||
|
||||
// -- Card grid scroll view --
|
||||
var scrollGo = new GameObject("CardGridScroll");
|
||||
scrollGo.transform.SetParent(canvasGo.transform, false);
|
||||
var scrollRect = scrollGo.AddComponent<ScrollRect>();
|
||||
var scrollImg = scrollGo.AddComponent<Image>();
|
||||
scrollImg.color = new Color(0f, 0f, 0f, 0.01f);
|
||||
SetAnchors(scrollGo.GetComponent<RectTransform>(), new Vector2(0.18f, 0.12f), new Vector2(1f, 0.86f));
|
||||
|
||||
var content = new GameObject("Content");
|
||||
content.transform.SetParent(scrollGo.transform, false);
|
||||
var contentRect = content.AddComponent<RectTransform>();
|
||||
contentRect.anchorMin = new Vector2(0f, 1f);
|
||||
contentRect.anchorMax = new Vector2(1f, 1f);
|
||||
contentRect.pivot = new Vector2(0.5f, 1f);
|
||||
content.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
var grid = content.AddComponent<GridLayoutGroup>();
|
||||
grid.cellSize = new Vector2(160, 220);
|
||||
grid.spacing = new Vector2(8, 8);
|
||||
grid.padding = new RectOffset(10, 10, 10, 10);
|
||||
grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
|
||||
grid.constraintCount = 6;
|
||||
|
||||
scrollRect.content = contentRect;
|
||||
scrollRect.horizontal = false;
|
||||
|
||||
// -- Page info & load more --
|
||||
var pageInfoText = CreateTMPText(canvasGo.transform, "PageInfoText", "Showing 0 of 0 cards", 16, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
SetAnchors(pageInfoText.GetComponent<RectTransform>(), new Vector2(0.18f, 0.08f), new Vector2(0.6f, 0.12f));
|
||||
|
||||
var loadMoreBtn = CreateTMPButton(canvasGo.transform, "LoadMoreButton", "Load More", new Color(0.2f, 0.4f, 0.6f));
|
||||
SetAnchors(loadMoreBtn.GetComponent<RectTransform>(), new Vector2(0.62f, 0.08f), new Vector2(0.82f, 0.12f));
|
||||
|
||||
// -- Loading spinner (simple panel) --
|
||||
var spinner = CreatePanel(canvasGo.transform, "LoadingSpinner", new Color(0f, 0f, 0f, 0.6f), stretch: true);
|
||||
CreateTMPText(spinner.transform, "SpinnerText", "Loading...", 32, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
spinner.SetActive(false);
|
||||
|
||||
// -- Detail panel --
|
||||
var detailPanel = CreatePanel(canvasGo.transform, "DetailPanel", new Color(0.08f, 0.08f, 0.14f, 0.97f), stretch: true);
|
||||
var detailCardFront = new GameObject("DetailCardFront");
|
||||
detailCardFront.transform.SetParent(detailPanel.transform, false);
|
||||
var detailFrontImg = detailCardFront.AddComponent<Image>();
|
||||
SetAnchors(detailCardFront.GetComponent<RectTransform>(), new Vector2(0.05f, 0.15f), new Vector2(0.4f, 0.92f));
|
||||
|
||||
var detailCardBack = new GameObject("DetailCardBack");
|
||||
detailCardBack.transform.SetParent(detailPanel.transform, false);
|
||||
detailCardBack.AddComponent<Image>().color = new Color(0.1f, 0.1f, 0.2f);
|
||||
SetAnchors(detailCardBack.GetComponent<RectTransform>(), new Vector2(0.05f, 0.15f), new Vector2(0.4f, 0.92f));
|
||||
|
||||
var detailName = CreateTMPText(detailPanel.transform, "DetailCardName", "", 28, FontStyles.Bold, TextAlignmentOptions.Left);
|
||||
SetAnchors(detailName.GetComponent<RectTransform>(), new Vector2(0.42f, 0.78f), new Vector2(0.95f, 0.92f));
|
||||
var detailMana = CreateTMPText(detailPanel.transform, "DetailManaCost", "", 22, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
SetAnchors(detailMana.GetComponent<RectTransform>(), new Vector2(0.42f, 0.70f), new Vector2(0.95f, 0.78f));
|
||||
var detailType = CreateTMPText(detailPanel.transform, "DetailType", "", 20, FontStyles.Italic, TextAlignmentOptions.Left);
|
||||
SetAnchors(detailType.GetComponent<RectTransform>(), new Vector2(0.42f, 0.63f), new Vector2(0.95f, 0.70f));
|
||||
var detailRarity = CreateTMPText(detailPanel.transform, "DetailRarity", "", 18, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
SetAnchors(detailRarity.GetComponent<RectTransform>(), new Vector2(0.42f, 0.57f), new Vector2(0.95f, 0.63f));
|
||||
var detailSet = CreateTMPText(detailPanel.transform, "DetailSet", "", 18, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
SetAnchors(detailSet.GetComponent<RectTransform>(), new Vector2(0.42f, 0.51f), new Vector2(0.95f, 0.57f));
|
||||
var detailOracle = CreateTMPText(detailPanel.transform, "DetailOracleText", "", 17, FontStyles.Normal, TextAlignmentOptions.TopLeft);
|
||||
SetAnchors(detailOracle.GetComponent<RectTransform>(), new Vector2(0.42f, 0.28f), new Vector2(0.95f, 0.51f));
|
||||
var detailQty = CreateTMPText(detailPanel.transform, "DetailQuantity", "", 18, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
SetAnchors(detailQty.GetComponent<RectTransform>(), new Vector2(0.42f, 0.22f), new Vector2(0.95f, 0.28f));
|
||||
|
||||
var addToDeckBtn = CreateTMPButton(detailPanel.transform, "DetailAddToDeckButton", "Add to Deck", new Color(0.2f, 0.55f, 0.2f));
|
||||
SetAnchors(addToDeckBtn.GetComponent<RectTransform>(), new Vector2(0.42f, 0.12f), new Vector2(0.68f, 0.20f));
|
||||
var closeDetailBtn = CreateTMPButton(detailPanel.transform, "DetailCloseButton", "Close", new Color(0.5f, 0.15f, 0.15f));
|
||||
SetAnchors(closeDetailBtn.GetComponent<RectTransform>(), new Vector2(0.70f, 0.12f), new Vector2(0.95f, 0.20f));
|
||||
detailPanel.SetActive(false);
|
||||
|
||||
// -- Error panel --
|
||||
var errorPanel = CreatePanel(canvasGo.transform, "ErrorPanel", new Color(0.5f, 0.1f, 0.1f, 0.95f));
|
||||
SetAnchors(errorPanel.GetComponent<RectTransform>(), new Vector2(0.2f, 0.02f), new Vector2(0.8f, 0.10f));
|
||||
var errorText = CreateTMPText(errorPanel.transform, "ErrorText", "", 16, FontStyles.Normal, TextAlignmentOptions.Center);
|
||||
SetAnchors(errorText.GetComponent<RectTransform>(), new Vector2(0f, 0.2f), new Vector2(0.75f, 1f));
|
||||
var retryBtn = CreateTMPButton(errorPanel.transform, "RetryButton", "Retry", new Color(0.6f, 0.25f, 0.25f));
|
||||
SetAnchors(retryBtn.GetComponent<RectTransform>(), new Vector2(0.78f, 0.2f), new Vector2(0.98f, 0.8f));
|
||||
errorPanel.SetActive(false);
|
||||
|
||||
// -- Wire controller --
|
||||
var ctrl = canvasGo.AddComponent<CommanderAILab.UI.CollectionController>();
|
||||
SetField(ctrl, "backButton", backBtn.GetComponent<Button>());
|
||||
SetField(ctrl, "searchInput", searchInput.GetComponent<TMP_InputField>());
|
||||
SetField(ctrl, "sortDropdown", sortDd.GetComponent<TMP_Dropdown>());
|
||||
SetField(ctrl, "searchButton", searchBtn.GetComponent<Button>());
|
||||
SetField(ctrl, "filterToggleButton", filterToggleBtn.GetComponent<Button>());
|
||||
SetField(ctrl, "filterPanel", filterPanel);
|
||||
SetField(ctrl, "colorFilterDropdown", colorDd.GetComponent<TMP_Dropdown>());
|
||||
SetField(ctrl, "typeFilterDropdown", typeDd.GetComponent<TMP_Dropdown>());
|
||||
SetField(ctrl, "setFilterDropdown", setDd.GetComponent<TMP_Dropdown>());
|
||||
SetField(ctrl, "rarityFilterDropdown", rarityDd.GetComponent<TMP_Dropdown>());
|
||||
SetField(ctrl, "ownedFilterDropdown", ownedDd.GetComponent<TMP_Dropdown>());
|
||||
SetField(ctrl, "applyFilterButton", applyBtn.GetComponent<Button>());
|
||||
SetField(ctrl, "clearFilterButton", clearBtn.GetComponent<Button>());
|
||||
SetField(ctrl, "cardGridParent", content.transform);
|
||||
SetField(ctrl, "cardPrefab", null); // assign in Inspector
|
||||
SetField(ctrl, "gridScrollRect", scrollGo.GetComponent<ScrollRect>());
|
||||
SetField(ctrl, "loadMoreButton", loadMoreBtn.GetComponent<Button>());
|
||||
SetField(ctrl, "pageInfoText", pageInfoText.GetComponent<TMP_Text>());
|
||||
SetField(ctrl, "loadingSpinner", spinner);
|
||||
SetField(ctrl, "detailPanel", detailPanel);
|
||||
SetField(ctrl, "detailCardFront", detailCardFront.GetComponent<Image>());
|
||||
SetField(ctrl, "detailCardBack", detailCardBack.GetComponent<Image>());
|
||||
SetField(ctrl, "detailCardName", detailName.GetComponent<TMP_Text>());
|
||||
SetField(ctrl, "detailManaCost", detailMana.GetComponent<TMP_Text>());
|
||||
SetField(ctrl, "detailType", detailType.GetComponent<TMP_Text>());
|
||||
SetField(ctrl, "detailRarity", detailRarity.GetComponent<TMP_Text>());
|
||||
SetField(ctrl, "detailSet", detailSet.GetComponent<TMP_Text>());
|
||||
SetField(ctrl, "detailOracleText", detailOracle.GetComponent<TMP_Text>());
|
||||
SetField(ctrl, "detailQuantity", detailQty.GetComponent<TMP_Text>());
|
||||
SetField(ctrl, "detailAddToDeckButton", addToDeckBtn.GetComponent<Button>());
|
||||
SetField(ctrl, "detailCloseButton", closeDetailBtn.GetComponent<Button>());
|
||||
SetField(ctrl, "errorPanel", errorPanel);
|
||||
SetField(ctrl, "errorText", errorText.GetComponent<TMP_Text>());
|
||||
SetField(ctrl, "retryButton", retryBtn.GetComponent<Button>());
|
||||
|
||||
// -- Save --
|
||||
if (!AssetDatabase.IsValidFolder("Assets/Scenes"))
|
||||
AssetDatabase.CreateFolder("Assets", "Scenes");
|
||||
string path = "Assets/Scenes/Collection.unity";
|
||||
EditorSceneManager.SaveScene(scene, path);
|
||||
var existing = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
|
||||
if (!existing.Any(s => s.path == path))
|
||||
existing.Add(new EditorBuildSettingsScene(path, true));
|
||||
EditorBuildSettings.scenes = existing.ToArray();
|
||||
Debug.Log("[CreateCollectionScene] Collection scene created and saved to " + path);
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
static void SetField(object target, string fieldName, object value)
|
||||
{
|
||||
var field = target.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field != null) field.SetValue(target, value);
|
||||
else Debug.LogWarning("Could not find field: " + fieldName);
|
||||
}
|
||||
|
||||
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
|
||||
{
|
||||
rt.anchorMin = min;
|
||||
rt.anchorMax = max;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
static GameObject CreatePanel(Transform parent, string name, Color color, bool stretch = false)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = color;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
if (stretch) SetAnchors(rt, Vector2.zero, Vector2.one);
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPText(Transform parent, string name, string text, int fontSize, FontStyles style, TextAlignmentOptions alignment)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var tmp = go.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text;
|
||||
tmp.fontSize = fontSize;
|
||||
tmp.fontStyle = style;
|
||||
tmp.alignment = alignment;
|
||||
tmp.color = Color.white;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
SetAnchors(rt, Vector2.zero, Vector2.one);
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPButton(Transform parent, string name, string label, Color bgColor)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = bgColor;
|
||||
var btn = go.AddComponent<Button>();
|
||||
btn.targetGraphic = img;
|
||||
var colors = btn.colors;
|
||||
colors.highlightedColor = bgColor * 1.2f;
|
||||
colors.pressedColor = bgColor * 0.8f;
|
||||
btn.colors = colors;
|
||||
|
||||
var textGo = new GameObject("Text");
|
||||
textGo.transform.SetParent(go.transform, false);
|
||||
var tmp = textGo.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = label;
|
||||
tmp.fontSize = 22;
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
tmp.color = Color.white;
|
||||
SetAnchors(textGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var le = go.AddComponent<LayoutElement>();
|
||||
le.minHeight = 50;
|
||||
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPDropdown(Transform parent, string name, string[] options)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = new Color(0.15f, 0.15f, 0.2f);
|
||||
|
||||
var labelGo = new GameObject("Label");
|
||||
labelGo.transform.SetParent(go.transform, false);
|
||||
var labelTmp = labelGo.AddComponent<TextMeshProUGUI>();
|
||||
labelTmp.text = options.Length > 0 ? options[0] : "";
|
||||
labelTmp.fontSize = 16;
|
||||
labelTmp.alignment = TextAlignmentOptions.Left;
|
||||
labelTmp.color = Color.white;
|
||||
SetAnchors(labelGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var dd = go.AddComponent<TMP_Dropdown>();
|
||||
dd.captionText = labelTmp;
|
||||
dd.ClearOptions();
|
||||
dd.AddOptions(new System.Collections.Generic.List<string>(options));
|
||||
|
||||
var le = go.AddComponent<LayoutElement>();
|
||||
le.minHeight = 40;
|
||||
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPInputField(Transform parent, string name, string defaultText)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = new Color(0.15f, 0.15f, 0.2f);
|
||||
|
||||
var textArea = new GameObject("Text Area");
|
||||
textArea.transform.SetParent(go.transform, false);
|
||||
textArea.AddComponent<RectMask2D>();
|
||||
SetAnchors(textArea.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var placeholder = new GameObject("Placeholder");
|
||||
placeholder.transform.SetParent(textArea.transform, false);
|
||||
var phTmp = placeholder.AddComponent<TextMeshProUGUI>();
|
||||
phTmp.text = defaultText;
|
||||
phTmp.fontSize = 18;
|
||||
phTmp.fontStyle = FontStyles.Italic;
|
||||
phTmp.color = new Color(0.5f, 0.5f, 0.5f, 0.8f);
|
||||
phTmp.alignment = TextAlignmentOptions.Left;
|
||||
SetAnchors(placeholder.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var inputText = new GameObject("Text");
|
||||
inputText.transform.SetParent(textArea.transform, false);
|
||||
var itTmp = inputText.AddComponent<TextMeshProUGUI>();
|
||||
itTmp.fontSize = 18;
|
||||
itTmp.color = Color.white;
|
||||
itTmp.alignment = TextAlignmentOptions.Left;
|
||||
SetAnchors(inputText.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var inputField = go.AddComponent<TMP_InputField>();
|
||||
inputField.textViewport = textArea.GetComponent<RectTransform>();
|
||||
inputField.textComponent = itTmp;
|
||||
inputField.placeholder = phTmp;
|
||||
inputField.text = defaultText;
|
||||
inputField.fontAsset = itTmp.font;
|
||||
|
||||
return go;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,532 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using TMPro;
|
||||
using System.Reflection;
|
||||
using System.Linq;
|
||||
|
||||
public static class CreateDeckBuilderScene
|
||||
{
|
||||
[MenuItem("Tools/Create DeckBuilder Scene")]
|
||||
public static void Create()
|
||||
{
|
||||
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
|
||||
|
||||
// Services
|
||||
var services = new GameObject("[Services]");
|
||||
services.AddComponent<CommanderAILab.Services.ApiClient>();
|
||||
services.AddComponent<CommanderAILab.Services.ImageCache>();
|
||||
|
||||
// Canvas
|
||||
var canvasGo = new GameObject("Canvas");
|
||||
var canvas = canvasGo.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
var scaler = canvasGo.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
canvasGo.AddComponent<GraphicRaycaster>();
|
||||
|
||||
if (Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>() == null)
|
||||
{
|
||||
var es = new GameObject("EventSystem");
|
||||
es.AddComponent<UnityEngine.EventSystems.EventSystem>();
|
||||
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
|
||||
}
|
||||
|
||||
// Background
|
||||
var bg = CreatePanel(canvasGo.transform, "Background", new Color(0.08f, 0.08f, 0.12f), stretch: true);
|
||||
|
||||
// Header
|
||||
var header = CreatePanel(canvasGo.transform, "Header", new Color(0.12f, 0.12f, 0.18f, 0.95f));
|
||||
SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(1f, 1f));
|
||||
var backBtn = CreateTMPButton(header.transform, "BackButton", "< Back", new Color(0.2f, 0.2f, 0.3f));
|
||||
SetAnchors(backBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.07f, 0.9f));
|
||||
var titleTxt = CreateTMPText(header.transform, "TitleText", "Deck Builder", 28, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(titleTxt.GetComponent<RectTransform>(), new Vector2(0.3f, 0.1f), new Vector2(0.7f, 0.9f));
|
||||
var exportBtn = CreateTMPButton(header.transform, "ExportToSimButton", "Export to Sim", new Color(0.2f, 0.45f, 0.7f));
|
||||
SetAnchors(exportBtn.GetComponent<RectTransform>(), new Vector2(0.88f, 0.1f), new Vector2(0.99f, 0.9f));
|
||||
|
||||
// ── LEFT: Deck List Panel ──────────────────────────────────────
|
||||
var leftPanel = CreatePanel(canvasGo.transform, "DeckListPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
|
||||
SetAnchors(leftPanel.GetComponent<RectTransform>(), new Vector2(0f, 0.06f), new Vector2(0.22f, 0.93f));
|
||||
|
||||
var deckNameInput = CreateTMPInputField(leftPanel.transform, "DeckNameInput", "New deck name...");
|
||||
SetAnchors(deckNameInput.GetComponent<RectTransform>(), new Vector2(0.02f, 0.91f), new Vector2(0.75f, 0.98f));
|
||||
var newDeckBtn = CreateTMPButton(leftPanel.transform, "NewDeckButton", "+", new Color(0.2f, 0.55f, 0.2f));
|
||||
SetAnchors(newDeckBtn.GetComponent<RectTransform>(), new Vector2(0.77f, 0.91f), new Vector2(0.98f, 0.98f));
|
||||
|
||||
var deckScroll = new GameObject("DeckListScroll");
|
||||
deckScroll.transform.SetParent(leftPanel.transform, false);
|
||||
var dsr = deckScroll.AddComponent<ScrollRect>();
|
||||
deckScroll.AddComponent<Image>().color = new Color(0,0,0,0.01f);
|
||||
SetAnchors(deckScroll.GetComponent<RectTransform>(), new Vector2(0f, 0f), new Vector2(1f, 0.9f));
|
||||
var deckContent = new GameObject("Content");
|
||||
deckContent.transform.SetParent(deckScroll.transform, false);
|
||||
var dcrect = deckContent.AddComponent<RectTransform>();
|
||||
dcrect.anchorMin = new Vector2(0,1); dcrect.anchorMax = new Vector2(1,1); dcrect.pivot = new Vector2(0.5f,1f);
|
||||
deckContent.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
var dcvlg = deckContent.AddComponent<VerticalLayoutGroup>();
|
||||
dcvlg.spacing = 4; dcvlg.padding = new RectOffset(4,4,4,4);
|
||||
dcvlg.childForceExpandWidth = true; dcvlg.childForceExpandHeight = false;
|
||||
dsr.content = dcrect; dsr.horizontal = false;
|
||||
|
||||
// ── CENTER: Active Deck Panel ──────────────────────────────────
|
||||
var centerPanel = CreatePanel(canvasGo.transform, "ActiveDeckPanel", new Color(0.09f, 0.09f, 0.14f, 0.97f));
|
||||
SetAnchors(centerPanel.GetComponent<RectTransform>(), new Vector2(0.22f, 0.06f), new Vector2(0.58f, 0.93f));
|
||||
|
||||
var deckTitleTxt = CreateTMPText(centerPanel.transform, "DeckTitleText", "Select a Deck", 22, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(deckTitleTxt.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(0.7f, 1f));
|
||||
var cardCountTxt = CreateTMPText(centerPanel.transform, "CardCountText", "0 / 100", 18, FontStyles.Normal, TextAlignmentOptions.Right);
|
||||
SetAnchors(cardCountTxt.GetComponent<RectTransform>(), new Vector2(0.7f, 0.93f), new Vector2(1f, 1f));
|
||||
|
||||
var validIndic = CreatePanel(centerPanel.transform, "ValidationIndicator", new Color(0.7f, 0.2f, 0.2f));
|
||||
SetAnchors(validIndic.GetComponent<RectTransform>(), new Vector2(0f, 0.88f), new Vector2(0.12f, 0.93f));
|
||||
var validTxt = CreateTMPText(centerPanel.transform, "ValidationText", "100 cards needed", 14, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
SetAnchors(validTxt.GetComponent<RectTransform>(), new Vector2(0.13f, 0.88f), new Vector2(1f, 0.93f));
|
||||
|
||||
// Commander
|
||||
var cmdInput = CreateTMPInputField(centerPanel.transform, "CommanderSearchInput", "Search commander...");
|
||||
SetAnchors(cmdInput.GetComponent<RectTransform>(), new Vector2(0.01f, 0.81f), new Vector2(0.65f, 0.87f));
|
||||
var cmdSearchBtn = CreateTMPButton(centerPanel.transform, "CommanderSearchButton", "Find", new Color(0.25f, 0.45f, 0.65f));
|
||||
SetAnchors(cmdSearchBtn.GetComponent<RectTransform>(), new Vector2(0.66f, 0.81f), new Vector2(0.82f, 0.87f));
|
||||
var colorIdDd = CreateTMPDropdown(centerPanel.transform, "ColorIdentityDropdown", new[]{"Any","W","U","B","R","G","WU","WB","WR","WG","UB","UR","UG","BR","BG","RG"});
|
||||
SetAnchors(colorIdDd.GetComponent<RectTransform>(), new Vector2(0.01f, 0.75f), new Vector2(0.98f, 0.81f));
|
||||
var cmdResultScroll = new GameObject("CommanderResultScroll");
|
||||
cmdResultScroll.transform.SetParent(centerPanel.transform, false);
|
||||
var cmrsr = cmdResultScroll.AddComponent<ScrollRect>();
|
||||
cmdResultScroll.AddComponent<Image>().color = new Color(0,0,0,0.01f);
|
||||
SetAnchors(cmdResultScroll.GetComponent<RectTransform>(), new Vector2(0f, 0.66f), new Vector2(1f, 0.75f));
|
||||
var cmdContent = CreateScrollContent(cmdResultScroll, out cmrsr);
|
||||
var selCmdTxt = CreateTMPText(centerPanel.transform, "SelectedCommanderText", "Commander: None", 15, FontStyles.Italic, TextAlignmentOptions.Left);
|
||||
SetAnchors(selCmdTxt.GetComponent<RectTransform>(), new Vector2(0.01f, 0.62f), new Vector2(0.98f, 0.66f));
|
||||
|
||||
// Deck card list
|
||||
var deckCardScroll = new GameObject("DeckCardListScroll");
|
||||
deckCardScroll.transform.SetParent(centerPanel.transform, false);
|
||||
SetAnchors(deckCardScroll.GetComponent<RectTransform>(), new Vector2(0f, 0.12f), new Vector2(1f, 0.62f));
|
||||
var dclsr = deckCardScroll.AddComponent<ScrollRect>();
|
||||
deckCardScroll.AddComponent<Image>().color = new Color(0,0,0,0.01f);
|
||||
var deckCardContent = CreateScrollContent(deckCardScroll, out dclsr);
|
||||
|
||||
var saveDeckBtn = CreateTMPButton(centerPanel.transform, "SaveDeckButton", "Save", new Color(0.2f, 0.55f, 0.2f));
|
||||
SetAnchors(saveDeckBtn.GetComponent<RectTransform>(), new Vector2(0.02f, 0.03f), new Vector2(0.35f, 0.11f));
|
||||
var deleteDeckBtn = CreateTMPButton(centerPanel.transform, "DeleteDeckButton", "Delete Deck", new Color(0.55f, 0.15f, 0.15f));
|
||||
SetAnchors(deleteDeckBtn.GetComponent<RectTransform>(), new Vector2(0.37f, 0.03f), new Vector2(0.7f, 0.11f));
|
||||
|
||||
// ── RIGHT: Card Search + Charts ────────────────────────────────
|
||||
var rightPanel = CreatePanel(canvasGo.transform, "CardSearchPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
|
||||
SetAnchors(rightPanel.GetComponent<RectTransform>(), new Vector2(0.58f, 0.06f), new Vector2(1f, 0.93f));
|
||||
|
||||
var cardSearchInput = CreateTMPInputField(rightPanel.transform, "CardSearchInput", "Search cards...");
|
||||
SetAnchors(cardSearchInput.GetComponent<RectTransform>(), new Vector2(0.01f, 0.92f), new Vector2(0.72f, 0.99f));
|
||||
var cardSearchBtn = CreateTMPButton(rightPanel.transform, "CardSearchButton", "Search", new Color(0.2f, 0.5f, 0.8f));
|
||||
SetAnchors(cardSearchBtn.GetComponent<RectTransform>(), new Vector2(0.73f, 0.92f), new Vector2(0.99f, 0.99f));
|
||||
|
||||
var cardResultScroll = new GameObject("CardSearchResultScroll");
|
||||
cardResultScroll.transform.SetParent(rightPanel.transform, false);
|
||||
SetAnchors(cardResultScroll.GetComponent<RectTransform>(), new Vector2(0f, 0.55f), new Vector2(1f, 0.92f));
|
||||
var crsr = cardResultScroll.AddComponent<ScrollRect>();
|
||||
cardResultScroll.AddComponent<Image>().color = new Color(0,0,0,0.01f);
|
||||
var cardResultContent = CreateScrollContent(cardResultScroll, out crsr);
|
||||
|
||||
// Mana curve
|
||||
var manaCurveLabel = CreateTMPText(rightPanel.transform, "ManaCurveLabel", "Mana Curve", 16, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(manaCurveLabel.GetComponent<RectTransform>(), new Vector2(0f, 0.49f), new Vector2(1f, 0.54f));
|
||||
var manaCurveParent = new GameObject("ManaCurveBarParent");
|
||||
manaCurveParent.transform.SetParent(rightPanel.transform, false);
|
||||
SetAnchors(manaCurveParent.GetComponent<RectTransform>(), new Vector2(0.02f, 0.33f), new Vector2(0.98f, 0.49f));
|
||||
var manaHlg = manaCurveParent.AddComponent<HorizontalLayoutGroup>();
|
||||
manaHlg.spacing = 4; manaHlg.childForceExpandWidth = true; manaHlg.childForceExpandHeight = false;
|
||||
|
||||
// Color pie
|
||||
var colorPieLabel = CreateTMPText(rightPanel.transform, "ColorPieLabel", "Color Identity", 16, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(colorPieLabel.GetComponent<RectTransform>(), new Vector2(0f, 0.27f), new Vector2(1f, 0.32f));
|
||||
var colorPieParent = new GameObject("ColorPieParent");
|
||||
colorPieParent.transform.SetParent(rightPanel.transform, false);
|
||||
SetAnchors(colorPieParent.GetComponent<RectTransform>(), new Vector2(0.02f, 0.13f), new Vector2(0.98f, 0.27f));
|
||||
var pieHlg = colorPieParent.AddComponent<HorizontalLayoutGroup>();
|
||||
pieHlg.spacing = 6; pieHlg.childForceExpandWidth = true; pieHlg.childForceExpandHeight = false;
|
||||
Color[] pieColors = { Color.white, new Color(0.3f,0.6f,1f), new Color(0.1f,0.1f,0.1f), new Color(0.8f,0.2f,0.2f), new Color(0.2f,0.7f,0.2f) };
|
||||
string[] pieLabels = { "W", "U", "B", "R", "G" };
|
||||
var pieSliceImages = new Image[pieColors.Length];
|
||||
for (int i = 0; i < pieColors.Length; i++)
|
||||
{
|
||||
var slice = new GameObject("Slice_" + pieLabels[i]);
|
||||
slice.transform.SetParent(colorPieParent.transform, false);
|
||||
var sliceImg = slice.AddComponent<Image>();
|
||||
sliceImg.color = pieColors[i];
|
||||
var sliceText = CreateTMPText(slice.transform, "Label", pieLabels[i], 14, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
pieSliceImages[i] = sliceImg;
|
||||
}
|
||||
|
||||
// -- Card type breakdown --
|
||||
var typeLabel = CreateTMPText(rightPanel.transform, "TypeBreakdownLabel", "Card Types", 16, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(typeLabel.GetComponent<RectTransform>(), new Vector2(0f, 0.08f), new Vector2(1f, 0.13f));
|
||||
var typeText = CreateTMPText(rightPanel.transform, "TypeBreakdownText", "", 14, FontStyles.Normal, TextAlignmentOptions.TopLeft);
|
||||
SetAnchors(typeText.GetComponent<RectTransform>(), new Vector2(0.05f, 0.01f), new Vector2(0.95f, 0.08f));
|
||||
|
||||
// ── Error Panel (hidden initially) ────────────────────────────────────
|
||||
var errorPanel = CreatePanel(canvasGo.transform, "ErrorPanel", new Color(0.15f, 0.05f, 0.05f, 0.97f));
|
||||
SetAnchors(errorPanel.GetComponent<RectTransform>(), new Vector2(0.25f, 0.35f), new Vector2(0.75f, 0.65f));
|
||||
errorPanel.SetActive(false);
|
||||
CreateTMPText(errorPanel.transform, "ErrorTitle", "Error", 22, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
var errorText = CreateTMPText(errorPanel.transform, "ErrorText", "", 16, FontStyles.Normal, TextAlignmentOptions.TopLeft);
|
||||
SetAnchors(errorText.GetComponent<RectTransform>(), new Vector2(0.05f, 0.25f), new Vector2(0.95f, 0.85f));
|
||||
var retryBtn = CreateTMPButton(errorPanel.transform, "RetryButton", "Retry", new Color(0.55f, 0.2f, 0.2f));
|
||||
SetAnchors(retryBtn.GetComponent<RectTransform>(), new Vector2(0.3f, 0.05f), new Vector2(0.7f, 0.22f));
|
||||
|
||||
// ── Prefabs (disabled GameObjects parented to canvas, used as prefabs) ──
|
||||
|
||||
// deckRowPrefab: horizontal layout with TMP_Text for deck name and a Button
|
||||
var deckRowPrefab = new GameObject("DeckRowPrefab");
|
||||
deckRowPrefab.transform.SetParent(canvasGo.transform, false);
|
||||
deckRowPrefab.SetActive(false);
|
||||
deckRowPrefab.AddComponent<Image>().color = new Color(0.15f, 0.15f, 0.22f, 0.9f);
|
||||
var drkHlg = deckRowPrefab.AddComponent<HorizontalLayoutGroup>();
|
||||
drkHlg.padding = new RectOffset(8, 8, 4, 4);
|
||||
drkHlg.spacing = 8;
|
||||
drkHlg.childForceExpandWidth = true;
|
||||
drkHlg.childForceExpandHeight = true;
|
||||
deckRowPrefab.AddComponent<LayoutElement>().minHeight = 40;
|
||||
var drkNameCell = new GameObject("DeckName");
|
||||
drkNameCell.transform.SetParent(deckRowPrefab.transform, false);
|
||||
var drkNameTxt = drkNameCell.AddComponent<TextMeshProUGUI>();
|
||||
drkNameTxt.text = "Deck"; drkNameTxt.fontSize = 16; drkNameTxt.color = Color.white;
|
||||
var drkBtn = new GameObject("SelectButton");
|
||||
drkBtn.transform.SetParent(deckRowPrefab.transform, false);
|
||||
drkBtn.AddComponent<Image>().color = new Color(0.2f, 0.4f, 0.7f);
|
||||
drkBtn.AddComponent<Button>();
|
||||
drkBtn.AddComponent<LayoutElement>().minWidth = 60;
|
||||
|
||||
// deckCardRowPrefab: horizontal layout with TMP_Text for card name, TMP_Text for quantity, and a remove Button
|
||||
var deckCardRowPrefab = new GameObject("DeckCardRowPrefab");
|
||||
deckCardRowPrefab.transform.SetParent(canvasGo.transform, false);
|
||||
deckCardRowPrefab.SetActive(false);
|
||||
deckCardRowPrefab.AddComponent<Image>().color = new Color(0.13f, 0.13f, 0.2f, 0.9f);
|
||||
var dcrHlg = deckCardRowPrefab.AddComponent<HorizontalLayoutGroup>();
|
||||
dcrHlg.padding = new RectOffset(6, 6, 3, 3);
|
||||
dcrHlg.spacing = 8;
|
||||
dcrHlg.childForceExpandWidth = true;
|
||||
dcrHlg.childForceExpandHeight = true;
|
||||
deckCardRowPrefab.AddComponent<LayoutElement>().minHeight = 36;
|
||||
var dcrNameCell = new GameObject("CardName");
|
||||
dcrNameCell.transform.SetParent(deckCardRowPrefab.transform, false);
|
||||
var dcrNameTxt = dcrNameCell.AddComponent<TextMeshProUGUI>();
|
||||
dcrNameTxt.text = "Card"; dcrNameTxt.fontSize = 15; dcrNameTxt.color = Color.white;
|
||||
dcrNameCell.AddComponent<LayoutElement>().flexibleWidth = 1f;
|
||||
var dcrQtyCell = new GameObject("ManaCost");
|
||||
dcrQtyCell.transform.SetParent(deckCardRowPrefab.transform, false);
|
||||
var dcrQtyTxt = dcrQtyCell.AddComponent<TextMeshProUGUI>();
|
||||
dcrQtyTxt.text = "{3}"; dcrQtyTxt.fontSize = 14; dcrQtyTxt.color = new Color(0.8f, 0.8f, 0.5f);
|
||||
dcrQtyCell.AddComponent<LayoutElement>().minWidth = 50;
|
||||
var dcrRemCell = new GameObject("RemoveButton");
|
||||
dcrRemCell.transform.SetParent(deckCardRowPrefab.transform, false);
|
||||
dcrRemCell.AddComponent<Image>().color = new Color(0.6f, 0.15f, 0.15f);
|
||||
dcrRemCell.AddComponent<Button>();
|
||||
dcrRemCell.AddComponent<LayoutElement>().minWidth = 40;
|
||||
|
||||
// commanderResultRowPrefab: horizontal layout with TMP_Text for commander name and a select Button
|
||||
var commanderResultRowPrefab = new GameObject("CommanderResultRowPrefab");
|
||||
commanderResultRowPrefab.transform.SetParent(canvasGo.transform, false);
|
||||
commanderResultRowPrefab.SetActive(false);
|
||||
commanderResultRowPrefab.AddComponent<Image>().color = new Color(0.15f, 0.18f, 0.25f, 0.9f);
|
||||
var crrHlg = commanderResultRowPrefab.AddComponent<HorizontalLayoutGroup>();
|
||||
crrHlg.padding = new RectOffset(8, 8, 4, 4);
|
||||
crrHlg.spacing = 8;
|
||||
crrHlg.childForceExpandWidth = true;
|
||||
crrHlg.childForceExpandHeight = true;
|
||||
commanderResultRowPrefab.AddComponent<LayoutElement>().minHeight = 36;
|
||||
var crrNameCell = new GameObject("CommanderName");
|
||||
crrNameCell.transform.SetParent(commanderResultRowPrefab.transform, false);
|
||||
var crrNameTxt = crrNameCell.AddComponent<TextMeshProUGUI>();
|
||||
crrNameTxt.text = "Commander"; crrNameTxt.fontSize = 15; crrNameTxt.color = Color.white;
|
||||
crrNameCell.AddComponent<LayoutElement>().flexibleWidth = 1f;
|
||||
var crrBtn = new GameObject("SelectButton");
|
||||
crrBtn.transform.SetParent(commanderResultRowPrefab.transform, false);
|
||||
crrBtn.AddComponent<Image>().color = new Color(0.25f, 0.45f, 0.65f);
|
||||
crrBtn.AddComponent<Button>();
|
||||
crrBtn.AddComponent<LayoutElement>().minWidth = 60;
|
||||
|
||||
// cardSearchRowPrefab: horizontal layout with TMP_Text for card name and an Add Button
|
||||
var cardSearchRowPrefab = new GameObject("CardSearchRowPrefab");
|
||||
cardSearchRowPrefab.transform.SetParent(canvasGo.transform, false);
|
||||
cardSearchRowPrefab.SetActive(false);
|
||||
cardSearchRowPrefab.AddComponent<Image>().color = new Color(0.12f, 0.15f, 0.2f, 0.9f);
|
||||
var csrHlg = cardSearchRowPrefab.AddComponent<HorizontalLayoutGroup>();
|
||||
csrHlg.padding = new RectOffset(8, 8, 4, 4);
|
||||
csrHlg.spacing = 8;
|
||||
csrHlg.childForceExpandWidth = true;
|
||||
csrHlg.childForceExpandHeight = true;
|
||||
cardSearchRowPrefab.AddComponent<LayoutElement>().minHeight = 36;
|
||||
var csrNameCell = new GameObject("CardName");
|
||||
csrNameCell.transform.SetParent(cardSearchRowPrefab.transform, false);
|
||||
var csrNameTxt = csrNameCell.AddComponent<TextMeshProUGUI>();
|
||||
csrNameTxt.text = "Card"; csrNameTxt.fontSize = 15; csrNameTxt.color = Color.white;
|
||||
csrNameCell.AddComponent<LayoutElement>().flexibleWidth = 1f;
|
||||
var csrAddBtn = new GameObject("AddButton");
|
||||
csrAddBtn.transform.SetParent(cardSearchRowPrefab.transform, false);
|
||||
csrAddBtn.AddComponent<Image>().color = new Color(0.2f, 0.55f, 0.2f);
|
||||
csrAddBtn.AddComponent<Button>();
|
||||
csrAddBtn.AddComponent<LayoutElement>().minWidth = 50;
|
||||
|
||||
// manaCurveBarPrefab: a simple Image (bar) with a TMP_Text label
|
||||
var manaCurveBarPrefab = new GameObject("ManaCurveBarPrefab");
|
||||
manaCurveBarPrefab.transform.SetParent(canvasGo.transform, false);
|
||||
manaCurveBarPrefab.SetActive(false);
|
||||
manaCurveBarPrefab.AddComponent<Image>().color = new Color(0.3f, 0.5f, 0.9f);
|
||||
var mcbLe = manaCurveBarPrefab.AddComponent<LayoutElement>();
|
||||
mcbLe.minWidth = 20; mcbLe.flexibleWidth = 1f;
|
||||
var mcbLabel = new GameObject("Label");
|
||||
mcbLabel.transform.SetParent(manaCurveBarPrefab.transform, false);
|
||||
var mcbLabelTxt = mcbLabel.AddComponent<TextMeshProUGUI>();
|
||||
mcbLabelTxt.text = "0"; mcbLabelTxt.fontSize = 12; mcbLabelTxt.color = Color.white;
|
||||
mcbLabelTxt.alignment = TextAlignmentOptions.Center;
|
||||
SetAnchors(mcbLabel.GetComponent<RectTransform>(), new Vector2(0f, 0f), new Vector2(1f, 0.25f));
|
||||
|
||||
// ── Wire DeckBuilderController via reflection ──────────────────────────
|
||||
var dbControllerGo = new GameObject("DeckBuilderController");
|
||||
dbControllerGo.transform.SetParent(canvasGo.transform, false);
|
||||
var dbController = dbControllerGo.AddComponent<CommanderAILab.UI.DeckBuilderController>();
|
||||
|
||||
// Navigation
|
||||
SetField(dbController, "backButton", backBtn.GetComponent<Button>());
|
||||
SetField(dbController, "exportToSimButton", exportBtn.GetComponent<Button>());
|
||||
|
||||
// Deck List Panel
|
||||
SetField(dbController, "deckListParent", (Transform)dcrect);
|
||||
SetField(dbController, "deckRowPrefab", deckRowPrefab);
|
||||
SetField(dbController, "newDeckButton", newDeckBtn.GetComponent<Button>());
|
||||
SetField(dbController, "deckNameInput", deckNameInput.GetComponent<TMP_InputField>());
|
||||
|
||||
// Active Deck Panel
|
||||
SetField(dbController, "deckTitleText", deckTitleTxt.GetComponent<TMP_Text>());
|
||||
SetField(dbController, "cardCountText", cardCountTxt.GetComponent<TMP_Text>());
|
||||
SetField(dbController, "validationIndicator", validIndic);
|
||||
SetField(dbController, "validationText", validTxt.GetComponent<TMP_Text>());
|
||||
SetField(dbController, "deckCardListParent", (Transform)deckCardContent);
|
||||
SetField(dbController, "deckCardRowPrefab", deckCardRowPrefab);
|
||||
SetField(dbController, "saveDeckButton", saveDeckBtn.GetComponent<Button>());
|
||||
SetField(dbController, "deleteDeckButton", deleteDeckBtn.GetComponent<Button>());
|
||||
|
||||
// Commander Search
|
||||
SetField(dbController, "commanderSearchInput", cmdInput.GetComponent<TMP_InputField>());
|
||||
SetField(dbController, "commanderSearchButton", cmdSearchBtn.GetComponent<Button>());
|
||||
SetField(dbController, "colorIdentityDropdown", colorIdDd.GetComponent<TMP_Dropdown>());
|
||||
SetField(dbController, "commanderResultParent", (Transform)cmdContent);
|
||||
SetField(dbController, "commanderResultRowPrefab", commanderResultRowPrefab);
|
||||
SetField(dbController, "selectedCommanderText", selCmdTxt.GetComponent<TMP_Text>());
|
||||
|
||||
// Card Search Panel
|
||||
SetField(dbController, "cardSearchInput", cardSearchInput.GetComponent<TMP_InputField>());
|
||||
SetField(dbController, "cardSearchButton", cardSearchBtn.GetComponent<Button>());
|
||||
SetField(dbController, "cardSearchResultParent", (Transform)cardResultContent);
|
||||
SetField(dbController, "cardSearchRowPrefab", cardSearchRowPrefab);
|
||||
|
||||
// Mana Curve
|
||||
SetField(dbController, "manaCurveBarParent", manaCurveParent.transform);
|
||||
SetField(dbController, "manaCurveBarPrefab", manaCurveBarPrefab);
|
||||
|
||||
// Color Pie
|
||||
SetField(dbController, "colorPieSlices", pieSliceImages);
|
||||
|
||||
// Error
|
||||
SetField(dbController, "errorPanel", errorPanel);
|
||||
SetField(dbController, "errorText", errorText.GetComponent<TMP_Text>());
|
||||
SetField(dbController, "retryButton", retryBtn.GetComponent<Button>());
|
||||
|
||||
// -- Save --
|
||||
if (!AssetDatabase.IsValidFolder("Assets/Scenes"))
|
||||
AssetDatabase.CreateFolder("Assets", "Scenes");
|
||||
string path = "Assets/Scenes/DeckBuilder.unity";
|
||||
EditorSceneManager.SaveScene(scene, path);
|
||||
var existing = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
|
||||
if (!existing.Any(s => s.path == path))
|
||||
existing.Add(new EditorBuildSettingsScene(path, true));
|
||||
EditorBuildSettings.scenes = existing.ToArray();
|
||||
Debug.Log("[CreateDeckBuilderScene] DeckBuilder scene created and saved to " + path);
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Viewport + Content hierarchy inside the given scroll GameObject,
|
||||
/// wires up the ScrollRect, and returns the Content RectTransform.
|
||||
/// </summary>
|
||||
static RectTransform CreateScrollContent(GameObject scrollGo, out ScrollRect scrollRect)
|
||||
{
|
||||
scrollRect = scrollGo.GetComponent<ScrollRect>();
|
||||
if (scrollRect == null)
|
||||
scrollRect = scrollGo.AddComponent<ScrollRect>();
|
||||
|
||||
// Viewport
|
||||
var viewport = new GameObject("Viewport");
|
||||
viewport.transform.SetParent(scrollGo.transform, false);
|
||||
viewport.AddComponent<RectMask2D>();
|
||||
var vpRect = viewport.GetComponent<RectTransform>();
|
||||
vpRect.anchorMin = Vector2.zero;
|
||||
vpRect.anchorMax = Vector2.one;
|
||||
vpRect.offsetMin = Vector2.zero;
|
||||
vpRect.offsetMax = Vector2.zero;
|
||||
|
||||
// Content
|
||||
var content = new GameObject("Content");
|
||||
content.transform.SetParent(viewport.transform, false);
|
||||
var contentRect = content.AddComponent<RectTransform>();
|
||||
contentRect.anchorMin = new Vector2(0f, 1f);
|
||||
contentRect.anchorMax = new Vector2(1f, 1f);
|
||||
contentRect.pivot = new Vector2(0.5f, 1f);
|
||||
contentRect.offsetMin = Vector2.zero;
|
||||
contentRect.offsetMax = Vector2.zero;
|
||||
content.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
var vlg = content.AddComponent<VerticalLayoutGroup>();
|
||||
vlg.spacing = 4;
|
||||
vlg.padding = new RectOffset(4, 4, 4, 4);
|
||||
vlg.childForceExpandWidth = true;
|
||||
vlg.childForceExpandHeight = false;
|
||||
|
||||
scrollRect.viewport = vpRect;
|
||||
scrollRect.content = contentRect;
|
||||
scrollRect.horizontal = false;
|
||||
|
||||
return contentRect;
|
||||
}
|
||||
|
||||
static void SetField(object target, string fieldName, object value)
|
||||
{
|
||||
var field = target.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field != null) field.SetValue(target, value);
|
||||
else Debug.LogWarning("Could not find field: " + fieldName);
|
||||
}
|
||||
|
||||
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
|
||||
{
|
||||
rt.anchorMin = min;
|
||||
rt.anchorMax = max;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
static GameObject CreatePanel(Transform parent, string name, Color color, bool stretch = false)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = color;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
if (stretch) SetAnchors(rt, Vector2.zero, Vector2.one);
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPText(Transform parent, string name, string text, int fontSize, FontStyles style, TextAlignmentOptions alignment)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var tmp = go.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text;
|
||||
tmp.fontSize = fontSize;
|
||||
tmp.fontStyle = style;
|
||||
tmp.alignment = alignment;
|
||||
tmp.color = Color.white;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
SetAnchors(rt, Vector2.zero, Vector2.one);
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPButton(Transform parent, string name, string label, Color bgColor)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = bgColor;
|
||||
var btn = go.AddComponent<Button>();
|
||||
btn.targetGraphic = img;
|
||||
var colors = btn.colors;
|
||||
colors.highlightedColor = bgColor * 1.2f;
|
||||
colors.pressedColor = bgColor * 0.8f;
|
||||
btn.colors = colors;
|
||||
|
||||
var textGo = new GameObject("Text");
|
||||
textGo.transform.SetParent(go.transform, false);
|
||||
var tmp = textGo.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = label;
|
||||
tmp.fontSize = 22;
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
tmp.color = Color.white;
|
||||
SetAnchors(textGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var le = go.AddComponent<LayoutElement>();
|
||||
le.minHeight = 50;
|
||||
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPDropdown(Transform parent, string name, string[] options)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = new Color(0.15f, 0.15f, 0.2f);
|
||||
|
||||
var labelGo = new GameObject("Label");
|
||||
labelGo.transform.SetParent(go.transform, false);
|
||||
var labelTmp = labelGo.AddComponent<TextMeshProUGUI>();
|
||||
labelTmp.text = options.Length > 0 ? options[0] : "";
|
||||
labelTmp.fontSize = 16;
|
||||
labelTmp.alignment = TextAlignmentOptions.Left;
|
||||
labelTmp.color = Color.white;
|
||||
SetAnchors(labelGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var dd = go.AddComponent<TMP_Dropdown>();
|
||||
dd.captionText = labelTmp;
|
||||
dd.ClearOptions();
|
||||
dd.AddOptions(new System.Collections.Generic.List<string>(options));
|
||||
|
||||
var le = go.AddComponent<LayoutElement>();
|
||||
le.minHeight = 40;
|
||||
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPInputField(Transform parent, string name, string defaultText)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = new Color(0.15f, 0.15f, 0.2f);
|
||||
|
||||
var textArea = new GameObject("Text Area");
|
||||
textArea.transform.SetParent(go.transform, false);
|
||||
textArea.AddComponent<RectMask2D>();
|
||||
SetAnchors(textArea.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var placeholder = new GameObject("Placeholder");
|
||||
placeholder.transform.SetParent(textArea.transform, false);
|
||||
var phTmp = placeholder.AddComponent<TextMeshProUGUI>();
|
||||
phTmp.text = defaultText;
|
||||
phTmp.fontSize = 18;
|
||||
phTmp.fontStyle = FontStyles.Italic;
|
||||
phTmp.color = new Color(0.5f, 0.5f, 0.5f, 0.8f);
|
||||
phTmp.alignment = TextAlignmentOptions.Left;
|
||||
SetAnchors(placeholder.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var inputText = new GameObject("Text");
|
||||
inputText.transform.SetParent(textArea.transform, false);
|
||||
var itTmp = inputText.AddComponent<TextMeshProUGUI>();
|
||||
itTmp.fontSize = 18;
|
||||
itTmp.color = Color.white;
|
||||
itTmp.alignment = TextAlignmentOptions.Left;
|
||||
SetAnchors(inputText.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var inputField = go.AddComponent<TMP_InputField>();
|
||||
inputField.textViewport = textArea.GetComponent<RectTransform>();
|
||||
inputField.textComponent = itTmp;
|
||||
inputField.placeholder = phTmp;
|
||||
inputField.text = defaultText;
|
||||
inputField.fontAsset = itTmp.font;
|
||||
|
||||
return go;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,248 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using TMPro;
|
||||
using System.Reflection;
|
||||
using System.Linq;
|
||||
|
||||
public static class CreateMainMenuScene
|
||||
{
|
||||
[MenuItem("Tools/Create MainMenu Scene")]
|
||||
public static void Create()
|
||||
{
|
||||
// -- Create new scene --
|
||||
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
|
||||
|
||||
// -- Services (persistent singletons) --
|
||||
var services = new GameObject("[Services]");
|
||||
services.AddComponent<CommanderAILab.Services.ApiClient>();
|
||||
services.AddComponent<CommanderAILab.Services.ImageCache>();
|
||||
|
||||
// -- Canvas --
|
||||
var canvasGo = new GameObject("Canvas");
|
||||
var canvas = canvasGo.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
var scaler = canvasGo.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
canvasGo.AddComponent<GraphicRaycaster>();
|
||||
|
||||
// -- Event System --
|
||||
if (Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>() == null)
|
||||
{
|
||||
var es = new GameObject("EventSystem");
|
||||
es.AddComponent<UnityEngine.EventSystems.EventSystem>();
|
||||
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
|
||||
}
|
||||
|
||||
// -- Background --
|
||||
var bg = CreatePanel(canvasGo.transform, "Background", Color.black, stretch: true);
|
||||
bg.GetComponent<Image>().color = new Color(0.08f, 0.08f, 0.12f, 1f);
|
||||
|
||||
// -- Server Panel (top) --
|
||||
var serverPanel = CreatePanel(canvasGo.transform, "ServerPanel", new Color(0.12f, 0.12f, 0.18f, 0.95f));
|
||||
var spRect = serverPanel.GetComponent<RectTransform>();
|
||||
SetAnchors(spRect, new Vector2(0.1f, 0.75f), new Vector2(0.9f, 0.95f));
|
||||
|
||||
var logoText = CreateTMPText(serverPanel.transform, "LogoText", "Commander AI Lab", 36, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(logoText.GetComponent<RectTransform>(), new Vector2(0f, 0.5f), new Vector2(1f, 1f));
|
||||
|
||||
var urlInput = CreateTMPInputField(serverPanel.transform, "ServerUrlInput", "http://localhost:8080");
|
||||
var urlRect = urlInput.GetComponent<RectTransform>();
|
||||
SetAnchors(urlRect, new Vector2(0.05f, 0.05f), new Vector2(0.55f, 0.45f));
|
||||
|
||||
var connectBtn = CreateTMPButton(serverPanel.transform, "ConnectButton", "Connect", new Color(0.2f, 0.6f, 0.2f));
|
||||
SetAnchors(connectBtn.GetComponent<RectTransform>(), new Vector2(0.58f, 0.05f), new Vector2(0.75f, 0.45f));
|
||||
|
||||
var statusImg = new GameObject("StatusIndicator");
|
||||
statusImg.transform.SetParent(serverPanel.transform, false);
|
||||
var statusImage = statusImg.AddComponent<Image>();
|
||||
statusImage.color = new Color(0.8f, 0.2f, 0.2f);
|
||||
var siRect = statusImg.GetComponent<RectTransform>();
|
||||
SetAnchors(siRect, new Vector2(0.78f, 0.15f), new Vector2(0.80f, 0.35f));
|
||||
|
||||
var statusText = CreateTMPText(serverPanel.transform, "StatusText", "Disconnected", 18, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
SetAnchors(statusText.GetComponent<RectTransform>(), new Vector2(0.81f, 0.05f), new Vector2(0.98f, 0.45f));
|
||||
|
||||
// -- Nav Panel (center) --
|
||||
var navPanel = CreatePanel(canvasGo.transform, "NavPanel", new Color(0.1f, 0.1f, 0.15f, 0.9f));
|
||||
SetAnchors(navPanel.GetComponent<RectTransform>(), new Vector2(0.1f, 0.15f), new Vector2(0.9f, 0.72f));
|
||||
|
||||
// Add vertical layout
|
||||
var vlg = navPanel.AddComponent<VerticalLayoutGroup>();
|
||||
vlg.spacing = 10;
|
||||
vlg.padding = new RectOffset(40, 40, 20, 20);
|
||||
vlg.childAlignment = TextAnchor.MiddleCenter;
|
||||
vlg.childForceExpandWidth = true;
|
||||
vlg.childForceExpandHeight = true;
|
||||
|
||||
string[] btnNames = { "CollectionButton", "DeckBuilderButton", "DeckGenButton", "SimulatorButton", "ScannerButton", "CoachButton", "TrainingButton" };
|
||||
string[] btnLabels = { "Collection", "Deck Builder", "Deck Generator", "Simulator", "Scanner", "Coach", "Training" };
|
||||
var navButtons = new GameObject[btnNames.Length];
|
||||
for (int i = 0; i < btnNames.Length; i++)
|
||||
{
|
||||
navButtons[i] = CreateTMPButton(navPanel.transform, btnNames[i], btnLabels[i], new Color(0.18f, 0.22f, 0.35f));
|
||||
}
|
||||
|
||||
// -- Error Panel (bottom, inactive) --
|
||||
var errorPanel = CreatePanel(canvasGo.transform, "ErrorPanel", new Color(0.5f, 0.1f, 0.1f, 0.95f));
|
||||
SetAnchors(errorPanel.GetComponent<RectTransform>(), new Vector2(0.2f, 0.02f), new Vector2(0.8f, 0.12f));
|
||||
|
||||
var errorText = CreateTMPText(errorPanel.transform, "ErrorText", "", 16, FontStyles.Normal, TextAlignmentOptions.Center);
|
||||
SetAnchors(errorText.GetComponent<RectTransform>(), new Vector2(0f, 0.3f), new Vector2(0.7f, 1f));
|
||||
|
||||
var retryBtn = CreateTMPButton(errorPanel.transform, "RetryButton", "Retry", new Color(0.6f, 0.3f, 0.1f));
|
||||
SetAnchors(retryBtn.GetComponent<RectTransform>(), new Vector2(0.72f, 0.15f), new Vector2(0.95f, 0.85f));
|
||||
|
||||
errorPanel.SetActive(false);
|
||||
|
||||
// -- Wire up MainMenuController --
|
||||
var controllerGo = new GameObject("MainMenuController");
|
||||
var controller = controllerGo.AddComponent<CommanderAILab.UI.MainMenuController>();
|
||||
|
||||
// Use reflection to set private serialized fields
|
||||
SetField(controller, "serverUrlInput", urlInput.GetComponent<TMP_InputField>());
|
||||
SetField(controller, "connectButton", connectBtn.GetComponent<Button>());
|
||||
SetField(controller, "statusIndicator", statusImage);
|
||||
SetField(controller, "statusText", statusText.GetComponent<TMP_Text>());
|
||||
SetField(controller, "collectionButton", navButtons[0].GetComponent<Button>());
|
||||
SetField(controller, "deckBuilderButton", navButtons[1].GetComponent<Button>());
|
||||
SetField(controller, "deckGenButton", navButtons[2].GetComponent<Button>());
|
||||
SetField(controller, "simulatorButton", navButtons[3].GetComponent<Button>());
|
||||
SetField(controller, "scannerButton", navButtons[4].GetComponent<Button>());
|
||||
SetField(controller, "coachButton", navButtons[5].GetComponent<Button>());
|
||||
SetField(controller, "trainingButton", navButtons[6].GetComponent<Button>());
|
||||
SetField(controller, "errorPanel", errorPanel);
|
||||
SetField(controller, "errorText", errorText.GetComponent<TMP_Text>());
|
||||
SetField(controller, "retryButton", retryBtn.GetComponent<Button>());
|
||||
|
||||
// -- Save --
|
||||
string path = "Assets/Scenes/MainMenu.unity";
|
||||
EditorSceneManager.SaveScene(scene, path);
|
||||
var existing = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
|
||||
if (!existing.Any(s => s.path == path))
|
||||
existing.Add(new EditorBuildSettingsScene(path, true));
|
||||
EditorBuildSettings.scenes = existing.ToArray();
|
||||
|
||||
Debug.Log("MainMenu scene created and saved to " + path);
|
||||
EditorUtility.DisplayDialog("Done", "MainMenu scene created!\n\nHit Play to test.\nMake sure your FastAPI backend is running on port 8080.", "OK");
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
static void SetField(object target, string fieldName, object value)
|
||||
{
|
||||
var field = target.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field != null) field.SetValue(target, value);
|
||||
else Debug.LogWarning("Could not find field: " + fieldName);
|
||||
}
|
||||
|
||||
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
|
||||
{
|
||||
rt.anchorMin = min;
|
||||
rt.anchorMax = max;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
static GameObject CreatePanel(Transform parent, string name, Color color, bool stretch = false)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = color;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
if (stretch) SetAnchors(rt, Vector2.zero, Vector2.one);
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPText(Transform parent, string name, string text, int fontSize, FontStyles style, TextAlignmentOptions alignment)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var tmp = go.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text;
|
||||
tmp.fontSize = fontSize;
|
||||
tmp.fontStyle = style;
|
||||
tmp.alignment = alignment;
|
||||
tmp.color = Color.white;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
SetAnchors(rt, Vector2.zero, Vector2.one);
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPButton(Transform parent, string name, string label, Color bgColor)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = bgColor;
|
||||
var btn = go.AddComponent<Button>();
|
||||
btn.targetGraphic = img;
|
||||
var colors = btn.colors;
|
||||
colors.highlightedColor = bgColor * 1.2f;
|
||||
colors.pressedColor = bgColor * 0.8f;
|
||||
btn.colors = colors;
|
||||
|
||||
var textGo = new GameObject("Text");
|
||||
textGo.transform.SetParent(go.transform, false);
|
||||
var tmp = textGo.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = label;
|
||||
tmp.fontSize = 22;
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
tmp.color = Color.white;
|
||||
SetAnchors(textGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var le = go.AddComponent<LayoutElement>();
|
||||
le.minHeight = 50;
|
||||
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPInputField(Transform parent, string name, string defaultText)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = new Color(0.15f, 0.15f, 0.2f);
|
||||
|
||||
// Text Area
|
||||
var textArea = new GameObject("Text Area");
|
||||
textArea.transform.SetParent(go.transform, false);
|
||||
textArea.AddComponent<RectMask2D>();
|
||||
SetAnchors(textArea.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
// Placeholder
|
||||
var placeholder = new GameObject("Placeholder");
|
||||
placeholder.transform.SetParent(textArea.transform, false);
|
||||
var phTmp = placeholder.AddComponent<TextMeshProUGUI>();
|
||||
phTmp.text = "Enter server URL...";
|
||||
phTmp.fontSize = 18;
|
||||
phTmp.fontStyle = FontStyles.Italic;
|
||||
phTmp.color = new Color(0.5f, 0.5f, 0.5f, 0.8f);
|
||||
phTmp.alignment = TextAlignmentOptions.Left;
|
||||
SetAnchors(placeholder.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
// Input text
|
||||
var inputText = new GameObject("Text");
|
||||
inputText.transform.SetParent(textArea.transform, false);
|
||||
var itTmp = inputText.AddComponent<TextMeshProUGUI>();
|
||||
itTmp.fontSize = 18;
|
||||
itTmp.color = Color.white;
|
||||
itTmp.alignment = TextAlignmentOptions.Left;
|
||||
SetAnchors(inputText.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var inputField = go.AddComponent<TMP_InputField>();
|
||||
inputField.textViewport = textArea.GetComponent<RectTransform>();
|
||||
inputField.textComponent = itTmp;
|
||||
inputField.placeholder = phTmp;
|
||||
inputField.text = defaultText;
|
||||
inputField.fontAsset = itTmp.font;
|
||||
|
||||
return go;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,167 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using TMPro;
|
||||
using System.Reflection;
|
||||
using System.Linq;
|
||||
|
||||
public static class CreateScannerScene
|
||||
{
|
||||
[MenuItem("Tools/Create Scanner Scene")]
|
||||
public static void Create()
|
||||
{
|
||||
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
|
||||
|
||||
var services = new GameObject("[Services]");
|
||||
services.AddComponent<CommanderAILab.Services.ApiClient>();
|
||||
services.AddComponent<CommanderAILab.Services.ImageCache>();
|
||||
|
||||
var canvasGo = new GameObject("Canvas");
|
||||
var canvas = canvasGo.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
var scaler = canvasGo.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
canvasGo.AddComponent<GraphicRaycaster>();
|
||||
|
||||
if (Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>() == null)
|
||||
{
|
||||
var es = new GameObject("EventSystem");
|
||||
es.AddComponent<UnityEngine.EventSystems.EventSystem>();
|
||||
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
|
||||
}
|
||||
|
||||
CreatePanel(canvasGo.transform, "Background", new Color(0.05f, 0.05f, 0.08f), stretch: true);
|
||||
|
||||
// Header
|
||||
var header = CreatePanel(canvasGo.transform, "Header", new Color(0.12f, 0.12f, 0.18f));
|
||||
SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(1f, 1f));
|
||||
var backBtn = CreateTMPButton(header.transform, "BackButton", "< Back", new Color(0.2f, 0.2f, 0.3f));
|
||||
SetAnchors(backBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.08f, 0.9f));
|
||||
var title = CreateTMPText(header.transform, "TitleText", "Card Scanner", 28, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(title.GetComponent<RectTransform>(), new Vector2(0.3f, 0.1f), new Vector2(0.7f, 0.9f));
|
||||
|
||||
// Camera feed (left)
|
||||
var camFeedGo = new GameObject("CameraFeedImage");
|
||||
camFeedGo.transform.SetParent(canvasGo.transform, false);
|
||||
camFeedGo.AddComponent<RawImage>().color = Color.black;
|
||||
SetAnchors(camFeedGo.GetComponent<RectTransform>(), new Vector2(0.01f, 0.20f), new Vector2(0.55f, 0.93f));
|
||||
|
||||
// Scan overlay (animated line placeholder)
|
||||
var scanOverlay = CreatePanel(canvasGo.transform, "ScanOverlay", new Color(0f, 0.8f, 0f, 0.35f));
|
||||
SetAnchors(scanOverlay.GetComponent<RectTransform>(), new Vector2(0.01f, 0.55f), new Vector2(0.55f, 0.57f));
|
||||
scanOverlay.SetActive(false);
|
||||
|
||||
// Camera status
|
||||
var camStatus = CreateTMPText(canvasGo.transform, "CameraStatusText", "Initializing camera...", 16, FontStyles.Normal, TextAlignmentOptions.Center);
|
||||
SetAnchors(camStatus.GetComponent<RectTransform>(), new Vector2(0.01f, 0.15f), new Vector2(0.55f, 0.20f));
|
||||
|
||||
// Capture + toggle buttons
|
||||
var captureBtn = CreateTMPButton(canvasGo.transform, "CaptureButton", "Scan Card", new Color(0.15f, 0.6f, 0.15f));
|
||||
SetAnchors(captureBtn.GetComponent<RectTransform>(), new Vector2(0.05f, 0.07f), new Vector2(0.32f, 0.14f));
|
||||
var toggleCamBtn = CreateTMPButton(canvasGo.transform, "ToggleCameraButton", "Flip Camera", new Color(0.2f, 0.3f, 0.5f));
|
||||
SetAnchors(toggleCamBtn.GetComponent<RectTransform>(), new Vector2(0.34f, 0.07f), new Vector2(0.54f, 0.14f));
|
||||
|
||||
// Result panel (right)
|
||||
var resultPanel = CreatePanel(canvasGo.transform, "ResultPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
|
||||
SetAnchors(resultPanel.GetComponent<RectTransform>(), new Vector2(0.56f, 0.20f), new Vector2(0.99f, 0.93f));
|
||||
|
||||
var resultImg = new GameObject("ResultCardImage");
|
||||
resultImg.transform.SetParent(resultPanel.transform, false);
|
||||
resultImg.AddComponent<Image>().color = new Color(0.15f, 0.15f, 0.2f);
|
||||
SetAnchors(resultImg.GetComponent<RectTransform>(), new Vector2(0.03f, 0.55f), new Vector2(0.45f, 0.98f));
|
||||
|
||||
var rName = CreateTMPText(resultPanel.transform, "ResultCardName", "", 22, FontStyles.Bold, TextAlignmentOptions.Left);
|
||||
SetAnchors(rName.GetComponent<RectTransform>(), new Vector2(0.48f, 0.88f), new Vector2(0.97f, 0.98f));
|
||||
var rType = CreateTMPText(resultPanel.transform, "ResultTypeLine", "", 16, FontStyles.Italic, TextAlignmentOptions.Left);
|
||||
SetAnchors(rType.GetComponent<RectTransform>(), new Vector2(0.48f, 0.80f), new Vector2(0.97f, 0.88f));
|
||||
var rOracle = CreateTMPText(resultPanel.transform, "ResultOracleText","", 14, FontStyles.Normal, TextAlignmentOptions.TopLeft);
|
||||
SetAnchors(rOracle.GetComponent<RectTransform>(), new Vector2(0.48f, 0.55f), new Vector2(0.97f, 0.80f));
|
||||
var rStatus = CreateTMPText(resultPanel.transform, "ResultStatus", "", 14, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
SetAnchors(rStatus.GetComponent<RectTransform>(), new Vector2(0.03f, 0.45f), new Vector2(0.97f, 0.55f));
|
||||
|
||||
var addCollBtn = CreateTMPButton(resultPanel.transform, "AddToCollectionButton", "Add to Collection", new Color(0.2f, 0.55f, 0.2f));
|
||||
SetAnchors(addCollBtn.GetComponent<RectTransform>(), new Vector2(0.03f, 0.03f), new Vector2(0.48f, 0.12f));
|
||||
var addDeckBtn = CreateTMPButton(resultPanel.transform, "AddToDeckButton", "Add to Deck", new Color(0.2f, 0.3f, 0.6f));
|
||||
SetAnchors(addDeckBtn.GetComponent<RectTransform>(), new Vector2(0.52f, 0.03f), new Vector2(0.97f, 0.12f));
|
||||
resultPanel.SetActive(false);
|
||||
|
||||
// -- Save --
|
||||
if (!AssetDatabase.IsValidFolder("Assets/Scenes"))
|
||||
AssetDatabase.CreateFolder("Assets", "Scenes");
|
||||
string path = "Assets/Scenes/Scanner.unity";
|
||||
EditorSceneManager.SaveScene(scene, path);
|
||||
var existing = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
|
||||
if (!existing.Any(s => s.path == path))
|
||||
existing.Add(new EditorBuildSettingsScene(path, true));
|
||||
EditorBuildSettings.scenes = existing.ToArray();
|
||||
Debug.Log("[CreateScannerScene] Scanner scene created and saved to " + path);
|
||||
}
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
|
||||
{
|
||||
rt.anchorMin = min;
|
||||
rt.anchorMax = max;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
static GameObject CreatePanel(Transform parent, string name, Color color, bool stretch = false)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = color;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
if (stretch) SetAnchors(rt, Vector2.zero, Vector2.one);
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPText(Transform parent, string name, string text, int fontSize, FontStyles style, TextAlignmentOptions alignment)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var tmp = go.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text;
|
||||
tmp.fontSize = fontSize;
|
||||
tmp.fontStyle = style;
|
||||
tmp.alignment = alignment;
|
||||
tmp.color = Color.white;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
SetAnchors(rt, Vector2.zero, Vector2.one);
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPButton(Transform parent, string name, string label, Color bgColor)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = bgColor;
|
||||
var btn = go.AddComponent<Button>();
|
||||
btn.targetGraphic = img;
|
||||
var colors = btn.colors;
|
||||
colors.highlightedColor = bgColor * 1.2f;
|
||||
colors.pressedColor = bgColor * 0.8f;
|
||||
btn.colors = colors;
|
||||
|
||||
var textGo = new GameObject("Text");
|
||||
textGo.transform.SetParent(go.transform, false);
|
||||
var tmp = textGo.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = label;
|
||||
tmp.fontSize = 22;
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
tmp.color = Color.white;
|
||||
SetAnchors(textGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var le = go.AddComponent<LayoutElement>();
|
||||
le.minHeight = 50;
|
||||
|
||||
return go;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,445 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using TMPro;
|
||||
using System.Reflection;
|
||||
using System.Linq;
|
||||
|
||||
public static class CreateSimulatorScene
|
||||
{
|
||||
[MenuItem("Tools/Create Simulator Scene")]
|
||||
public static void Create()
|
||||
{
|
||||
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────
|
||||
var services = new GameObject("[Services]");
|
||||
services.AddComponent<CommanderAILab.Services.ApiClient>();
|
||||
|
||||
// ── Canvas ────────────────────────────────────────────────────────────
|
||||
var canvasGo = new GameObject("Canvas");
|
||||
var canvas = canvasGo.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
var scaler = canvasGo.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
canvasGo.AddComponent<GraphicRaycaster>();
|
||||
|
||||
if (Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>() == null)
|
||||
{
|
||||
var es = new GameObject("EventSystem");
|
||||
es.AddComponent<UnityEngine.EventSystems.EventSystem>();
|
||||
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
|
||||
}
|
||||
|
||||
// ── Background ────────────────────────────────────────────────────────
|
||||
CreatePanel(canvasGo.transform, "Background", new Color(0.08f, 0.08f, 0.12f), stretch: true);
|
||||
|
||||
// ── Header (Top Bar) ──────────────────────────────────────────────────
|
||||
var header = CreatePanel(canvasGo.transform, "Header", new Color(0.12f, 0.12f, 0.18f, 0.95f));
|
||||
SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(1f, 1f));
|
||||
|
||||
var backBtn = CreateTMPButton(header.transform, "BackButton", "< Back", new Color(0.2f, 0.2f, 0.3f));
|
||||
SetAnchors(backBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.08f, 0.9f));
|
||||
|
||||
var titleTxt = CreateTMPText(header.transform, "TitleText", "Simulator", 28, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(titleTxt.GetComponent<RectTransform>(), new Vector2(0.3f, 0.1f), new Vector2(0.7f, 0.9f));
|
||||
|
||||
var startSimBtn = CreateTMPButton(header.transform, "StartSimButton", "Start Simulation", new Color(0.2f, 0.55f, 0.2f));
|
||||
SetAnchors(startSimBtn.GetComponent<RectTransform>(), new Vector2(0.82f, 0.1f), new Vector2(0.99f, 0.9f));
|
||||
|
||||
// ── Config Panel (left) ───────────────────────────────────────────────
|
||||
var configPanel = CreatePanel(canvasGo.transform, "ConfigPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
|
||||
SetAnchors(configPanel.GetComponent<RectTransform>(), new Vector2(0.01f, 0.3f), new Vector2(0.49f, 0.93f));
|
||||
var cfgVlg = configPanel.AddComponent<VerticalLayoutGroup>();
|
||||
cfgVlg.padding = new RectOffset(16, 16, 16, 16);
|
||||
cfgVlg.spacing = 10;
|
||||
cfgVlg.childForceExpandWidth = true;
|
||||
cfgVlg.childForceExpandHeight = false;
|
||||
|
||||
CreateTMPText(configPanel.transform, "ConfigTitle", "Simulation Config", 20, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
|
||||
// Deck dropdowns
|
||||
var deckDropdown0 = CreateTMPDropdown(configPanel.transform, "DeckDropdown0", new[] { "-- Select Deck --" });
|
||||
var deckDropdown1 = CreateTMPDropdown(configPanel.transform, "DeckDropdown1", new[] { "-- Select Deck --" });
|
||||
var deckDropdown2 = CreateTMPDropdown(configPanel.transform, "DeckDropdown2", new[] { "-- Select Deck --" });
|
||||
var deckDropdown3 = CreateTMPDropdown(configPanel.transform, "DeckDropdown3", new[] { "-- Select Deck --" });
|
||||
|
||||
// Num players slider
|
||||
var numPlayersLabel = CreateTMPText(configPanel.transform, "NumPlayersLabel", "Players: 2", 16, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
var numPlayersSlider = CreateSlider(configPanel.transform, "NumPlayersSlider");
|
||||
|
||||
// Num games input
|
||||
CreateTMPText(configPanel.transform, "NumGamesLabel", "Number of Games:", 16, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
var numGamesInput = CreateTMPInputField(configPanel.transform, "NumGamesInput", "100");
|
||||
|
||||
// Threads input
|
||||
CreateTMPText(configPanel.transform, "ThreadsLabel", "Threads:", 16, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
var threadsInput = CreateTMPInputField(configPanel.transform, "ThreadsInput", "4");
|
||||
|
||||
// AI Profile dropdown
|
||||
CreateTMPText(configPanel.transform, "AiProfileLabel", "AI Profile:", 16, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
var aiProfileDropdown = CreateTMPDropdown(configPanel.transform, "AiProfileDropdown", new[] { "Default" });
|
||||
|
||||
// ── Status Panel (center, hidden initially) ───────────────────────────
|
||||
var statusPanel = CreatePanel(canvasGo.transform, "StatusPanel", new Color(0.09f, 0.09f, 0.14f, 0.97f));
|
||||
SetAnchors(statusPanel.GetComponent<RectTransform>(), new Vector2(0.01f, 0.06f), new Vector2(0.49f, 0.29f));
|
||||
statusPanel.SetActive(false);
|
||||
var stVlg = statusPanel.AddComponent<VerticalLayoutGroup>();
|
||||
stVlg.padding = new RectOffset(12, 12, 10, 10);
|
||||
stVlg.spacing = 6;
|
||||
stVlg.childForceExpandWidth = true;
|
||||
stVlg.childForceExpandHeight = false;
|
||||
|
||||
var statusText = CreateTMPText(statusPanel.transform, "StatusText", "Ready", 16, FontStyles.Normal, TextAlignmentOptions.TopLeft);
|
||||
var progressBar = CreateSlider(statusPanel.transform, "ProgressBar");
|
||||
var progressText = CreateTMPText(statusPanel.transform, "ProgressText", "0%", 14, FontStyles.Normal, TextAlignmentOptions.Center);
|
||||
var elapsedText = CreateTMPText(statusPanel.transform, "ElapsedText", "Elapsed: 0s", 14, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
var simsPerSecText = CreateTMPText(statusPanel.transform, "SimsPerSecText", "0 sims/s", 14, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
|
||||
// ── Results Panel (right, hidden initially) ───────────────────────────
|
||||
var resultsPanel = CreatePanel(canvasGo.transform, "ResultsPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
|
||||
SetAnchors(resultsPanel.GetComponent<RectTransform>(), new Vector2(0.51f, 0.06f), new Vector2(0.99f, 0.93f));
|
||||
resultsPanel.SetActive(false);
|
||||
|
||||
CreateTMPText(resultsPanel.transform, "ResultsTitle", "Results", 22, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
var winnerText = CreateTMPText(resultsPanel.transform, "WinnerText", "Winner: —", 18, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(winnerText.GetComponent<RectTransform>(), new Vector2(0f, 0.88f), new Vector2(1f, 0.95f));
|
||||
|
||||
// Results scroll view
|
||||
var resultsScroll = new GameObject("ResultsScroll");
|
||||
resultsScroll.transform.SetParent(resultsPanel.transform, false);
|
||||
SetAnchors(resultsScroll.GetComponent<RectTransform>(), new Vector2(0f, 0.15f), new Vector2(1f, 0.88f));
|
||||
ScrollRect resSr;
|
||||
var resultsContent = CreateScrollContent(resultsScroll, out resSr);
|
||||
|
||||
var historyBtn = CreateTMPButton(resultsPanel.transform, "HistoryButton", "View History", new Color(0.2f, 0.4f, 0.7f));
|
||||
SetAnchors(historyBtn.GetComponent<RectTransform>(), new Vector2(0.2f, 0.03f), new Vector2(0.8f, 0.13f));
|
||||
|
||||
// ── Error Panel (hidden initially) ────────────────────────────────────
|
||||
var errorPanel = CreatePanel(canvasGo.transform, "ErrorPanel", new Color(0.15f, 0.05f, 0.05f, 0.97f));
|
||||
SetAnchors(errorPanel.GetComponent<RectTransform>(), new Vector2(0.25f, 0.35f), new Vector2(0.75f, 0.65f));
|
||||
errorPanel.SetActive(false);
|
||||
|
||||
CreateTMPText(errorPanel.transform, "ErrorTitle", "Error", 22, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
var errorText = CreateTMPText(errorPanel.transform, "ErrorText", "", 16, FontStyles.Normal, TextAlignmentOptions.TopLeft);
|
||||
SetAnchors(errorText.GetComponent<RectTransform>(), new Vector2(0.05f, 0.25f), new Vector2(0.95f, 0.85f));
|
||||
var retryBtn = CreateTMPButton(errorPanel.transform, "RetryButton", "Retry", new Color(0.55f, 0.2f, 0.2f));
|
||||
SetAnchors(retryBtn.GetComponent<RectTransform>(), new Vector2(0.3f, 0.05f), new Vector2(0.7f, 0.22f));
|
||||
|
||||
// ── Result Row Prefab (disabled child object used as prefab) ──────────
|
||||
var resultRowPrefab = new GameObject("ResultRowPrefab");
|
||||
resultRowPrefab.transform.SetParent(canvasGo.transform, false);
|
||||
resultRowPrefab.SetActive(false);
|
||||
var rowImg = resultRowPrefab.AddComponent<Image>();
|
||||
rowImg.color = new Color(0.15f, 0.15f, 0.2f, 0.9f);
|
||||
var rowHlg = resultRowPrefab.AddComponent<HorizontalLayoutGroup>();
|
||||
rowHlg.padding = new RectOffset(8, 8, 4, 4);
|
||||
rowHlg.spacing = 12;
|
||||
rowHlg.childForceExpandWidth = false;
|
||||
rowHlg.childForceExpandHeight = true;
|
||||
var rowLe = resultRowPrefab.AddComponent<LayoutElement>();
|
||||
rowLe.minHeight = 40;
|
||||
|
||||
string[] colNames = { "DeckName", "WinRate", "Wins", "Losses" };
|
||||
float[] colWidths = { 300f, 120f, 100f, 100f };
|
||||
for (int i = 0; i < colNames.Length; i++)
|
||||
{
|
||||
var cell = new GameObject(colNames[i]);
|
||||
cell.transform.SetParent(resultRowPrefab.transform, false);
|
||||
var cellTxt = cell.AddComponent<TextMeshProUGUI>();
|
||||
cellTxt.text = colNames[i];
|
||||
cellTxt.fontSize = 16;
|
||||
cellTxt.color = Color.white;
|
||||
cellTxt.alignment = TextAlignmentOptions.Left;
|
||||
var cellLe = cell.AddComponent<LayoutElement>();
|
||||
cellLe.minWidth = colWidths[i];
|
||||
cellLe.preferredWidth = colWidths[i];
|
||||
cellLe.flexibleWidth = i == 0 ? 1f : 0f;
|
||||
}
|
||||
|
||||
// ── Wire SimulationController via reflection ───────────────────────────
|
||||
var controllerGo = new GameObject("SimulationController");
|
||||
controllerGo.transform.SetParent(canvasGo.transform, false);
|
||||
var controller = controllerGo.AddComponent<CommanderAILab.UI.SimulationController>();
|
||||
|
||||
SetField(controller, "backButton", backBtn.GetComponent<Button>());
|
||||
SetField(controller, "startSimButton", startSimBtn.GetComponent<Button>());
|
||||
|
||||
SetField(controller, "deckDropdown0", deckDropdown0.GetComponent<TMP_Dropdown>());
|
||||
SetField(controller, "deckDropdown1", deckDropdown1.GetComponent<TMP_Dropdown>());
|
||||
SetField(controller, "deckDropdown2", deckDropdown2.GetComponent<TMP_Dropdown>());
|
||||
SetField(controller, "deckDropdown3", deckDropdown3.GetComponent<TMP_Dropdown>());
|
||||
SetField(controller, "numGamesInput", numGamesInput.GetComponent<TMP_InputField>());
|
||||
SetField(controller, "numPlayersSlider", numPlayersSlider.GetComponent<Slider>());
|
||||
SetField(controller, "numPlayersLabel", numPlayersLabel.GetComponent<TMP_Text>());
|
||||
SetField(controller, "threadsInput", threadsInput.GetComponent<TMP_InputField>());
|
||||
SetField(controller, "aiProfileDropdown", aiProfileDropdown.GetComponent<TMP_Dropdown>());
|
||||
|
||||
SetField(controller, "statusText", statusText.GetComponent<TMP_Text>());
|
||||
SetField(controller, "progressBar", progressBar.GetComponent<Slider>());
|
||||
SetField(controller, "progressText", progressText.GetComponent<TMP_Text>());
|
||||
SetField(controller, "elapsedText", elapsedText.GetComponent<TMP_Text>());
|
||||
SetField(controller, "simsPerSecText", simsPerSecText.GetComponent<TMP_Text>());
|
||||
|
||||
SetField(controller, "resultsParent", resultsContent);
|
||||
SetField(controller, "resultRowPrefab", resultRowPrefab);
|
||||
SetField(controller, "winnerText", winnerText.GetComponent<TMP_Text>());
|
||||
SetField(controller, "historyButton", historyBtn.GetComponent<Button>());
|
||||
|
||||
SetField(controller, "errorPanel", errorPanel);
|
||||
SetField(controller, "errorText", errorText.GetComponent<TMP_Text>());
|
||||
SetField(controller, "retryButton", retryBtn.GetComponent<Button>());
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────
|
||||
if (!AssetDatabase.IsValidFolder("Assets/Scenes"))
|
||||
AssetDatabase.CreateFolder("Assets", "Scenes");
|
||||
string path = "Assets/Scenes/Simulator.unity";
|
||||
EditorSceneManager.SaveScene(scene, path);
|
||||
var existing = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
|
||||
if (!existing.Any(s => s.path == path))
|
||||
existing.Add(new EditorBuildSettingsScene(path, true));
|
||||
EditorBuildSettings.scenes = existing.ToArray();
|
||||
Debug.Log("[CreateSimulatorScene] Simulator scene created and saved to " + path);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
static void SetField(object target, string fieldName, object value)
|
||||
{
|
||||
var field = target.GetType().GetField(fieldName,
|
||||
BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field != null) field.SetValue(target, value);
|
||||
else Debug.LogWarning("[CreateSimulatorScene] Could not find field: " + fieldName);
|
||||
}
|
||||
|
||||
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
|
||||
{
|
||||
rt.anchorMin = min;
|
||||
rt.anchorMax = max;
|
||||
rt.offsetMin = Vector2.zero;
|
||||
rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
static GameObject CreatePanel(Transform parent, string name, Color color, bool stretch = false)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = color;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
if (stretch) SetAnchors(rt, Vector2.zero, Vector2.one);
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPText(Transform parent, string name, string text, int fontSize, FontStyles style, TextAlignmentOptions alignment)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var tmp = go.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text;
|
||||
tmp.fontSize = fontSize;
|
||||
tmp.fontStyle = style;
|
||||
tmp.alignment = alignment;
|
||||
tmp.color = Color.white;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
SetAnchors(rt, Vector2.zero, Vector2.one);
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPButton(Transform parent, string name, string label, Color bgColor)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = bgColor;
|
||||
var btn = go.AddComponent<Button>();
|
||||
btn.targetGraphic = img;
|
||||
var colors = btn.colors;
|
||||
colors.highlightedColor = bgColor * 1.2f;
|
||||
colors.pressedColor = bgColor * 0.8f;
|
||||
btn.colors = colors;
|
||||
|
||||
var textGo = new GameObject("Text");
|
||||
textGo.transform.SetParent(go.transform, false);
|
||||
var tmp = textGo.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = label;
|
||||
tmp.fontSize = 22;
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
tmp.color = Color.white;
|
||||
SetAnchors(textGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var le = go.AddComponent<LayoutElement>();
|
||||
le.minHeight = 50;
|
||||
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPInputField(Transform parent, string name, string defaultText)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = new Color(0.15f, 0.15f, 0.2f);
|
||||
|
||||
var textArea = new GameObject("Text Area");
|
||||
textArea.transform.SetParent(go.transform, false);
|
||||
textArea.AddComponent<RectMask2D>();
|
||||
SetAnchors(textArea.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var placeholder = new GameObject("Placeholder");
|
||||
placeholder.transform.SetParent(textArea.transform, false);
|
||||
var phTmp = placeholder.AddComponent<TextMeshProUGUI>();
|
||||
phTmp.text = defaultText;
|
||||
phTmp.fontSize = 18;
|
||||
phTmp.fontStyle = FontStyles.Italic;
|
||||
phTmp.color = new Color(0.5f, 0.5f, 0.5f, 0.8f);
|
||||
phTmp.alignment = TextAlignmentOptions.Left;
|
||||
SetAnchors(placeholder.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var inputText = new GameObject("Text");
|
||||
inputText.transform.SetParent(textArea.transform, false);
|
||||
var itTmp = inputText.AddComponent<TextMeshProUGUI>();
|
||||
itTmp.fontSize = 18;
|
||||
itTmp.color = Color.white;
|
||||
itTmp.alignment = TextAlignmentOptions.Left;
|
||||
SetAnchors(inputText.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var inputField = go.AddComponent<TMP_InputField>();
|
||||
inputField.textViewport = textArea.GetComponent<RectTransform>();
|
||||
inputField.textComponent = itTmp;
|
||||
inputField.placeholder = phTmp;
|
||||
inputField.text = defaultText;
|
||||
inputField.fontAsset = itTmp.font;
|
||||
|
||||
var le = go.AddComponent<LayoutElement>();
|
||||
le.minHeight = 45;
|
||||
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPDropdown(Transform parent, string name, string[] options)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = new Color(0.15f, 0.15f, 0.2f);
|
||||
|
||||
var labelGo = new GameObject("Label");
|
||||
labelGo.transform.SetParent(go.transform, false);
|
||||
var labelTmp = labelGo.AddComponent<TextMeshProUGUI>();
|
||||
labelTmp.text = options.Length > 0 ? options[0] : "";
|
||||
labelTmp.fontSize = 16;
|
||||
labelTmp.alignment = TextAlignmentOptions.Left;
|
||||
labelTmp.color = Color.white;
|
||||
SetAnchors(labelGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var dd = go.AddComponent<TMP_Dropdown>();
|
||||
dd.captionText = labelTmp;
|
||||
dd.ClearOptions();
|
||||
dd.AddOptions(new System.Collections.Generic.List<string>(options));
|
||||
|
||||
var le = go.AddComponent<LayoutElement>();
|
||||
le.minHeight = 40;
|
||||
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateSlider(Transform parent, string name)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
|
||||
// Background track
|
||||
var bg = new GameObject("Background");
|
||||
bg.transform.SetParent(go.transform, false);
|
||||
var bgImg = bg.AddComponent<Image>();
|
||||
bgImg.color = new Color(0.2f, 0.2f, 0.25f);
|
||||
SetAnchors(bg.GetComponent<RectTransform>(), new Vector2(0f, 0.25f), new Vector2(1f, 0.75f));
|
||||
|
||||
// Fill area
|
||||
var fillArea = new GameObject("Fill Area");
|
||||
fillArea.transform.SetParent(go.transform, false);
|
||||
SetAnchors(fillArea.GetComponent<RectTransform>(), new Vector2(0f, 0.25f), new Vector2(1f, 0.75f));
|
||||
|
||||
var fill = new GameObject("Fill");
|
||||
fill.transform.SetParent(fillArea.transform, false);
|
||||
var fillImg = fill.AddComponent<Image>();
|
||||
fillImg.color = new Color(0.2f, 0.6f, 0.3f);
|
||||
var fillRt = fill.GetComponent<RectTransform>();
|
||||
fillRt.anchorMin = Vector2.zero;
|
||||
fillRt.anchorMax = Vector2.one;
|
||||
fillRt.offsetMin = Vector2.zero;
|
||||
fillRt.offsetMax = Vector2.zero;
|
||||
|
||||
// Handle
|
||||
var handleArea = new GameObject("Handle Slide Area");
|
||||
handleArea.transform.SetParent(go.transform, false);
|
||||
SetAnchors(handleArea.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
|
||||
|
||||
var handle = new GameObject("Handle");
|
||||
handle.transform.SetParent(handleArea.transform, false);
|
||||
var handleImg = handle.AddComponent<Image>();
|
||||
handleImg.color = new Color(0.8f, 0.8f, 0.9f);
|
||||
var handleRt = handle.GetComponent<RectTransform>();
|
||||
handleRt.sizeDelta = new Vector2(20f, 20f);
|
||||
|
||||
var slider = go.AddComponent<Slider>();
|
||||
slider.fillRect = fillRt;
|
||||
slider.handleRect = handleRt;
|
||||
slider.targetGraphic = handleImg;
|
||||
slider.direction = Slider.Direction.LeftToRight;
|
||||
slider.minValue = 0f;
|
||||
slider.maxValue = 1f;
|
||||
slider.value = 0f;
|
||||
|
||||
var le = go.AddComponent<LayoutElement>();
|
||||
le.minHeight = 30;
|
||||
|
||||
return go;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Viewport + Content hierarchy inside the given scroll GameObject,
|
||||
/// wires up the ScrollRect, and returns the Content RectTransform.
|
||||
/// </summary>
|
||||
static RectTransform CreateScrollContent(GameObject scrollGo, out ScrollRect scrollRect)
|
||||
{
|
||||
scrollRect = scrollGo.GetComponent<ScrollRect>();
|
||||
if (scrollRect == null)
|
||||
scrollRect = scrollGo.AddComponent<ScrollRect>();
|
||||
|
||||
var viewport = new GameObject("Viewport");
|
||||
viewport.transform.SetParent(scrollGo.transform, false);
|
||||
viewport.AddComponent<RectMask2D>();
|
||||
var vpRect = viewport.GetComponent<RectTransform>();
|
||||
vpRect.anchorMin = Vector2.zero;
|
||||
vpRect.anchorMax = Vector2.one;
|
||||
vpRect.offsetMin = Vector2.zero;
|
||||
vpRect.offsetMax = Vector2.zero;
|
||||
|
||||
var content = new GameObject("Content");
|
||||
content.transform.SetParent(viewport.transform, false);
|
||||
var contentRect = content.AddComponent<RectTransform>();
|
||||
contentRect.anchorMin = new Vector2(0f, 1f);
|
||||
contentRect.anchorMax = new Vector2(1f, 1f);
|
||||
contentRect.pivot = new Vector2(0.5f, 1f);
|
||||
contentRect.offsetMin = Vector2.zero;
|
||||
contentRect.offsetMax = Vector2.zero;
|
||||
content.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
var vlg = content.AddComponent<VerticalLayoutGroup>();
|
||||
vlg.spacing = 4;
|
||||
vlg.padding = new RectOffset(4, 4, 4, 4);
|
||||
vlg.childForceExpandWidth = true;
|
||||
vlg.childForceExpandHeight = false;
|
||||
|
||||
scrollRect.viewport = vpRect;
|
||||
scrollRect.content = contentRect;
|
||||
scrollRect.horizontal = false;
|
||||
|
||||
return contentRect;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,251 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using TMPro;
|
||||
using System.Reflection;
|
||||
using System.Linq;
|
||||
|
||||
public static class CreateTrainingScene
|
||||
{
|
||||
[MenuItem("Tools/Create Training Scene")]
|
||||
public static void Create()
|
||||
{
|
||||
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
|
||||
|
||||
var services = new GameObject("[Services]");
|
||||
services.AddComponent<CommanderAILab.Services.ApiClient>();
|
||||
|
||||
var canvasGo = new GameObject("Canvas");
|
||||
var canvas = canvasGo.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
var scaler = canvasGo.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
canvasGo.AddComponent<GraphicRaycaster>();
|
||||
|
||||
if (Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>() == null)
|
||||
{
|
||||
var es = new GameObject("EventSystem");
|
||||
es.AddComponent<UnityEngine.EventSystems.EventSystem>();
|
||||
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
|
||||
}
|
||||
|
||||
CreatePanel(canvasGo.transform, "Background", new Color(0.05f, 0.05f, 0.08f), stretch: true);
|
||||
|
||||
// Header
|
||||
var header = CreatePanel(canvasGo.transform, "Header", new Color(0.12f, 0.12f, 0.18f));
|
||||
SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(1f, 1f));
|
||||
var backBtn = CreateTMPButton(header.transform, "BackButton", "< Back", new Color(0.2f, 0.2f, 0.3f));
|
||||
SetAnchors(backBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.08f, 0.9f));
|
||||
var title = CreateTMPText(header.transform, "TitleText", "ML Training Dashboard", 28, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(title.GetComponent<RectTransform>(), new Vector2(0.1f, 0.1f), new Vector2(0.9f, 0.9f));
|
||||
|
||||
// Left column: Stats + Controls
|
||||
var leftCol = CreatePanel(canvasGo.transform, "LeftColumn", new Color(0.08f, 0.08f, 0.12f));
|
||||
SetAnchors(leftCol.GetComponent<RectTransform>(), new Vector2(0f, 0.01f), new Vector2(0.28f, 0.92f));
|
||||
|
||||
// Stats panel
|
||||
var statsPanel = CreatePanel(leftCol.transform, "StatsPanel", new Color(0.12f, 0.12f, 0.18f));
|
||||
SetAnchors(statsPanel.GetComponent<RectTransform>(), new Vector2(0.02f, 0.72f), new Vector2(0.98f, 0.99f));
|
||||
var statsTitle = CreateTMPText(statsPanel.transform, "StatsTitle", "TRAINING DATA", 16, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(statsTitle.GetComponent<RectTransform>(), new Vector2(0f, 0.78f), new Vector2(1f, 1f));
|
||||
var cardCountTxt = CreateTMPText(statsPanel.transform, "CardCountText", "Cards: --", 14, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
SetAnchors(cardCountTxt.GetComponent<RectTransform>(), new Vector2(0.05f, 0.55f), new Vector2(0.95f, 0.77f));
|
||||
var sessionCountTxt = CreateTMPText(statsPanel.transform, "SessionCountText", "Sessions: --", 14, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
SetAnchors(sessionCountTxt.GetComponent<RectTransform>(), new Vector2(0.05f, 0.33f), new Vector2(0.95f, 0.55f));
|
||||
var modelVerTxt = CreateTMPText(statsPanel.transform, "ModelVersionText", "Model: --", 14, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
SetAnchors(modelVerTxt.GetComponent<RectTransform>(), new Vector2(0.05f, 0.11f), new Vector2(0.95f, 0.33f));
|
||||
var lastTrainedTxt = CreateTMPText(statsPanel.transform, "LastTrainedText", "Last trained: --", 12, FontStyles.Normal, TextAlignmentOptions.Left);
|
||||
SetAnchors(lastTrainedTxt.GetComponent<RectTransform>(), new Vector2(0.05f, 0f), new Vector2(0.95f, 0.11f));
|
||||
|
||||
// Controls panel
|
||||
var ctrlPanel = CreatePanel(leftCol.transform, "ControlsPanel", new Color(0.12f, 0.12f, 0.18f));
|
||||
SetAnchors(ctrlPanel.GetComponent<RectTransform>(), new Vector2(0.02f, 0.42f), new Vector2(0.98f, 0.71f));
|
||||
var statusTxt = CreateTMPText(ctrlPanel.transform, "StatusText", "Ready.", 13, FontStyles.Normal, TextAlignmentOptions.Center);
|
||||
SetAnchors(statusTxt.GetComponent<RectTransform>(), new Vector2(0.02f, 0.75f), new Vector2(0.98f, 1f));
|
||||
var startBtn = CreateTMPButton(ctrlPanel.transform, "StartTrainingButton", "Start Training", new Color(0.1f, 0.5f, 0.1f));
|
||||
SetAnchors(startBtn.GetComponent<RectTransform>(), new Vector2(0.05f, 0.45f), new Vector2(0.95f, 0.73f));
|
||||
var stopBtn = CreateTMPButton(ctrlPanel.transform, "StopTrainingButton", "Stop", new Color(0.5f, 0.1f, 0.1f));
|
||||
SetAnchors(stopBtn.GetComponent<RectTransform>(), new Vector2(0.05f, 0.15f), new Vector2(0.95f, 0.43f));
|
||||
var exportBtn = CreateTMPButton(ctrlPanel.transform, "ExportModelButton", "Export Model", new Color(0.2f, 0.3f, 0.5f));
|
||||
SetAnchors(exportBtn.GetComponent<RectTransform>(), new Vector2(0.05f, 0.01f), new Vector2(0.95f, 0.14f));
|
||||
|
||||
// Center column: Live Charts
|
||||
var centerCol = CreatePanel(canvasGo.transform, "CenterColumn", new Color(0.08f, 0.08f, 0.12f));
|
||||
SetAnchors(centerCol.GetComponent<RectTransform>(), new Vector2(0.29f, 0.01f), new Vector2(0.69f, 0.92f));
|
||||
|
||||
var accLabel = CreateTMPText(centerCol.transform, "AccuracyLabel", "Accuracy: --", 14, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(accLabel.GetComponent<RectTransform>(), new Vector2(0f, 0.87f), new Vector2(1f, 0.93f));
|
||||
var accChart = CreatePanel(centerCol.transform, "AccuracyChart", new Color(0.05f, 0.15f, 0.05f));
|
||||
SetAnchors(accChart.GetComponent<RectTransform>(), new Vector2(0.02f, 0.5f), new Vector2(0.98f, 0.86f));
|
||||
|
||||
var lossLabel = CreateTMPText(centerCol.transform, "LossLabel", "Loss: --", 14, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(lossLabel.GetComponent<RectTransform>(), new Vector2(0f, 0.43f), new Vector2(1f, 0.49f));
|
||||
var lossChart = CreatePanel(centerCol.transform, "LossChart", new Color(0.15f, 0.05f, 0.05f));
|
||||
SetAnchors(lossChart.GetComponent<RectTransform>(), new Vector2(0.02f, 0.06f), new Vector2(0.98f, 0.42f));
|
||||
|
||||
// Feature importance title
|
||||
var featTitle = CreateTMPText(centerCol.transform, "FeatureTitle", "Feature Importance", 13, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(featTitle.GetComponent<RectTransform>(), new Vector2(0f, 0.01f), new Vector2(1f, 0.05f));
|
||||
|
||||
// Right column: Model History + Log
|
||||
var rightCol = CreatePanel(canvasGo.transform, "RightColumn", new Color(0.08f, 0.08f, 0.12f));
|
||||
SetAnchors(rightCol.GetComponent<RectTransform>(), new Vector2(0.7f, 0.01f), new Vector2(1f, 0.92f));
|
||||
|
||||
// History panel
|
||||
var histPanel = CreatePanel(rightCol.transform, "HistoryPanel", new Color(0.12f, 0.12f, 0.18f));
|
||||
SetAnchors(histPanel.GetComponent<RectTransform>(), new Vector2(0.02f, 0.52f), new Vector2(0.98f, 0.99f));
|
||||
var histTitle = CreateTMPText(histPanel.transform, "HistoryTitle", "MODEL HISTORY", 15, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(histTitle.GetComponent<RectTransform>(), new Vector2(0f, 0.88f), new Vector2(1f, 1f));
|
||||
var histScrollGo = new GameObject("HistoryScroll");
|
||||
histScrollGo.transform.SetParent(histPanel.transform, false);
|
||||
var histScroll = histScrollGo.AddComponent<ScrollRect>();
|
||||
var histScrollRT = histScrollGo.GetComponent<RectTransform>();
|
||||
SetAnchors(histScrollRT, new Vector2(0.01f, 0.01f), new Vector2(0.99f, 0.87f));
|
||||
var histContent = new GameObject("Content");
|
||||
histContent.transform.SetParent(histScrollGo.transform, false);
|
||||
var histContentRT = histContent.AddComponent<RectTransform>();
|
||||
histContentRT.anchorMin = Vector2.zero; histContentRT.anchorMax = new Vector2(1f, 0f);
|
||||
histContentRT.pivot = new Vector2(0.5f, 0f);
|
||||
histContent.AddComponent<VerticalLayoutGroup>().spacing = 4;
|
||||
histContent.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
histScroll.content = histContentRT;
|
||||
histScroll.vertical = true; histScroll.horizontal = false;
|
||||
|
||||
// Log panel
|
||||
var logPanel = CreatePanel(rightCol.transform, "LogPanel", new Color(0.06f, 0.06f, 0.06f));
|
||||
SetAnchors(logPanel.GetComponent<RectTransform>(), new Vector2(0.02f, 0.01f), new Vector2(0.98f, 0.51f));
|
||||
var logTitle = CreateTMPText(logPanel.transform, "LogTitle", "TRAINING LOG", 14, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
SetAnchors(logTitle.GetComponent<RectTransform>(), new Vector2(0f, 0.88f), new Vector2(1f, 1f));
|
||||
var logScrollGo = new GameObject("LogScroll");
|
||||
logScrollGo.transform.SetParent(logPanel.transform, false);
|
||||
var logScroll = logScrollGo.AddComponent<ScrollRect>();
|
||||
SetAnchors(logScrollGo.GetComponent<RectTransform>(), new Vector2(0.01f, 0.01f), new Vector2(0.99f, 0.87f));
|
||||
var logContent = new GameObject("LogContent");
|
||||
logContent.transform.SetParent(logScrollGo.transform, false);
|
||||
var logContentRT = logContent.AddComponent<RectTransform>();
|
||||
logContentRT.anchorMin = Vector2.zero; logContentRT.anchorMax = new Vector2(1f, 0f);
|
||||
logContentRT.pivot = new Vector2(0.5f, 0f);
|
||||
var logText = logContent.AddComponent<TextMeshProUGUI>();
|
||||
logText.fontSize = 11; logText.color = new Color(0.7f, 1f, 0.7f);
|
||||
logText.text = "";
|
||||
logContent.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
logScroll.content = logContentRT;
|
||||
logScroll.vertical = true; logScroll.horizontal = false;
|
||||
|
||||
// Error panel
|
||||
var errorPanel = CreatePanel(canvasGo.transform, "ErrorPanel", new Color(0.4f, 0.05f, 0.05f, 0.95f));
|
||||
SetAnchors(errorPanel.GetComponent<RectTransform>(), new Vector2(0.3f, 0.4f), new Vector2(0.7f, 0.6f));
|
||||
errorPanel.SetActive(false);
|
||||
var errorTxt = CreateTMPText(errorPanel.transform, "ErrorText", "", 16, FontStyles.Normal, TextAlignmentOptions.Center);
|
||||
SetAnchors(errorTxt.GetComponent<RectTransform>(), new Vector2(0.05f, 0.1f), new Vector2(0.95f, 0.9f));
|
||||
|
||||
// Wire up TrainingController
|
||||
var controllerGo = new GameObject("TrainingController");
|
||||
controllerGo.transform.SetParent(canvasGo.transform, false);
|
||||
var ctrl = controllerGo.AddComponent<CommanderAILab.UI.TrainingController>();
|
||||
|
||||
SetPrivateField(ctrl, "backButton", backBtn.GetComponent<Button>());
|
||||
SetPrivateField(ctrl, "cardCountText", cardCountTxt.GetComponent<TextMeshProUGUI>());
|
||||
SetPrivateField(ctrl, "sessionCountText", sessionCountTxt.GetComponent<TextMeshProUGUI>());
|
||||
SetPrivateField(ctrl, "modelVersionText", modelVerTxt.GetComponent<TextMeshProUGUI>());
|
||||
SetPrivateField(ctrl, "lastTrainedText", lastTrainedTxt.GetComponent<TextMeshProUGUI>());
|
||||
SetPrivateField(ctrl, "startTrainingButton", startBtn.GetComponent<Button>());
|
||||
SetPrivateField(ctrl, "stopTrainingButton", stopBtn.GetComponent<Button>());
|
||||
SetPrivateField(ctrl, "exportModelButton", exportBtn.GetComponent<Button>());
|
||||
SetPrivateField(ctrl, "statusText", statusTxt.GetComponent<TextMeshProUGUI>());
|
||||
SetPrivateField(ctrl, "accuracyChartRect", accChart.GetComponent<RectTransform>());
|
||||
SetPrivateField(ctrl, "lossChartRect", lossChart.GetComponent<RectTransform>());
|
||||
SetPrivateField(ctrl, "accuracyLabel", accLabel.GetComponent<TextMeshProUGUI>());
|
||||
SetPrivateField(ctrl, "lossLabel", lossLabel.GetComponent<TextMeshProUGUI>());
|
||||
SetPrivateField(ctrl, "historyParent", histContent.transform);
|
||||
SetPrivateField(ctrl, "logText", logText);
|
||||
SetPrivateField(ctrl, "logScrollRect", logScroll);
|
||||
SetPrivateField(ctrl, "errorPanel", errorPanel);
|
||||
SetPrivateField(ctrl, "errorText", errorTxt.GetComponent<TextMeshProUGUI>());
|
||||
|
||||
// -- Save --
|
||||
if (!AssetDatabase.IsValidFolder("Assets/Scenes"))
|
||||
AssetDatabase.CreateFolder("Assets", "Scenes");
|
||||
string path = "Assets/Scenes/Training.unity";
|
||||
EditorSceneManager.SaveScene(scene, path);
|
||||
var existing = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
|
||||
if (!existing.Any(s => s.path == path))
|
||||
existing.Add(new EditorBuildSettingsScene(path, true));
|
||||
EditorBuildSettings.scenes = existing.ToArray();
|
||||
UnityEngine.Debug.Log("[CreateTrainingScene] Training scene created and saved to " + path);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
static GameObject CreatePanel(Transform parent, string name, Color color, bool stretch = false)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = color;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
if (stretch)
|
||||
{
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPText(Transform parent, string name, string text, int size,
|
||||
FontStyles style, TextAlignmentOptions align)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var tmp = go.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text;
|
||||
tmp.fontSize = size;
|
||||
tmp.fontStyle = style;
|
||||
tmp.alignment = align;
|
||||
tmp.color = Color.white;
|
||||
return go;
|
||||
}
|
||||
|
||||
static GameObject CreateTMPButton(Transform parent, string name, string label, Color bgColor)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(parent, false);
|
||||
var img = go.AddComponent<Image>();
|
||||
img.color = bgColor;
|
||||
var btn = go.AddComponent<Button>();
|
||||
var colors = btn.colors;
|
||||
colors.highlightedColor = bgColor * 1.3f;
|
||||
colors.pressedColor = bgColor * 0.7f;
|
||||
btn.colors = colors;
|
||||
var txtGo = new GameObject("Text");
|
||||
txtGo.transform.SetParent(go.transform, false);
|
||||
var tmp = txtGo.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = label;
|
||||
tmp.fontSize = 16;
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
tmp.color = Color.white;
|
||||
var txtRT = txtGo.GetComponent<RectTransform>();
|
||||
txtRT.anchorMin = Vector2.zero;
|
||||
txtRT.anchorMax = Vector2.one;
|
||||
txtRT.offsetMin = txtRT.offsetMax = Vector2.zero;
|
||||
return go;
|
||||
}
|
||||
|
||||
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
|
||||
{
|
||||
rt.anchorMin = min;
|
||||
rt.anchorMax = max;
|
||||
rt.offsetMin = rt.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
static void SetPrivateField(object obj, string fieldName, object value)
|
||||
{
|
||||
var field = obj.GetType().GetField(fieldName,
|
||||
BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
field?.SetValue(obj, value);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
var FileUploadPlugin = {
|
||||
_objectName: null,
|
||||
_methodName: null,
|
||||
_fileInput: null,
|
||||
|
||||
WebGLFileUploadInit: function (objectNamePtr, methodNamePtr) {
|
||||
this._objectName = UTF8ToString(objectNamePtr);
|
||||
this._methodName = UTF8ToString(methodNamePtr);
|
||||
|
||||
if (!this._fileInput) {
|
||||
this._fileInput = document.createElement('input');
|
||||
this._fileInput.type = 'file';
|
||||
this._fileInput.accept = 'image/*';
|
||||
this._fileInput.style.display = 'none';
|
||||
document.body.appendChild(this._fileInput);
|
||||
|
||||
var self = this;
|
||||
this._fileInput.addEventListener('change', function (e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (evt) {
|
||||
var base64 = evt.target.result.split(',')[1];
|
||||
SendMessage(self._objectName, self._methodName, base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = '';
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
WebGLFileUploadClick: function () {
|
||||
if (this._fileInput) {
|
||||
this._fileInput.click();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mergeInto(LibraryManager.library, FileUploadPlugin);
|
||||
@@ -0,0 +1,38 @@
|
||||
var WebGLInput = {
|
||||
GetURLParam: function(keyPtr) {
|
||||
var key = UTF8ToString(keyPtr);
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var value = params.get(key) || '';
|
||||
var bufferSize = lengthBytesUTF8(value) + 1;
|
||||
var buffer = _malloc(bufferSize);
|
||||
stringToUTF8(value, buffer, bufferSize);
|
||||
return buffer;
|
||||
},
|
||||
|
||||
OpenFileDialog: function(acceptPtr, callbackObjPtr, callbackMethodPtr) {
|
||||
var accept = UTF8ToString(acceptPtr);
|
||||
var callbackObj = UTF8ToString(callbackObjPtr);
|
||||
var callbackMethod = UTF8ToString(callbackMethodPtr);
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = accept;
|
||||
input.onchange = function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(ev) {
|
||||
var base64 = ev.target.result.split(',')[1];
|
||||
SendMessage(callbackObj, callbackMethod, base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
|
||||
SetLoadingProgress: function(progress) {
|
||||
var bar = document.getElementById('unity-progress-bar-full');
|
||||
if (bar) bar.style.width = (progress * 100) + '%';
|
||||
}
|
||||
};
|
||||
|
||||
mergeInto(LibraryManager.library, WebGLInput);
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# This file keeps the folder in git
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// CardController — manages a single card prefab instance.
|
||||
/// Handles: card data binding, image loading (async Scryfall),
|
||||
/// flip animation, hover scale + glow, click raise + detail panel,
|
||||
/// tooltip overlay, drag support, mobile gestures.
|
||||
/// Attach to a UI Image-based card prefab.
|
||||
/// </summary>
|
||||
public class CardController : MonoBehaviour,
|
||||
IPointerEnterHandler, IPointerExitHandler, IPointerClickHandler,
|
||||
IBeginDragHandler, IDragHandler, IEndDragHandler
|
||||
{
|
||||
[Header("Card Faces")]
|
||||
[SerializeField] private Image frontFaceImage;
|
||||
[SerializeField] private Image backFaceImage;
|
||||
|
||||
[Header("Glow / Outline")]
|
||||
[SerializeField] private Outline glowOutline;
|
||||
[SerializeField] private Color glowColor = new Color(1f, 0.85f, 0f, 0.9f);
|
||||
|
||||
[Header("Tooltip")]
|
||||
[SerializeField] private GameObject tooltipPanel;
|
||||
[SerializeField] private TMP_Text tooltipName;
|
||||
[SerializeField] private TMP_Text tooltipManaCost;
|
||||
[SerializeField] private TMP_Text tooltipTypeLine;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[SerializeField] private float hoverScaleMultiplier = 1.12f;
|
||||
[SerializeField] private float hoverRaiseY = 15f;
|
||||
[SerializeField] private float flipDuration = 0.35f;
|
||||
[SerializeField] private float hoverDuration = 0.15f;
|
||||
[SerializeField] private float clickRaiseY = 40f;
|
||||
|
||||
[Header("LOD")]
|
||||
[SerializeField] private Sprite lowResPlaceholder;
|
||||
|
||||
// Public events for parent scenes
|
||||
public event Action<CardModel> OnCardClicked;
|
||||
public event Action<CardController> OnDragStarted;
|
||||
public event Action<CardController, Vector2> OnDragMoved;
|
||||
public event Action<CardController> OnDragEnded;
|
||||
|
||||
private CardModel _card;
|
||||
private Vector3 _originalScale;
|
||||
private Vector3 _originalPosition;
|
||||
private RectTransform _rectTransform;
|
||||
private bool _isFaceUp = true;
|
||||
private bool _isFlipping = false;
|
||||
private bool _isHovered = false;
|
||||
private bool _isDragging = false;
|
||||
private Vector2 _dragOffset;
|
||||
private Canvas _parentCanvas;
|
||||
|
||||
// Mobile gesture tracking
|
||||
private float _pointerDownTime;
|
||||
private bool _pointerHeld;
|
||||
private const float LongPressThreshold = 0.4f;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_rectTransform = GetComponent<RectTransform>();
|
||||
_originalScale = transform.localScale;
|
||||
_originalPosition = transform.localPosition;
|
||||
_parentCanvas = GetComponentInParent<Canvas>();
|
||||
|
||||
if (tooltipPanel != null) tooltipPanel.SetActive(false);
|
||||
if (glowOutline != null)
|
||||
{
|
||||
glowOutline.effectColor = new Color(glowColor.r, glowColor.g, glowColor.b, 0f);
|
||||
glowOutline.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data Binding ────────────────────────────────────────────────
|
||||
public void SetCardData(CardModel card)
|
||||
{
|
||||
_card = card;
|
||||
if (tooltipName != null) tooltipName.text = card.name;
|
||||
if (tooltipManaCost != null) tooltipManaCost.text = card.manaCost;
|
||||
if (tooltipTypeLine != null) tooltipTypeLine.text = card.typeLine;
|
||||
|
||||
// LOD: show low-res placeholder first
|
||||
if (lowResPlaceholder != null && frontFaceImage != null)
|
||||
frontFaceImage.sprite = lowResPlaceholder;
|
||||
|
||||
// Async load high-res from Scryfall
|
||||
if (!string.IsNullOrEmpty(card.imageUri))
|
||||
ImageCache.Instance.GetSprite(card.imageUri, sprite =>
|
||||
{
|
||||
if (frontFaceImage != null) frontFaceImage.sprite = sprite;
|
||||
});
|
||||
}
|
||||
|
||||
public CardModel GetCardData() => _card;
|
||||
|
||||
// ── Flip Animation (Y-axis rotation, swap front/back at 90°) ────
|
||||
public void Flip()
|
||||
{
|
||||
if (_isFlipping) return;
|
||||
StartCoroutine(FlipCoroutine());
|
||||
}
|
||||
|
||||
private IEnumerator FlipCoroutine()
|
||||
{
|
||||
_isFlipping = true;
|
||||
float half = flipDuration * 0.5f;
|
||||
// First half: rotate to 90°
|
||||
float t = 0f;
|
||||
while (t < half)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
float angle = Mathf.Lerp(0f, 90f, t / half);
|
||||
transform.localRotation = Quaternion.Euler(0f, angle, 0f);
|
||||
yield return null;
|
||||
}
|
||||
// Swap face visibility
|
||||
_isFaceUp = !_isFaceUp;
|
||||
if (frontFaceImage != null) frontFaceImage.gameObject.SetActive(_isFaceUp);
|
||||
if (backFaceImage != null) backFaceImage.gameObject.SetActive(!_isFaceUp);
|
||||
// Second half: rotate from 90° back to 0°
|
||||
t = 0f;
|
||||
while (t < half)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
float angle = Mathf.Lerp(90f, 0f, t / half);
|
||||
transform.localRotation = Quaternion.Euler(0f, angle, 0f);
|
||||
yield return null;
|
||||
}
|
||||
transform.localRotation = Quaternion.identity;
|
||||
_isFlipping = false;
|
||||
}
|
||||
|
||||
// ── Hover: scale-up + glow outline + raise card ───────────────
|
||||
public void OnPointerEnter(PointerEventData eventData)
|
||||
{
|
||||
if (_isDragging) return;
|
||||
_isHovered = true;
|
||||
StopAllCoroutines();
|
||||
StartCoroutine(AnimateHover(true));
|
||||
if (tooltipPanel != null) tooltipPanel.SetActive(true);
|
||||
}
|
||||
|
||||
public void OnPointerExit(PointerEventData eventData)
|
||||
{
|
||||
if (_isDragging) return;
|
||||
_isHovered = false;
|
||||
StopAllCoroutines();
|
||||
StartCoroutine(AnimateHover(false));
|
||||
if (tooltipPanel != null) tooltipPanel.SetActive(false);
|
||||
}
|
||||
|
||||
private IEnumerator AnimateHover(bool entering)
|
||||
{
|
||||
Vector3 targetScale = entering ? _originalScale * hoverScaleMultiplier : _originalScale;
|
||||
Vector3 targetPos = entering
|
||||
? _originalPosition + new Vector3(0, hoverRaiseY, 0)
|
||||
: _originalPosition;
|
||||
float targetGlow = entering ? 1f : 0f;
|
||||
|
||||
Vector3 startScale = transform.localScale;
|
||||
Vector3 startPos = transform.localPosition;
|
||||
float startGlow = glowOutline != null ? glowOutline.effectColor.a : 0f;
|
||||
|
||||
if (entering && glowOutline != null) glowOutline.enabled = true;
|
||||
|
||||
float t = 0f;
|
||||
while (t < hoverDuration)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
float p = t / hoverDuration;
|
||||
float smooth = p * p * (3f - 2f * p); // smoothstep
|
||||
transform.localScale = Vector3.Lerp(startScale, targetScale, smooth);
|
||||
transform.localPosition = Vector3.Lerp(startPos, targetPos, smooth);
|
||||
if (glowOutline != null)
|
||||
{
|
||||
float a = Mathf.Lerp(startGlow, targetGlow, smooth);
|
||||
glowOutline.effectColor = new Color(glowColor.r, glowColor.g, glowColor.b, a);
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
|
||||
transform.localScale = targetScale;
|
||||
transform.localPosition = targetPos;
|
||||
if (!entering && glowOutline != null) glowOutline.enabled = false;
|
||||
}
|
||||
|
||||
// ── Click: raise card + shadow + detail panel ────────────────
|
||||
public void OnPointerClick(PointerEventData eventData)
|
||||
{
|
||||
if (_isDragging) return;
|
||||
StartCoroutine(ClickRaiseAnimation());
|
||||
OnCardClicked?.Invoke(_card);
|
||||
}
|
||||
|
||||
private IEnumerator ClickRaiseAnimation()
|
||||
{
|
||||
Vector3 raised = transform.localPosition + new Vector3(0, clickRaiseY, 0);
|
||||
float dur = 0.12f;
|
||||
float t = 0f;
|
||||
Vector3 start = transform.localPosition;
|
||||
while (t < dur)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
transform.localPosition = Vector3.Lerp(start, raised, t / dur);
|
||||
yield return null;
|
||||
}
|
||||
yield return new WaitForSeconds(0.08f);
|
||||
t = 0f;
|
||||
Vector3 target = _isHovered
|
||||
? _originalPosition + new Vector3(0, hoverRaiseY, 0)
|
||||
: _originalPosition;
|
||||
while (t < dur)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
transform.localPosition = Vector3.Lerp(raised, target, t / dur);
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag Support (for DeckBuilder scene) ────────────────────
|
||||
public void OnBeginDrag(PointerEventData eventData)
|
||||
{
|
||||
_isDragging = true;
|
||||
if (tooltipPanel != null) tooltipPanel.SetActive(false);
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
_parentCanvas.transform as RectTransform,
|
||||
eventData.position, eventData.pressEventCamera, out _dragOffset);
|
||||
_dragOffset = (Vector2)transform.localPosition - _dragOffset;
|
||||
transform.SetAsLastSibling();
|
||||
OnDragStarted?.Invoke(this);
|
||||
}
|
||||
|
||||
public void OnDrag(PointerEventData eventData)
|
||||
{
|
||||
if (!_isDragging) return;
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
_parentCanvas.transform as RectTransform,
|
||||
eventData.position, eventData.pressEventCamera, out Vector2 localPoint);
|
||||
transform.localPosition = localPoint + _dragOffset;
|
||||
OnDragMoved?.Invoke(this, eventData.position);
|
||||
}
|
||||
|
||||
public void OnEndDrag(PointerEventData eventData)
|
||||
{
|
||||
_isDragging = false;
|
||||
OnDragEnded?.Invoke(this);
|
||||
}
|
||||
|
||||
// ── Mobile: tap = hover, long-press = detail, drag = move ─────
|
||||
public void SimulateMobileTap()
|
||||
{
|
||||
// Toggle hover state on tap (mobile has no hover)
|
||||
if (_isHovered)
|
||||
{
|
||||
_isHovered = false;
|
||||
StartCoroutine(AnimateHover(false));
|
||||
if (tooltipPanel != null) tooltipPanel.SetActive(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_isHovered = true;
|
||||
StartCoroutine(AnimateHover(true));
|
||||
if (tooltipPanel != null) tooltipPanel.SetActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
public void SimulateMobileLongPress()
|
||||
{
|
||||
OnCardClicked?.Invoke(_card);
|
||||
}
|
||||
|
||||
/// <summary>Reset card position (e.g. after failed drag)</summary>
|
||||
public void ResetPosition()
|
||||
{
|
||||
transform.localPosition = _originalPosition;
|
||||
transform.localScale = _originalScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single MTG card from the collection or deck.
|
||||
/// Maps to the card objects returned by /api/collection and /api/decks.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class CardModel
|
||||
{
|
||||
[JsonProperty("id")] public string id;
|
||||
[JsonProperty("name")] public string name;
|
||||
[JsonProperty("oracle_id")] public string oracleId;
|
||||
[JsonProperty("scryfall_id")] public string scryfallId;
|
||||
[JsonProperty("image_uri")] public string imageUri;
|
||||
[JsonProperty("mana_cost")] public string manaCost;
|
||||
[JsonProperty("cmc")] public float cmc;
|
||||
[JsonProperty("type_line")] public string typeLine;
|
||||
[JsonProperty("card_type")] public string cardType;
|
||||
[JsonProperty("colors")] public List<string> colors;
|
||||
[JsonProperty("color_identity")] public List<string> colorIdentity;
|
||||
[JsonProperty("set")] public string set;
|
||||
[JsonProperty("rarity")] public string rarity;
|
||||
[JsonProperty("oracle_text")] public string oracleText;
|
||||
[JsonProperty("power")] public string power;
|
||||
[JsonProperty("toughness")] public string toughness;
|
||||
[JsonProperty("quantity")] public int quantity;
|
||||
[JsonProperty("owned_qty")] public int ownedQty;
|
||||
[JsonProperty("is_proxy")] public bool isProxy;
|
||||
[JsonProperty("roles")] public List<string> roles;
|
||||
[JsonProperty("source")] public string source;
|
||||
|
||||
public override string ToString() => $"{name} ({manaCost}) [{typeLine}]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Maps to POST /api/coach/chat response.</summary>
|
||||
[Serializable]
|
||||
public class CoachResponse
|
||||
{
|
||||
[JsonProperty("reply")] public string reply;
|
||||
[JsonProperty("deck_report")] public string deckReport;
|
||||
[JsonProperty("suggestions")] public string[] suggestions;
|
||||
|
||||
public override string ToString() => reply ?? "(empty coach response)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Paginated collection list from GET /api/collection.</summary>
|
||||
[Serializable]
|
||||
public class CollectionResponse
|
||||
{
|
||||
[JsonProperty("cards")] public List<CardModel> cards;
|
||||
[JsonProperty("total")] public int total;
|
||||
[JsonProperty("page")] public int page;
|
||||
[JsonProperty("per_page")] public int perPage;
|
||||
|
||||
public override string ToString() => $"Collection: {total} cards (page {page})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Commander archetype info from /api/lab/meta/commanders.</summary>
|
||||
[Serializable]
|
||||
public class CommanderMeta
|
||||
{
|
||||
[JsonProperty("name")] public string name;
|
||||
[JsonProperty("source")] public string source;
|
||||
[JsonProperty("archetype")] public string archetype;
|
||||
[JsonProperty("colorIdentity")] public List<string> colorIdentity;
|
||||
|
||||
public override string ToString() => $"{name} ({archetype}) [{string.Join(",", colorIdentity ?? new List<string>())}]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Maps to GeneratedDeckCard from /api/deckgen/v3.</summary>
|
||||
[Serializable]
|
||||
public class GeneratedDeckCard
|
||||
{
|
||||
[JsonProperty("scryfall_id")] public string scryfallId;
|
||||
[JsonProperty("name")] public string name;
|
||||
[JsonProperty("type_line")] public string typeLine;
|
||||
[JsonProperty("mana_cost")] public string manaCost;
|
||||
[JsonProperty("cmc")] public float cmc;
|
||||
[JsonProperty("card_type")] public string cardType;
|
||||
[JsonProperty("roles")] public List<string> roles;
|
||||
[JsonProperty("source")] public string source;
|
||||
[JsonProperty("quantity")] public int quantity;
|
||||
[JsonProperty("image_url")] public string imageUrl;
|
||||
[JsonProperty("owned_qty")] public int ownedQty;
|
||||
[JsonProperty("is_proxy")] public bool isProxy;
|
||||
|
||||
public override string ToString() => $"{name} ({manaCost})";
|
||||
}
|
||||
|
||||
/// <summary>Response wrapper for /api/deckgen/v3.</summary>
|
||||
[Serializable]
|
||||
public class DeckGenResponse
|
||||
{
|
||||
[JsonProperty("cards")] public List<GeneratedDeckCard> cards;
|
||||
[JsonProperty("commander")] public string commander;
|
||||
[JsonProperty("strategy")] public string strategy;
|
||||
|
||||
public override string ToString() => $"Generated deck: {commander} ({cards?.Count ?? 0} cards)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
[Serializable]
|
||||
public class DeckModel
|
||||
{
|
||||
[JsonProperty("id")] public string id;
|
||||
[JsonProperty("name")] public string name;
|
||||
[JsonProperty("commander")] public string commander;
|
||||
[JsonProperty("colors")] public List<string> colors;
|
||||
[JsonProperty("cards")] public List<CardModel> cards;
|
||||
[JsonProperty("card_count")] public int cardCount;
|
||||
[JsonProperty("created_at")] public string createdAt;
|
||||
[JsonProperty("updated_at")] public string updatedAt;
|
||||
|
||||
public override string ToString() => $"{name} ({commander}) [{cardCount} cards]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Wrapper for the precon list returned by /api/lab/precons.</summary>
|
||||
[Serializable]
|
||||
public class PreconListWrapper
|
||||
{
|
||||
[JsonProperty("precons")] public List<string> precons;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Maps to DeckInfo from /api/lab/precons.</summary>
|
||||
[Serializable]
|
||||
public class PreconModel
|
||||
{
|
||||
[JsonProperty("name")] public string name;
|
||||
[JsonProperty("filename")] public string filename;
|
||||
|
||||
public override string ToString() => $"{name} ({filename})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Maps to StartResponse from /api/lab/start.</summary>
|
||||
[Serializable]
|
||||
public class SimStartResponse
|
||||
{
|
||||
[JsonProperty("batchId")] public string batchId;
|
||||
[JsonProperty("status")] public string status;
|
||||
[JsonProperty("message")] public string message;
|
||||
|
||||
public override string ToString() => $"Batch {batchId}: {status}";
|
||||
}
|
||||
|
||||
/// <summary>Maps to StatusResponse from /api/lab/status/{id}.</summary>
|
||||
[Serializable]
|
||||
public class SimStatusModel
|
||||
{
|
||||
[JsonProperty("batchId")] public string batchId;
|
||||
[JsonProperty("running")] public bool running;
|
||||
[JsonProperty("completed")] public int completed;
|
||||
[JsonProperty("total")] public int total;
|
||||
[JsonProperty("threads")] public int threads;
|
||||
[JsonProperty("elapsedMs")] public int elapsedMs;
|
||||
[JsonProperty("error")] public string error;
|
||||
[JsonProperty("simsPerSec")] public float simsPerSec;
|
||||
[JsonProperty("run_id")] public string runId;
|
||||
[JsonProperty("games_completed")] public int gamesCompleted;
|
||||
[JsonProperty("total_games")] public int totalGames;
|
||||
[JsonProperty("current_decks")] public List<string> currentDecks;
|
||||
|
||||
public override string ToString() => $"Batch {batchId}: {completed}/{total} ({(running ? "running" : "done")})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
namespace CommanderAILab.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton HTTP client for Commander AI Lab FastAPI backend.
|
||||
/// All REST calls go through this service.
|
||||
/// </summary>
|
||||
public class ApiClient : MonoBehaviour
|
||||
{
|
||||
public static ApiClient Instance { get; private set; }
|
||||
|
||||
[SerializeField] private string baseUrl = "http://localhost:8080";
|
||||
[SerializeField] private float timeoutSeconds = 30f;
|
||||
|
||||
public string BaseUrl
|
||||
{
|
||||
get => baseUrl;
|
||||
set
|
||||
{
|
||||
baseUrl = value.TrimEnd('/');
|
||||
PlayerPrefs.SetString("ApiBaseUrl", baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
baseUrl = PlayerPrefs.GetString("ApiBaseUrl", baseUrl);
|
||||
}
|
||||
|
||||
// -- Generic Helpers ------------------------------------------------
|
||||
|
||||
public Coroutine Get<T>(string path, Action<T> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(GetCoroutine(path, onSuccess, onError));
|
||||
}
|
||||
|
||||
public Coroutine Post<TReq, TRes>(string path, TReq body, Action<TRes> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(PostCoroutine(path, body, onSuccess, onError));
|
||||
}
|
||||
|
||||
public Coroutine PostRaw(string path, string json, Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(PostRawCoroutine(path, json, onSuccess, onError));
|
||||
}
|
||||
|
||||
public Coroutine GetRaw(string path, Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(GetRawCoroutine(path, onSuccess, onError));
|
||||
}
|
||||
|
||||
public Coroutine PatchRaw(string path, string json, Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(PatchRawCoroutine(path, json, onSuccess, onError));
|
||||
}
|
||||
|
||||
public Coroutine Delete(string path, Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(DeleteCoroutine(path, onSuccess, onError));
|
||||
}
|
||||
|
||||
// -- Coroutine implementations --------------------------------------
|
||||
|
||||
private IEnumerator GetCoroutine<T>(string path, Action<T> onSuccess, Action<string> onError)
|
||||
{
|
||||
var url = $"{baseUrl}{path}";
|
||||
using var request = UnityWebRequest.Get(url);
|
||||
request.timeout = (int)timeoutSeconds;
|
||||
yield return request.SendWebRequest();
|
||||
if (request.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ApiClient] GET {path} failed: {request.error}");
|
||||
onError?.Invoke(request.error);
|
||||
yield break;
|
||||
}
|
||||
var obj = JsonUtility.FromJson<T>(request.downloadHandler.text);
|
||||
onSuccess?.Invoke(obj);
|
||||
}
|
||||
|
||||
private IEnumerator PostCoroutine<TReq, TRes>(string path, TReq body, Action<TRes> onSuccess, Action<string> onError)
|
||||
{
|
||||
var url = $"{baseUrl}{path}";
|
||||
var json = JsonUtility.ToJson(body);
|
||||
using var request = new UnityWebRequest(url, "POST");
|
||||
request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
|
||||
request.downloadHandler = new DownloadHandlerBuffer();
|
||||
request.SetRequestHeader("Content-Type", "application/json");
|
||||
request.timeout = (int)timeoutSeconds;
|
||||
yield return request.SendWebRequest();
|
||||
if (request.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ApiClient] POST {path} failed: {request.error}");
|
||||
onError?.Invoke(request.error);
|
||||
yield break;
|
||||
}
|
||||
var obj = JsonUtility.FromJson<TRes>(request.downloadHandler.text);
|
||||
onSuccess?.Invoke(obj);
|
||||
}
|
||||
|
||||
private IEnumerator PostRawCoroutine(string path, string json, Action<string> onSuccess, Action<string> onError)
|
||||
{
|
||||
var url = $"{baseUrl}{path}";
|
||||
using var request = new UnityWebRequest(url, "POST");
|
||||
request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
|
||||
request.downloadHandler = new DownloadHandlerBuffer();
|
||||
request.SetRequestHeader("Content-Type", "application/json");
|
||||
request.timeout = (int)timeoutSeconds;
|
||||
yield return request.SendWebRequest();
|
||||
if (request.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ApiClient] POST {path} failed: {request.error}");
|
||||
onError?.Invoke(request.error);
|
||||
yield break;
|
||||
}
|
||||
onSuccess?.Invoke(request.downloadHandler.text);
|
||||
}
|
||||
|
||||
private IEnumerator PatchRawCoroutine(string path, string json, Action<string> onSuccess, Action<string> onError)
|
||||
{
|
||||
var url = $"{baseUrl}{path}";
|
||||
using var request = new UnityWebRequest(url, "PATCH");
|
||||
request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
|
||||
request.downloadHandler = new DownloadHandlerBuffer();
|
||||
request.SetRequestHeader("Content-Type", "application/json");
|
||||
request.timeout = (int)timeoutSeconds;
|
||||
yield return request.SendWebRequest();
|
||||
if (request.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ApiClient] PATCH {path} failed: {request.error}");
|
||||
onError?.Invoke(request.error);
|
||||
yield break;
|
||||
}
|
||||
onSuccess?.Invoke(request.downloadHandler.text);
|
||||
}
|
||||
|
||||
private IEnumerator DeleteCoroutine(string path, Action<string> onSuccess, Action<string> onError)
|
||||
{
|
||||
var url = $"{baseUrl}{path}";
|
||||
using var request = UnityWebRequest.Delete(url);
|
||||
request.downloadHandler = new DownloadHandlerBuffer();
|
||||
request.timeout = (int)timeoutSeconds;
|
||||
yield return request.SendWebRequest();
|
||||
if (request.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ApiClient] DELETE {path} failed: {request.error}");
|
||||
onError?.Invoke(request.error);
|
||||
yield break;
|
||||
}
|
||||
onSuccess?.Invoke(request.downloadHandler.text);
|
||||
}
|
||||
|
||||
// -- Raw GET helper (returns JSON string) ---------------------------
|
||||
|
||||
private IEnumerator GetRawCoroutine(string path, Action<string> onSuccess, Action<string> onError)
|
||||
{
|
||||
var url = $"{baseUrl}{path}";
|
||||
using var request = UnityWebRequest.Get(url);
|
||||
request.timeout = (int)timeoutSeconds;
|
||||
yield return request.SendWebRequest();
|
||||
if (request.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ApiClient] GET {path} failed: {request.error}");
|
||||
onError?.Invoke(request.error);
|
||||
yield break;
|
||||
}
|
||||
onSuccess?.Invoke(request.downloadHandler.text);
|
||||
}
|
||||
|
||||
// -- Health Check ---------------------------------------------------
|
||||
|
||||
public Coroutine HealthCheck(Action<bool> callback)
|
||||
{
|
||||
return StartCoroutine(HealthCheckCoroutine(callback));
|
||||
}
|
||||
|
||||
private IEnumerator HealthCheckCoroutine(Action<bool> callback)
|
||||
{
|
||||
using var request = UnityWebRequest.Get($"{baseUrl}/api/health");
|
||||
request.timeout = 5;
|
||||
yield return request.SendWebRequest();
|
||||
callback?.Invoke(request.result == UnityWebRequest.Result.Success);
|
||||
}
|
||||
|
||||
// -- Collection -----------------------------------------------------
|
||||
|
||||
public Coroutine GetCollection(Action<string> onSuccess, Action<string> onError = null, string search = null)
|
||||
{
|
||||
var path = string.IsNullOrEmpty(search)
|
||||
? "/api/collection"
|
||||
: $"/api/collection?search={UnityWebRequest.EscapeURL(search)}";
|
||||
return StartCoroutine(GetRawCoroutine(path, onSuccess, onError));
|
||||
}
|
||||
|
||||
// -- Decks ----------------------------------------------------------
|
||||
|
||||
public Coroutine GetDecks(Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(GetRawCoroutine("/api/lab/decks", onSuccess, onError));
|
||||
}
|
||||
|
||||
public Coroutine CreateDeck(string deckJson, Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return PostRaw("/api/decks", deckJson, onSuccess, onError);
|
||||
}
|
||||
|
||||
// -- Simulator ------------------------------------------------------
|
||||
|
||||
public Coroutine GetProfiles(Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(GetRawCoroutine("/api/lab/profiles", onSuccess, onError));
|
||||
}
|
||||
|
||||
public Coroutine StartSim(string configJson, Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return PostRaw("/api/lab/start", configJson, onSuccess, onError);
|
||||
}
|
||||
|
||||
public Coroutine GetSimStatus(string simId, Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(GetRawCoroutine($"/api/lab/status?batchId={simId}", onSuccess, onError));
|
||||
}
|
||||
|
||||
public Coroutine GetSimResults(string simId, Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(GetRawCoroutine($"/api/lab/result?batchId={simId}", onSuccess, onError));
|
||||
}
|
||||
|
||||
// -- Coach ----------------------------------------------------------
|
||||
|
||||
public Coroutine CoachChat(string messageJson, Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return PostRaw("/api/coach/chat", messageJson, onSuccess, onError);
|
||||
}
|
||||
|
||||
// -- Deck Generator -------------------------------------------------
|
||||
|
||||
public Coroutine GenerateDeck(string requestJson, Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return PostRaw("/api/deckgen/v3", requestJson, onSuccess, onError);
|
||||
}
|
||||
|
||||
// -- Meta -----------------------------------------------------------
|
||||
|
||||
public Coroutine GetCommanders(Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(GetRawCoroutine("/api/lab/meta/commanders", onSuccess, onError));
|
||||
}
|
||||
|
||||
public Coroutine GetPrecons(Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(GetRawCoroutine("/api/lab/precons", onSuccess, onError));
|
||||
}
|
||||
|
||||
// -- Scanner --------------------------------------------------------
|
||||
|
||||
public Coroutine ScanCard(byte[] imageBytes, Action<string> onSuccess, Action<string> onError = null)
|
||||
{
|
||||
return StartCoroutine(ScanCardCoroutine(imageBytes, onSuccess, onError));
|
||||
}
|
||||
|
||||
private IEnumerator ScanCardCoroutine(byte[] imageBytes, Action<string> onSuccess, Action<string> onError)
|
||||
{
|
||||
var url = $"{baseUrl}/api/scanner/scan";
|
||||
var form = new WWWForm();
|
||||
form.AddBinaryData("file", imageBytes, "card.jpg", "image/jpeg");
|
||||
using var request = UnityWebRequest.Post(url, form);
|
||||
request.timeout = (int)timeoutSeconds;
|
||||
yield return request.SendWebRequest();
|
||||
if (request.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ApiClient] POST /api/scanner/scan failed: {request.error}");
|
||||
onError?.Invoke(request.error);
|
||||
yield break;
|
||||
}
|
||||
onSuccess?.Invoke(request.downloadHandler.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user