Compare commits
238
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c34cb0966 | ||
|
|
c67ce422d8 | ||
|
|
cb4b48e362 | ||
|
|
4a8db4db6b | ||
|
|
9c0fa64a69 | ||
|
|
c338f16965 | ||
|
|
a7ab0e43a5 | ||
|
|
8b527d7c30 | ||
|
|
36760dd922 | ||
|
|
963a5db638 | ||
|
|
b1996a8deb | ||
|
|
c44d0b32bb | ||
|
|
a78df75014 | ||
|
|
9020d29745 | ||
|
|
b0a34ae9e2 | ||
|
|
4c4f5ce0ff | ||
|
|
1e7f4b4e31 | ||
|
|
488575f3df | ||
|
|
fd7f0258f3 | ||
|
|
7284b8617d | ||
|
|
678cc13fe0 | ||
|
|
19f83a546b | ||
|
|
c53e82bfeb | ||
|
|
f8b13703b8 | ||
|
|
eb59a2cd2e | ||
|
|
ff6040b53e | ||
|
|
db7bc62d7e | ||
|
|
fbffe81846 | ||
|
|
9369cf5450 | ||
|
|
e38401a740 | ||
|
|
111764615d | ||
|
|
2088da6fd8 | ||
|
|
a2fbc30b2b | ||
|
|
a62640668b | ||
|
|
b8e12dff1b | ||
|
|
ea0a785174 | ||
|
|
9f1277f233 | ||
|
|
66ce887fe3 | ||
|
|
80f63f192a | ||
|
|
46ed2f5606 | ||
|
|
93a421b90a | ||
|
|
ee95f26bc4 | ||
|
|
2ee574f678 | ||
|
|
473a2bdcc7 | ||
|
|
a6e50ee0f0 | ||
|
|
e2a6a75fd9 | ||
|
|
030bdf2b81 | ||
|
|
c83f7e0141 | ||
|
|
e5377ef4bc | ||
|
|
56ea2602b4 | ||
|
|
d86109c5c0 | ||
|
|
d9dd098ca3 | ||
|
|
69ce5b340e | ||
|
|
103a74a0e1 | ||
|
|
147121582b | ||
|
|
8be96ab47d | ||
|
|
e2653b3f72 | ||
|
|
7efac34279 | ||
|
|
b00df14f30 | ||
|
|
3542efd982 | ||
|
|
1f93b5510d | ||
|
|
5c31be6023 | ||
|
|
76139477be | ||
|
|
0e5d7ba7d3 | ||
|
|
4ec6ed57d5 | ||
|
|
08d81a4336 | ||
|
|
f6ed40a253 | ||
|
|
9fd39e42c7 | ||
|
|
72107df2ae | ||
|
|
2738bbdba4 | ||
|
|
0bbc78ef23 | ||
|
|
c2b470e251 | ||
|
|
6135feb644 | ||
|
|
b3b664872b | ||
|
|
a80e8908e1 | ||
|
|
a8ef10d5b7 | ||
|
|
86af4b6de5 | ||
|
|
332606d6b5 | ||
|
|
70dfa3d3e0 | ||
|
|
f970b6d571 | ||
|
|
5ade916207 | ||
|
|
cc53c58344 | ||
|
|
3e64aa67eb | ||
|
|
70a7c07b81 | ||
|
|
1b4e9f5a66 | ||
|
|
c962d1f16a | ||
|
|
dcdf15c56a | ||
|
|
b0214009f4 | ||
|
|
5fe0ac2bd6 | ||
|
|
fc09249ad3 | ||
|
|
d2c7783ce1 | ||
|
|
0ccf35f241 | ||
|
|
9a5105b0d6 | ||
|
|
6a688d1cd0 | ||
|
|
d9639a8414 | ||
|
|
633fd43b28 | ||
|
|
45c72aadc9 | ||
|
|
81ab7ac34a | ||
|
|
e0fe4144ae | ||
|
|
68aa6eac3f | ||
|
|
c870dd35fb | ||
|
|
35ce67ed6f | ||
|
|
232750a951 | ||
|
|
46c1c8cb01 | ||
|
|
64a4cd3c7c | ||
|
|
3f6b66fce3 | ||
|
|
2887c09550 | ||
|
|
84d84a211f | ||
|
|
12f90dd276 | ||
|
|
0b755c8428 | ||
|
|
50f36f284c | ||
|
|
369cc9243f | ||
|
|
22d3b64c2d | ||
|
|
beb3dac4d5 | ||
|
|
330801107c | ||
|
|
95c5a93584 | ||
|
|
377a704a90 | ||
|
|
cc481bd71b | ||
|
|
c6d02fd38c | ||
|
|
0f2d3583ac | ||
|
|
2b355ed9ba | ||
|
|
b4d8c2f50e | ||
|
|
9db1ed112f | ||
|
|
75568bfdb1 | ||
|
|
d834247885 | ||
|
|
310a408509 | ||
|
|
ef7bde841f | ||
|
|
87abfd801e | ||
|
|
8eba39cca7 | ||
|
|
bc9656a59f | ||
|
|
86b96c09f9 | ||
|
|
9a46b0f80c | ||
|
|
0d2da18783 | ||
|
|
6a84708c65 | ||
|
|
9b892e5289 | ||
|
|
f2a782f0bd | ||
|
|
da67879491 | ||
|
|
18beb9192e | ||
|
|
ce80b6d9c5 | ||
|
|
d8e355de2b | ||
|
|
9d2a043d3f | ||
|
|
85224f082e | ||
|
|
c6c3bfabf8 | ||
|
|
c6329d377a | ||
|
|
3f7f40d626 | ||
|
|
bc023ab6f6 | ||
|
|
83a8f288b7 | ||
|
|
e3f0787c73 | ||
|
|
3802bc3d78 | ||
|
|
2936fd37eb | ||
|
|
cad2f00ec7 | ||
|
|
6b50c6ee7d | ||
|
|
40a166188a | ||
|
|
a501a9755d | ||
|
|
2d8a55fdc6 | ||
|
|
b0d0b75dbb | ||
|
|
94b20f1347 | ||
|
|
c980e1b6d5 | ||
|
|
7c7be013c2 | ||
|
|
14eb72aaff | ||
|
|
81a1bc9022 | ||
|
|
6534380f67 | ||
|
|
1ee55d4428 | ||
|
|
432a16e87d | ||
|
|
c5a1f65e13 | ||
|
|
a95ae45e18 | ||
|
|
ed255c1a21 | ||
|
|
9899da9b98 | ||
|
|
86ae69112f | ||
|
|
1cc0b696d3 | ||
|
|
b248be867b | ||
|
|
2a9b043a24 | ||
|
|
9863f7590b | ||
|
|
9436276b69 | ||
|
|
d06618031a | ||
|
|
58c6908b85 | ||
|
|
682c4191f9 | ||
|
|
2add53d3c2 | ||
|
|
918d760dcb | ||
|
|
18ddd3027b | ||
|
|
a82a7d4dc6 | ||
|
|
d5fdc150f7 | ||
|
|
173a7f4f3b | ||
|
|
a988779bb4 | ||
|
|
7901fef495 | ||
|
|
d21928da53 | ||
|
|
9b1654a81e | ||
|
|
2435e67a6e | ||
|
|
69fe6cbffd | ||
|
|
3fe3ba0e62 | ||
|
|
643529e874 | ||
|
|
9746a41178 | ||
|
|
b0f48cde62 | ||
|
|
ec9719659a | ||
|
|
729283c3bd | ||
|
|
52dcf95c59 | ||
|
|
4f5135e85b | ||
|
|
ff3b134cb9 | ||
|
|
1c85733743 | ||
|
|
a71abfa7d0 | ||
|
|
abb1e08249 | ||
|
|
57b0598717 | ||
|
|
292801d445 | ||
|
|
71c90a588f | ||
|
|
5550c27744 | ||
|
|
986c501410 | ||
|
|
d3431ff246 | ||
|
|
36dba9e90a | ||
|
|
b9369af52f | ||
|
|
6dd1de894c | ||
|
|
3f1bcb4d12 | ||
|
|
52762ee0aa | ||
|
|
af8e59ed9a | ||
|
|
0923b75d79 | ||
|
|
20172c90b4 | ||
|
|
089c1abc8d | ||
|
|
646da59e2a | ||
|
|
10a7662d51 | ||
|
|
a6171b5180 | ||
|
|
b09fe4b48b | ||
|
|
cb8867a808 | ||
|
|
3d6365cfb4 | ||
|
|
397d02240e | ||
|
|
0c842b97fd | ||
|
|
7e94b29c6d | ||
|
|
fbc3bdb6b5 | ||
|
|
a5b9d70f04 | ||
|
|
8103a06992 | ||
|
|
85df46364a | ||
|
|
32f3c9dfa3 | ||
|
|
7bc4aac0aa | ||
|
|
70ad16fc79 | ||
|
|
6298f83abd | ||
|
|
d1422d6f64 | ||
|
|
4e8a999ebd | ||
|
|
aa5e381c59 | ||
|
|
3f66a8ed15 | ||
|
|
f4da7674b6 |
No files matched your search
@@ -9,3 +9,7 @@ XIMILAR_API_KEY=your-ximilar-api-key-here
|
|||||||
# Perplexity API Key (AI deck research & generation)
|
# Perplexity API Key (AI deck research & generation)
|
||||||
# Get yours at: https://www.perplexity.ai/settings/api
|
# Get yours at: https://www.perplexity.ai/settings/api
|
||||||
PPLX_API_KEY=your-perplexity-api-key-here
|
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,
|
def __init__(self, api_key: str, model: str = SONAR,
|
||||||
timeout: int = 120):
|
timeout: int = 120, base_url: str = None):
|
||||||
if not api_key:
|
if not api_key and not base_url:
|
||||||
raise ValueError("Perplexity API key is required")
|
raise ValueError("Perplexity API key is required (or provide base_url for local)")
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.model = model
|
self.model = model
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self._client = OpenAI(
|
self._client = OpenAI(
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
base_url="https://api.perplexity.ai",
|
base_url=base_url or "https://api.perplexity.ai",
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,26 +72,30 @@ class PerplexityClient:
|
|||||||
self,
|
self,
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
user_prompt: str,
|
user_prompt: str,
|
||||||
json_schema: dict,
|
json_schema: dict = None,
|
||||||
schema_name: str = "response",
|
schema_name: str = "response",
|
||||||
model: str = None,
|
model: str = None,
|
||||||
temperature: float = 0.2,
|
temperature: float = 0.2,
|
||||||
max_tokens: int = 8192,
|
max_tokens: int = 8192,
|
||||||
) -> PerplexityResponse:
|
) -> 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:
|
Args:
|
||||||
system_prompt: System message
|
system_prompt: System message (should instruct JSON output)
|
||||||
user_prompt: User message
|
user_prompt: User message
|
||||||
json_schema: JSON Schema dict for response_format
|
json_schema: (ignored) Kept for call-site compatibility
|
||||||
schema_name: Name for the schema (used in response_format)
|
schema_name: (ignored) Kept for call-site compatibility
|
||||||
model: Override model (sonar, sonar-pro)
|
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
|
max_tokens: Max output tokens
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PerplexityResponse with parsed_json guaranteed by schema
|
PerplexityResponse with parsed_json from the model's output
|
||||||
"""
|
"""
|
||||||
use_model = model or self.model
|
use_model = model or self.model
|
||||||
|
|
||||||
@@ -106,14 +110,7 @@ class PerplexityClient:
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
response_format={
|
)
|
||||||
"type": "json_schema",
|
|
||||||
"json_schema": {
|
|
||||||
"name": schema_name,
|
|
||||||
"schema": json_schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
choice = response.choices[0]
|
choice = response.choices[0]
|
||||||
content = choice.message.content or ""
|
content = choice.message.content or ""
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from .models import (
|
|||||||
DeckReport, CoachGoals, CoachSession,
|
DeckReport, CoachGoals, CoachSession,
|
||||||
SuggestedCut, SuggestedAdd, CoachStatus,
|
SuggestedCut, SuggestedAdd, CoachStatus,
|
||||||
)
|
)
|
||||||
from .llm_client import LMStudioClient
|
from .llm_client import LLMClient
|
||||||
from .embeddings import MTGEmbeddingIndex
|
from .embeddings import MTGEmbeddingIndex
|
||||||
from .prompt_template import build_system_prompt, build_user_prompt
|
from .prompt_template import build_system_prompt, build_user_prompt
|
||||||
|
|
||||||
@@ -36,12 +36,12 @@ class CoachService:
|
|||||||
Usage:
|
Usage:
|
||||||
embeddings = MTGEmbeddingIndex()
|
embeddings = MTGEmbeddingIndex()
|
||||||
embeddings.load()
|
embeddings.load()
|
||||||
llm = LMStudioClient()
|
llm = LLMClient()
|
||||||
coach = CoachService(llm, embeddings)
|
coach = CoachService(llm, embeddings)
|
||||||
session = await coach.run_coaching_session("edgar-markov")
|
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.llm = llm
|
||||||
self.embeddings = embeddings
|
self.embeddings = embeddings
|
||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
|
|||||||
+11
-10
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Commander AI Lab — Coach Configuration
|
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.
|
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
|
# Resolve relative to wherever the lab is installed
|
||||||
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
|
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
|
||||||
|
|
||||||
# ── LM Studio (OpenAI-compatible API) ──────────────────────
|
# ── Ollama (OpenAI-compatible API) ──────────────────────
|
||||||
LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://192.168.0.122:1234/v1")
|
LLM_URL = os.environ.get("LLM_URL", "http://192.168.0.240:11434/v1")
|
||||||
LM_STUDIO_MODEL = os.environ.get("LM_STUDIO_MODEL", "local-model")
|
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
|
||||||
LM_STUDIO_TIMEOUT = int(os.environ.get("LM_STUDIO_TIMEOUT", "120")) # seconds
|
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "120")) # seconds
|
||||||
LM_STUDIO_MAX_RETRIES = 3
|
LLM_MAX_RETRIES = 3
|
||||||
|
|
||||||
# ── Embeddings ─────────────────────────────────────────────
|
# ── Embeddings ─────────────────────────────────────────────
|
||||||
EMBEDDINGS_DIR = LAB_ROOT / "data"
|
EMBEDDINGS_DIR = LAB_ROOT / "data"
|
||||||
@@ -33,7 +33,7 @@ MAX_CANDIDATES_PER_UNDERPERFORMER = 10
|
|||||||
MAX_UNDERPERFORMERS = 8
|
MAX_UNDERPERFORMERS = 8
|
||||||
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
|
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
|
||||||
|
|
||||||
# ── LLM Generation Settings (Coach — LM Studio) ──────────────
|
# ── LLM Generation Settings (Coach — Ollama) ──────────────
|
||||||
DEFAULT_TEMPERATURE = 0.7
|
DEFAULT_TEMPERATURE = 0.7
|
||||||
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
|
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
|
||||||
# so we need a generous limit to avoid truncation
|
# so we need a generous limit to avoid truncation
|
||||||
@@ -41,9 +41,10 @@ DEFAULT_MAX_TOKENS = 8192
|
|||||||
|
|
||||||
# ── Perplexity Deck Generation Settings ───────────────────────
|
# ── Perplexity Deck Generation Settings ───────────────────────
|
||||||
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
|
# 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)
|
# 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_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
|
||||||
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
|
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
|
# Maximum number of alternatives to suggest per missing card
|
||||||
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
|
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
|
||||||
# Model for substitution fallback (sonar is fine — small focused queries)
|
# 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
|
# Enable Perplexity fallback for low-confidence embedding matches
|
||||||
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
|
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
|
/v1/chat/completions endpoint. Includes retry logic for
|
||||||
non-JSON responses and configurable timeout.
|
non-JSON responses and configurable timeout.
|
||||||
"""
|
"""
|
||||||
@@ -14,8 +14,8 @@ from urllib.request import urlopen, Request
|
|||||||
from urllib.error import URLError
|
from urllib.error import URLError
|
||||||
|
|
||||||
from .config import (
|
from .config import (
|
||||||
LM_STUDIO_URL, LM_STUDIO_MODEL,
|
LLM_URL, LLM_MODEL,
|
||||||
LM_STUDIO_TIMEOUT, LM_STUDIO_MAX_RETRIES,
|
LLM_TIMEOUT, LLM_MAX_RETRIES,
|
||||||
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
|
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,12 +34,12 @@ class LLMResponse:
|
|||||||
self.model = model
|
self.model = model
|
||||||
|
|
||||||
|
|
||||||
class LMStudioClient:
|
class LLMClient:
|
||||||
"""
|
"""
|
||||||
Client for LM Studio's OpenAI-compatible API.
|
Client for Ollama's OpenAI-compatible API.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
client = LMStudioClient()
|
client = LLMClient()
|
||||||
response = await client.chat(system_prompt, user_prompt)
|
response = await client.chat(system_prompt, user_prompt)
|
||||||
if response.parsed_json:
|
if response.parsed_json:
|
||||||
suggestions = response.parsed_json
|
suggestions = response.parsed_json
|
||||||
@@ -47,20 +47,20 @@ class LMStudioClient:
|
|||||||
|
|
||||||
def __init__(self, base_url: str = None, model: str = None,
|
def __init__(self, base_url: str = None, model: str = None,
|
||||||
timeout: int = None):
|
timeout: int = None):
|
||||||
self.base_url = (base_url or LM_STUDIO_URL).rstrip("/")
|
self.base_url = (base_url or LLM_URL).rstrip("/")
|
||||||
self.model = model or LM_STUDIO_MODEL
|
self.model = model or LLM_MODEL
|
||||||
self.timeout = timeout or LM_STUDIO_TIMEOUT
|
self.timeout = timeout or LLM_TIMEOUT
|
||||||
self._resolved_model = None # actual model name from /models endpoint
|
self._resolved_model = None # actual model name from /models endpoint
|
||||||
|
|
||||||
def _resolve_model(self) -> str:
|
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:
|
if self._resolved_model:
|
||||||
return self._resolved_model
|
return self._resolved_model
|
||||||
try:
|
try:
|
||||||
status = self.check_connection()
|
status = self.check_connection()
|
||||||
if status.get("connected") and status.get("active_model"):
|
if status.get("connected") and status.get("active_model"):
|
||||||
self._resolved_model = status["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
|
return self._resolved_model
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -196,8 +196,8 @@ class LMStudioClient:
|
|||||||
temperature: float = None, max_tokens: int = None,
|
temperature: float = None, max_tokens: int = None,
|
||||||
force_json: bool = True) -> LLMResponse:
|
force_json: bool = True) -> LLMResponse:
|
||||||
"""
|
"""
|
||||||
Synchronous chat call to LM Studio.
|
Synchronous chat call to Ollama.
|
||||||
Retries up to LM_STUDIO_MAX_RETRIES on failure or non-JSON response.
|
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
|
||||||
"""
|
"""
|
||||||
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
|
||||||
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
|
||||||
@@ -205,9 +205,9 @@ class LMStudioClient:
|
|||||||
temp, tokens, force_json)
|
temp, tokens, force_json)
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
for attempt in range(1, LM_STUDIO_MAX_RETRIES + 1):
|
for attempt in range(1, LLM_MAX_RETRIES + 1):
|
||||||
try:
|
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(
|
req = Request(
|
||||||
f"{self.base_url}/chat/completions",
|
f"{self.base_url}/chat/completions",
|
||||||
data=json.dumps(body).encode("utf-8"),
|
data=json.dumps(body).encode("utf-8"),
|
||||||
@@ -239,12 +239,12 @@ class LMStudioClient:
|
|||||||
body = self._build_request_body(
|
body = self._build_request_body(
|
||||||
system_prompt, user_prompt, temp, tokens, False)
|
system_prompt, user_prompt, temp, tokens, False)
|
||||||
|
|
||||||
if attempt < LM_STUDIO_MAX_RETRIES:
|
if attempt < LLM_MAX_RETRIES:
|
||||||
import time
|
import time
|
||||||
time.sleep(2 * attempt) # Simple backoff
|
time.sleep(2 * attempt) # Simple backoff
|
||||||
|
|
||||||
raise ConnectionError(
|
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,
|
async def chat(self, system_prompt: str, user_prompt: str,
|
||||||
@@ -263,7 +263,7 @@ class LMStudioClient:
|
|||||||
|
|
||||||
def check_connection(self) -> dict:
|
def check_connection(self) -> dict:
|
||||||
"""
|
"""
|
||||||
Check if LM Studio is reachable. Returns status dict.
|
Check if Ollama is reachable. Returns status dict.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
req = Request(
|
req = Request(
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ class CardAlternative(BaseModel):
|
|||||||
|
|
||||||
class DeckCardWithStatus(BaseModel):
|
class DeckCardWithStatus(BaseModel):
|
||||||
"""A deck card enriched with ownership and substitution data."""
|
"""A deck card enriched with ownership and substitution data."""
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
extra = "ignore"
|
||||||
name: str
|
name: str
|
||||||
count: int = 1
|
count: int = 1
|
||||||
category: str = ""
|
category: str = ""
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ class DeckGeneratorV3:
|
|||||||
truncated = card_count < 90 # Commander decks should have ~100 cards
|
truncated = card_count < 90 # Commander decks should have ~100 cards
|
||||||
logger.info("Deck generated: %d cards, bracket %d%s",
|
logger.info("Deck generated: %d cards, bracket %d%s",
|
||||||
card_count,
|
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 "")
|
" (TRUNCATED — response may have hit token limit)" if truncated else "")
|
||||||
|
|
||||||
# Step 5: Check ownership and build enriched card list
|
# 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
|
<input
|
||||||
type="text"
|
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}
|
value={dsUrl}
|
||||||
onChange={e => setDsUrl(e.target.value)}
|
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"
|
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 {
|
export interface PplxStatus {
|
||||||
configured: boolean
|
configured: boolean
|
||||||
key_prefix: string
|
|
||||||
}
|
}
|
||||||
+32
-1
@@ -1,15 +1,46 @@
|
|||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||||
import LobbyPage from './pages/LobbyPage';
|
import LobbyPage from './pages/LobbyPage';
|
||||||
import RoomPage from './pages/RoomPage';
|
import RoomPage from './pages/RoomPage';
|
||||||
import GamePage from './pages/GamePage';
|
import GamePage from './pages/GamePage';
|
||||||
|
import SimulatorPage from './pages/SimulatorPage';
|
||||||
|
|
||||||
|
function Nav() {
|
||||||
|
const location = useLocation();
|
||||||
|
// Hide nav on active game pages
|
||||||
|
if (location.pathname.startsWith('/game/')) return null;
|
||||||
|
const links = [
|
||||||
|
{ to: '/', label: 'Lobby' },
|
||||||
|
{ to: '/simulator', label: 'Simulator' },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<nav className="bg-bg-bar border-b border-border-zone px-4 py-2 flex items-center gap-4">
|
||||||
|
<span className="text-sm font-bold text-white mr-4">Commander AI Lab</span>
|
||||||
|
{links.map((l) => (
|
||||||
|
<Link
|
||||||
|
key={l.to}
|
||||||
|
to={l.to}
|
||||||
|
className={`text-sm px-2 py-1 rounded transition-colors ${
|
||||||
|
location.pathname === l.to
|
||||||
|
? 'text-white bg-accent-blue/20'
|
||||||
|
: 'text-text-muted hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Nav />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LobbyPage />} />
|
<Route path="/" element={<LobbyPage />} />
|
||||||
<Route path="/room/:roomId" element={<RoomPage />} />
|
<Route path="/room/:roomId" element={<RoomPage />} />
|
||||||
<Route path="/game/:gameId" element={<GamePage />} />
|
<Route path="/game/:gameId" element={<GamePage />} />
|
||||||
|
<Route path="/simulator" element={<SimulatorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { SEAT_COLOURS } from '../constants';
|
||||||
|
import type { SimDeck, SimResult } from '../types/simulator';
|
||||||
|
|
||||||
|
const SEAT_LABELS = ['Seat 1 (You)', 'Seat 2', 'Seat 3', 'Seat 4'];
|
||||||
|
|
||||||
|
type SimStatus = 'idle' | 'running' | 'complete' | 'error';
|
||||||
|
|
||||||
|
export default function SimulatorPage() {
|
||||||
|
// Deck list from API
|
||||||
|
const [decks, setDecks] = useState<SimDeck[]>([]);
|
||||||
|
const [loadingDecks, setLoadingDecks] = useState(true);
|
||||||
|
|
||||||
|
// Slot selections (4 slots, null = empty)
|
||||||
|
const [slots, setSlots] = useState<(number | null)[]>([null, null, null, null]);
|
||||||
|
|
||||||
|
// Config
|
||||||
|
const [numGames, setNumGames] = useState(10);
|
||||||
|
const [engineType, setEngineType] = useState('heuristic');
|
||||||
|
const [recordLogs, setRecordLogs] = useState(true);
|
||||||
|
|
||||||
|
// Sim state
|
||||||
|
const [simId, setSimId] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<SimStatus>('idle');
|
||||||
|
const [completed, setCompleted] = useState(0);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [result, setResult] = useState<SimResult | null>(null);
|
||||||
|
const [games, setGames] = useState<unknown[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// Fetch decks on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/decks')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
const list = (data.decks || []).map((d: Record<string, unknown>) => ({
|
||||||
|
id: d.id as number,
|
||||||
|
name: d.name as string,
|
||||||
|
commander_name: (d.commander_name || '') as string,
|
||||||
|
color_identity: (d.color_identity || []) as string[],
|
||||||
|
}));
|
||||||
|
setDecks(list);
|
||||||
|
})
|
||||||
|
.catch(() => setDecks([]))
|
||||||
|
.finally(() => setLoadingDecks(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Polling
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'running' || !simId) return;
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sim/status?simId=${simId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setCompleted(data.completed ?? 0);
|
||||||
|
setTotal(data.total ?? 0);
|
||||||
|
if (data.status === 'complete') {
|
||||||
|
setStatus('complete');
|
||||||
|
// Fetch results
|
||||||
|
const rres = await fetch(`/api/sim/result?simId=${simId}`);
|
||||||
|
const rdata = await rres.json();
|
||||||
|
setResult(rdata.summary ?? null);
|
||||||
|
setGames(rdata.games ?? []);
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
setStatus('error');
|
||||||
|
setError(data.error || 'Simulation failed');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep polling
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
};
|
||||||
|
}, [status, simId]);
|
||||||
|
|
||||||
|
const selectedCount = slots.filter((s) => s !== null).length;
|
||||||
|
const canRun = selectedCount >= 2 && status !== 'running';
|
||||||
|
|
||||||
|
const handleRun = async () => {
|
||||||
|
const deckIds = slots.filter((s): s is number => s !== null);
|
||||||
|
setStatus('running');
|
||||||
|
setCompleted(0);
|
||||||
|
setTotal(numGames);
|
||||||
|
setResult(null);
|
||||||
|
setGames([]);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sim/run-n-player', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
deckIds,
|
||||||
|
numGames,
|
||||||
|
recordLogs,
|
||||||
|
engineType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setStatus('error');
|
||||||
|
setError(data.error || 'Failed to start simulation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSimId(data.simId);
|
||||||
|
setTotal(data.total);
|
||||||
|
} catch {
|
||||||
|
setStatus('error');
|
||||||
|
setError('Network error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSlot = (idx: number, value: number | null) => {
|
||||||
|
setSlots((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = value;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDeckById = (id: number) => decks.find((d) => d.id === id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg-base text-white p-6">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">Batch Simulator</h1>
|
||||||
|
<p className="text-text-muted text-sm mb-6">
|
||||||
|
Run N-player Monte Carlo simulations with your decks.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Deck Selection */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Deck Slots</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{SEAT_LABELS.map((label, idx) => {
|
||||||
|
const selected = slots[idx];
|
||||||
|
const deck = selected !== null ? getDeckById(selected) : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-bg-zone rounded-md p-4"
|
||||||
|
style={{
|
||||||
|
border: `2px solid ${selected !== null ? SEAT_COLOURS[idx] : '#3a4060'}`,
|
||||||
|
boxShadow: selected !== null ? `0 0 8px ${SEAT_COLOURS[idx]}40` : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-sm font-medium mb-2"
|
||||||
|
style={{ color: SEAT_COLOURS[idx] }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={selected ?? ''}
|
||||||
|
onChange={(e) => setSlot(idx, e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="w-full bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||||
|
>
|
||||||
|
<option value="">— Empty —</option>
|
||||||
|
{decks.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{deck && (
|
||||||
|
<div className="mt-2 text-xs text-text-muted">
|
||||||
|
{deck.commander_name && (
|
||||||
|
<div className="truncate">{deck.commander_name}</div>
|
||||||
|
)}
|
||||||
|
{deck.color_identity.length > 0 && (
|
||||||
|
<div>{deck.color_identity.join(' ')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{loadingDecks && (
|
||||||
|
<p className="text-text-muted text-sm mt-2">Loading decks...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config Row */}
|
||||||
|
<div className="flex flex-wrap items-end gap-4 mb-6 bg-bg-zone rounded-md border border-border-zone p-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-text-muted mb-1">Games</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
value={numGames}
|
||||||
|
onChange={(e) => setNumGames(Math.max(1, Math.min(1000, Number(e.target.value))))}
|
||||||
|
className="w-24 bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-text-muted mb-1">Engine</label>
|
||||||
|
<select
|
||||||
|
value={engineType}
|
||||||
|
onChange={(e) => setEngineType(e.target.value)}
|
||||||
|
className="bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
|
||||||
|
>
|
||||||
|
<option value="heuristic">Heuristic AI</option>
|
||||||
|
<option value="deepseek">DeepSeek AI</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="recordLogs"
|
||||||
|
checked={recordLogs}
|
||||||
|
onChange={(e) => setRecordLogs(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="recordLogs" className="text-sm text-text-muted">
|
||||||
|
Record Logs
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={!canRun}
|
||||||
|
className="bg-accent-blue text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{status === 'running' ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
Running...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Run Simulation'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
{status === 'running' && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between text-sm text-text-muted mb-1">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>
|
||||||
|
{completed} / {total} games
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-bg-panel rounded-full h-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent-blue rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{status === 'error' && error && (
|
||||||
|
<div className="mb-6 bg-red-900/30 border border-red-700 rounded-md p-3 text-sm text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{status === 'complete' && result && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4 text-sm text-text-muted">
|
||||||
|
<span>{result.totalGames} games</span>
|
||||||
|
<span>{result.playerCount} players</span>
|
||||||
|
<span>{result.elapsedSeconds}s elapsed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Win Rate Bar Chart */}
|
||||||
|
<div className="bg-bg-zone rounded-md border border-border-zone p-4">
|
||||||
|
<h3 className="text-sm font-semibold mb-3">Win Rates</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{result.players.map((p) => (
|
||||||
|
<div key={p.seat} className="flex items-center gap-3">
|
||||||
|
<div className="w-28 text-sm truncate" style={{ color: SEAT_COLOURS[p.seat] }}>
|
||||||
|
{p.deckName}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-bg-panel rounded-full h-5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${p.winRate}%`,
|
||||||
|
backgroundColor: SEAT_COLOURS[p.seat],
|
||||||
|
minWidth: p.winRate > 0 ? '2px' : '0',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-16 text-right text-sm font-mono">
|
||||||
|
{p.winRate}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Table */}
|
||||||
|
<div className="bg-bg-zone rounded-md border border-border-zone overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-zone text-text-muted">
|
||||||
|
<th className="text-left px-3 py-2">Seat</th>
|
||||||
|
<th className="text-left px-3 py-2">Deck</th>
|
||||||
|
<th className="text-right px-3 py-2">Wins</th>
|
||||||
|
<th className="text-right px-3 py-2">Win%</th>
|
||||||
|
<th className="text-right px-3 py-2">Avg Turns</th>
|
||||||
|
<th className="text-right px-3 py-2">Avg Dmg</th>
|
||||||
|
<th className="text-right px-3 py-2">Avg Spells</th>
|
||||||
|
<th className="text-right px-3 py-2">Avg Creatures</th>
|
||||||
|
<th className="text-right px-3 py-2">Avg Removal</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.players.map((p) => {
|
||||||
|
const isWinner = result.players.every((o) => o.wins <= p.wins);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={p.seat}
|
||||||
|
className={`border-b border-border-zone ${isWinner ? 'font-semibold' : ''}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${SEAT_COLOURS[p.seat]}${isWinner ? '20' : '0a'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2" style={{ color: SEAT_COLOURS[p.seat] }}>
|
||||||
|
P{p.seat + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">{p.deckName}</td>
|
||||||
|
<td className="text-right px-3 py-2">{p.wins}</td>
|
||||||
|
<td className="text-right px-3 py-2">{p.winRate}%</td>
|
||||||
|
<td className="text-right px-3 py-2">{p.avgTurns}</td>
|
||||||
|
<td className="text-right px-3 py-2">{p.avgDamageDealt}</td>
|
||||||
|
<td className="text-right px-3 py-2">{p.avgSpellsCast}</td>
|
||||||
|
<td className="text-right px-3 py-2">{p.avgCreaturesPlayed}</td>
|
||||||
|
<td className="text-right px-3 py-2">{p.avgRemovalUsed}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-Game Logs (collapsible) */}
|
||||||
|
{games.length > 0 && (
|
||||||
|
<div className="bg-bg-zone rounded-md border border-border-zone">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLogs(!showLogs)}
|
||||||
|
className="w-full text-left px-4 py-3 text-sm font-medium flex justify-between items-center hover:bg-bg-panel transition-colors"
|
||||||
|
>
|
||||||
|
<span>Per-Game Results ({games.length} games)</span>
|
||||||
|
<span className="text-text-muted">{showLogs ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{showLogs && (
|
||||||
|
<div className="px-4 pb-3 max-h-96 overflow-y-auto">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{games.map((g: unknown, i: number) => {
|
||||||
|
const game = g as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-3 text-xs text-text-muted py-1 border-b border-border-zone"
|
||||||
|
>
|
||||||
|
<span className="w-12 font-mono">#{game.gameNumber as number}</span>
|
||||||
|
<span>
|
||||||
|
Winner:{' '}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: SEAT_COLOURS[(game.winner ?? game.winningSeat ?? 0) as number],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
P{((game.winner ?? game.winningSeat ?? 0) as number) + 1}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>{game.turns as number} turns</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface SimDeck {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
commander_name: string;
|
||||||
|
color_identity: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimPlayerResult {
|
||||||
|
seat: number;
|
||||||
|
deckName: string;
|
||||||
|
wins: number;
|
||||||
|
winRate: number;
|
||||||
|
avgTurns: number;
|
||||||
|
avgDamageDealt: number;
|
||||||
|
avgSpellsCast: number;
|
||||||
|
avgCreaturesPlayed: number;
|
||||||
|
avgRemovalUsed: number;
|
||||||
|
avgRampPlayed: number;
|
||||||
|
avgCardsDrawn: number;
|
||||||
|
avgMaxBoardSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimResult {
|
||||||
|
playerCount: number;
|
||||||
|
totalGames: number;
|
||||||
|
players: SimPlayerResult[];
|
||||||
|
elapsedSeconds: number;
|
||||||
|
}
|
||||||
+163
-22
@@ -8,6 +8,12 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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:
|
try:
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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")
|
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 ────────────────────────────────────────────────────────
|
||||||
app = FastAPI(title="Commander AI Lab API", version="3.0.0")
|
app = FastAPI(title="Commander AI Lab API", version="3.0.0")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=allowed_origins,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
@@ -60,17 +71,25 @@ app.include_router(coach_router)
|
|||||||
app.include_router(ml_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")
|
@app.on_event("startup")
|
||||||
async def _on_startup():
|
async def _on_startup():
|
||||||
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
|
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
|
||||||
import routes.shared as _shared
|
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()
|
_shared.init_collection_db()
|
||||||
if not _shared.PRECON_INDEX:
|
if not _shared.PRECON_INDEX:
|
||||||
_shared.download_precon_database()
|
_shared.download_precon_database()
|
||||||
if not _shared.COMMANDER_META:
|
if not _shared.COMMANDER_META:
|
||||||
_shared.load_commander_meta()
|
_shared.load_commander_meta()
|
||||||
|
|
||||||
|
|
||||||
# ── Static UI ──────────────────────────────────────────────────
|
# ── Static UI ──────────────────────────────────────────────────
|
||||||
_legacy_ui_dir = Path(__file__).parent / "ui"
|
_legacy_ui_dir = Path(__file__).parent / "ui"
|
||||||
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||||
@@ -81,7 +100,6 @@ elif _spa_dir.exists():
|
|||||||
_spa_assets = _spa_dir / "assets"
|
_spa_assets = _spa_dir / "assets"
|
||||||
if _spa_assets.exists():
|
if _spa_assets.exists():
|
||||||
app.mount("/assets", StaticFiles(directory=str(_spa_assets)), name="spa-assets")
|
app.mount("/assets", StaticFiles(directory=str(_spa_assets)), name="spa-assets")
|
||||||
|
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
async def _spa_catchall(full_path: str):
|
async def _spa_catchall(full_path: str):
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -90,21 +108,20 @@ elif _spa_dir.exists():
|
|||||||
return FileResponse(str(requested_file))
|
return FileResponse(str(requested_file))
|
||||||
return FileResponse(str(_spa_dir / "index.html"))
|
return FileResponse(str(_spa_dir / "index.html"))
|
||||||
|
|
||||||
|
|
||||||
# ── Startup ────────────────────────────────────────────────────
|
# ── Startup ────────────────────────────────────────────────────
|
||||||
def _parse_args():
|
def _parse_args():
|
||||||
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
|
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
|
||||||
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
|
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-dir", default=os.environ.get("FORGE_DIR", ""))
|
||||||
p.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_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("--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("--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("--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")
|
p.add_argument("--verbose", "-v", action="store_true")
|
||||||
return p.parse_args()
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_forge_decks_dir() -> str:
|
def _resolve_forge_decks_dir() -> str:
|
||||||
import sys
|
import sys
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
@@ -122,6 +139,97 @@ def _resolve_forge_decks_dir() -> str:
|
|||||||
return str(candidate)
|
return str(candidate)
|
||||||
return ""
|
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:
|
def _resolve_lab_jar() -> str:
|
||||||
target_dir = Path(__file__).parent / "target"
|
target_dir = Path(__file__).parent / "target"
|
||||||
@@ -137,29 +245,63 @@ def _resolve_lab_jar() -> str:
|
|||||||
return str(jars[0])
|
return str(jars[0])
|
||||||
return ""
|
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():
|
def main():
|
||||||
args = _parse_args()
|
args = _parse_args()
|
||||||
setup_logging(logging.DEBUG if args.verbose else logging.INFO)
|
setup_logging(logging.DEBUG if args.verbose else logging.INFO)
|
||||||
|
|
||||||
CFG.forge_jar = args.forge_jar
|
CFG.forge_jar = args.forge_jar or _resolve_forge_jar()
|
||||||
CFG.forge_dir = args.forge_dir
|
CFG.forge_dir = args.forge_dir or _resolve_forge_dir()
|
||||||
CFG.forge_decks_dir = args.forge_decks_dir or _resolve_forge_decks_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.lab_jar = args.lab_jar or _resolve_lab_jar()
|
||||||
CFG.port = args.port
|
CFG.precon_dir = args.precon_dir or _resolve_precon_dir()
|
||||||
|
CFG.port = args.port
|
||||||
CFG.ximilar_api_key = args.ximilar_key
|
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
|
from routes.shared import get_java17
|
||||||
j17 = get_java17()
|
j17 = get_java17()
|
||||||
|
|
||||||
log.info("Commander AI Lab — API Server v3.0.0")
|
log.info("Commander AI Lab — API Server v3.0.0")
|
||||||
log.info(f" Port: {CFG.port}")
|
log.info(f" Port: {CFG.port}")
|
||||||
log.info(f" Forge JAR: {CFG.forge_jar or 'NOT SET'}")
|
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" Forge Dir: {CFG.forge_dir or 'NOT SET'}")
|
||||||
log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET'}")
|
log.info(f" Decks Dir: {CFG.forge_decks_dir or 'NOT SET'}")
|
||||||
log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET'}")
|
log.info(f" Precon Dir: {CFG.precon_dir or 'NOT SET'}")
|
||||||
log.info(f" Java 17: {j17}")
|
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()
|
load_commander_meta()
|
||||||
download_precon_database()
|
download_precon_database()
|
||||||
@@ -169,6 +311,5 @@ def main():
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
|
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
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
|
# Print game outcome distribution
|
||||||
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
|
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
|
||||||
log.info(f"\n{'Outcome':<25} {'Count':>8}")
|
logger.info(f"\n{'Outcome':<25} {'Count':>8}")
|
||||||
log.info("─" * 35)
|
logger.info("─" * 35)
|
||||||
for o, c in sorted(zip(unique_outcomes, outcome_counts), key=lambda x: -x[1]):
|
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
|
# Print playstyle distribution
|
||||||
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
|
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
|
||||||
log.info(f"\n{'Playstyle':<25} {'Count':>8}")
|
logger.info(f"\n{'Playstyle':<25} {'Count':>8}")
|
||||||
log.info("─" * 35)
|
logger.info("─" * 35)
|
||||||
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
|
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 {
|
return {
|
||||||
"states": states,
|
"states": states,
|
||||||
@@ -288,7 +288,7 @@ def main():
|
|||||||
max_samples=args.max_samples,
|
max_samples=args.max_samples,
|
||||||
)
|
)
|
||||||
if not dataset:
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
if args.no_split:
|
if args.no_split:
|
||||||
@@ -300,7 +300,7 @@ def main():
|
|||||||
save_dataset(val, f"{base}-val.npz")
|
save_dataset(val, f"{base}-val.npz")
|
||||||
save_dataset(test, f"{base}-test.npz")
|
save_dataset(test, f"{base}-test.npz")
|
||||||
|
|
||||||
log.info("\nDataset build complete.")
|
logger.info("\nDataset build complete.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+57
-1
@@ -9,7 +9,7 @@ class StartRequest(BaseModel):
|
|||||||
numGames: int = 100
|
numGames: int = 100
|
||||||
threads: int = 4
|
threads: int = 4
|
||||||
seed: Optional[int] = None
|
seed: Optional[int] = None
|
||||||
clock: int = 120
|
clock: int = 300
|
||||||
deckSources: Optional[list[Optional[dict]]] = None
|
deckSources: Optional[list[Optional[dict]]] = None
|
||||||
useLearnedPolicy: bool = False
|
useLearnedPolicy: bool = False
|
||||||
policyStyle: str = "midrange"
|
policyStyle: str = "midrange"
|
||||||
@@ -114,6 +114,62 @@ class DeckGenV3Request(BaseModel):
|
|||||||
deck_name: Optional[str] = ""
|
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):
|
class DeckGenV3SubstituteRequest(BaseModel):
|
||||||
card_name: str
|
card_name: str
|
||||||
substitute_name: str
|
substitute_name: str
|
||||||
@@ -20,6 +20,7 @@ class Config:
|
|||||||
forge_dir: str = ""
|
forge_dir: str = ""
|
||||||
forge_decks_dir: str = ""
|
forge_decks_dir: str = ""
|
||||||
lab_jar: str = ""
|
lab_jar: str = ""
|
||||||
|
precon_dir: str = ""
|
||||||
results_dir: str = "results"
|
results_dir: str = "results"
|
||||||
port: int = 8080
|
port: int = 8080
|
||||||
ximilar_api_key: str = ""
|
ximilar_api_key: str = ""
|
||||||
|
|||||||
@@ -67,6 +67,13 @@
|
|||||||
<version>3.45.1.0</version>
|
<version>3.45.1.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- WebSocket server for Unity/Godot client -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.java-websocket</groupId>
|
||||||
|
<artifactId>Java-WebSocket</artifactId>
|
||||||
|
<version>1.5.6</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Testing -->
|
<!-- Testing -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
|||||||
@@ -7,3 +7,9 @@ uvicorn[standard]>=0.23.0
|
|||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
httpx>=0.24.0
|
httpx>=0.24.0
|
||||||
websockets>=11.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 json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from models.requests import CoachRequestBody, CoachChatMessage, CoachChatRequest, CoachApplyRequest, CoachGoalsRequest
|
||||||
|
|
||||||
from routes.shared import (
|
from routes.shared import (
|
||||||
CFG,
|
CFG,
|
||||||
@@ -58,14 +59,14 @@ def init_coach_service():
|
|||||||
"""Initialize the coach service with LLM client and embeddings."""
|
"""Initialize the coach service with LLM client and embeddings."""
|
||||||
global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
|
global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
|
||||||
try:
|
try:
|
||||||
from coach.llm_client import LMStudioClient
|
from coach.llm_client import LLMClient
|
||||||
from coach.embeddings import MTGEmbeddingIndex
|
from coach.embeddings import MTGEmbeddingIndex
|
||||||
from coach.coach_service import CoachService
|
from coach.coach_service import CoachService
|
||||||
from coach.config import ensure_dirs
|
from coach.config import ensure_dirs
|
||||||
|
|
||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
|
|
||||||
_coach_llm = LMStudioClient()
|
_coach_llm = LLMClient()
|
||||||
_coach_embeddings = MTGEmbeddingIndex()
|
_coach_embeddings = MTGEmbeddingIndex()
|
||||||
|
|
||||||
# Try to load embeddings (non-blocking — download happens on first use)
|
# Try to load embeddings (non-blocking — download happens on first use)
|
||||||
@@ -86,42 +87,28 @@ def init_coach_service():
|
|||||||
if llm_status.get("connected"):
|
if llm_status.get("connected"):
|
||||||
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
||||||
else:
|
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 (Ollama-based)
|
||||||
|
|
||||||
# Initialize V3 Deck Generator (Perplexity)
|
|
||||||
_deck_gen_v3_error = None
|
_deck_gen_v3_error = None
|
||||||
if CFG.pplx_api_key:
|
try:
|
||||||
try:
|
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
|
||||||
from coach.clients.perplexity_client import PerplexityClient
|
_deck_gen_v3 = DeckBuilderAdapter(
|
||||||
from coach.services.deck_generator import DeckGeneratorV3
|
db_conn_factory=_get_db_conn,
|
||||||
from coach.config import DECK_GEN_MODEL
|
model="gpt-oss:20b",
|
||||||
|
)
|
||||||
pplx_client = PerplexityClient(
|
log_deckgen.info(" Deck Gen V3: Initialized (Ollama DeckBuilderAdapter)")
|
||||||
api_key=CFG.pplx_api_key,
|
except ImportError as e:
|
||||||
model=DECK_GEN_MODEL,
|
_deck_gen_v3_error = f"Missing dependency: {e}"
|
||||||
)
|
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||||
_deck_gen_v3 = DeckGeneratorV3(
|
_deck_gen_v3 = None
|
||||||
pplx_client=pplx_client,
|
except Exception as e:
|
||||||
db_conn_factory=_get_db_conn,
|
_deck_gen_v3_error = str(e)
|
||||||
embedding_index=_coach_embeddings,
|
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
||||||
)
|
_deck_gen_v3 = None
|
||||||
log_deckgen.info(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})")
|
|
||||||
except ImportError as e:
|
|
||||||
_deck_gen_v3_error = f"Missing dependency: {e}. Run: pip install openai"
|
|
||||||
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
|
||||||
_deck_gen_v3 = None
|
|
||||||
except Exception as e:
|
|
||||||
_deck_gen_v3_error = str(e)
|
|
||||||
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
|
||||||
_deck_gen_v3 = None
|
|
||||||
else:
|
|
||||||
_deck_gen_v3_error = "PPLX_API_KEY not set"
|
|
||||||
log_deckgen.info(" Deck Gen V3: Skipped (no PPLX_API_KEY)")
|
|
||||||
|
|
||||||
except Exception as e:
|
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
|
_coach_service = None
|
||||||
|
|
||||||
|
|
||||||
@@ -186,33 +173,6 @@ async def coach_get_report(deck_id: str):
|
|||||||
return report.model_dump()
|
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}")
|
@router.post("/api/coach/decks/{deck_id}")
|
||||||
async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
|
async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
|
||||||
"""Trigger a coaching session for a deck."""
|
"""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
|
from coach.models import DeckReport, CardPerformance, DeckStructure
|
||||||
conn = _get_db_conn()
|
conn = _get_db_conn()
|
||||||
|
|
||||||
# Find the deck by slug match against the deck name
|
# --- Input validation ---
|
||||||
rows = conn.execute(
|
import re
|
||||||
"SELECT id, name, commander_name, color_identity FROM decks ORDER BY id"
|
if not deck_slug or len(deck_slug) > 200:
|
||||||
).fetchall()
|
return None
|
||||||
|
deck_slug = re.sub(r'[^a-zA-Z0-9 _-]', '', deck_slug)
|
||||||
|
if not deck_slug:
|
||||||
|
return None
|
||||||
|
|
||||||
matched_deck = None
|
# Find the deck by parameterized query instead of full-table scan
|
||||||
for r in rows:
|
slug_lower = deck_slug.lower()
|
||||||
name = r["name"] or ""
|
slug_hyphenated = slug_lower.replace(' ', '-')
|
||||||
slug = name.lower().replace(" ", "-")
|
clean_slug = re.sub(r'[^a-z0-9]+', '-', slug_lower).strip('-')
|
||||||
# Also try a more thorough slugify
|
|
||||||
import re
|
if slug_lower.isdigit():
|
||||||
clean_slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
|
# Numeric slug -- match by ID directly
|
||||||
if slug == deck_slug.lower() or clean_slug == deck_slug.lower() or name.lower() == deck_slug.lower():
|
matched_deck = conn.execute(
|
||||||
matched_deck = r
|
"SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
|
||||||
break
|
(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:
|
if matched_deck is None:
|
||||||
return None
|
return None
|
||||||
@@ -451,7 +425,7 @@ async def coach_chat(body: CoachChatRequest):
|
|||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
from urllib.request import urlopen, Request as UrlRequest
|
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()
|
model_name = _coach_llm._resolve_model()
|
||||||
llm_body = {
|
llm_body = {
|
||||||
@@ -477,7 +451,7 @@ async def coach_chat(body: CoachChatRequest):
|
|||||||
import http.client
|
import http.client
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions")
|
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"),
|
conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"),
|
||||||
headers={"Content-Type": "application/json"})
|
headers={"Content-Type": "application/json"})
|
||||||
resp = conn.getresponse()
|
resp = conn.getresponse()
|
||||||
@@ -526,7 +500,7 @@ async def coach_chat(body: CoachChatRequest):
|
|||||||
import asyncio
|
import asyncio
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
def _call():
|
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"))
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
raw = await loop.run_in_executor(None, _call)
|
raw = await loop.run_in_executor(None, _call)
|
||||||
@@ -598,6 +572,7 @@ async def coach_apply_suggestions(body: CoachApplyRequest):
|
|||||||
# Try Scryfall API lookup
|
# Try Scryfall API lookup
|
||||||
try:
|
try:
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from urllib.request import urlopen, Request as UrlRequest
|
||||||
encoded = urllib.parse.quote(card_name)
|
encoded = urllib.parse.quote(card_name)
|
||||||
scry_req = UrlRequest(
|
scry_req = UrlRequest(
|
||||||
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
|
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
|
||||||
@@ -694,5 +669,3 @@ async def coach_generate_reports():
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, f"Report generation failed: {e}")
|
raise HTTPException(500, f"Report generation failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
+1
-490
@@ -18,13 +18,8 @@ from __future__ import annotations
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
from datetime import datetime
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
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))
|
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
|
# Collection Import Endpoint
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
@@ -956,21 +485,3 @@ async def scryfall_cache_evict_expired():
|
|||||||
return {"evicted": evicted, "message": f"Evicted {evicted} expired entries"}
|
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 datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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 import APIRouter, HTTPException, Request as FastAPIRequest
|
||||||
from fastapi.responses import JSONResponse
|
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).
|
Fuzzy-search Scryfall for a card name (used by the scanner pipeline).
|
||||||
Returns the raw Scryfall JSON dict, or None on failure.
|
Returns the raw Scryfall JSON dict, or None on failure.
|
||||||
"""
|
"""
|
||||||
from urllib.parse import quote
|
|
||||||
_scryfall_rate_limit()
|
_scryfall_rate_limit()
|
||||||
try:
|
try:
|
||||||
encoded = quote(name)
|
encoded = quote(name)
|
||||||
@@ -1098,4 +1099,3 @@ def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
|
|||||||
return data
|
return data
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
+124
-164
@@ -29,10 +29,11 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.request import urlopen, Request
|
from urllib.request import urlopen, Request
|
||||||
|
import httpx
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
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
|
import routes.coach as _coach # for _deck_gen_v3 / _deck_gen_v3_error module-level access
|
||||||
from routes.shared import (
|
from routes.shared import (
|
||||||
@@ -43,6 +44,7 @@ from routes.shared import (
|
|||||||
DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest,
|
DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest,
|
||||||
GeneratedDeckCard, DeckGenerationSourceConfig,
|
GeneratedDeckCard, DeckGenerationSourceConfig,
|
||||||
)
|
)
|
||||||
|
from services.deck_service import _write_dck_file, _build_dck_lines
|
||||||
|
|
||||||
router = APIRouter(tags=["deckgen"])
|
router = APIRouter(tags=["deckgen"])
|
||||||
|
|
||||||
@@ -147,37 +149,48 @@ def _scryfall_to_commander(data: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _get_collection_for_colors(color_identity: list) -> list:
|
def _get_collection_for_colors(color_identity: list) -> list:
|
||||||
"""
|
"""Fetch collection cards whose color identity is a subset of *color_identity*.
|
||||||
Fetch all collection cards compatible with the given color identity.
|
|
||||||
Returns list of dicts with card details.
|
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()
|
conn = _get_db_conn()
|
||||||
rows = conn.execute(
|
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
|
||||||
"SELECT * FROM collection_entries WHERE quantity > 0"
|
|
||||||
).fetchall()
|
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 = []
|
valid = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
card = _row_to_dict(row)
|
card = dict(row)
|
||||||
card_ci = card.get("color_identity", [])
|
ci = card.get("color_identity", "[]")
|
||||||
if isinstance(card_ci, str):
|
try:
|
||||||
try:
|
card["color_identity"] = json.loads(ci) if isinstance(ci, str) else ci
|
||||||
card_ci = json.loads(card_ci)
|
except Exception:
|
||||||
except Exception:
|
card["color_identity"] = []
|
||||||
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
|
|
||||||
valid.append(card)
|
valid.append(card)
|
||||||
|
|
||||||
return valid
|
return valid
|
||||||
|
|
||||||
|
|
||||||
@@ -540,29 +553,8 @@ async def deck_generator_commit(req: DeckGenerationRequest):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# Also export a .dck file to the Forge decks directory so it appears in the Sim Lab
|
|
||||||
try:
|
try:
|
||||||
decks_dir = CFG.forge_decks_dir
|
_write_dck_file(deck_name, commander['name'], cards, fallback_id=deck_id)
|
||||||
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}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_deckgen.warning(f" Warning: Failed to export .dck file: {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:
|
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()
|
conn = _get_db_conn()
|
||||||
|
|
||||||
# Base query: all collection entries with oracle text
|
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
|
||||||
where = "WHERE quantity > 0"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
# Filter by color identity if provided
|
# SQL handles the colour-identity subset check via json_each
|
||||||
if color_identity:
|
ci_filter = """
|
||||||
# Cards must only contain colors in the commander's identity (+ colorless)
|
AND NOT EXISTS (
|
||||||
ci_set = set(c.upper() for c in color_identity)
|
SELECT 1 FROM json_each(
|
||||||
# We'll do a post-filter since color_identity is JSON in the DB
|
CASE WHEN collection_entries.color_identity IS NULL
|
||||||
pass # filter in Python after fetch
|
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"""
|
rows = conn.execute(f"""
|
||||||
SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity,
|
SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity,
|
||||||
color_identity, category, is_game_changer, salt_score
|
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
|
ORDER BY edhrec_rank ASC, tcg_price DESC
|
||||||
""", params).fetchall()
|
""", 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
|
# Role detection from type_line, oracle_text, and category
|
||||||
def detect_role(row):
|
def detect_role(row):
|
||||||
tl = (row['type_line'] or '').lower()
|
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': [],
|
'ramp': [], 'card_draw': [], 'removal': [], 'board_wipes': [],
|
||||||
'lands': [], 'win_conditions': [], 'creatures': [], 'utility': []
|
'lands': [], 'win_conditions': [], 'creatures': [], 'utility': []
|
||||||
}
|
}
|
||||||
filtered_count = 0
|
filtered_count = len(rows) # SQL already filtered
|
||||||
|
|
||||||
for r in rows:
|
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)
|
role = detect_role(r)
|
||||||
groups[role].append({
|
groups[role].append({
|
||||||
'name': r['name'],
|
'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:
|
async 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."""
|
"""Call Perplexity API chat/completions endpoint (non-blocking).
|
||||||
if not CFG.pplx_api_key:
|
Uses httpx.AsyncClient so the uvicorn event loop is never stalled.
|
||||||
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
|
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 = {
|
payload = {
|
||||||
'model': 'sonar',
|
'model': model,
|
||||||
'messages': messages,
|
'messages': messages,
|
||||||
'max_tokens': max_tokens,
|
'max_tokens': max_tokens,
|
||||||
'temperature': temperature,
|
'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:
|
try:
|
||||||
with urlopen(req, timeout=120) as resp:
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
data = json.loads(resp.read())
|
resp = await client.post(
|
||||||
choices = data.get('choices', [])
|
f'{base_url}/chat/completions',
|
||||||
if not choices:
|
json=payload,
|
||||||
raise ValueError('Empty response from Perplexity API')
|
headers=headers,
|
||||||
content = choices[0].get('message', {}).get('content', '')
|
)
|
||||||
usage = data.get('usage', {})
|
resp.raise_for_status()
|
||||||
log_pplx.debug(f'tokens: prompt={usage.get("prompt_tokens","?")}, '
|
data = resp.json()
|
||||||
f'completion={usage.get("completion_tokens","?")}, '
|
except httpx.HTTPStatusError as e:
|
||||||
f'model={data.get("model","?")}')
|
raise HTTPException(502, f'Perplexity API returned {e.response.status_code}: {e.response.text[:200]}')
|
||||||
return content
|
except httpx.RequestError as e:
|
||||||
except URLError as e:
|
|
||||||
raise HTTPException(502, f'Perplexity API call failed: {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:
|
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."""
|
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': 'system', 'content': system_msg},
|
||||||
{'role': 'user', 'content': user_msg},
|
{'role': 'user', 'content': user_msg},
|
||||||
], max_tokens=8192)
|
], 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."""
|
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': 'system', 'content': system_msg},
|
||||||
{'role': 'user', 'content': user_msg},
|
{'role': 'user', 'content': user_msg},
|
||||||
], max_tokens=8192, temperature=0.3)
|
], max_tokens=8192, temperature=0.3)
|
||||||
@@ -1224,7 +1218,6 @@ async def pplx_status():
|
|||||||
"""Check if Perplexity API is configured."""
|
"""Check if Perplexity API is configured."""
|
||||||
return {
|
return {
|
||||||
'configured': bool(CFG.pplx_api_key),
|
'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 {
|
return {
|
||||||
'initialized': _coach._deck_gen_v3 is not None,
|
'initialized': _coach._deck_gen_v3 is not None,
|
||||||
'pplx_configured': bool(CFG.pplx_api_key),
|
'pplx_configured': bool(CFG.pplx_api_key),
|
||||||
'model': _coach._deck_gen_v3.pplx.model if _coach._deck_gen_v3 else None,
|
'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': (
|
'embeddings_loaded': False,
|
||||||
_coach_embeddings.loaded if _coach_embeddings else False
|
'embedding_cards': 0,
|
||||||
),
|
|
||||||
'embedding_cards': (
|
|
||||||
_coach_embeddings.card_count if _coach_embeddings else 0
|
|
||||||
),
|
|
||||||
'error': _coach._deck_gen_v3_error,
|
'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
|
6. Return complete deck with substitution data
|
||||||
"""
|
"""
|
||||||
if _coach._deck_gen_v3 is None:
|
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:
|
if not req.commander_name or len(req.commander_name.strip()) < 2:
|
||||||
raise HTTPException(400, 'Commander name is required (min 2 chars)')
|
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:
|
except ValueError as e:
|
||||||
return JSONResponse({'error': str(e)}, status_code=422)
|
return JSONResponse({'error': str(e)}, status_code=422)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_deckgen.error(f'Error: {e}')
|
log_deckgen.error(f'V3 generate error: {e}', exc_info=True)
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
raise HTTPException(500, f'Deck generation failed: {str(e)}')
|
raise HTTPException(500, f'Deck generation failed: {str(e)}')
|
||||||
|
|
||||||
|
|
||||||
@@ -1394,25 +1381,8 @@ async def deck_gen_v3_commit(req: DeckGenV3Request):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# Export .dck for Forge
|
|
||||||
try:
|
try:
|
||||||
decks_dir = CFG.forge_decks_dir
|
_write_dck_file(deck_name, commander['name'], cards, fallback_id=deck_id, resolve_substitutes=True)
|
||||||
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}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_deckgen.error(f" .dck export failed: {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', [])
|
cards = result.get('cards', [])
|
||||||
commander = result.get('commander', {})
|
commander = result.get('commander', {})
|
||||||
|
|
||||||
lines = [
|
lines = _build_dck_lines(
|
||||||
'[metadata]',
|
f"{commander.get('name', 'Deck')} - V3 Auto",
|
||||||
f'Name={commander.get("name", "Deck")} - V3 Auto',
|
commander.get('name', ''),
|
||||||
'',
|
cards,
|
||||||
'[Commander]',
|
resolve_substitutes=True,
|
||||||
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}")
|
|
||||||
|
|
||||||
dck_content = '\n'.join(lines)
|
dck_content = '\n'.join(lines)
|
||||||
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_')
|
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_')
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
+241
-3
@@ -15,6 +15,7 @@ Python simulator & DeepSeek AI opponent endpoints:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -33,6 +34,8 @@ from routes.shared import (
|
|||||||
_get_deepseek_brain,
|
_get_deepseek_brain,
|
||||||
_ml_logging_enabled,
|
_ml_logging_enabled,
|
||||||
)
|
)
|
||||||
|
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["deepseek"])
|
router = APIRouter(tags=["deepseek"])
|
||||||
|
|
||||||
@@ -40,6 +43,26 @@ router = APIRouter(tags=["deepseek"])
|
|||||||
_sim_runs = {} # sim_id -> { status, result, error }
|
_sim_runs = {} # sim_id -> { status, result, error }
|
||||||
_sim_lock = _threading.Lock()
|
_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)
|
# 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)
|
name_b=opponent_name, **kw)
|
||||||
game_data = result.to_dict()
|
game_data = result.to_dict()
|
||||||
game_data['gameNumber'] = i + 1
|
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)
|
game_results.append(game_data)
|
||||||
|
|
||||||
if result.winner == 0:
|
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]['status'] = 'complete'
|
||||||
_sim_runs[sim_id]['result'] = {'summary': summary, 'games': game_results}
|
_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):
|
def _fail_sim(sim_id, error):
|
||||||
"""Mark a sim run as errored."""
|
"""Mark a sim run as errored."""
|
||||||
with _sim_lock:
|
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_a = build_deck(decklist)
|
||||||
deck_b = _generate_training_deck()
|
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(
|
game_results, stats, elapsed = _run_game_loop(
|
||||||
engine, deck_a, deck_b, deck_name, 'Training Deck',
|
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_a = _build_deck_from_card_data(card_data)
|
||||||
deck_b = _generate_training_deck()
|
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(
|
game_results, stats, elapsed = _run_game_loop(
|
||||||
engine, deck_a, deck_b, deck_name, 'Training Deck',
|
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:
|
if brain and not brain._connected:
|
||||||
brain.check_connection()
|
brain.check_connection()
|
||||||
|
|
||||||
|
validator = _get_validator_brain() # None if disabled
|
||||||
engine = DeepSeekGameEngine(
|
engine = DeepSeekGameEngine(
|
||||||
brain=brain, ai_player_index=1,
|
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(
|
game_results, stats, elapsed = _run_game_loop(
|
||||||
engine, deck_a, deck_b, deck_name, 'DeepSeek AI',
|
engine, deck_a, deck_b, deck_name, 'DeepSeek AI',
|
||||||
@@ -295,6 +370,106 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
|
|||||||
_fail_sim(sim_id, e)
|
_fail_sim(sim_id, e)
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
# N-Player Sim Thread
|
||||||
|
# ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sim_thread_n_player(sim_id: str, decks_data: list[list[dict]],
|
||||||
|
deck_names: list[str], num_games: int,
|
||||||
|
record_logs: bool, engine_type: str):
|
||||||
|
"""Background thread: N-player (2-4) batch simulation."""
|
||||||
|
try:
|
||||||
|
_ensure_src_path()
|
||||||
|
from commander_ai_lab.sim.engine import GameEngine
|
||||||
|
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
|
||||||
|
|
||||||
|
with _sim_lock:
|
||||||
|
_sim_runs[sim_id]['status'] = 'running'
|
||||||
|
|
||||||
|
n_players = len(decks_data)
|
||||||
|
built_decks = [_build_deck_from_card_data(cd) for cd in decks_data]
|
||||||
|
|
||||||
|
if engine_type == 'deepseek':
|
||||||
|
brain = _get_deepseek_brain()
|
||||||
|
if brain and not brain._connected:
|
||||||
|
brain.check_connection()
|
||||||
|
engine = DeepSeekGameEngine(
|
||||||
|
brain=brain, ai_player_index=1,
|
||||||
|
max_turns=25, record_log=record_logs, ml_log=True)
|
||||||
|
else:
|
||||||
|
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
|
||||||
|
|
||||||
|
# Per-seat accumulators
|
||||||
|
seat_wins = [0] * n_players
|
||||||
|
seat_turns = [0] * n_players
|
||||||
|
seat_damage_dealt = [0] * n_players
|
||||||
|
seat_spells_cast = [0] * n_players
|
||||||
|
seat_creatures_played = [0] * n_players
|
||||||
|
seat_removal_used = [0] * n_players
|
||||||
|
seat_ramp_played = [0] * n_players
|
||||||
|
seat_cards_drawn = [0] * n_players
|
||||||
|
seat_max_board = [0] * n_players
|
||||||
|
|
||||||
|
game_results = []
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
for i in range(num_games):
|
||||||
|
result = engine.run_n(decks=built_decks, names=deck_names)
|
||||||
|
game_data = result.to_dict()
|
||||||
|
game_data['gameNumber'] = i + 1
|
||||||
|
game_results.append(game_data)
|
||||||
|
|
||||||
|
if 0 <= result.winner_seat < n_players:
|
||||||
|
seat_wins[result.winner_seat] += 1
|
||||||
|
|
||||||
|
for pr in result.players:
|
||||||
|
si = pr.seat_index
|
||||||
|
seat_turns[si] += result.turns
|
||||||
|
if pr.stats:
|
||||||
|
seat_damage_dealt[si] += pr.stats.damage_dealt
|
||||||
|
seat_spells_cast[si] += pr.stats.spells_cast
|
||||||
|
seat_creatures_played[si] += pr.stats.creatures_played
|
||||||
|
seat_removal_used[si] += pr.stats.removal_used
|
||||||
|
seat_ramp_played[si] += pr.stats.ramp_played
|
||||||
|
seat_cards_drawn[si] += pr.stats.cards_drawn
|
||||||
|
seat_max_board[si] += pr.stats.max_board_size
|
||||||
|
|
||||||
|
with _sim_lock:
|
||||||
|
_sim_runs[sim_id]['completed'] = i + 1
|
||||||
|
|
||||||
|
elapsed = time.time() - start
|
||||||
|
n = num_games if num_games > 0 else 1
|
||||||
|
|
||||||
|
players_summary = []
|
||||||
|
for si in range(n_players):
|
||||||
|
players_summary.append({
|
||||||
|
'seat': si,
|
||||||
|
'deckName': deck_names[si],
|
||||||
|
'wins': seat_wins[si],
|
||||||
|
'winRate': round(seat_wins[si] / n * 100, 1),
|
||||||
|
'avgTurns': round(seat_turns[si] / n, 1),
|
||||||
|
'avgDamageDealt': round(seat_damage_dealt[si] / n, 1),
|
||||||
|
'avgSpellsCast': round(seat_spells_cast[si] / n, 1),
|
||||||
|
'avgCreaturesPlayed': round(seat_creatures_played[si] / n, 1),
|
||||||
|
'avgRemovalUsed': round(seat_removal_used[si] / n, 1),
|
||||||
|
'avgRampPlayed': round(seat_ramp_played[si] / n, 1),
|
||||||
|
'avgCardsDrawn': round(seat_cards_drawn[si] / n, 1),
|
||||||
|
'avgMaxBoardSize': round(seat_max_board[si] / n, 1),
|
||||||
|
})
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
'playerCount': n_players,
|
||||||
|
'totalGames': num_games,
|
||||||
|
'players': players_summary,
|
||||||
|
'elapsedSeconds': round(elapsed, 3),
|
||||||
|
}
|
||||||
|
_finish_sim(sim_id, summary, game_results)
|
||||||
|
except Exception as e:
|
||||||
|
_fail_sim(sim_id, e)
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
# Simulation API Endpoints
|
# Simulation API Endpoints
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
@@ -446,6 +621,43 @@ async def sim_run_deepseek(request: FastAPIRequest):
|
|||||||
'total': num_games, 'deckName': deck_name,
|
'total': num_games, 'deckName': deck_name,
|
||||||
'opponentType': 'deepseek'})
|
'opponentType': 'deepseek'})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/api/sim/run-n-player')
|
||||||
|
async def sim_run_n_player(request: FastAPIRequest):
|
||||||
|
"""Start an N-player (2-4) batch simulation."""
|
||||||
|
body = await request.json()
|
||||||
|
deck_ids = body.get('deckIds', [])
|
||||||
|
num_games = body.get('numGames', 10)
|
||||||
|
record_logs = body.get('recordLogs', True)
|
||||||
|
engine_type = body.get('engineType', 'heuristic')
|
||||||
|
|
||||||
|
if len(deck_ids) < 2 or len(deck_ids) > 4:
|
||||||
|
return JSONResponse({'error': '2-4 decks required'}, status_code=400)
|
||||||
|
if num_games < 1 or num_games > 1000:
|
||||||
|
return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400)
|
||||||
|
|
||||||
|
decks_data = []
|
||||||
|
deck_names = []
|
||||||
|
for did in deck_ids:
|
||||||
|
name, cards = _fetch_deck_card_data(did)
|
||||||
|
if name is None:
|
||||||
|
return JSONResponse({'error': f'Deck {did} not found'}, status_code=404)
|
||||||
|
if not cards:
|
||||||
|
return JSONResponse({'error': f'Deck {did} has no cards'}, status_code=400)
|
||||||
|
deck_names.append(name)
|
||||||
|
decks_data.append(cards)
|
||||||
|
|
||||||
|
sim_id = _create_sim_run(num_games, ' vs '.join(deck_names))
|
||||||
|
_threading.Thread(target=_run_sim_thread_n_player,
|
||||||
|
args=(sim_id, decks_data, deck_names, num_games,
|
||||||
|
record_logs, engine_type),
|
||||||
|
daemon=True).start()
|
||||||
|
return JSONResponse({
|
||||||
|
'simId': sim_id, 'status': 'queued', 'total': num_games,
|
||||||
|
'deckNames': deck_names, 'playerCount': len(deck_ids),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
# DeepSeek AI Brain Endpoints
|
# DeepSeek AI Brain Endpoints
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
@@ -560,3 +772,29 @@ async def deepseek_logs():
|
|||||||
'flushedPath': flushed_path,
|
'flushedPath': flushed_path,
|
||||||
'logFiles': log_files[-20:],
|
'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 glob
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
@@ -38,8 +39,6 @@ from routes.shared import (
|
|||||||
StartResponse,
|
StartResponse,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
AI_PROFILES,
|
AI_PROFILES,
|
||||||
PRECON_DIR,
|
|
||||||
PRECON_INDEX,
|
|
||||||
build_java_command,
|
build_java_command,
|
||||||
run_batch_subprocess,
|
run_batch_subprocess,
|
||||||
_run_deepseek_batch_thread,
|
_run_deepseek_batch_thread,
|
||||||
@@ -54,6 +53,19 @@ from routes.shared import (
|
|||||||
router = APIRouter(tags=["lab"])
|
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)
|
@router.post("/api/lab/start", response_model=StartResponse)
|
||||||
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||||
"""Start a new batch simulation run."""
|
"""Start a new batch simulation run."""
|
||||||
@@ -164,8 +176,12 @@ async def get_result(batchId: Optional[str] = None):
|
|||||||
raise HTTPException(500, state.error)
|
raise HTTPException(500, state.error)
|
||||||
if not state.result_path or not os.path.exists(state.result_path):
|
if not state.result_path or not os.path.exists(state.result_path):
|
||||||
raise HTTPException(500, "Result file not found")
|
raise HTTPException(500, "Result file not found")
|
||||||
with open(state.result_path, "r", encoding="utf-8") as f:
|
try:
|
||||||
data = json.load(f)
|
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)
|
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)
|
# 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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -37,42 +38,60 @@ log_ml = logging.getLogger("commander_ai_lab.ml")
|
|||||||
|
|
||||||
router = APIRouter(tags=["ml"])
|
router = APIRouter(tags=["ml"])
|
||||||
|
|
||||||
# ---- module-level state (moved from lab_api.py) ----
|
# ── module-level singletons ──────────────────────────────────────────────────
|
||||||
|
|
||||||
_policy_service = None
|
_policy_service = None
|
||||||
_policy_service_init_attempted = False
|
_policy_service_init_attempted = False
|
||||||
|
|
||||||
_training_state = {
|
# ── Thread-safe state objects ────────────────────────────────────────────────
|
||||||
"running": False,
|
@dataclasses.dataclass
|
||||||
"progress": 0,
|
class _TrainingState:
|
||||||
"total_epochs": 0,
|
running: bool = False
|
||||||
"current_epoch": 0,
|
progress: int = 0
|
||||||
"phase": "idle",
|
total_epochs: int = 0
|
||||||
"message": "",
|
current_epoch: int = 0
|
||||||
"metrics": None,
|
phase: str = "idle"
|
||||||
"result": None,
|
message: str = ""
|
||||||
"error": None,
|
metrics: object = None
|
||||||
"started_at": None,
|
result: object = None
|
||||||
}
|
error: object = None
|
||||||
|
started_at: object = None
|
||||||
|
|
||||||
_ppo_state = {
|
def snapshot(self) -> dict:
|
||||||
"running": False,
|
return dataclasses.asdict(self)
|
||||||
"iteration": 0,
|
|
||||||
"total_iterations": 0,
|
|
||||||
"phase": "idle",
|
|
||||||
"message": "",
|
|
||||||
"metrics": None,
|
|
||||||
"result": None,
|
|
||||||
"error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
_tournament_state = {
|
@dataclasses.dataclass
|
||||||
"running": False,
|
class _PPOState:
|
||||||
"phase": "idle",
|
running: bool = False
|
||||||
"message": "",
|
iteration: int = 0
|
||||||
"result": None,
|
total_iterations: int = 0
|
||||||
"error": None,
|
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)
|
# module-level toggle (local copy; the shared one is for batch runs)
|
||||||
_ml_logging_enabled_local = _ml_logging_enabled
|
_ml_logging_enabled_local = _ml_logging_enabled
|
||||||
@@ -252,16 +271,17 @@ def _run_training_pipeline(
|
|||||||
batch_size: int, patience: int, rebuild_dataset: bool,
|
batch_size: int, patience: int, rebuild_dataset: bool,
|
||||||
):
|
):
|
||||||
"""Run the full ML training pipeline in a background thread."""
|
"""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:
|
try:
|
||||||
import sys
|
import sys
|
||||||
project_root = str(Path(__file__).resolve().parent.parent)
|
project_root = str(Path(__file__).resolve().parent.parent)
|
||||||
if project_root not in sys.path:
|
if project_root not in sys.path:
|
||||||
sys.path.insert(0, project_root)
|
sys.path.insert(0, project_root)
|
||||||
_training_state["running"] = True
|
with _training_lock:
|
||||||
_training_state["started_at"] = datetime.now().isoformat()
|
_training_state.running = True
|
||||||
_training_state["error"] = None
|
_training_state.started_at = datetime.now().isoformat()
|
||||||
_training_state["result"] = None
|
_training_state.error = None
|
||||||
|
_training_state.result = None
|
||||||
data_dir = os.path.join(project_root, "ml", "models")
|
data_dir = os.path.join(project_root, "ml", "models")
|
||||||
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
||||||
os.makedirs(data_dir, exist_ok=True)
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
@@ -269,8 +289,9 @@ def _run_training_pipeline(
|
|||||||
# Phase 1: Build Dataset
|
# Phase 1: Build Dataset
|
||||||
train_path = os.path.join(data_dir, "train.npz")
|
train_path = os.path.join(data_dir, "train.npz")
|
||||||
if rebuild_dataset or not os.path.exists(train_path):
|
if rebuild_dataset or not os.path.exists(train_path):
|
||||||
_training_state["phase"] = "building"
|
with _training_lock:
|
||||||
_training_state["message"] = "Loading card embeddings & building dataset..."
|
_training_state.phase = "building"
|
||||||
|
_training_state.message = "Loading card embeddings & building dataset..."
|
||||||
log_ml.info("Building dataset (loading embeddings, may auto-download)...")
|
log_ml.info("Building dataset (loading embeddings, may auto-download)...")
|
||||||
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
|
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
|
||||||
dataset = build_dataset(results_dir=results_dir)
|
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(val_ds, os.path.join(data_dir, "val.npz"))
|
||||||
save_dataset(test_ds, os.path.join(data_dir, "test.npz"))
|
save_dataset(test_ds, os.path.join(data_dir, "test.npz"))
|
||||||
total_samples = len(dataset["states"])
|
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")
|
log_ml.info(f"Dataset built: {total_samples} samples")
|
||||||
# Phase 2: Train
|
# Phase 2: Train
|
||||||
_training_state["phase"] = "training"
|
with _training_lock:
|
||||||
_training_state["total_epochs"] = epochs
|
_training_state.phase = "training"
|
||||||
_training_state["current_epoch"] = 0
|
_training_state.total_epochs = epochs
|
||||||
_training_state["message"] = f"Training policy network ({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}")
|
log_ml.info(f"Starting training: {epochs} epochs, lr={lr}, bs={batch_size}")
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
@@ -312,11 +335,13 @@ def _run_training_pipeline(
|
|||||||
checkpoint_dir=ckpt_dir,
|
checkpoint_dir=ckpt_dir,
|
||||||
)
|
)
|
||||||
summary = trainer.train(train_states, train_labels, val_states, val_labels)
|
summary = trainer.train(train_states, train_labels, val_states, val_labels)
|
||||||
_training_state["current_epoch"] = summary.get("epochs_trained", epochs)
|
with _training_lock:
|
||||||
_training_state["metrics"] = summary
|
_training_state.current_epoch = summary.get("epochs_trained", epochs)
|
||||||
|
_training_state.metrics = summary
|
||||||
# Phase 3: Evaluate
|
# Phase 3: Evaluate
|
||||||
_training_state["phase"] = "evaluating"
|
with _training_lock:
|
||||||
_training_state["message"] = "Evaluating on test set..."
|
_training_state.phase = "evaluating"
|
||||||
|
_training_state.message = "Evaluating on test set..."
|
||||||
test_path = os.path.join(data_dir, "test.npz")
|
test_path = os.path.join(data_dir, "test.npz")
|
||||||
eval_results = None
|
eval_results = None
|
||||||
if os.path.exists(test_path):
|
if os.path.exists(test_path):
|
||||||
@@ -328,15 +353,16 @@ def _run_training_pipeline(
|
|||||||
with open(eval_path, "w") as f:
|
with open(eval_path, "w") as f:
|
||||||
json.dump(eval_results, f, indent=2)
|
json.dump(eval_results, f, indent=2)
|
||||||
# Done
|
# Done
|
||||||
_training_state["phase"] = "done"
|
with _training_lock:
|
||||||
_training_state["running"] = False
|
_training_state.phase = "done"
|
||||||
_training_state["result"] = {
|
_training_state.running = False
|
||||||
"training": summary,
|
_training_state.result = {
|
||||||
"evaluation": eval_results,
|
"training": summary,
|
||||||
"checkpoint": summary.get("checkpoint_path", ""),
|
"evaluation": eval_results,
|
||||||
"device": device,
|
"checkpoint": summary.get("checkpoint_path", ""),
|
||||||
}
|
"device": device,
|
||||||
_training_state["message"] = f"Training complete! Best val acc: {summary.get('best_val_accuracy', 0):.1%}"
|
}
|
||||||
|
_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")
|
log_ml.info(f"Complete: {summary.get('best_val_accuracy', 0):.1%} val accuracy")
|
||||||
# Auto-reload policy server
|
# Auto-reload policy server
|
||||||
if _policy_service and _policy_service._loaded:
|
if _policy_service and _policy_service._loaded:
|
||||||
@@ -346,10 +372,11 @@ def _run_training_pipeline(
|
|||||||
_policy_service_init_attempted = False
|
_policy_service_init_attempted = False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
_training_state["phase"] = "error"
|
with _training_lock:
|
||||||
_training_state["running"] = False
|
_training_state.phase = "error"
|
||||||
_training_state["error"] = str(e)
|
_training_state.running = False
|
||||||
_training_state["message"] = f"Training failed: {e}"
|
_training_state.error = str(e)
|
||||||
|
_training_state.message = f"Training failed: {e}"
|
||||||
log_ml.error(f"ERROR: {e}")
|
log_ml.error(f"ERROR: {e}")
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
@@ -357,7 +384,7 @@ def _run_training_pipeline(
|
|||||||
@router.post("/api/ml/train")
|
@router.post("/api/ml/train")
|
||||||
async def ml_start_training(request: FastAPIRequest):
|
async def ml_start_training(request: FastAPIRequest):
|
||||||
"""Trigger ML training pipeline from the web UI."""
|
"""Trigger ML training pipeline from the web UI."""
|
||||||
if _training_state["running"]:
|
if _training_state.running:
|
||||||
raise HTTPException(409, "Training already in progress")
|
raise HTTPException(409, "Training already in progress")
|
||||||
body = await request.json() if await request.body() else {}
|
body = await request.json() if await request.body() else {}
|
||||||
epochs = body.get("epochs", 50)
|
epochs = body.get("epochs", 50)
|
||||||
@@ -366,12 +393,16 @@ async def ml_start_training(request: FastAPIRequest):
|
|||||||
patience = body.get("patience", 10)
|
patience = body.get("patience", 10)
|
||||||
rebuild = body.get("rebuildDataset", True)
|
rebuild = body.get("rebuildDataset", True)
|
||||||
results_dir = os.path.join(str(Path(__file__).resolve().parent.parent), "results")
|
results_dir = os.path.join(str(Path(__file__).resolve().parent.parent), "results")
|
||||||
_training_state.update({
|
with _training_lock:
|
||||||
"running": True, "progress": 0, "total_epochs": epochs,
|
_training_state.running = True
|
||||||
"current_epoch": 0, "phase": "starting",
|
_training_state.progress = 0
|
||||||
"message": "Initializing training pipeline...",
|
_training_state.total_epochs = epochs
|
||||||
"metrics": None, "result": None, "error": None,
|
_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(
|
thread = threading.Thread(
|
||||||
target=_run_training_pipeline,
|
target=_run_training_pipeline,
|
||||||
args=(results_dir, epochs, lr, batch_size, patience, rebuild),
|
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")
|
@router.get("/api/ml/train/status")
|
||||||
async def ml_training_status():
|
async def ml_training_status():
|
||||||
"""Get current training pipeline status."""
|
"""Get current training pipeline status."""
|
||||||
return dict(_training_state)
|
with _training_lock:
|
||||||
|
return _training_state.snapshot()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/ml/data/status")
|
@router.get("/api/ml/data/status")
|
||||||
@@ -461,15 +500,15 @@ def _run_ppo_pipeline(
|
|||||||
load_supervised: str,
|
load_supervised: str,
|
||||||
):
|
):
|
||||||
"""Run PPO training in a background thread."""
|
"""Run PPO training in a background thread."""
|
||||||
global _ppo_state
|
|
||||||
try:
|
try:
|
||||||
import sys
|
import sys
|
||||||
project_root = str(Path(__file__).resolve().parent.parent)
|
project_root = str(Path(__file__).resolve().parent.parent)
|
||||||
if project_root not in sys.path:
|
if project_root not in sys.path:
|
||||||
sys.path.insert(0, project_root)
|
sys.path.insert(0, project_root)
|
||||||
_ppo_state["running"] = True
|
with _ppo_lock:
|
||||||
_ppo_state["error"] = None
|
_ppo_state.running = True
|
||||||
_ppo_state["result"] = None
|
_ppo_state.error = None
|
||||||
|
_ppo_state.result = None
|
||||||
from ml.training.ppo_trainer import PPOTrainer, PPOConfig
|
from ml.training.ppo_trainer import PPOTrainer, PPOConfig
|
||||||
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
||||||
config = PPOConfig(
|
config = PPOConfig(
|
||||||
@@ -482,30 +521,33 @@ def _run_ppo_pipeline(
|
|||||||
)
|
)
|
||||||
trainer = PPOTrainer(config)
|
trainer = PPOTrainer(config)
|
||||||
def progress_cb(iteration, metrics):
|
def progress_cb(iteration, metrics):
|
||||||
_ppo_state["iteration"] = iteration
|
with _ppo_lock:
|
||||||
_ppo_state["phase"] = "training"
|
_ppo_state.iteration = iteration
|
||||||
_ppo_state["message"] = f"Iteration {iteration}/{iterations} | WR: {metrics.get('win_rate', 0):.0%}"
|
_ppo_state.phase = "training"
|
||||||
_ppo_state["metrics"] = metrics
|
_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)
|
summary = trainer.train(progress_callback=progress_cb)
|
||||||
_ppo_state["phase"] = "done"
|
with _ppo_lock:
|
||||||
_ppo_state["running"] = False
|
_ppo_state.phase = "done"
|
||||||
_ppo_state["result"] = summary
|
_ppo_state.running = False
|
||||||
_ppo_state["message"] = f"PPO complete! Best win rate: {summary.get('best_win_rate', 0):.0%}"
|
_ppo_state.result = summary
|
||||||
|
_ppo_state.message = f"PPO complete! Best win rate: {summary.get('best_win_rate', 0):.0%}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
_ppo_state["phase"] = "error"
|
with _ppo_lock:
|
||||||
_ppo_state["running"] = False
|
_ppo_state.phase = "error"
|
||||||
_ppo_state["error"] = str(e)
|
_ppo_state.running = False
|
||||||
_ppo_state["message"] = f"PPO failed: {e}"
|
_ppo_state.error = str(e)
|
||||||
|
_ppo_state.message = f"PPO failed: {e}"
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/ml/train/ppo")
|
@router.post("/api/ml/train/ppo")
|
||||||
async def ml_start_ppo(request: FastAPIRequest):
|
async def ml_start_ppo(request: FastAPIRequest):
|
||||||
"""Start PPO training pipeline."""
|
"""Start PPO training pipeline."""
|
||||||
if _ppo_state["running"]:
|
if _ppo_state.running:
|
||||||
raise HTTPException(409, "PPO training already in progress")
|
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")
|
raise HTTPException(409, "Supervised training in progress -- wait for it to finish")
|
||||||
body = await request.json() if await request.body() else {}
|
body = await request.json() if await request.body() else {}
|
||||||
iterations = body.get("iterations", 100)
|
iterations = body.get("iterations", 100)
|
||||||
@@ -518,11 +560,15 @@ async def ml_start_ppo(request: FastAPIRequest):
|
|||||||
opponent = body.get("opponent", "heuristic")
|
opponent = body.get("opponent", "heuristic")
|
||||||
playstyle = body.get("playstyle", "midrange")
|
playstyle = body.get("playstyle", "midrange")
|
||||||
load_sup = body.get("loadSupervised", "")
|
load_sup = body.get("loadSupervised", "")
|
||||||
_ppo_state.update({
|
with _ppo_lock:
|
||||||
"running": True, "iteration": 0, "total_iterations": iterations,
|
_ppo_state.running = True
|
||||||
"phase": "starting", "message": "Initializing PPO...",
|
_ppo_state.iteration = 0
|
||||||
"metrics": None, "result": None, "error": None,
|
_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(
|
thread = threading.Thread(
|
||||||
target=_run_ppo_pipeline,
|
target=_run_ppo_pipeline,
|
||||||
args=(iterations, episodes, ppo_epochs, batch_size, lr,
|
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")
|
@router.get("/api/ml/train/ppo/status")
|
||||||
async def ml_ppo_status():
|
async def ml_ppo_status():
|
||||||
"""Get PPO training status."""
|
"""Get PPO training status."""
|
||||||
return dict(_ppo_state)
|
with _ppo_lock:
|
||||||
|
return _ppo_state.snapshot()
|
||||||
|
|
||||||
|
|
||||||
def _run_tournament_pipeline(episodes: int, playstyle: str):
|
def _run_tournament_pipeline(episodes: int, playstyle: str):
|
||||||
"""Run tournament in a background thread."""
|
"""Run tournament in a background thread."""
|
||||||
global _tournament_state
|
|
||||||
try:
|
try:
|
||||||
import sys
|
import sys
|
||||||
project_root = str(Path(__file__).resolve().parent.parent)
|
project_root = str(Path(__file__).resolve().parent.parent)
|
||||||
if project_root not in sys.path:
|
if project_root not in sys.path:
|
||||||
sys.path.insert(0, project_root)
|
sys.path.insert(0, project_root)
|
||||||
_tournament_state["running"] = True
|
with _tournament_lock:
|
||||||
_tournament_state["error"] = None
|
_tournament_state.running = True
|
||||||
_tournament_state["result"] = None
|
_tournament_state.error = None
|
||||||
_tournament_state["phase"] = "running"
|
_tournament_state.result = None
|
||||||
_tournament_state["message"] = "Running tournament..."
|
_tournament_state.phase = "running"
|
||||||
|
_tournament_state.message = "Running tournament..."
|
||||||
from ml.eval.tournament import (
|
from ml.eval.tournament import (
|
||||||
run_tournament, HeuristicPolicy, RandomPolicy, LearnedPolicy,
|
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")
|
output_path = os.path.join(ckpt_dir, "tournament_results.json")
|
||||||
with open(output_path, "w") as f:
|
with open(output_path, "w") as f:
|
||||||
json.dump(result.to_dict(), f, indent=2)
|
json.dump(result.to_dict(), f, indent=2)
|
||||||
_tournament_state["phase"] = "done"
|
with _tournament_lock:
|
||||||
_tournament_state["running"] = False
|
_tournament_state.phase = "done"
|
||||||
_tournament_state["result"] = result.to_dict()
|
_tournament_state.running = False
|
||||||
_tournament_state["message"] = f"Tournament complete! {result.total_matches} matches"
|
_tournament_state.result = result.to_dict()
|
||||||
|
_tournament_state.message = f"Tournament complete! {result.total_matches} matches"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
_tournament_state["phase"] = "error"
|
with _tournament_lock:
|
||||||
_tournament_state["running"] = False
|
_tournament_state.phase = "error"
|
||||||
_tournament_state["error"] = str(e)
|
_tournament_state.running = False
|
||||||
_tournament_state["message"] = f"Tournament failed: {e}"
|
_tournament_state.error = str(e)
|
||||||
|
_tournament_state.message = f"Tournament failed: {e}"
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/ml/tournament")
|
@router.post("/api/ml/tournament")
|
||||||
async def ml_start_tournament(request: FastAPIRequest):
|
async def ml_start_tournament(request: FastAPIRequest):
|
||||||
"""Start a tournament evaluation."""
|
"""Start a tournament evaluation."""
|
||||||
if _tournament_state["running"]:
|
if _tournament_state.running:
|
||||||
raise HTTPException(409, "Tournament already in progress")
|
raise HTTPException(409, "Tournament already in progress")
|
||||||
body = await request.json() if await request.body() else {}
|
body = await request.json() if await request.body() else {}
|
||||||
episodes = body.get("episodes", 50)
|
episodes = body.get("episodes", 50)
|
||||||
playstyle = body.get("playstyle", "midrange")
|
playstyle = body.get("playstyle", "midrange")
|
||||||
_tournament_state.update({
|
with _tournament_lock:
|
||||||
"running": True, "phase": "starting",
|
_tournament_state.running = True
|
||||||
"message": "Initializing tournament...",
|
_tournament_state.phase = "starting"
|
||||||
"result": None, "error": None,
|
_tournament_state.message = "Initializing tournament..."
|
||||||
})
|
_tournament_state.result = None
|
||||||
|
_tournament_state.error = None
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=_run_tournament_pipeline,
|
target=_run_tournament_pipeline,
|
||||||
args=(episodes, playstyle),
|
args=(episodes, playstyle),
|
||||||
@@ -612,7 +662,8 @@ async def ml_start_tournament(request: FastAPIRequest):
|
|||||||
@router.get("/api/ml/tournament/status")
|
@router.get("/api/ml/tournament/status")
|
||||||
async def ml_tournament_status():
|
async def ml_tournament_status():
|
||||||
"""Get tournament status."""
|
"""Get tournament status."""
|
||||||
return dict(_tournament_state)
|
with _tournament_lock:
|
||||||
|
return _tournament_state.snapshot()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/ml/tournament/results")
|
@router.get("/api/ml/tournament/results")
|
||||||
@@ -624,3 +675,55 @@ async def ml_tournament_results():
|
|||||||
with open(results_path, "r") as f:
|
with open(results_path, "r") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
return {"error": "No tournament results found. Run a tournament first."}
|
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
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
import routes.shared as _shared # module reference, not value import
|
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"])
|
router = APIRouter(tags=["precon"])
|
||||||
|
|
||||||
@@ -30,13 +31,13 @@ async def list_precons():
|
|||||||
@router.post("/api/lab/precons/install")
|
@router.post("/api/lab/precons/install")
|
||||||
async def install_precon(req: dict):
|
async def install_precon(req: dict):
|
||||||
"""Install a precon deck to the Forge decks directory.
|
"""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:
|
if not file_name:
|
||||||
raise HTTPException(400, "fileName is required")
|
raise HTTPException(400, "fileName is required")
|
||||||
|
|
||||||
src = PRECON_DIR / file_name
|
src = _get_precon_dir() / file_name
|
||||||
if not src.exists():
|
if not src.exists():
|
||||||
raise HTTPException(404, f"Precon not found: {file_name}")
|
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")
|
@router.post("/api/lab/precons/install-batch")
|
||||||
async def install_precons_batch(req: dict):
|
async def install_precons_batch(req: dict):
|
||||||
"""Install multiple precon decks at once.
|
"""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:
|
if not file_names:
|
||||||
raise HTTPException(400, "fileNames list is required")
|
raise HTTPException(400, "fileNames list is required")
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ async def install_precons_batch(req: dict):
|
|||||||
|
|
||||||
results = []
|
results = []
|
||||||
for file_name in file_names:
|
for file_name in file_names:
|
||||||
src = PRECON_DIR / file_name
|
src = _get_precon_dir() / file_name
|
||||||
if not src.exists():
|
if not src.exists():
|
||||||
results.append({"fileName": file_name, "installed": False, "error": "not found"})
|
results.append({"fileName": file_name, "installed": False, "error": "not found"})
|
||||||
continue
|
continue
|
||||||
|
|||||||
+3
-2
@@ -186,6 +186,7 @@ from services.import_service import (
|
|||||||
from services.precon_service import (
|
from services.precon_service import (
|
||||||
PRECON_DIR, PRECON_INDEX, GITHUB_PRECON_URL, PRECON_CACHE_HOURS,
|
PRECON_DIR, PRECON_INDEX, GITHUB_PRECON_URL, PRECON_CACHE_HOURS,
|
||||||
load_precon_index, _sanitize_filename, _deck_to_dck, download_precon_database,
|
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)
|
# Java / Sim Helpers (extracted to services/forge_runner.py)
|
||||||
from services.forge_runner import (
|
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_batch_subprocess, _run_process_blocking,
|
||||||
_run_deepseek_batch_thread, _get_deepseek_brain,
|
_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_raw = []
|
||||||
kw = " ".join(str(k).lower() for k in 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 ("search your library for a" in ot and "land" in ot)
|
||||||
or "treasure token" in ot or "add mana" in ot):
|
or "treasure token" in ot or "add mana" in ot):
|
||||||
roles.append("Ramp")
|
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 re.search(r"deals? x damage to (target|any target)", ot)
|
||||||
or "target creature gets -" in ot or "fights target" in ot):
|
or "target creature gets -" in ot or "fights target" in ot):
|
||||||
roles.append("Removal")
|
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"all creatures get -\d+/-\d+", ot)
|
||||||
or re.search(r"each creature gets -\d+/-\d+", ot)
|
or re.search(r"each creature gets -\d+/-\d+", ot)
|
||||||
or ("deals" in ot and "to each creature" in 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))\
|
if ("target creature gains" in ot and ("hexproof" in ot or "indestructible" in ot or "shroud" in ot))\
|
||||||
or ("protection" in kw):
|
or ("protection" in kw):
|
||||||
roles.append("Protection")
|
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")
|
roles.append("Sacrifice")
|
||||||
if ("return" in ot and ("from your graveyard" in ot and "onto the battlefield" in ot)
|
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)):
|
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 "each opponent loses" in ot and "life" in ot
|
||||||
or "deals damage to each opponent" in ot):
|
or "deals damage to each opponent" in ot):
|
||||||
roles.append("Burn")
|
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 ("enters the battlefield tapped" in ot and "opponents" in ot)
|
||||||
or "each player can't" in ot or "players can't" 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)):
|
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")
|
log_collect = logging.getLogger("commander_ai_lab.collection")
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
# Collection Database
|
# Collection Database
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
@@ -20,6 +19,7 @@ _db_local = threading.local()
|
|||||||
|
|
||||||
|
|
||||||
def _get_db_conn() -> sqlite3.Connection:
|
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:
|
if not hasattr(_db_local, "conn") or _db_local.conn is None:
|
||||||
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
|
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
@@ -27,11 +27,26 @@ def _get_db_conn() -> sqlite3.Connection:
|
|||||||
return _db_local.conn
|
return _db_local.conn
|
||||||
|
|
||||||
|
|
||||||
def init_collection_db():
|
def _close_db_conn() -> None:
|
||||||
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
|
"""Close and discard the thread-local connection (call on thread teardown)."""
|
||||||
conn.row_factory = sqlite3.Row
|
conn = getattr(_db_local, "conn", None)
|
||||||
cur = conn.cursor()
|
if conn is not None:
|
||||||
cur.executescript("""
|
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 (
|
CREATE TABLE IF NOT EXISTS card_records (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
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_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_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_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 INDEX IF NOT EXISTS idx_cr_name ON card_records(name);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS decks (
|
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);
|
CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id);
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# ── Column migrations (additive only) ─────────────────────────────────
|
||||||
_migrate_columns = [
|
_migrate_columns = [
|
||||||
("mana_cost", "TEXT DEFAULT ''"),
|
("mana_cost", "TEXT DEFAULT ''"),
|
||||||
("power", "TEXT DEFAULT ''"),
|
("power", "TEXT DEFAULT ''"),
|
||||||
("toughness", "TEXT DEFAULT ''"),
|
("toughness", "TEXT DEFAULT ''"),
|
||||||
("rarity", "TEXT DEFAULT ''"),
|
("rarity", "TEXT DEFAULT ''"),
|
||||||
("set_name", "TEXT DEFAULT ''"),
|
("set_name", "TEXT DEFAULT ''"),
|
||||||
("edhrec_rank", "INTEGER DEFAULT 0"),
|
("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:
|
for col_name, col_def in _migrate_columns:
|
||||||
if col_name not in existing_cols:
|
if col_name not in existing_cols:
|
||||||
conn.execute(f"ALTER TABLE collection_entries ADD COLUMN {col_name} {col_def}")
|
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.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
|
# Collection Utility Helpers
|
||||||
# ══════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════
|
||||||
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
|
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
|
||||||
|
|
||||||
VALID_SORT_FIELDS = {
|
VALID_SORT_FIELDS = {
|
||||||
"name", "cmc", "tcg_price", "salt_score", "category", "color_identity",
|
"name", "cmc", "tcg_price", "salt_score", "category", "color_identity",
|
||||||
"quantity", "type_line", "finish", "rarity", "set_code", "power",
|
"quantity", "type_line", "finish", "rarity", "set_code", "power",
|
||||||
@@ -154,8 +174,6 @@ VALID_SORT_FIELDS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _row_to_dict(row) -> dict:
|
def _row_to_dict(row) -> dict:
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
@@ -169,12 +187,12 @@ def _row_to_dict(row) -> dict:
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
def _snake_to_camel(s: str) -> str:
|
def _snake_to_camel(s: str) -> str:
|
||||||
parts = s.split("_")
|
parts = s.split("_")
|
||||||
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
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", "")
|
scryfall_id = card.get("scryfall_id", "")
|
||||||
if scryfall_id:
|
if scryfall_id:
|
||||||
card["imageUrl"] = f"https://api.scryfall.com/cards/{scryfall_id}?format=image&version=normal"
|
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
|
return card
|
||||||
|
|
||||||
|
|
||||||
def _build_collection_filters(
|
def _build_collection_filters(
|
||||||
q=None, colors=None, types=None, isLegendary=None, isBasic=None,
|
q=None,
|
||||||
isGameChanger=None, highSalt=None, finish=None, cmcMin=None, cmcMax=None,
|
colors=None,
|
||||||
priceMin=None, priceMax=None, category=None, rarity=None, setCode=None,
|
types=None,
|
||||||
powerMin=None, powerMax=None, toughMin=None, toughMax=None, keyword=None,
|
isLegendary=None,
|
||||||
edhrecMin=None, edhrecMax=None, qtyMin=None, qtyMax=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:
|
) -> tuple:
|
||||||
conditions = []
|
conditions = []
|
||||||
params = []
|
params = []
|
||||||
if q:
|
if q:
|
||||||
like_q = f"%{q}%"
|
like_q = f"%{q}%"
|
||||||
conditions.append("(name LIKE ? OR type_line LIKE ? OR oracle_text LIKE ?)")
|
conditions.append("(name LIKE ? OR type_line LIKE ? OR oracle_text LIKE ?)")
|
||||||
params.extend([like_q, like_q, like_q])
|
params.extend([like_q, like_q, like_q])
|
||||||
@@ -264,4 +301,4 @@ def _row_to_dict(row) -> dict:
|
|||||||
if qtyMax is not None:
|
if qtyMax is not None:
|
||||||
conditions.append("quantity <= ?"); params.append(qtyMax)
|
conditions.append("quantity <= ?"); params.append(qtyMax)
|
||||||
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
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"
|
return "Other"
|
||||||
|
|
||||||
|
|
||||||
def _get_deck_or_404(deck_id: int):
|
def _get_deck_or_404(deck_id: int):
|
||||||
conn = _get_db_conn()
|
conn = _get_db_conn()
|
||||||
row = conn.execute("SELECT * FROM decks WHERE id = ?", (deck_id,)).fetchone()
|
row = conn.execute("SELECT * FROM decks WHERE id = ?", (deck_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
@@ -47,7 +47,7 @@ def _classify_card_type(type_line: str) -> str:
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
def _compute_deck_analysis(deck_id: int) -> dict:
|
def _compute_deck_analysis(deck_id: int) -> dict:
|
||||||
conn = _get_db_conn()
|
conn = _get_db_conn()
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT dc.id, dc.scryfall_id, dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
|
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
|
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||||
WHERE dc.deck_id = ?
|
WHERE dc.deck_id = ?
|
||||||
""", (deck_id,)).fetchall()
|
""", (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
|
counts_by_type["Other"] = 0
|
||||||
mana_curve = {"0": 0, "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6+": 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}
|
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)
|
analysis = _compute_deck_analysis(deck_id)
|
||||||
current = analysis["counts_by_type"].get(card_type, 0)
|
current = analysis["counts_by_type"].get(card_type, 0)
|
||||||
target_max = _TYPE_TARGETS.get(card_type, [0, 9999])[1]
|
target_max = _TYPE_TARGETS.get(card_type, [0, 9999])[1]
|
||||||
return (current + count_to_add) <= target_max
|
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 = name.lower()
|
||||||
slug = re.sub(r"[',.]" , "", slug)
|
slug = re.sub(r"[',.]" , "", slug)
|
||||||
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
||||||
return slug.strip("-")
|
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}."""
|
"""Parse a Forge .dck file into {name, commanders, mainboard, colorIdentity, totalCards}."""
|
||||||
path = Path(deck_path)
|
path = Path(deck_path)
|
||||||
if not path.exists():
|
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}
|
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]"]
|
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
|
||||||
for name, qty in profile.get("commanders", {}).items():
|
for name, qty in profile.get("commanders", {}).items():
|
||||||
lines.append(f"{qty} {name}")
|
lines.append(f"{qty} {name}")
|
||||||
@@ -174,21 +174,92 @@ def _classify_card_type(type_line: str) -> str:
|
|||||||
return out_path
|
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."""
|
"""Load cards from a deck by name, returning list of card dicts."""
|
||||||
conn = _get_db_conn()
|
conn = _get_db_conn()
|
||||||
deck_row = conn.execute("SELECT id FROM decks WHERE name = ?", (deck_name,)).fetchone()
|
deck_row = conn.execute("SELECT id FROM decks WHERE name = ?", (deck_name,)).fetchone()
|
||||||
if not deck_row:
|
if not deck_row:
|
||||||
return []
|
deck_id = deck_row[0]
|
||||||
deck_id = deck_row[0]
|
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
|
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
|
FROM deck_cards dc
|
||||||
LEFT JOIN (
|
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
|
FROM collection_entries GROUP BY scryfall_id
|
||||||
) ce ON ce.scryfall_id = dc.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 = ?
|
WHERE dc.deck_id = ?
|
||||||
""", (deck_id,)).fetchall()
|
""", (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."""
|
"""Forge batch simulation runner: Java discovery, command building, process management."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import sys
|
||||||
import time
|
import threading
|
||||||
from pathlib import Path
|
import time
|
||||||
from typing import Optional
|
from datetime import datetime as _datetime
|
||||||
|
from pathlib import Path
|
||||||
from models.state import CFG, BatchState
|
from typing import Optional
|
||||||
from services.deck_service import parse_dck_file
|
|
||||||
|
from models.state import CFG, BatchState
|
||||||
log = logging.getLogger("lab.batch")
|
from services.deck_service import parse_dck_file, _load_deck_cards_by_name
|
||||||
|
|
||||||
|
log = logging.getLogger("lab.batch")
|
||||||
def _find_java17() -> str:
|
log_sim = logging.getLogger("commander_ai_lab.sim")
|
||||||
search_dirs = [r'C:\Program Files\Eclipse Adoptium', r'C:\Program Files\Java']
|
|
||||||
for d in search_dirs:
|
# Global DeepSeek brain instance (lazy-initialized)
|
||||||
if os.path.isdir(d):
|
_deepseek_brain = None
|
||||||
for child in os.listdir(d):
|
_deepseek_lock = threading.Lock()
|
||||||
if child.startswith('jdk-17'):
|
|
||||||
candidate = os.path.join(d, child, 'bin', 'java.exe')
|
# ── Global watchdog: single thread monitors all active batches ──
|
||||||
if os.path.isfile(candidate):
|
_watchdog_registry: dict[str, dict] = {} # batch_id -> {state, proc, last_activity}
|
||||||
return candidate
|
_watchdog_lock = threading.Lock()
|
||||||
return 'java'
|
_watchdog_started = False
|
||||||
|
_STALL_LIMIT = 600 # 10 min
|
||||||
|
|
||||||
_JAVA17_PATH = None
|
def _global_watchdog_loop():
|
||||||
|
"""Single daemon thread that checks all active batches for stalls."""
|
||||||
|
while True:
|
||||||
def get_java17() -> str:
|
time.sleep(5)
|
||||||
global _JAVA17_PATH
|
with _watchdog_lock:
|
||||||
if _JAVA17_PATH is None:
|
finished = []
|
||||||
_JAVA17_PATH = _find_java17()
|
for bid, entry in _watchdog_registry.items():
|
||||||
return _JAVA17_PATH
|
proc = entry["proc"]
|
||||||
|
if proc.poll() is not None:
|
||||||
|
finished.append(bid)
|
||||||
def build_java_command(
|
continue
|
||||||
decks: list, num_games: int, threads: int, seed: Optional[int],
|
if (time.monotonic() - entry["last_activity"]) > _STALL_LIMIT:
|
||||||
clock: int, output_path: str, use_learned_policy: bool = False,
|
log.warning(f"Stall detected for batch {bid}, killing process")
|
||||||
policy_server: str = "http://localhost:8080", policy_style: str = "midrange",
|
entry["state"].log_lines.append("[WATCHDOG] Process stalled. Killed.")
|
||||||
policy_greedy: bool = False, ai_simplified: bool = False,
|
proc.kill()
|
||||||
ai_think_time_ms: int = -1, max_queue_depth: int = -1,
|
entry["state"].running = False
|
||||||
) -> list:
|
entry["state"].error = "Stall detected (10 min no output)"
|
||||||
java17 = get_java17()
|
finished.append(bid)
|
||||||
forge_jar = CFG.forge_jar
|
for bid in finished:
|
||||||
cmd = [
|
del _watchdog_registry[bid]
|
||||||
java17, '-jar', forge_jar, '-sim',
|
|
||||||
'-decks', ','.join(str(d) for d in decks),
|
def _ensure_watchdog_started():
|
||||||
'-games', str(num_games),
|
global _watchdog_started
|
||||||
'-threads', str(threads),
|
if not _watchdog_started:
|
||||||
'-clock', str(clock),
|
with _watchdog_lock:
|
||||||
'-output', output_path,
|
if not _watchdog_started:
|
||||||
]
|
t = threading.Thread(target=_global_watchdog_loop, daemon=True)
|
||||||
if seed is not None:
|
t.start()
|
||||||
cmd += ['-seed', str(seed)]
|
_watchdog_started = True
|
||||||
if use_learned_policy:
|
|
||||||
cmd += ['-policy', policy_style, '-policyServer', policy_server]
|
def _register_batch(batch_id: str, state, proc):
|
||||||
if policy_greedy:
|
_ensure_watchdog_started()
|
||||||
cmd += ['-policyGreedy']
|
with _watchdog_lock:
|
||||||
if ai_simplified:
|
_watchdog_registry[batch_id] = {
|
||||||
cmd += ['-aiSimplified']
|
"state": state, "proc": proc, "last_activity": time.monotonic()
|
||||||
if ai_think_time_ms > 0:
|
}
|
||||||
cmd += ['-aiThinkTimeMs', str(ai_think_time_ms)]
|
|
||||||
if max_queue_depth > 0:
|
def _touch_batch(batch_id: str):
|
||||||
cmd += ['-maxQueueDepth', str(max_queue_depth)]
|
with _watchdog_lock:
|
||||||
return cmd
|
if batch_id in _watchdog_registry:
|
||||||
|
_watchdog_registry[batch_id]["last_activity"] = time.monotonic()
|
||||||
|
|
||||||
def _run_process_blocking(state: BatchState, cmd: list):
|
def _unregister_batch(batch_id: str):
|
||||||
"""Run Forge subprocess, parse progress, generate deck reports."""
|
with _watchdog_lock:
|
||||||
log.info(f"Running: {' '.join(cmd)}")
|
_watchdog_registry.pop(batch_id, None)
|
||||||
state.started_at = datetime.datetime.now().isoformat()
|
|
||||||
state.status = "running"
|
|
||||||
env = os.environ.copy()
|
# ── Java 17 Discovery ───────────────────────────────────────────────
|
||||||
java17 = get_java17()
|
|
||||||
java_bin = os.path.dirname(java17)
|
# Ordered list of directories to scan for JDK 17 installations.
|
||||||
if java_bin:
|
# Covers: Eclipse Temurin, Oracle, Amazon Corretto, Microsoft, Azul Zulu, SAP.
|
||||||
env["JAVA_HOME"] = os.path.dirname(java_bin)
|
_JAVA17_SEARCH_DIRS = [
|
||||||
env["PATH"] = java_bin + os.pathsep + env.get("PATH", "")
|
r'C:\Program Files\Eclipse Adoptium',
|
||||||
try:
|
r'C:\Program Files\Java',
|
||||||
proc = subprocess.Popen(
|
r'C:\Program Files\Amazon Corretto',
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
r'C:\Program Files\Microsoft',
|
||||||
text=True, bufsize=1, env=env,
|
r'C:\Program Files\Zulu',
|
||||||
)
|
r'C:\Program Files\BellSoft',
|
||||||
except Exception as e:
|
r'C:\Program Files\SapMachine',
|
||||||
state.status = "error"
|
]
|
||||||
state.error = str(e)
|
|
||||||
log.error(f"Failed to start subprocess: {e}")
|
|
||||||
return
|
def _find_java17() -> str:
|
||||||
last_activity = [time.monotonic()]
|
"""
|
||||||
stall_limit = 300 # 5 min
|
Search well-known JDK install directories for a Java 17 binary.
|
||||||
def watchdog():
|
Falls back to JAVA17_HOME env var, then JAVA_HOME, then warns and returns 'java'.
|
||||||
while proc.poll() is None:
|
Returns the absolute path to java.exe if found, otherwise 'java'.
|
||||||
if (time.monotonic() - last_activity[0]) > stall_limit:
|
"""
|
||||||
log.warning("Stall detected, killing process")
|
# 1. Explicit override via env var
|
||||||
proc.kill()
|
env_override = os.environ.get('JAVA17_HOME', '').strip()
|
||||||
state.status = "error"
|
if env_override:
|
||||||
state.error = "Stall detected (5 min no output)"
|
candidate = os.path.join(env_override, 'bin', 'java.exe')
|
||||||
return
|
if os.path.isfile(candidate):
|
||||||
time.sleep(5)
|
log.info(f"Java 17 found via JAVA17_HOME: {candidate}")
|
||||||
wd = threading.Thread(target=watchdog, daemon=True)
|
return candidate
|
||||||
wd.start()
|
log.warning(f"JAVA17_HOME set to '{env_override}' but java.exe not found there.")
|
||||||
output_lines = []
|
|
||||||
for line in proc.stdout:
|
# 2. Scan vendor directories
|
||||||
last_activity[0] = time.monotonic()
|
for d in _JAVA17_SEARCH_DIRS:
|
||||||
line = line.rstrip()
|
if not os.path.isdir(d):
|
||||||
output_lines.append(line)
|
continue
|
||||||
gm = re.match(r'\[Game (\d+)/(\d+)\]', line)
|
try:
|
||||||
if gm:
|
children = os.listdir(d)
|
||||||
state.games_done = int(gm.group(1))
|
except OSError:
|
||||||
state.games_total = int(gm.group(2))
|
continue
|
||||||
pm = re.match(r'\[PROGRESS\].*?(\d+\.?\d*)\s*sims/sec', line)
|
for child in sorted(children): # sorted = deterministic, picks lowest 17.x
|
||||||
if pm:
|
if re.match(r'jdk-?17', child, re.IGNORECASE) or re.match(r'temurin-17', child, re.IGNORECASE):
|
||||||
state.throughput = float(pm.group(1))
|
candidate = os.path.join(d, child, 'bin', 'java.exe')
|
||||||
proc.wait()
|
if os.path.isfile(candidate):
|
||||||
state.log = '\n'.join(output_lines)
|
log.info(f"Java 17 found: {candidate}")
|
||||||
if proc.returncode != 0:
|
return candidate
|
||||||
state.status = "error"
|
|
||||||
state.error = f"Exit code {proc.returncode}"
|
# 3. JAVA_HOME fallback — only accept if it IS version 17
|
||||||
else:
|
java_home = os.environ.get('JAVA_HOME', '').strip()
|
||||||
state.status = "done"
|
if java_home:
|
||||||
state.games_done = state.games_total
|
candidate = os.path.join(java_home, 'bin', 'java.exe')
|
||||||
state.ended_at = datetime.datetime.now().isoformat()
|
if os.path.isfile(candidate):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
async def run_batch_subprocess(
|
[candidate, '-version'],
|
||||||
state: BatchState, decks: list, num_games: int, threads: int,
|
capture_output=True, text=True, timeout=5
|
||||||
seed: Optional[int] = None, clock: int = 6000, output_path: str = "results",
|
)
|
||||||
use_learned_policy: bool = False, **kwargs,
|
version_output = result.stderr or result.stdout
|
||||||
):
|
if 'version "17' in version_output:
|
||||||
"""Async wrapper that runs Forge subprocess in executor."""
|
log.info(f"Java 17 found via JAVA_HOME: {candidate}")
|
||||||
cmd = build_java_command(
|
return candidate
|
||||||
decks, num_games, threads, seed, clock, output_path,
|
else:
|
||||||
use_learned_policy=use_learned_policy, **kwargs,
|
log.warning(
|
||||||
)
|
f"JAVA_HOME points to '{java_home}' but it is not Java 17. "
|
||||||
loop = asyncio.get_event_loop()
|
f"Version output: {version_output.strip()!r}"
|
||||||
await loop.run_in_executor(None, _run_process_blocking, state, cmd)
|
)
|
||||||
|
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."""
|
"""Precon deck download, caching, and conversion logic."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.request import urlopen, Request
|
from urllib.request import urlopen, Request
|
||||||
|
|
||||||
log = logging.getLogger("commander_ai_lab.api")
|
log = logging.getLogger("commander_ai_lab.api")
|
||||||
|
|
||||||
PRECON_DIR = Path(__file__).parent.parent / "precon-decks"
|
# Fallback default — used only when CFG.precon_dir has not been resolved yet.
|
||||||
PRECON_INDEX: list[dict] = []
|
_DEFAULT_PRECON_DIR = Path(__file__).parent.parent / "precon-decks"
|
||||||
GITHUB_PRECON_URL = (
|
|
||||||
"https://raw.githubusercontent.com/taw/magic-preconstructed-decks-data/"
|
PRECON_INDEX: list[dict] = []
|
||||||
"master/decks_v2.json"
|
GITHUB_PRECON_URL = (
|
||||||
)
|
"https://raw.githubusercontent.com/taw/magic-preconstructed-decks-data/"
|
||||||
PRECON_CACHE_HOURS = 168
|
"master/decks_v2.json"
|
||||||
|
)
|
||||||
|
PRECON_CACHE_HOURS = 168
|
||||||
def load_precon_index():
|
_SCRYFALL_COLLECTION_URL = "https://api.scryfall.com/cards/collection"
|
||||||
global PRECON_INDEX
|
|
||||||
idx_path = PRECON_DIR / "precon-index.json"
|
|
||||||
if idx_path.exists():
|
def _get_precon_dir() -> Path:
|
||||||
with open(idx_path, "r", encoding="utf-8") as f:
|
"""Return the active precon-decks directory from CFG, with fallback."""
|
||||||
PRECON_INDEX = json.load(f)
|
from models.state import CFG
|
||||||
log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
|
if CFG.precon_dir:
|
||||||
else:
|
return Path(CFG.precon_dir)
|
||||||
PRECON_INDEX = []
|
return _DEFAULT_PRECON_DIR
|
||||||
log.info(f" Precons: index not found at {idx_path}")
|
|
||||||
|
|
||||||
|
# Re-export for backwards compat — callers that import PRECON_DIR at module
|
||||||
def _sanitize_filename(name: str) -> str:
|
# level will get the default. Runtime code should call _get_precon_dir().
|
||||||
safe = re.sub(r'[<>:"/\\|?*]', '', name)
|
PRECON_DIR = _DEFAULT_PRECON_DIR
|
||||||
safe = safe.replace(' ', '_').replace("'", '').replace('!', '')
|
|
||||||
return re.sub(r'_+', '_', safe).strip('_')
|
|
||||||
|
def load_precon_index():
|
||||||
|
"""Load precon index from disk into the shared PRECON_INDEX list.
|
||||||
def _deck_to_dck(deck_data: dict) -> str:
|
|
||||||
lines = ["[metadata]", f"Name={deck_data['name']}"]
|
We mutate the list in-place (clear + extend) so that any module that
|
||||||
commanders = deck_data.get('commander', [])
|
captured a reference via ``from precon_service import PRECON_INDEX``
|
||||||
if commanders:
|
will see the updated contents.
|
||||||
lines.append("[Commander]")
|
"""
|
||||||
for card in commanders:
|
precon_dir = _get_precon_dir()
|
||||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
idx_path = precon_dir / "precon-index.json"
|
||||||
cards = deck_data.get('cards', [])
|
if idx_path.exists():
|
||||||
if cards:
|
with open(idx_path, "r", encoding="utf-8") as f:
|
||||||
lines.append("[Main]")
|
data = json.load(f)
|
||||||
for card in cards:
|
PRECON_INDEX.clear()
|
||||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
PRECON_INDEX.extend(data)
|
||||||
sideboard = deck_data.get('sideboard', [])
|
log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
|
||||||
if sideboard:
|
else:
|
||||||
lines.append("[Sideboard]")
|
PRECON_INDEX.clear()
|
||||||
for card in sideboard:
|
log.info(f" Precons: index not found at {idx_path}")
|
||||||
lines.append(f"{card.get('count', 1)} {card['name']}")
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
def _sanitize_filename(name: str) -> str:
|
||||||
|
safe = re.sub(r'[<>:"/\\|?*]', '', name)
|
||||||
def download_precon_database(force: bool = False) -> dict:
|
safe = safe.replace(' ', '_').replace("'", '').replace('!', '')
|
||||||
global PRECON_INDEX
|
return re.sub(r'_+', '_', safe).strip('_')
|
||||||
idx_path = PRECON_DIR / "precon-index.json"
|
|
||||||
if not force and idx_path.exists():
|
|
||||||
try:
|
def _deck_to_dck(deck_data: dict) -> str:
|
||||||
with open(idx_path, "r", encoding="utf-8") as f:
|
lines = ["[metadata]", f"Name={deck_data['name']}"]
|
||||||
existing = json.load(f)
|
commanders = deck_data.get('commander', [])
|
||||||
if len(existing) > 50:
|
if commanders:
|
||||||
age_hours = (time.time() - idx_path.stat().st_mtime) / 3600
|
lines.append("[Commander]")
|
||||||
if age_hours < PRECON_CACHE_HOURS:
|
for card in commanders:
|
||||||
log.info(f" Precons: {len(existing)} decks cached ({age_hours:.0f}h old)")
|
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||||
PRECON_INDEX = existing
|
cards = deck_data.get('cards', [])
|
||||||
return {"downloaded": 0, "skipped": True, "total": len(existing), "error": None}
|
if cards:
|
||||||
except Exception:
|
lines.append("[Main]")
|
||||||
pass
|
for card in cards:
|
||||||
log.info(" Precons: Downloading full precon database from GitHub...")
|
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||||
try:
|
sideboard = deck_data.get('sideboard', [])
|
||||||
req = Request(GITHUB_PRECON_URL, headers={"User-Agent": "CommanderAILab/3.0"})
|
if sideboard:
|
||||||
with urlopen(req, timeout=120) as resp:
|
lines.append("[Sideboard]")
|
||||||
all_decks = json.loads(resp.read())
|
for card in sideboard:
|
||||||
except Exception as e:
|
lines.append(f"{card.get('count', 1)} {card['name']}")
|
||||||
msg = f"Failed to download precon database: {e}"
|
return "\n".join(lines) + "\n"
|
||||||
log.error(f" Precons: ERROR - {msg}")
|
|
||||||
if idx_path.exists():
|
|
||||||
load_precon_index()
|
def download_precon_database(force: bool = False) -> dict:
|
||||||
return {"downloaded": 0, "skipped": False, "error": msg}
|
"""Download precon database from GitHub and populate PRECON_INDEX.
|
||||||
commander_decks = [
|
|
||||||
d for d in all_decks
|
We mutate the list in-place (clear + extend) so that any module that
|
||||||
if d.get('type') == 'Commander Deck'
|
captured a reference via ``from precon_service import PRECON_INDEX``
|
||||||
and (d.get('format') or '').lower() == 'commander'
|
will see the updated contents.
|
||||||
]
|
"""
|
||||||
log.info(f" Precons: Found {len(commander_decks)} Commander precon decks")
|
precon_dir = _get_precon_dir()
|
||||||
PRECON_DIR.mkdir(parents=True, exist_ok=True)
|
idx_path = precon_dir / "precon-index.json"
|
||||||
name_counts = Counter(_sanitize_filename(d['name']) for d in commander_decks)
|
if not force and idx_path.exists():
|
||||||
dup_names = {n for n, c in name_counts.items() if c > 1}
|
try:
|
||||||
index = []
|
with open(idx_path, "r", encoding="utf-8") as f:
|
||||||
written = 0
|
existing = json.load(f)
|
||||||
for deck in sorted(commander_decks, key=lambda d: (d.get('release_date', ''), d.get('name', ''))):
|
if len(existing) > 50:
|
||||||
safe_name = _sanitize_filename(deck['name'])
|
# Invalidate cache if missing enrichment fields (imageUrl, colors)
|
||||||
if safe_name in dup_names:
|
has_enrichment = existing[0].get("imageUrl") or existing[0].get("colors")
|
||||||
sc = (deck.get('set_code') or 'unk').upper()
|
age_hours = (time.time() - idx_path.stat().st_mtime) / 3600
|
||||||
safe_name = f"{safe_name}_{sc}"
|
if has_enrichment and age_hours < PRECON_CACHE_HOURS:
|
||||||
file_name = f"{safe_name}.dck"
|
log.info(f" Precons: {len(existing)} decks cached ({age_hours:.0f}h old)")
|
||||||
dck_path = PRECON_DIR / file_name
|
PRECON_INDEX.clear()
|
||||||
commanders = deck.get('commander', [])
|
PRECON_INDEX.extend(existing)
|
||||||
cmdr_names = [c['name'] for c in commanders] if commanders else []
|
return {"downloaded": 0, "skipped": True, "total": len(existing), "error": None}
|
||||||
total_cards = sum(c.get('count', 1) for c in deck.get('cards', [])) + sum(c.get('count', 1) for c in commanders)
|
if not has_enrichment:
|
||||||
with open(dck_path, "w", encoding="utf-8") as f:
|
log.info(" Precons: Cache missing enrichment data, re-downloading...")
|
||||||
f.write(_deck_to_dck(deck))
|
except Exception:
|
||||||
written += 1
|
pass
|
||||||
release = deck.get('release_date', '')
|
log.info(" Precons: Downloading full precon database from GitHub...")
|
||||||
year = int(release[:4]) if release and len(release) >= 4 else 0
|
try:
|
||||||
index.append({
|
req = Request(GITHUB_PRECON_URL, headers={"User-Agent": "CommanderAILab/3.0"})
|
||||||
"name": deck['name'], "commander": cmdr_names[0] if cmdr_names else "Unknown",
|
with urlopen(req, timeout=120) as resp:
|
||||||
"commanders": cmdr_names, "colors": [],
|
all_decks = json.loads(resp.read())
|
||||||
"set": deck.get('set_name', ''), "setCode": deck.get('set_code', ''),
|
except Exception as e:
|
||||||
"year": year, "releaseDate": release, "theme": "",
|
msg = f"Failed to download precon database: {e}"
|
||||||
"fileName": file_name, "cardCount": total_cards,
|
log.error(f" Precons: ERROR - {msg}")
|
||||||
})
|
if idx_path.exists():
|
||||||
with open(idx_path, "w", encoding="utf-8") as f:
|
load_precon_index()
|
||||||
json.dump(index, f, indent=2, ensure_ascii=False)
|
return {"downloaded": 0, "skipped": False, "error": msg}
|
||||||
PRECON_INDEX = index
|
commander_decks = [
|
||||||
log.info(f" Precons: {written} .dck files written, index saved")
|
d for d in all_decks
|
||||||
return {"downloaded": written, "skipped": False, "total": written, "error": None}
|
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
|
# Lazy import to avoid circular dependency
|
||||||
def _detect_card_roles(oracle_text, type_line, keywords):
|
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)
|
return _dcr(oracle_text, type_line, keywords)
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ class _ScryfallCache:
|
|||||||
self._misses = 0
|
self._misses = 0
|
||||||
self._init_db()
|
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 = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||||
self._conn.execute("""
|
self._conn.execute("""
|
||||||
@@ -43,11 +43,11 @@ class _ScryfallCache:
|
|||||||
""")
|
""")
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _make_key(name: str, set_code: str, collector_number: str) -> str:
|
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()}"
|
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)
|
key = self._make_key(name, set_code, collector_number)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self._conn.execute(
|
row = self._conn.execute(
|
||||||
@@ -59,7 +59,7 @@ class _ScryfallCache:
|
|||||||
self._misses += 1
|
self._misses += 1
|
||||||
return None
|
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)
|
key = self._make_key(name, set_code, collector_number)
|
||||||
blob = json.dumps(data)
|
blob = json.dumps(data)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -69,12 +69,12 @@ class _ScryfallCache:
|
|||||||
)
|
)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
def stats(self) -> dict:
|
def stats(self) -> dict:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||||
return {"cached": count, "hits": self._hits, "misses": self._misses}
|
return {"cached": count, "hits": self._hits, "misses": self._misses}
|
||||||
|
|
||||||
def clear(self) -> int:
|
def clear(self) -> int:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||||
self._conn.execute("DELETE FROM scryfall_cache")
|
self._conn.execute("DELETE FROM scryfall_cache")
|
||||||
@@ -95,7 +95,7 @@ class _ScryfallCache:
|
|||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
_scryfall_cache = _ScryfallCache()
|
_scryfall_cache = _ScryfallCache()
|
||||||
_scryfall_lock = threading.Lock()
|
_scryfall_lock = threading.Lock()
|
||||||
_scryfall_last_call = 0.0
|
_scryfall_last_call = 0.0
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ def _scryfall_rate_limit():
|
|||||||
_scryfall_last_call = time.monotonic()
|
_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
|
from urllib.parse import quote
|
||||||
cached = _scryfall_cache.get(name, set_code, collector_number)
|
cached = _scryfall_cache.get(name, set_code, collector_number)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
@@ -133,7 +133,7 @@ def _scryfall_rate_limit():
|
|||||||
card_data = json.loads(resp.read().decode("utf-8"))
|
card_data = json.loads(resp.read().decode("utf-8"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = str(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
|
err_detail = card_data.get("details", "unknown error") if card_data else last_error
|
||||||
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
|
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
|
||||||
_scryfall_cache.put(name, set_code, collector_number, card_data)
|
_scryfall_cache.put(name, set_code, collector_number, card_data)
|
||||||
@@ -143,7 +143,7 @@ def _scryfall_rate_limit():
|
|||||||
return card_data
|
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)
|
card_data = _fetch_scryfall_api(name, set_code, collector_number)
|
||||||
if not card_data or "_error" in card_data:
|
if not card_data or "_error" in card_data:
|
||||||
return card_data or {}
|
return card_data or {}
|
||||||
@@ -165,7 +165,7 @@ def _scryfall_rate_limit():
|
|||||||
oracle_text = card_data.get("oracle_text", "")
|
oracle_text = card_data.get("oracle_text", "")
|
||||||
if not oracle_text and card_data.get("card_faces"):
|
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"))
|
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", "")
|
toughness = card_data.get("toughness", "")
|
||||||
if not power and card_data.get("card_faces"):
|
if not power and card_data.get("card_faces"):
|
||||||
power = card_data["card_faces"][0].get("power", "")
|
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."""
|
"""Fuzzy-search Scryfall for a card name. Returns raw Scryfall JSON dict, or None on failure."""
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
_scryfall_rate_limit()
|
_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,
|
action describing what to do this turn (play land, cast spell, attack,
|
||||||
hold/pass, use removal, use board wipe, cast ramp, cast commander).
|
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
|
ollama run gpt-oss:20b
|
||||||
|
|
||||||
Also supports:
|
Also supports:
|
||||||
@@ -58,17 +58,17 @@ class DeepSeekConfig:
|
|||||||
|
|
||||||
Example — LM Studio::
|
Example — LM Studio::
|
||||||
config = DeepSeekConfig(
|
config = DeepSeekConfig(
|
||||||
api_base="http://192.168.0.122:1234",
|
api_base="http://192.168.0.240:11434",
|
||||||
model="deepseek-r1-distill-qwen-8b",
|
model="deepseek-r1-distill-qwen-8b",
|
||||||
max_tokens=2048, # R1 needs room for <think> blocks
|
max_tokens=2048, # R1 needs room for <think> blocks
|
||||||
)
|
)
|
||||||
|
|
||||||
Example — Ollama (default)::
|
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 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 name as registered in Ollama
|
||||||
model: str = "gpt-oss:20b"
|
model: str = "gpt-oss:20b"
|
||||||
|
|
||||||
@@ -784,7 +784,8 @@ class DeepSeekBrain:
|
|||||||
"source": result.get("source"),
|
"source": result.get("source"),
|
||||||
"latency_ms": result.get("latency_ms"),
|
"latency_ms": result.get("latency_ms"),
|
||||||
},
|
},
|
||||||
"game_result": None,
|
"validation": result.get("validation"),
|
||||||
|
"game_result": None,
|
||||||
}
|
}
|
||||||
self._decision_log.append(entry)
|
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_engine import DeepSeekGameEngine
|
||||||
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
|
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()
|
brain.check_connection()
|
||||||
engine = DeepSeekGameEngine(brain=brain, ai_player_index=1)
|
engine = DeepSeekGameEngine(brain=brain, ai_player_index=1)
|
||||||
result = engine.run(deck_a, deck_b, name_a="My Deck", name_b="DeepSeek AI")
|
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.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.rules import AI_DEFAULT_WEIGHTS, enrich_card, score_card
|
||||||
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig, build_deck_context
|
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")
|
logger = logging.getLogger("deepseek_engine")
|
||||||
|
|
||||||
@@ -54,12 +57,13 @@ class DeepSeekGameEngine:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
brain: DeepSeekBrain | None = None,
|
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,
|
max_turns: int = 25,
|
||||||
starting_life: int = 40,
|
starting_life: int = 40,
|
||||||
weights: Optional[dict] = None,
|
weights: Optional[dict] = None,
|
||||||
record_log: bool = False,
|
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.brain = brain or DeepSeekBrain()
|
||||||
self.ai_player_index = ai_player_index
|
self.ai_player_index = ai_player_index
|
||||||
@@ -68,7 +72,16 @@ class DeepSeekGameEngine:
|
|||||||
self.weights = weights or AI_DEFAULT_WEIGHTS
|
self.weights = weights or AI_DEFAULT_WEIGHTS
|
||||||
self.record_log = record_log
|
self.record_log = record_log
|
||||||
self.ml_log = ml_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(
|
def run(
|
||||||
self,
|
self,
|
||||||
@@ -247,6 +260,24 @@ class DeepSeekGameEngine:
|
|||||||
|
|
||||||
# Ask DeepSeek what to do — with full deck intelligence
|
# Ask DeepSeek what to do — with full deck intelligence
|
||||||
decision = self.brain.choose_action(sim, pi, turn, available_mana, deck_context=deck_context)
|
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")
|
action = decision.get("action", "hold")
|
||||||
target_card_name = decision.get("target_card", "")
|
target_card_name = decision.get("target_card", "")
|
||||||
reasoning = decision.get("reasoning", "")
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -72,6 +75,35 @@ AI_DEFAULT_WEIGHTS: dict[str, float] = {
|
|||||||
"commander_bonus": 5.0,
|
"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
|
# 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"
|
// "Game Outcome: Ai(1)-Edgar Markov has won because all opponents have lost"
|
||||||
private static final Pattern WINNER_PATTERN =
|
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"
|
// "Game Outcome: Ai(2)-Grimgrin has lost because life total reached 0"
|
||||||
private static final Pattern LOSER_PATTERN =
|
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!"
|
// "Game Result: Game 1 ended in 9201 ms. Ai(1)-Edgar Markov has won!"
|
||||||
private static final Pattern GAME_RESULT_PATTERN =
|
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)
|
// 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."
|
// Also support older format: "Ai(1)-Name's life is now 27."
|
||||||
private static final Pattern LIFE_CHANGE_PATTERN =
|
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"
|
// 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 =
|
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
|
// "Ai(1)-Name loses N life" — life loss event
|
||||||
private static final Pattern LIFE_LOSS_PATTERN =
|
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
|
// "Ai(1)-Name gains N life" — life gain event
|
||||||
private static final Pattern LIFE_GAIN_PATTERN =
|
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 ────────────
|
// ── Verbose game-log patterns for extracting combat stats ────────────
|
||||||
|
|
||||||
@@ -117,12 +117,12 @@ public class BatchRunner {
|
|||||||
// Format: "Add To Stack: Ai(N)-DeckName cast CardName"
|
// Format: "Add To Stack: Ai(N)-DeckName cast CardName"
|
||||||
// Note: Forge uses past tense "cast" not "casts"
|
// Note: Forge uses past tense "cast" not "casts"
|
||||||
private static final Pattern CAST_PATTERN =
|
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."
|
// "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
|
// Forge verbose log uses "played" (past tense) with set info in parens, no trailing period
|
||||||
private static final Pattern LAND_PLAY_PATTERN =
|
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
|
// "Zone Change: CardName (N) was put into Graveyard from Battlefield." — creature/permanent death
|
||||||
// Also: "is destroyed" / "dies" from older format
|
// 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)"
|
// "deals N combat damage" or "deals N damage to Ai(X)"
|
||||||
private static final Pattern DAMAGE_PATTERN =
|
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
|
// "commander damage" — specifically commander damage dealt
|
||||||
private static final Pattern CMDR_DAMAGE_PATTERN =
|
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) ─────────────────────────
|
// ── 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.
|
// 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.
|
// In verbose mode, draws can only be inferred from "Draw step" phase entries.
|
||||||
private static final Pattern DRAW_PATTERN =
|
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)"
|
// Turn boundary: "Turn 5 (Ai(1)-Name)" or "Turn 5 (Ai(2)-Name)"
|
||||||
// Some Forge versions use: "== Turn X (Ai(N)-Name) =="
|
// Some Forge versions use: "== Turn X (Ai(N)-Name) =="
|
||||||
private static final Pattern VERBOSE_TURN_PATTERN =
|
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
|
// "CardName deals N damage" — damage by a specific card
|
||||||
private static final Pattern CARD_DAMAGE_PATTERN =
|
private static final Pattern CARD_DAMAGE_PATTERN =
|
||||||
@@ -582,9 +586,11 @@ public class BatchRunner {
|
|||||||
Set<Integer> loserSeats = new HashSet<>();
|
Set<Integer> loserSeats = new HashSet<>();
|
||||||
Set<Integer> winnerCandidates = new HashSet<>(); // Track all "has won" seats
|
Set<Integer> winnerCandidates = new HashSet<>(); // Track all "has won" seats
|
||||||
int maxVerboseTurn = 0; // Fallback turn count from verbose log
|
int maxVerboseTurn = 0; // Fallback turn count from verbose log
|
||||||
|
|
||||||
for (String line : lines) {
|
for (String line : lines) {
|
||||||
line = line.trim();
|
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"
|
// Parse turn count: "Game Outcome: Turn 11"
|
||||||
Matcher turnMatcher = TURN_PATTERN.matcher(line);
|
Matcher turnMatcher = TURN_PATTERN.matcher(line);
|
||||||
@@ -713,7 +719,7 @@ public class BatchRunner {
|
|||||||
|
|
||||||
// Count mulligans from non-quiet output
|
// Count mulligans from non-quiet output
|
||||||
if (line.toLowerCase().contains("mulligan")) {
|
if (line.toLowerCase().contains("mulligan")) {
|
||||||
Matcher mullMatcher = Pattern.compile("Ai\\((\\d+)\\)").matcher(line);
|
Matcher mullMatcher = Pattern.compile("Ai\\(?(\\d+)\\)?").matcher(line);
|
||||||
if (mullMatcher.find()) {
|
if (mullMatcher.find()) {
|
||||||
int aiNum = Integer.parseInt(mullMatcher.group(1));
|
int aiNum = Integer.parseInt(mullMatcher.group(1));
|
||||||
int seat = aiNum - 1;
|
int seat = aiNum - 1;
|
||||||
@@ -791,8 +797,32 @@ public class BatchRunner {
|
|||||||
for (int ci = 0; ci < decks.size(); ci++) {
|
for (int ci = 0; ci < decks.size(); ci++) {
|
||||||
commanderNames[ci] = decks.get(ci).commanderName;
|
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) {
|
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();
|
line = line.trim();
|
||||||
if (line.isEmpty()) continue;
|
if (line.isEmpty()) continue;
|
||||||
|
|
||||||
@@ -824,18 +854,30 @@ public class BatchRunner {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match damage dealt: "deals N damage to Ai(X)"
|
// Match damage dealt to players and track commander damage
|
||||||
Matcher dmgMatcher = DAMAGE_PATTERN.matcher(line);
|
Matcher playerDmgMatcher = PLAYER_DAMAGE_SOURCE_PATTERN.matcher(line);
|
||||||
while (dmgMatcher.find()) {
|
if (playerDmgMatcher.find()) {
|
||||||
// Note: this captures damage TO a player, not FROM.
|
String sourceCard = playerDmgMatcher.group(1).trim();
|
||||||
// We track it as creature/combat damage events
|
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"
|
// Match creature deaths: "is destroyed" / "dies"
|
||||||
if (CREATURE_DESTROYED_PATTERN.matcher(line).find()) {
|
if (CREATURE_DESTROYED_PATTERN.matcher(line).find()) {
|
||||||
// Try to figure out which player's creature died
|
// Try to figure out which player's creature died
|
||||||
// Look for Ai(N) reference before the destruction text
|
// 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)
|
// We attribute destruction to the OPPOSING player (the one who caused it)
|
||||||
// But since we can't reliably determine the attacker, count it globally
|
// But since we can't reliably determine the attacker, count it globally
|
||||||
// For each seat, count creatures they destroyed (opponent's creatures that died)
|
// For each seat, count creatures they destroyed (opponent's creatures that died)
|
||||||
@@ -860,7 +902,7 @@ public class BatchRunner {
|
|||||||
if (cdmg.find()) {
|
if (cdmg.find()) {
|
||||||
int amount = Integer.parseInt(cdmg.group(1));
|
int amount = Integer.parseInt(cdmg.group(1));
|
||||||
// Try to find the source player
|
// 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()) {
|
if (srcPlayer.find()) {
|
||||||
int seat = Integer.parseInt(srcPlayer.group(1)) - 1;
|
int seat = Integer.parseInt(srcPlayer.group(1)) - 1;
|
||||||
if (seat >= 0 && seat < decks.size()) {
|
if (seat >= 0 && seat < decks.size()) {
|
||||||
@@ -879,6 +921,14 @@ public class BatchRunner {
|
|||||||
// Leave at 0 rather than fabricating data
|
// 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) ──
|
// ── Build per-card stats from verbose output (for coach analytics) ──
|
||||||
if (!quiet) {
|
if (!quiet) {
|
||||||
try {
|
try {
|
||||||
@@ -1267,4 +1317,49 @@ public class BatchRunner {
|
|||||||
}
|
}
|
||||||
return result;
|
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 winRate;
|
||||||
public Double avgTurnsToWin; // null if zero wins
|
public Double avgTurnsToWin; // null if zero wins
|
||||||
public double avgMulligans;
|
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 double avgFinalLife;
|
||||||
public WinConditionBreakdown winConditionBreakdown;
|
public WinConditionBreakdown winConditionBreakdown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ public class StatsAggregator {
|
|||||||
long[] totalTurnsOnWin = new long[podSize];
|
long[] totalTurnsOnWin = new long[podSize];
|
||||||
long[] totalMulligans = new long[podSize];
|
long[] totalMulligans = new long[podSize];
|
||||||
long[] totalFinalLife = 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];
|
int[] gameCount = new int[podSize];
|
||||||
WinConditionBreakdown[] breakdowns = new WinConditionBreakdown[podSize];
|
WinConditionBreakdown[] breakdowns = new WinConditionBreakdown[podSize];
|
||||||
for (int i = 0; i < podSize; i++) {
|
for (int i = 0; i < podSize; i++) {
|
||||||
@@ -46,6 +51,11 @@ public class StatsAggregator {
|
|||||||
gameCount[seat]++;
|
gameCount[seat]++;
|
||||||
totalMulligans[seat] += pr.mulligans;
|
totalMulligans[seat] += pr.mulligans;
|
||||||
totalFinalLife[seat] += pr.finalLife;
|
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) {
|
if (pr.isWinner) {
|
||||||
wins[seat]++;
|
wins[seat]++;
|
||||||
@@ -70,6 +80,11 @@ public class StatsAggregator {
|
|||||||
ds.draws = draws[i];
|
ds.draws = draws[i];
|
||||||
ds.winRate = completedGames > 0 ? (double) wins[i] / completedGames : 0.0;
|
ds.winRate = completedGames > 0 ? (double) wins[i] / completedGames : 0.0;
|
||||||
ds.avgTurnsToWin = wins[i] > 0 ? (double) totalTurnsOnWin[i] / wins[i] : null;
|
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.avgMulligans = gameCount[i] > 0 ? (double) totalMulligans[i] / gameCount[i] : 0.0;
|
||||||
ds.avgFinalLife = gameCount[i] > 0 ? (double) totalFinalLife[i] / gameCount[i] : 0.0;
|
ds.avgFinalLife = gameCount[i] > 0 ? (double) totalFinalLife[i] / gameCount[i] : 0.0;
|
||||||
ds.winConditionBreakdown = breakdowns[i];
|
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);
|
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 {
|
.lab-arch-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 1px 7px;
|
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-substituted .dg-stat-num { color: #60a5fa; }
|
||||||
.dg-stat-missing .dg-stat-num { color: #f59e0b; }
|
.dg-stat-missing .dg-stat-num { color: #f59e0b; }
|
||||||
.dg-stat-price .dg-stat-num { color: #a78bfa; }
|
.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">
|
<div class="dg-model-row">
|
||||||
<label class="dg-label">AI Model</label>
|
<label class="dg-label">AI Model</label>
|
||||||
<select class="dg-select" id="dg-model">
|
<select class="dg-select" id="dg-model">
|
||||||
<option value="sonar">Sonar (fast, ~$0.004)</option>
|
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
|
||||||
<option value="sonar-pro">Sonar Pro (deep, ~$0.04)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,6 +145,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- RIGHT: Preview Results -->
|
<!-- RIGHT: Preview Results -->
|
||||||
@@ -160,7 +164,7 @@
|
|||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div class="dg-loading" id="dg-loading" style="display:none">
|
<div class="dg-loading" id="dg-loading" style="display:none">
|
||||||
<div class="dg-spinner"></div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
|
|||||||
+37
-12
@@ -5,7 +5,7 @@
|
|||||||
* V3 Endpoints:
|
* V3 Endpoints:
|
||||||
* GET /api/deck/v3/status — check V3 generator status
|
* GET /api/deck/v3/status — check V3 generator status
|
||||||
* GET /api/deck-generator/commander-search — autocomplete commanders
|
* 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/commit — generate + save to Deck Builder
|
||||||
* POST /api/deck/v3/export/csv — export as CSV
|
* POST /api/deck/v3/export/csv — export as CSV
|
||||||
* POST /api/deck/v3/export/dck — export as Forge .dck
|
* POST /api/deck/v3/export/dck — export as Forge .dck
|
||||||
@@ -68,7 +68,9 @@ const DeckGenerator = (() => {
|
|||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
state.v3Ready = data.initialized;
|
state.v3Ready = data.initialized;
|
||||||
if (!state.v3Ready) {
|
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) {
|
} catch (e) {
|
||||||
console.warn('V3 status check failed:', e);
|
console.warn('V3 status check failed:', e);
|
||||||
@@ -223,7 +225,6 @@ const DeckGenerator = (() => {
|
|||||||
|
|
||||||
const img = $('dg-commander-img');
|
const img = $('dg-commander-img');
|
||||||
if (cmdr.image_url) {
|
if (cmdr.image_url) {
|
||||||
// Use normal size for readable oracle text; ensure we don't have small version
|
|
||||||
var imgUrl = cmdr.image_url;
|
var imgUrl = cmdr.image_url;
|
||||||
if (imgUrl.indexOf('version=small') !== -1) {
|
if (imgUrl.indexOf('version=small') !== -1) {
|
||||||
imgUrl = imgUrl.replace('version=small', 'version=normal');
|
imgUrl = imgUrl.replace('version=small', 'version=normal');
|
||||||
@@ -253,14 +254,39 @@ const DeckGenerator = (() => {
|
|||||||
$('dg-commander-dropdown').classList.remove('open');
|
$('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 ───────────────────────────────────────
|
// ── Generate Deck ───────────────────────────────────────
|
||||||
async function generateDeck() {
|
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.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();
|
const body = buildRequestBody();
|
||||||
|
logMessage('Sending request to AI model... this may take a moment.', 'info');
|
||||||
state.lastRequestBody = body;
|
state.lastRequestBody = body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -276,12 +302,17 @@ const DeckGenerator = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.previewResult = await resp.json();
|
state.previewResult = await resp.json();
|
||||||
renderResults(state.previewResult);
|
logMessage('AI response received! Processing deck data...', 'success');
|
||||||
|
|
||||||
const stats = state.previewResult.stats || {};
|
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');
|
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message || 'Generation failed', 'error');
|
toast(e.message || 'Generation failed', 'error');
|
||||||
|
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,7 +396,6 @@ const DeckGenerator = (() => {
|
|||||||
const cards = result.cards || [];
|
const cards = result.cards || [];
|
||||||
const commander = result.commander;
|
const commander = result.commander;
|
||||||
|
|
||||||
// Group by category
|
|
||||||
const groups = {};
|
const groups = {};
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
const cat = card.category || 'Other';
|
const cat = card.category || 'Other';
|
||||||
@@ -380,7 +410,6 @@ const DeckGenerator = (() => {
|
|||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
// Commander section
|
|
||||||
if (commander) {
|
if (commander) {
|
||||||
html += '<div class="dg-card-group">'
|
html += '<div class="dg-card-group">'
|
||||||
+ '<div class="dg-group-header">'
|
+ '<div class="dg-group-header">'
|
||||||
@@ -395,7 +424,6 @@ const DeckGenerator = (() => {
|
|||||||
+ '</div></div>';
|
+ '</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card groups
|
|
||||||
for (const type of sortedTypes) {
|
for (const type of sortedTypes) {
|
||||||
const groupCards = groups[type];
|
const groupCards = groups[type];
|
||||||
if (!groupCards || groupCards.length === 0) continue;
|
if (!groupCards || groupCards.length === 0) continue;
|
||||||
@@ -419,7 +447,6 @@ const DeckGenerator = (() => {
|
|||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
|
||||||
// Collapse toggles
|
|
||||||
container.querySelectorAll('.dg-group-header').forEach(header => {
|
container.querySelectorAll('.dg-group-header').forEach(header => {
|
||||||
header.addEventListener('click', () => {
|
header.addEventListener('click', () => {
|
||||||
const type = header.dataset.type;
|
const type = header.dataset.type;
|
||||||
@@ -438,7 +465,6 @@ const DeckGenerator = (() => {
|
|||||||
.map(r => '<span class="dg-role-chip">' + escHtml(r) + '</span>')
|
.map(r => '<span class="dg-role-chip">' + escHtml(r) + '</span>')
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
// Substitution indicator
|
|
||||||
let subHtml = '';
|
let subHtml = '';
|
||||||
if (card.status === 'substituted' && card.selected_substitute) {
|
if (card.status === 'substituted' && card.selected_substitute) {
|
||||||
subHtml = '<div class="dg-card-sub-line">'
|
subHtml = '<div class="dg-card-sub-line">'
|
||||||
@@ -577,7 +603,6 @@ const DeckGenerator = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and copy text
|
|
||||||
const lines = ['Shopping List — ' + (data.commander || 'Deck'), ''];
|
const lines = ['Shopping List — ' + (data.commander || 'Deck'), ''];
|
||||||
for (const item of list) {
|
for (const item of list) {
|
||||||
lines.push(item.count + 'x ' + item.name + ' ($' + (item.estimated_price_usd || 0).toFixed(2) + ') — ' + item.category);
|
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 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 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="global.css" />
|
||||||
<link rel="stylesheet" href="ai-lab.css" />
|
<link rel="stylesheet" href="ai-lab.css?v=4" />
|
||||||
<style>
|
<style>
|
||||||
/* ── Dashboard Feature Grid ──────────────────── */
|
/* ── Dashboard Feature Grid ──────────────────── */
|
||||||
.feature-grid {
|
.feature-grid {
|
||||||
@@ -196,6 +196,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="ai-lab.js"></script>
|
<script src="ai-lab.js?v=4"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+1
-1
@@ -62,7 +62,7 @@
|
|||||||
<div class="deepseek-config-row">
|
<div class="deepseek-config-row">
|
||||||
<div class="form-group sim-field-grow">
|
<div class="form-group sim-field-grow">
|
||||||
<label class="form-label" for="ds-api-base">API Endpoint</label>
|
<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>
|
||||||
<div class="form-group sim-field-auto">
|
<div class="form-group sim-field-auto">
|
||||||
<label class="form-label" for="ds-model">Model</label>
|
<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user