Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c67222de1 | ||
|
|
d8238ec11f | ||
|
|
7aed64a660 | ||
|
|
e3497a8f74 | ||
|
|
6a655628c8 | ||
|
|
e3d4eb5785 | ||
|
|
abee551ab4 | ||
|
|
9fdb6d29fb | ||
|
|
2c8c6c26bd | ||
|
|
9ffefd463c | ||
|
|
4c99957e76 | ||
|
|
e5e25a7553 | ||
|
|
8d56feee80 | ||
|
|
837bb3d6f9 | ||
|
|
98b81ceab2 | ||
|
|
fae04a9336 | ||
|
|
8fd155a925 | ||
|
|
34ce9bfbf7 | ||
|
|
07010a71ed | ||
|
|
ac536999eb | ||
|
|
2e43511d87 | ||
|
|
1bebe38e66 | ||
|
|
eca1653024 | ||
|
|
08f1cb7581 | ||
|
|
9d9e8675a9 | ||
|
|
64526b7bee | ||
|
|
e9732d292f | ||
|
|
fa5349ea7c | ||
|
|
1c491bc2b0 | ||
|
|
81fe5108d2 | ||
|
|
61fa37dc48 | ||
|
|
0118950939 | ||
|
|
22cf23b24d | ||
|
|
0010b0f394 | ||
|
|
d0cdafa66a | ||
|
|
bcf558418a |
No files matched your search
+20
-2
@@ -252,9 +252,19 @@ class CoachService:
|
||||
candidates[card_name] = [m.to_dict() for m in matches]
|
||||
else:
|
||||
logger.warning("Embeddings not loaded — skipping candidate search")
|
||||
# Phase 3: RAG vector search for additional card context
|
||||
rag_cards: List[dict] = []
|
||||
try:
|
||||
from services.rag_store import query_cards
|
||||
query_text = f"{report.commander} {' '.join(report.colorIdentity)} commander deck"
|
||||
rag_cards = query_cards(query_text, n_results=15, color_identity=report.colorIdentity)
|
||||
logger.info("RAG returned %d cards for '%s'", len(rag_cards), deck_id)
|
||||
except Exception as e:
|
||||
logger.warning("RAG query failed (non-fatal): %s", e)
|
||||
|
||||
|
||||
system_prompt = build_system_prompt(report, goals)
|
||||
user_prompt = build_user_prompt(report, candidates)
|
||||
user_prompt = build_user_prompt(report, candidates, rag_cards=rag_cards)
|
||||
|
||||
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
|
||||
|
||||
@@ -332,9 +342,17 @@ class CoachService:
|
||||
if report is None:
|
||||
raise ValueError(f"Deck report not found for: {deck_id}")
|
||||
|
||||
# Phase 3: light RAG query for quick digest too
|
||||
rag_cards: List[dict] = []
|
||||
try:
|
||||
from services.rag_store import query_cards
|
||||
query_text = f"{report.commander} {' '.join(report.colorIdentity)}"
|
||||
rag_cards = query_cards(query_text, n_results=8, color_identity=report.colorIdentity)
|
||||
except Exception:
|
||||
pass
|
||||
from .prompt_template import build_quick_system_prompt, build_user_prompt
|
||||
system_prompt = build_quick_system_prompt(report, goals)
|
||||
user_prompt = build_user_prompt(report, {}) # no candidates for speed
|
||||
user_prompt = build_user_prompt(report, {}, rag_cards=rag_cards) # RAG-enriched quick digest # no candidates for speed
|
||||
|
||||
logger.info("Quick digest for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
|
||||
|
||||
|
||||
+3
-9
@@ -28,6 +28,7 @@ from urllib.parse import quote
|
||||
|
||||
import numpy as np
|
||||
|
||||
from services.card_text import build_card_text
|
||||
from .config import EMBEDDINGS_DIR, EMBEDDINGS_NPZ, EMBEDDINGS_PARQUET
|
||||
|
||||
logger = logging.getLogger("coach.embeddings")
|
||||
@@ -114,15 +115,8 @@ def _fetch_scryfall_card(card_name: str) -> Optional[dict]:
|
||||
|
||||
|
||||
def _build_card_text(card: dict) -> str:
|
||||
"""Build the text string to embed, matching minimaxir/mtg-embeddings format."""
|
||||
parts = [card["name"]]
|
||||
if card.get("mana_cost"):
|
||||
parts.append(card["mana_cost"])
|
||||
if card.get("type_line"):
|
||||
parts.append(card["type_line"])
|
||||
if card.get("oracle_text"):
|
||||
parts.append(card["oracle_text"])
|
||||
return " | ".join(parts)
|
||||
"""Delegate to shared build_card_text() -- see services/card_text.py."""
|
||||
return build_card_text(card)
|
||||
|
||||
|
||||
class CardMatch:
|
||||
|
||||
@@ -236,6 +236,9 @@ Avg Game Length: {avg_game_length:.1f} turns
|
||||
### Underperforming Cards (candidates for cuts)
|
||||
{underperformer_section}
|
||||
|
||||
|
||||
### RAG-Retrieved Similar Cards (from ChromaDB)
|
||||
{rag_section}
|
||||
### Candidate Replacement Cards (from embeddings search)
|
||||
{candidate_section}
|
||||
|
||||
@@ -336,6 +339,24 @@ def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
|
||||
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_rag_cards(rag_cards: List[dict]) -> str:
|
||||
"""Format RAG-retrieved cards from ChromaDB vector search."""
|
||||
if not rag_cards:
|
||||
return "No RAG results available."
|
||||
lines = []
|
||||
for card in rag_cards[:15]:
|
||||
name = card.get("name", "Unknown")
|
||||
types = card.get("types", "")
|
||||
mv = card.get("mana_value", "?")
|
||||
mc = card.get("mana_cost", "")
|
||||
dist = card.get("distance", 0)
|
||||
text = card.get("text", "")[:80]
|
||||
lines.append(f"- {name} ({mc}, MV:{mv}, {types}) [distance: {dist:.3f}]")
|
||||
if text:
|
||||
lines.append(f" Oracle: {text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
def _format_matchups(report: DeckReport) -> str:
|
||||
"""Format per-opponent matchup data."""
|
||||
@@ -402,7 +423,8 @@ def build_quick_system_prompt(report: DeckReport,
|
||||
|
||||
|
||||
def build_user_prompt(report: DeckReport,
|
||||
candidates: Dict[str, List[dict]] = None) -> str:
|
||||
candidates: Dict[str, List[dict]] = None,
|
||||
rag_cards: List[dict] = None) -> str:
|
||||
"""Build the user prompt with full deck report data."""
|
||||
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
|
||||
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
|
||||
@@ -426,5 +448,6 @@ def build_user_prompt(report: DeckReport,
|
||||
underperformer_section=_format_underperformers(
|
||||
report.cards, report.underperformers),
|
||||
candidate_section=_format_candidates(candidates or {}),
|
||||
rag_section=_format_rag_cards(rag_cards or []),
|
||||
combo_section=_format_combos(report),
|
||||
)
|
||||
+53
-20
@@ -26,7 +26,7 @@ except ImportError:
|
||||
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
|
||||
import sys; sys.exit(1)
|
||||
|
||||
from models.state import CFG, COMMANDER_META, load_commander_meta
|
||||
from models.state import CFG, COMMANDER_META, load_commander_meta, validate_forge_config
|
||||
from services.database import init_collection_db
|
||||
from services.logging import setup_logging
|
||||
from services.precon_service import PRECON_INDEX, download_precon_database
|
||||
@@ -42,6 +42,7 @@ from routes.deckgen import router as deckgen_router
|
||||
from routes.coach import router as coach_router, init_coach_service
|
||||
from routes.ml import router as ml_router
|
||||
from routes.ws_game import router as ws_game_router
|
||||
from routes.game import router as game_router
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
@@ -50,25 +51,62 @@ _allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
|
||||
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
||||
|
||||
|
||||
# ── Lifespan (replaces deprecated @app.on_event) ───────────────────────────────
|
||||
# ── Lifespan (replaces deprecated @app.on_event) ────────────────────────────────────────────────────────
|
||||
@asynccontextmanager
|
||||
async def _lifespan(application: FastAPI):
|
||||
# ──── STARTUP ────
|
||||
if not CFG.precon_dir:
|
||||
CFG.precon_dir = _resolve_precon_dir()
|
||||
init_collection_db()
|
||||
validate_forge_config()
|
||||
if not PRECON_INDEX:
|
||||
download_precon_database()
|
||||
if not COMMANDER_META:
|
||||
load_commander_meta()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# RAG Phase 1: kick off Scryfall bulk download in background thread.
|
||||
# run_in_executor returns a Future; we do NOT await it so startup
|
||||
# completes immediately and the download runs concurrently.
|
||||
from services.scryfall_bulk import ensure_bulk_db
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_in_executor(None, ensure_bulk_db)
|
||||
|
||||
# RAG Phase 2: kick off ChromaDB vector store build in background thread.
|
||||
from services.rag_store import ensure_rag_store
|
||||
loop.run_in_executor(None, ensure_rag_store)
|
||||
|
||||
# RAG Phase 3: background staleness monitor (rebuilds if >14 days old)
|
||||
async def _rag_staleness_monitor():
|
||||
"""Periodically check if the RAG index needs rebuilding."""
|
||||
import time as _time
|
||||
while True:
|
||||
await asyncio.sleep(6 * 3600) # check every 6 hours
|
||||
try:
|
||||
from services.rag_store import check_staleness, build_index
|
||||
info = check_staleness()
|
||||
if info.get("stale"):
|
||||
log.info("RAG staleness monitor: %s — triggering rebuild.", info["reason"])
|
||||
loop.run_in_executor(None, lambda: build_index(force=True))
|
||||
else:
|
||||
log.debug("RAG staleness monitor: index is current (%.1f days).", info.get("age_days", 0))
|
||||
except Exception as exc:
|
||||
log.warning("RAG staleness monitor failed: %s", exc)
|
||||
asyncio.ensure_future(_rag_staleness_monitor())
|
||||
|
||||
# Policy routes: register if ML policy model is available
|
||||
try:
|
||||
from ml.serving.policy_server import PolicyInferenceService
|
||||
from routes.policy import register_policy_routes
|
||||
_policy_svc = PolicyInferenceService()
|
||||
if _policy_svc.load():
|
||||
register_policy_routes(application, _policy_svc)
|
||||
log.info("Policy routes registered (/api/policy/*)")
|
||||
else:
|
||||
log.warning("Policy model not loaded — /api/policy/* routes inactive")
|
||||
except ImportError:
|
||||
log.info("Policy service not available (ml.serving not installed)")
|
||||
except Exception as e:
|
||||
log.warning("Policy route registration failed (non-fatal): %s", e)
|
||||
|
||||
yield # ← server is running
|
||||
|
||||
# ──── SHUTDOWN ────
|
||||
@@ -76,7 +114,7 @@ async def _lifespan(application: FastAPI):
|
||||
await async_close_db()
|
||||
|
||||
|
||||
# ── App ────────────────────────────────────────────────────────────────
|
||||
# ── App ─────────────────────────────────────────────────────────────────────────────
|
||||
app = FastAPI(title="Commander AI Lab API", version="3.0.0", lifespan=_lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -98,9 +136,10 @@ app.include_router(deckgen_router)
|
||||
app.include_router(coach_router)
|
||||
app.include_router(ws_game_router)
|
||||
app.include_router(ml_router)
|
||||
app.include_router(game_router) # live game session routes
|
||||
|
||||
|
||||
# ── API endpoints ────────────────────────────────────────────────────────────
|
||||
# ── API endpoints ─────────────────────────────────────────────────────────────────────────────
|
||||
# NOTE: all @app.get routes MUST be registered before app.mount() calls.
|
||||
# Once StaticFiles is mounted at "/", it registers a wildcard catch-all that
|
||||
# shadows any routes added after it.
|
||||
@@ -110,9 +149,10 @@ async def health_check():
|
||||
"""Health check endpoint for load balancers and Unity client polling."""
|
||||
return {"status": "ok"}
|
||||
|
||||
# ── Static UI (MUST come after all @app.get routes) ────────────────────────────
|
||||
_legacy_ui_dir = Path(__file__).parent / "ui"
|
||||
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||
# ── Static UI (MUST come after all @app.get routes) ──────────────────────────────────────────
|
||||
from pathlib import Path as _Path
|
||||
_legacy_ui_dir = _Path(__file__).parent / "ui"
|
||||
_spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||
|
||||
if _legacy_ui_dir.exists():
|
||||
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
|
||||
@@ -130,7 +170,7 @@ elif _spa_dir.exists():
|
||||
return FileResponse(str(_spa_dir / "index.html"))
|
||||
|
||||
|
||||
# ── Argument parsing + auto-detection helpers ─────────────────────────────────
|
||||
# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────────────
|
||||
def _parse_args():
|
||||
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
|
||||
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
|
||||
@@ -297,16 +337,9 @@ def main():
|
||||
init_collection_db()
|
||||
init_coach_service()
|
||||
|
||||
# RAG Phase 1: synchronous boot-time check so logs are visible.
|
||||
# The async lifespan handler also fires it in a thread on uvicorn startup,
|
||||
# but calling here ensures it runs (and logs) before the first request.
|
||||
from services.scryfall_bulk import ensure_bulk_db
|
||||
ensure_bulk_db()
|
||||
# RAG Phase 1 + 2 are handled by _lifespan when uvicorn starts.
|
||||
# No need to call them here — doing so causes a double-run.
|
||||
|
||||
|
||||
# RAG Phase 2: synchronous boot-time ChromaDB build.
|
||||
from services.rag_store import ensure_rag_store
|
||||
ensure_rag_store()
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -25,9 +26,35 @@ class Config:
|
||||
port: int = 8080
|
||||
ximilar_api_key: str = ""
|
||||
pplx_api_key: str = ""
|
||||
anthropic_api_key: str = ""
|
||||
|
||||
|
||||
CFG = Config()
|
||||
# Pre-populate from env so ASGI mode (uvicorn lab_api:app) works without main()
|
||||
CFG.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
|
||||
|
||||
def validate_forge_config() -> None:
|
||||
"""Warn clearly at startup if FORGE_DIR is misconfigured."""
|
||||
if not CFG.forge_dir:
|
||||
log.warning("FORGE_DIR not set -- Forge card enrichment disabled. "
|
||||
"Set FORGE_DIR to your forge-repo root or forge-gui directory.")
|
||||
return
|
||||
base = Path(CFG.forge_dir)
|
||||
a = base / "forge-gui" / "res" / "cardsfolder"
|
||||
b = base / "res" / "cardsfolder"
|
||||
if not a.is_dir() and not b.is_dir():
|
||||
log.warning(
|
||||
"FORGE_DIR=%s is set but cardsfolder not found at:\n"
|
||||
" %s\n %s\n"
|
||||
"Check that FORGE_DIR points to forge-repo root or forge-gui/.",
|
||||
CFG.forge_dir, a, b
|
||||
)
|
||||
else:
|
||||
resolved = a if a.is_dir() else b
|
||||
log.info("Forge cardsfolder confirmed at %s", resolved)
|
||||
CFG.ximilar_api_key = os.environ.get("XIMILAR_API_KEY", "")
|
||||
CFG.pplx_api_key = os.environ.get("PPLX_API_KEY", "")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -49,7 +76,9 @@ class BatchState:
|
||||
self.sims_per_sec: float = 0.0
|
||||
|
||||
|
||||
import asyncio as _asyncio
|
||||
active_batches: dict[str, BatchState] = {}
|
||||
active_batches_lock = _asyncio.Lock()
|
||||
|
||||
|
||||
COMMANDER_META: dict = {}
|
||||
|
||||
@@ -25,6 +25,28 @@ dev = [
|
||||
"httpx>=0.24.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
# RAG Phase 2: ChromaDB vector store + Ollama embedding support
|
||||
# Install with: pip install ".[rag]"
|
||||
rag = [
|
||||
"chromadb>=0.5.0",
|
||||
]
|
||||
# Local coach embeddings (sentence-transformers, no Ollama required)
|
||||
# Install with: pip install ".[coach-embeddings]"
|
||||
coach-embeddings = [
|
||||
"sentence-transformers>=3.0.0",
|
||||
"torch>=2.0.0",
|
||||
"pandas>=1.5.0",
|
||||
"pyarrow>=12.0.0",
|
||||
]
|
||||
# Full local AI stack (RAG + coach embeddings)
|
||||
# Install with: pip install ".[all]"
|
||||
all = [
|
||||
"chromadb>=0.5.0",
|
||||
"sentence-transformers>=3.0.0",
|
||||
"torch>=2.0.0",
|
||||
"pandas>=1.5.0",
|
||||
"pyarrow>=12.0.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
+457
-434
@@ -59,506 +59,529 @@ _coach_llm = None
|
||||
_deck_gen_v3 = None
|
||||
_deck_gen_v3_error = None
|
||||
|
||||
# Known basic land names whose type_line may be absent when the card was added
|
||||
# via plain-text import and has no matching collection_entries row yet.
|
||||
_BASIC_LAND_NAMES: frozenset[str] = frozenset({
|
||||
"plains", "island", "swamp", "mountain", "forest", "wastes",
|
||||
"snow-covered plains", "snow-covered island", "snow-covered swamp",
|
||||
"snow-covered mountain", "snow-covered forest",
|
||||
})
|
||||
|
||||
|
||||
def init_coach_service():
|
||||
"""Initialize the coach service with LLM client and embeddings."""
|
||||
global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
|
||||
try:
|
||||
from coach.llm_client import LLMClient
|
||||
from coach.embeddings import MTGEmbeddingIndex
|
||||
from coach.coach_service import CoachService
|
||||
from coach.config import ensure_dirs
|
||||
from services.database import _get_db_conn
|
||||
ensure_dirs()
|
||||
_coach_llm = LLMClient()
|
||||
_coach_embeddings = MTGEmbeddingIndex()
|
||||
"""Initialize the coach service with LLM client and embeddings."""
|
||||
global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
|
||||
try:
|
||||
from coach.config import EMBEDDINGS_NPZ
|
||||
if EMBEDDINGS_NPZ.exists():
|
||||
_coach_embeddings.load()
|
||||
log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
|
||||
else:
|
||||
log_coach.warning(" Coach: Embeddings not yet downloaded")
|
||||
from coach.llm_client import LLMClient
|
||||
from coach.embeddings import MTGEmbeddingIndex
|
||||
from coach.coach_service import CoachService
|
||||
from coach.config import ensure_dirs
|
||||
from services.database import _get_db_conn
|
||||
ensure_dirs()
|
||||
_coach_llm = LLMClient()
|
||||
_coach_embeddings = MTGEmbeddingIndex()
|
||||
try:
|
||||
from coach.config import EMBEDDINGS_NPZ
|
||||
if EMBEDDINGS_NPZ.exists():
|
||||
_coach_embeddings.load()
|
||||
log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
|
||||
else:
|
||||
log_coach.warning(" Coach: Embeddings not yet downloaded")
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings load failed: {e}")
|
||||
_coach_service = CoachService(_coach_llm, _coach_embeddings)
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
log_coach.info(f" Coach LLM: Anthropic Claude ({ANTHROPIC_MODEL})")
|
||||
else:
|
||||
llm_status = _coach_llm.check_connection_sync()
|
||||
if llm_status.get("connected"):
|
||||
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
||||
else:
|
||||
log_coach.warning(" Coach LLM: Not connected (start Ollama on localhost:11434)")
|
||||
_deck_gen_v3_error = None
|
||||
try:
|
||||
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
|
||||
if DECK_GEN_PROVIDER == "anthropic":
|
||||
_deck_gen_v3 = DeckBuilderAdapter(db_conn_factory=_get_db_conn, model=DECK_GEN_MODEL)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (Anthropic Claude {DECK_GEN_MODEL})")
|
||||
else:
|
||||
from coach.config import LLM_MODEL
|
||||
_deck_gen_v3 = DeckBuilderAdapter(db_conn_factory=_get_db_conn, model=LLM_MODEL)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (Ollama {LLM_MODEL})")
|
||||
except ImportError as e:
|
||||
_deck_gen_v3_error = f"Missing dependency: {e}"
|
||||
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||
_deck_gen_v3 = None
|
||||
except Exception as e:
|
||||
_deck_gen_v3_error = str(e)
|
||||
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
||||
_deck_gen_v3 = None
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings load failed: {e}")
|
||||
_coach_service = CoachService(_coach_llm, _coach_embeddings)
|
||||
if COACH_PROVIDER == "anthropic":
|
||||
log_coach.info(f" Coach LLM: Anthropic Claude ({ANTHROPIC_MODEL})")
|
||||
else:
|
||||
llm_status = _coach_llm.check_connection_sync()
|
||||
if llm_status.get("connected"):
|
||||
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
||||
else:
|
||||
log_coach.warning(" Coach LLM: Not connected (start Ollama on localhost:11434)")
|
||||
_deck_gen_v3_error = None
|
||||
try:
|
||||
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
|
||||
if DECK_GEN_PROVIDER == "anthropic":
|
||||
_deck_gen_v3 = DeckBuilderAdapter(db_conn_factory=_get_db_conn, model=DECK_GEN_MODEL)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (Anthropic Claude {DECK_GEN_MODEL})")
|
||||
else:
|
||||
from coach.config import LLM_MODEL
|
||||
_deck_gen_v3 = DeckBuilderAdapter(db_conn_factory=_get_db_conn, model=LLM_MODEL)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (Ollama {LLM_MODEL})")
|
||||
except ImportError as e:
|
||||
_deck_gen_v3_error = f"Missing dependency: {e}"
|
||||
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||
_deck_gen_v3 = None
|
||||
except Exception as e:
|
||||
_deck_gen_v3_error = str(e)
|
||||
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
||||
_deck_gen_v3 = None
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Failed to initialize: {e}\n")
|
||||
_coach_service = None
|
||||
|
||||
|
||||
log_coach.error(f" Coach: Failed to initialize: {e}\n")
|
||||
_coach_service = None
|
||||
|
||||
|
||||
@router.get("/api/coach/status")
|
||||
async def coach_status():
|
||||
if _coach_service is None:
|
||||
if _coach_service is None:
|
||||
return {"llmConnected": False, "embeddingsLoaded": False, "embeddingCards": 0, "deckReportsAvailable": 0, "error": "Coach service not initialized"}
|
||||
status = _coach_service.get_status()
|
||||
return status.model_dump()
|
||||
status = _coach_service.get_status()
|
||||
return status.model_dump()
|
||||
|
||||
|
||||
@router.get("/api/coach/decks")
|
||||
async def coach_list_decks():
|
||||
"""List all decks available for coaching (async DB)."""
|
||||
rows = await async_fetchall(
|
||||
"""SELECT d.id as deck_id, d.name as deck_name, d.commander_name,
|
||||
COUNT(dc.id) as card_count
|
||||
FROM decks d LEFT JOIN deck_cards dc ON dc.deck_id = d.id
|
||||
GROUP BY d.id ORDER BY d.name"""
|
||||
)
|
||||
report_ids = set()
|
||||
if _coach_service:
|
||||
report_ids = set(_coach_service.list_deck_reports())
|
||||
decks = []
|
||||
for r in rows:
|
||||
deck_name = r["deck_name"]
|
||||
has_report = any(
|
||||
deck_name.lower().replace(" ", "-") == rid.lower()
|
||||
or deck_name.lower() == rid.lower()
|
||||
for rid in report_ids
|
||||
"""List all decks available for coaching (async DB)."""
|
||||
rows = await async_fetchall(
|
||||
"""SELECT d.id as deck_id, d.name as deck_name, d.commander_name,
|
||||
COUNT(dc.id) as card_count
|
||||
FROM decks d LEFT JOIN deck_cards dc ON dc.deck_id = d.id
|
||||
GROUP BY d.id ORDER BY d.name"""
|
||||
)
|
||||
decks.append({
|
||||
"deck_id": r["deck_id"], "deck_name": r["deck_name"],
|
||||
"commander": r["commander_name"] or "",
|
||||
"card_count": r["card_count"], "has_report": has_report,
|
||||
"report_count": 1 if has_report else 0, "last_report_date": None,
|
||||
})
|
||||
return decks
|
||||
report_ids = set()
|
||||
if _coach_service:
|
||||
report_ids = set(_coach_service.list_deck_reports())
|
||||
decks = []
|
||||
for r in rows:
|
||||
deck_name = r["deck_name"]
|
||||
has_report = any(
|
||||
deck_name.lower().replace(" ", "-") == rid.lower()
|
||||
or deck_name.lower() == rid.lower()
|
||||
for rid in report_ids
|
||||
)
|
||||
decks.append({
|
||||
"deck_id": r["deck_id"], "deck_name": r["deck_name"],
|
||||
"commander": r["commander_name"] or "",
|
||||
"card_count": r["card_count"], "has_report": has_report,
|
||||
"report_count": 1 if has_report else 0, "last_report_date": None,
|
||||
})
|
||||
return decks
|
||||
|
||||
|
||||
@router.get("/api/coach/decks/{deck_id}/report")
|
||||
async def coach_get_report(deck_id: str):
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
report = _coach_service.load_deck_report(deck_id)
|
||||
if report is None:
|
||||
raise HTTPException(404, f"Deck report not found: {deck_id}")
|
||||
return report.model_dump()
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
report = _coach_service.load_deck_report(deck_id)
|
||||
if report is None:
|
||||
raise HTTPException(404, f"Deck report not found: {deck_id}")
|
||||
return report.model_dump()
|
||||
|
||||
|
||||
@router.post("/api/coach/decks/{deck_id}")
|
||||
async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
if _coach_embeddings and not _coach_embeddings.loaded:
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
if _coach_embeddings and not _coach_embeddings.loaded:
|
||||
try:
|
||||
_coach_embeddings.load(force_download=True)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings download failed: {e}")
|
||||
goals = None
|
||||
if body and body.goals:
|
||||
from coach.models import CoachGoals
|
||||
try:
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
fallback_report = None
|
||||
try:
|
||||
_coach_embeddings.load(force_download=True)
|
||||
fallback_report = await _build_deck_report_from_db(deck_id)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings download failed: {e}")
|
||||
goals = None
|
||||
if body and body.goals:
|
||||
from coach.models import CoachGoals
|
||||
log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
|
||||
try:
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
fallback_report = None
|
||||
try:
|
||||
fallback_report = await _build_deck_report_from_db(deck_id)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
|
||||
try:
|
||||
session = await _coach_service.run_coaching_session(deck_id, goals, fallback_report=fallback_report)
|
||||
return session.model_dump()
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ConnectionError as e:
|
||||
raise HTTPException(503, f"LLM connection failed: {e}")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Coach session failed: {e}")
|
||||
session = await _coach_service.run_coaching_session(deck_id, goals, fallback_report=fallback_report)
|
||||
return session.model_dump()
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ConnectionError as e:
|
||||
raise HTTPException(503, f"LLM connection failed: {e}")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Coach session failed: {e}")
|
||||
|
||||
|
||||
@router.post("/api/coach/decks/{deck_id}/quick")
|
||||
async def coach_run_quick_digest(deck_id: str, body: CoachRequestBody = None):
|
||||
"""Fast ~5s quick digest: summary, top 3 upgrades, commander dependency."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
goals = None
|
||||
if body and body.goals:
|
||||
from coach.models import CoachGoals
|
||||
"""Fast ~5s quick digest: summary, top 3 upgrades, commander dependency."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
goals = None
|
||||
if body and body.goals:
|
||||
from coach.models import CoachGoals
|
||||
try:
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
fallback_report = None
|
||||
try:
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
fallback_report = None
|
||||
try:
|
||||
fallback_report = await _build_deck_report_from_db(deck_id)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Quick fallback failed for '{deck_id}': {e}")
|
||||
try:
|
||||
session = await _coach_service.run_quick_digest(deck_id, goals, fallback_report=fallback_report)
|
||||
return session.model_dump()
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ConnectionError as e:
|
||||
raise HTTPException(503, f"LLM connection failed: {e}")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Quick digest failed: {e}")
|
||||
fallback_report = await _build_deck_report_from_db(deck_id)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Quick fallback failed for '{deck_id}': {e}")
|
||||
try:
|
||||
session = await _coach_service.run_quick_digest(deck_id, goals, fallback_report=fallback_report)
|
||||
return session.model_dump()
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ConnectionError as e:
|
||||
raise HTTPException(503, f"LLM connection failed: {e}")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Quick digest failed: {e}")
|
||||
|
||||
|
||||
async def _build_deck_report_from_db(deck_slug: str):
|
||||
"""Build a lightweight DeckReport from DB (async). Allows coaching without simulation data."""
|
||||
from coach.models import DeckReport, CardPerformance, DeckStructure
|
||||
import re
|
||||
if not deck_slug or len(deck_slug) > 200:
|
||||
return None
|
||||
deck_slug = re.sub(r'[^a-zA-Z0-9 _-]', '', deck_slug)
|
||||
if not deck_slug:
|
||||
return None
|
||||
slug_lower = deck_slug.lower()
|
||||
slug_hyphenated = slug_lower.replace(' ', '-')
|
||||
clean_slug = re.sub(r'[^a-z0-9]+', '-', slug_lower).strip('-')
|
||||
if slug_lower.isdigit():
|
||||
matched_deck = await async_fetchone(
|
||||
"SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
|
||||
(int(slug_lower),)
|
||||
"""Build a lightweight DeckReport from DB (async). Allows coaching without simulation data."""
|
||||
from coach.models import DeckReport, CardPerformance, DeckStructure
|
||||
import re
|
||||
if not deck_slug or len(deck_slug) > 200:
|
||||
return None
|
||||
deck_slug = re.sub(r'[^a-zA-Z0-9 _-]', '', deck_slug)
|
||||
if not deck_slug:
|
||||
return None
|
||||
slug_lower = deck_slug.lower()
|
||||
slug_hyphenated = slug_lower.replace(' ', '-')
|
||||
clean_slug = re.sub(r'[^a-z0-9]+', '-', slug_lower).strip('-')
|
||||
if slug_lower.isdigit():
|
||||
matched_deck = await async_fetchone(
|
||||
"SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
|
||||
(int(slug_lower),)
|
||||
)
|
||||
else:
|
||||
matched_deck = await async_fetchone(
|
||||
"""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)
|
||||
)
|
||||
if matched_deck is None:
|
||||
return None
|
||||
deck_id = matched_deck["id"]
|
||||
deck_name = matched_deck["name"]
|
||||
commander = matched_deck["commander_name"] or ""
|
||||
ci_raw = matched_deck["color_identity"] or "[]"
|
||||
try:
|
||||
color_identity = json.loads(ci_raw) if isinstance(ci_raw, str) else ci_raw
|
||||
except Exception:
|
||||
color_identity = []
|
||||
card_rows = await async_fetchall(
|
||||
"""SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
|
||||
ce.type_line, ce.cmc, ce.oracle_text
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name)
|
||||
WHERE dc.deck_id = ?""",
|
||||
(deck_id,)
|
||||
)
|
||||
else:
|
||||
matched_deck = await async_fetchone(
|
||||
"""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)
|
||||
cards = []
|
||||
type_counts = {}
|
||||
cmc_buckets = [0] * 8
|
||||
land_count = 0
|
||||
for cr in card_rows:
|
||||
card_name = cr["card_name"] or ""
|
||||
type_line = cr["type_line"] or ""
|
||||
cmc = cr["cmc"] or 0
|
||||
qty = cr["quantity"] or 1
|
||||
role_tag = cr["role_tag"] or ""
|
||||
|
||||
# Fallback for cards added via plain-text import that have no
|
||||
# collection_entries row yet (type_line comes back empty from the LEFT JOIN).
|
||||
# Basic lands are identified by name so their land_count is never lost.
|
||||
if not type_line and card_name.lower() in _BASIC_LAND_NAMES:
|
||||
type_line = "Basic Land"
|
||||
|
||||
tags = []
|
||||
if role_tag:
|
||||
tags.append(role_tag)
|
||||
if "Land" in type_line:
|
||||
tags.append("land")
|
||||
land_count += qty
|
||||
elif "Creature" in type_line:
|
||||
tags.append("creature")
|
||||
elif "Instant" in type_line:
|
||||
tags.append("instant")
|
||||
elif "Sorcery" in type_line:
|
||||
tags.append("sorcery")
|
||||
elif "Artifact" in type_line:
|
||||
tags.append("artifact")
|
||||
elif "Enchantment" in type_line:
|
||||
tags.append("enchantment")
|
||||
elif "Planeswalker" in type_line:
|
||||
tags.append("planeswalker")
|
||||
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
|
||||
if t in type_line:
|
||||
type_counts[t] = type_counts.get(t, 0) + qty
|
||||
# Mirrors deck_service._compute_deck_analysis(): lands are excluded from
|
||||
# the mana curve so cmc=0 lands don't inflate the free-spell bucket and
|
||||
# mislead the LLM coach into thinking the deck has many 0-cost spells.
|
||||
bucket = min(int(cmc), 7)
|
||||
if "Land" not in type_line:
|
||||
cmc_buckets[bucket] += qty
|
||||
cards.append(CardPerformance(name=card_name, drawnRate=0.0, castRate=0.0, impactScore=0.0, tags=tags))
|
||||
return DeckReport(
|
||||
deckId=deck_slug, commander=commander, colorIdentity=color_identity,
|
||||
cards=cards, structure=DeckStructure(landCount=land_count, curveBuckets=cmc_buckets, cardTypeCounts=type_counts),
|
||||
)
|
||||
if matched_deck is None:
|
||||
return None
|
||||
deck_id = matched_deck["id"]
|
||||
deck_name = matched_deck["name"]
|
||||
commander = matched_deck["commander_name"] or ""
|
||||
ci_raw = matched_deck["color_identity"] or "[]"
|
||||
try:
|
||||
color_identity = json.loads(ci_raw) if isinstance(ci_raw, str) else ci_raw
|
||||
except Exception:
|
||||
color_identity = []
|
||||
card_rows = await async_fetchall(
|
||||
"""SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
|
||||
ce.type_line, ce.cmc, ce.oracle_text
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name)
|
||||
WHERE dc.deck_id = ?""",
|
||||
(deck_id,)
|
||||
)
|
||||
cards = []
|
||||
type_counts = {}
|
||||
cmc_buckets = [0] * 8
|
||||
land_count = 0
|
||||
for cr in card_rows:
|
||||
card_name = cr["card_name"] or ""
|
||||
type_line = cr["type_line"] or ""
|
||||
cmc = cr["cmc"] or 0
|
||||
qty = cr["quantity"] or 1
|
||||
role_tag = cr["role_tag"] or ""
|
||||
tags = []
|
||||
if role_tag:
|
||||
tags.append(role_tag)
|
||||
if "Land" in type_line:
|
||||
tags.append("land")
|
||||
land_count += qty
|
||||
elif "Creature" in type_line:
|
||||
tags.append("creature")
|
||||
elif "Instant" in type_line:
|
||||
tags.append("instant")
|
||||
elif "Sorcery" in type_line:
|
||||
tags.append("sorcery")
|
||||
elif "Artifact" in type_line:
|
||||
tags.append("artifact")
|
||||
elif "Enchantment" in type_line:
|
||||
tags.append("enchantment")
|
||||
elif "Planeswalker" in type_line:
|
||||
tags.append("planeswalker")
|
||||
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
|
||||
if t in type_line:
|
||||
type_counts[t] = type_counts.get(t, 0) + qty
|
||||
bucket = min(int(cmc), 7)
|
||||
cmc_buckets[bucket] += qty
|
||||
cards.append(CardPerformance(name=card_name, drawnRate=0.0, castRate=0.0, impactScore=0.0, tags=tags))
|
||||
return DeckReport(
|
||||
deckId=deck_slug, commander=commander, colorIdentity=color_identity,
|
||||
cards=cards, structure=DeckStructure(landCount=land_count, curveBuckets=cmc_buckets, cardTypeCounts=type_counts),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/coach/sessions")
|
||||
async def coach_list_sessions(deck_id: str = None):
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
return {"sessions": _coach_service.list_sessions(deck_id)}
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
return {"sessions": _coach_service.list_sessions(deck_id)}
|
||||
|
||||
|
||||
@router.get("/api/coach/sessions/{session_id}")
|
||||
async def coach_get_session(session_id: str):
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
session = _coach_service.load_session(session_id)
|
||||
if session is None:
|
||||
raise HTTPException(404, f"Session not found: {session_id}")
|
||||
return session.model_dump()
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
session = _coach_service.load_session(session_id)
|
||||
if session is None:
|
||||
raise HTTPException(404, f"Session not found: {session_id}")
|
||||
return session.model_dump()
|
||||
|
||||
|
||||
@router.post("/api/coach/embeddings/download")
|
||||
async def coach_download_embeddings():
|
||||
if _coach_embeddings is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
try:
|
||||
_coach_embeddings.load(force_download=True)
|
||||
return {"success": True, "cards": _coach_embeddings.card_count,
|
||||
"message": f"Loaded {_coach_embeddings.card_count} card embeddings"}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to download embeddings: {e}")
|
||||
if _coach_embeddings is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
try:
|
||||
_coach_embeddings.load(force_download=True)
|
||||
return {"success": True, "cards": _coach_embeddings.card_count,
|
||||
"message": f"Loaded {_coach_embeddings.card_count} card embeddings"}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to download embeddings: {e}")
|
||||
|
||||
|
||||
@router.get("/api/coach/embeddings/search")
|
||||
async def coach_search_similar(card: str, colors: str = None, top_n: int = 10):
|
||||
if _coach_embeddings is None or not _coach_embeddings.loaded:
|
||||
raise HTTPException(503, "Embeddings not loaded")
|
||||
color_filter = list(colors.upper()) if colors else None
|
||||
matches = _coach_embeddings.search_similar(query_card=card, color_filter=color_filter, top_n=top_n)
|
||||
return {"query": card, "matches": [m.to_dict() for m in matches]}
|
||||
if _coach_embeddings is None or not _coach_embeddings.loaded:
|
||||
raise HTTPException(503, "Embeddings not loaded")
|
||||
color_filter = list(colors.upper()) if colors else None
|
||||
matches = _coach_embeddings.search_similar(query_card=card, color_filter=color_filter, top_n=top_n)
|
||||
return {"query": card, "matches": [m.to_dict() for m in matches]}
|
||||
|
||||
|
||||
@router.post("/api/coach/chat")
|
||||
async def coach_chat(body: CoachChatRequest):
|
||||
"""Multi-turn coaching chat. Fully async - no event loop blocking."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
report = _coach_service.load_deck_report(body.deck_id)
|
||||
from coach.prompt_template import build_system_prompt
|
||||
from coach.models import CoachGoals
|
||||
goals = None
|
||||
if body.goals:
|
||||
"""Multi-turn coaching chat. Fully async - no event loop blocking."""
|
||||
if _coach_service is None:
|
||||
raise HTTPException(500, "Coach service not initialized")
|
||||
report = _coach_service.load_deck_report(body.deck_id)
|
||||
from coach.prompt_template import build_system_prompt
|
||||
from coach.models import CoachGoals
|
||||
goals = None
|
||||
if body.goals:
|
||||
try:
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
system_prompt = ""
|
||||
if report:
|
||||
system_prompt = build_system_prompt(report, goals)
|
||||
else:
|
||||
system_prompt = (
|
||||
"You are an expert Magic: The Gathering Commander deck coach. "
|
||||
"Answer questions about deck building, strategy, card choices, and game theory. "
|
||||
"Be specific and actionable in your advice."
|
||||
)
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
for msg in body.messages:
|
||||
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
|
||||
# ── Anthropic Claude provider path (async httpx) ──
|
||||
if COACH_PROVIDER == 'anthropic':
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(400, 'Anthropic API key not configured.')
|
||||
import anthropic
|
||||
aclient = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
|
||||
if body.stream:
|
||||
async def anthropic_stream():
|
||||
async with aclient.messages.stream(
|
||||
model=ANTHROPIC_MODEL, system=system_prompt,
|
||||
messages=[{"role": m["role"], "content": m["content"]} for m in messages if m["role"] != "system"],
|
||||
max_tokens=4096, temperature=0.7,
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
yield f"data: {json.dumps({'content': text})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return StreamingResponse(anthropic_stream(), media_type="text/event-stream")
|
||||
else:
|
||||
try:
|
||||
resp = await aclient.messages.create(
|
||||
model=ANTHROPIC_MODEL, system=system_prompt,
|
||||
messages=[{"role": m["role"], "content": m["content"]} for m in messages if m["role"] != "system"],
|
||||
max_tokens=4096, temperature=0.7,
|
||||
)
|
||||
content = resp.content[0].text if resp.content else ""
|
||||
usage = resp.usage
|
||||
return {
|
||||
"content": content, "model": resp.model,
|
||||
"prompt_tokens": usage.input_tokens if usage else 0,
|
||||
"completion_tokens": usage.output_tokens if usage else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Anthropic API call failed: {e}")
|
||||
# ── Local Ollama provider path (async httpx) ──
|
||||
try:
|
||||
goals = CoachGoals(**body.goals)
|
||||
except Exception:
|
||||
goals = None
|
||||
system_prompt = ""
|
||||
if report:
|
||||
system_prompt = build_system_prompt(report, goals)
|
||||
else:
|
||||
system_prompt = (
|
||||
"You are an expert Magic: The Gathering Commander deck coach. "
|
||||
"Answer questions about deck building, strategy, card choices, and game theory. "
|
||||
"Be specific and actionable in your advice."
|
||||
)
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
for msg in body.messages:
|
||||
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
|
||||
# ── Anthropic Claude provider path (async httpx) ──
|
||||
if COACH_PROVIDER == 'anthropic':
|
||||
if not ANTHROPIC_API_KEY:
|
||||
raise HTTPException(400, 'Anthropic API key not configured.')
|
||||
import anthropic
|
||||
aclient = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
|
||||
if body.stream:
|
||||
async def anthropic_stream():
|
||||
async with aclient.messages.stream(
|
||||
model=ANTHROPIC_MODEL, system=system_prompt,
|
||||
messages=[{"role": m["role"], "content": m["content"]} for m in messages if m["role"] != "system"],
|
||||
max_tokens=4096, temperature=0.7,
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
yield f"data: {json.dumps({'content': text})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return StreamingResponse(anthropic_stream(), media_type="text/event-stream")
|
||||
else:
|
||||
try:
|
||||
resp = await aclient.messages.create(
|
||||
model=ANTHROPIC_MODEL, system=system_prompt,
|
||||
messages=[{"role": m["role"], "content": m["content"]} for m in messages if m["role"] != "system"],
|
||||
max_tokens=4096, temperature=0.7,
|
||||
)
|
||||
content = resp.content[0].text if resp.content else ""
|
||||
usage = resp.usage
|
||||
return {
|
||||
"content": content, "model": resp.model,
|
||||
"prompt_tokens": usage.input_tokens if usage else 0,
|
||||
"completion_tokens": usage.output_tokens if usage else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Anthropic API call failed: {e}")
|
||||
# ── Local Ollama provider path (async httpx) ──
|
||||
try:
|
||||
model_name = await _coach_llm._resolve_model()
|
||||
llm_body = {"model": model_name, "messages": messages, "temperature": 0.7, "max_tokens": 4096}
|
||||
if body.stream:
|
||||
llm_body["stream"] = True
|
||||
async def generate():
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(LLM_TIMEOUT))) as client:
|
||||
async with client.stream("POST", f"{_coach_llm.base_url}/chat/completions",
|
||||
json=llm_body, headers={"Content-Type": "application/json"}) as resp:
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if data == "[DONE]":
|
||||
yield "data: [DONE]\n\n"
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
content = chunk.get("choices", [{}])[0].get("delta", {}).get("content", "")
|
||||
if content:
|
||||
yield f"data: {json.dumps({'content': content})}\n\n"
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
else:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(LLM_TIMEOUT))) as client:
|
||||
resp = await client.post(
|
||||
f"{_coach_llm.base_url}/chat/completions",
|
||||
json=llm_body, headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
content = _coach_llm._strip_think_tags(content)
|
||||
usage = raw.get("usage", {})
|
||||
return {
|
||||
"content": content, "model": raw.get("model", ""),
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Chat failed: {e}")
|
||||
model_name = await _coach_llm._resolve_model()
|
||||
llm_body = {"model": model_name, "messages": messages, "temperature": 0.7, "max_tokens": 4096}
|
||||
if body.stream:
|
||||
llm_body["stream"] = True
|
||||
async def generate():
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(LLM_TIMEOUT))) as client:
|
||||
async with client.stream("POST", f"{_coach_llm.base_url}/chat/completions",
|
||||
json=llm_body, headers={"Content-Type": "application/json"}) as resp:
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if data == "[DONE]":
|
||||
yield "data: [DONE]\n\n"
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
content = chunk.get("choices", [{}])[0].get("delta", {}).get("content", "")
|
||||
if content:
|
||||
yield f"data: {json.dumps({'content': content})}\n\n"
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
else:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(LLM_TIMEOUT))) as client:
|
||||
resp = await client.post(
|
||||
f"{_coach_llm.base_url}/chat/completions",
|
||||
json=llm_body, headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
content = _coach_llm._strip_think_tags(content)
|
||||
usage = raw.get("usage", {})
|
||||
return {
|
||||
"content": content, "model": raw.get("model", ""),
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Chat failed: {e}")
|
||||
|
||||
|
||||
@router.post("/api/coach/apply")
|
||||
async def coach_apply_suggestions(body: CoachApplyRequest):
|
||||
"""Apply accepted coaching suggestions to a deck (async DB + async HTTP)."""
|
||||
db = await async_get_db()
|
||||
deck = await async_fetchone("SELECT * FROM decks WHERE id = ?", (body.deck_id,))
|
||||
if not deck:
|
||||
raise HTTPException(404, f"Deck {body.deck_id} not found")
|
||||
results = {"cuts": [], "adds": [], "errors": []}
|
||||
for card_name in body.accepted_cuts:
|
||||
row = await async_fetchone(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
)
|
||||
if row:
|
||||
await async_execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": card_name, "status": "removed"})
|
||||
else:
|
||||
row = await async_fetchone(
|
||||
"SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
)
|
||||
if row:
|
||||
await async_execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": row["card_name"], "status": "removed"})
|
||||
else:
|
||||
results["errors"].append({"name": card_name, "error": "Card not found in deck"})
|
||||
for card_name in body.accepted_adds:
|
||||
ce = await async_fetchone(
|
||||
"SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(card_name,)
|
||||
)
|
||||
scryfall_id = None
|
||||
resolved_name = card_name
|
||||
if ce:
|
||||
scryfall_id = ce["scryfall_id"]
|
||||
resolved_name = ce["name"]
|
||||
else:
|
||||
try:
|
||||
import urllib.parse
|
||||
encoded = urllib.parse.quote(card_name)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
|
||||
headers={"User-Agent": "commander-ai-lab/1.0"}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
card_data = resp.json()
|
||||
scryfall_id = card_data.get("id")
|
||||
resolved_name = card_data.get("name", card_name)
|
||||
except Exception:
|
||||
pass
|
||||
if scryfall_id:
|
||||
existing = await async_fetchone(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
|
||||
(body.deck_id, scryfall_id)
|
||||
)
|
||||
if not existing:
|
||||
await async_execute(
|
||||
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)",
|
||||
(body.deck_id, scryfall_id, resolved_name)
|
||||
)
|
||||
results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"})
|
||||
else:
|
||||
results["adds"].append({"name": resolved_name, "status": "already_in_deck"})
|
||||
else:
|
||||
results["errors"].append({"name": card_name, "error": "Could not resolve card"})
|
||||
await async_execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,))
|
||||
await async_commit()
|
||||
results["total_cuts"] = len(results["cuts"])
|
||||
results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"])
|
||||
return results
|
||||
"""Apply accepted coaching suggestions to a deck (async DB + async HTTP)."""
|
||||
db = await async_get_db()
|
||||
deck = await async_fetchone("SELECT * FROM decks WHERE id = ?", (body.deck_id,))
|
||||
if not deck:
|
||||
raise HTTPException(404, f"Deck {body.deck_id} not found")
|
||||
results = {"cuts": [], "adds": [], "errors": []}
|
||||
try:
|
||||
for card_name in body.accepted_cuts:
|
||||
row = await async_fetchone(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
)
|
||||
if row:
|
||||
await async_execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": card_name, "status": "removed"})
|
||||
else:
|
||||
row = await async_fetchone(
|
||||
"SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1",
|
||||
(body.deck_id, card_name)
|
||||
)
|
||||
if row:
|
||||
await async_execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
|
||||
results["cuts"].append({"name": row["card_name"], "status": "removed"})
|
||||
else:
|
||||
results["errors"].append({"name": card_name, "error": "Card not found in deck"})
|
||||
for card_name in body.accepted_adds:
|
||||
ce = await async_fetchone(
|
||||
"SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(card_name,)
|
||||
)
|
||||
scryfall_id = None
|
||||
resolved_name = card_name
|
||||
if ce:
|
||||
scryfall_id = ce["scryfall_id"]
|
||||
resolved_name = ce["name"]
|
||||
else:
|
||||
try:
|
||||
import urllib.parse
|
||||
encoded = urllib.parse.quote(card_name)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
|
||||
headers={"User-Agent": "commander-ai-lab/1.0"}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
card_data = resp.json()
|
||||
scryfall_id = card_data.get("id")
|
||||
resolved_name = card_data.get("name", card_name)
|
||||
except Exception:
|
||||
pass
|
||||
if scryfall_id:
|
||||
existing = await async_fetchone(
|
||||
"SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
|
||||
(body.deck_id, scryfall_id)
|
||||
)
|
||||
if not existing:
|
||||
await async_execute(
|
||||
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)",
|
||||
(body.deck_id, scryfall_id, resolved_name)
|
||||
)
|
||||
results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"})
|
||||
else:
|
||||
results["adds"].append({"name": resolved_name, "status": "already_in_deck"})
|
||||
else:
|
||||
results["errors"].append({"name": card_name, "error": "Could not resolve card"})
|
||||
await async_execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,))
|
||||
await async_commit()
|
||||
except Exception as e:
|
||||
try:
|
||||
await async_execute("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(500, f"Apply failed and was rolled back: {e}")
|
||||
results["total_cuts"] = len(results["cuts"])
|
||||
results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"])
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/api/coach/cards-like")
|
||||
async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
|
||||
"""Find similar cards using embeddings (async DB for ownership check)."""
|
||||
if _coach_embeddings is None or not _coach_embeddings.loaded:
|
||||
raise HTTPException(503, "Embeddings not loaded")
|
||||
color_filter = list(colors.upper()) if colors else None
|
||||
matches = _coach_embeddings.search_similar(query_card=card, color_filter=color_filter, top_n=top_n)
|
||||
results = []
|
||||
for m in matches:
|
||||
owned = await async_fetchone(
|
||||
"SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)",
|
||||
(m.name,)
|
||||
)
|
||||
owned_qty = (owned["qty"] or 0) if owned else 0
|
||||
ce = await async_fetchone(
|
||||
"SELECT scryfall_id, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(m.name,)
|
||||
)
|
||||
results.append({
|
||||
"name": m.name, "similarity": round(m.similarity, 4),
|
||||
"types": m.types, "mana_value": m.mana_value,
|
||||
"mana_cost": m.mana_cost, "text": m.text[:200] if m.text else "",
|
||||
"owned_qty": owned_qty,
|
||||
"image_url": f"https://api.scryfall.com/cards/{ce['scryfall_id']}?format=image&version=normal" if ce and ce["scryfall_id"] else None,
|
||||
"tcg_price": ce["tcg_price"] if ce else None,
|
||||
})
|
||||
return {"query": card, "results": results}
|
||||
"""Find similar cards using embeddings (async DB for ownership check)."""
|
||||
if _coach_embeddings is None or not _coach_embeddings.loaded:
|
||||
raise HTTPException(503, "Embeddings not loaded")
|
||||
color_filter = list(colors.upper()) if colors else None
|
||||
matches = _coach_embeddings.search_similar(query_card=card, color_filter=color_filter, top_n=top_n)
|
||||
results = []
|
||||
for m in matches:
|
||||
owned = await async_fetchone(
|
||||
"SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)",
|
||||
(m.name,)
|
||||
)
|
||||
owned_qty = (owned["qty"] or 0) if owned else 0
|
||||
ce = await async_fetchone(
|
||||
"SELECT scryfall_id, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(m.name,)
|
||||
)
|
||||
results.append({
|
||||
"name": m.name, "similarity": round(m.similarity, 4),
|
||||
"types": m.types, "mana_value": m.mana_value,
|
||||
"mana_cost": m.mana_cost, "text": m.text[:200] if m.text else "",
|
||||
"owned_qty": owned_qty,
|
||||
"image_url": f"https://api.scryfall.com/cards/{ce['scryfall_id']}?format=image&version=normal" if ce and ce["scryfall_id"] else None,
|
||||
"tcg_price": ce["tcg_price"] if ce else None,
|
||||
})
|
||||
return {"query": card, "results": results}
|
||||
|
||||
|
||||
@router.post("/api/coach/reports/generate")
|
||||
async def coach_generate_reports():
|
||||
try:
|
||||
from coach.report_generator import generate_deck_reports
|
||||
lab_root = Path(__file__).parent.parent
|
||||
results_dir = str(lab_root / CFG.results_dir)
|
||||
reports_dir = str(lab_root / "deck-reports")
|
||||
updated = generate_deck_reports(results_dir, reports_dir)
|
||||
return {
|
||||
"status": "ok", "decksUpdated": updated, "count": len(updated),
|
||||
"message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found",
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Report generation failed: {e}")
|
||||
try:
|
||||
from coach.report_generator import generate_deck_reports
|
||||
lab_root = Path(__file__).parent.parent
|
||||
results_dir = str(lab_root / CFG.results_dir)
|
||||
reports_dir = str(lab_root / "deck-reports")
|
||||
updated = generate_deck_reports(results_dir, reports_dir)
|
||||
return {
|
||||
"status": "ok", "decksUpdated": updated, "count": len(updated),
|
||||
"message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found",
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Report generation failed: {e}")
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
"""Live game session routes.
|
||||
|
||||
Endpoints:
|
||||
POST /api/game/start — create a new live game session (called by LobbySetupModal)
|
||||
POST /api/game/action — submit a human player action (called by HumanActionBar)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
log = logging.getLogger("routes.game")
|
||||
|
||||
router = APIRouter(tags=["game"])
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# Request / Response models
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
class SeatConfig(BaseModel):
|
||||
seat_index: int = 0
|
||||
seat_type: str = "AI" # "Human" | "AI Aggro" | "AI Control" | "AI Combo" | "AI Political"
|
||||
deck_name: str = ""
|
||||
player_name: str = ""
|
||||
|
||||
|
||||
class StartGameRequest(BaseModel):
|
||||
seats: List[SeatConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StartGameResponse(BaseModel):
|
||||
game_id: str
|
||||
status: str = "started"
|
||||
ws_url: str
|
||||
|
||||
|
||||
class GameActionRequest(BaseModel):
|
||||
game_id: str
|
||||
seat: int = 0
|
||||
action: str # "pass_priority" | "end_turn" | "declare_attackers" |
|
||||
# "declare_blockers" | "select_target"
|
||||
target_id: Optional[str] = None
|
||||
extra: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GameActionResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
game_id: str
|
||||
action: str
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# In-memory session store (replace with DB / Redis for persistence)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
# { game_id: { "seats": [...], "status": "active" | "ended", "pending_actions": [...] } }
|
||||
_sessions: dict[str, dict] = {}
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# POST /api/game/start
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/game/start", response_model=StartGameResponse)
|
||||
async def start_game(req: StartGameRequest):
|
||||
"""Create a new live game session.
|
||||
|
||||
Called by Unity's LobbySetupModal after the player configures seats.
|
||||
Returns a game_id that Unity uses to connect to the WebSocket at
|
||||
/ws/game/{game_id}.
|
||||
|
||||
TODO: Wire Forge engine launch here once the IPC layer is ready.
|
||||
For now, creates a session record and returns the game_id so Unity
|
||||
can connect and receive state pushes from the Forge IPC client.
|
||||
"""
|
||||
if not req.seats:
|
||||
raise HTTPException(status_code=400, detail="At least one seat is required.")
|
||||
|
||||
game_id = uuid.uuid4().hex
|
||||
|
||||
_sessions[game_id] = {
|
||||
"game_id": game_id,
|
||||
"seats": [s.dict() for s in req.seats],
|
||||
"status": "active",
|
||||
"pending_actions": [],
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Game session created: game_id=%s seats=%d",
|
||||
game_id,
|
||||
len(req.seats),
|
||||
)
|
||||
|
||||
return StartGameResponse(
|
||||
game_id=game_id,
|
||||
status="started",
|
||||
ws_url=f"/ws/game/{game_id}",
|
||||
)
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# POST /api/game/action
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/game/action", response_model=GameActionResponse)
|
||||
async def game_action(req: GameActionRequest):
|
||||
"""Submit a human player action during a live game.
|
||||
|
||||
Called by Unity's HumanActionBar when seat 0 (human) has priority.
|
||||
The action is queued in the session's pending_actions list, where
|
||||
the Forge IPC polling loop will pick it up.
|
||||
|
||||
Valid actions:
|
||||
pass_priority — pass priority to the next player
|
||||
end_turn — end the current turn
|
||||
declare_attackers — declare attacking creatures
|
||||
declare_blockers — declare blocking creatures
|
||||
select_target — select a target (requires target_id)
|
||||
"""
|
||||
valid_actions = {
|
||||
"pass_priority", "end_turn",
|
||||
"declare_attackers", "declare_blockers",
|
||||
"select_target",
|
||||
}
|
||||
|
||||
if req.action not in valid_actions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown action '{req.action}'. Valid: {sorted(valid_actions)}",
|
||||
)
|
||||
|
||||
if req.action == "select_target" and not req.target_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="select_target requires a target_id.",
|
||||
)
|
||||
|
||||
session = _sessions.get(req.game_id)
|
||||
if session is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Game session '{req.game_id}' not found.",
|
||||
)
|
||||
|
||||
if session["status"] != "active":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Game session '{req.game_id}' is not active (status={session['status']}).",
|
||||
)
|
||||
|
||||
action_record = {
|
||||
"seat": req.seat,
|
||||
"action": req.action,
|
||||
"target_id": req.target_id,
|
||||
"extra": req.extra,
|
||||
}
|
||||
session["pending_actions"].append(action_record)
|
||||
|
||||
log.info(
|
||||
"Action queued: game_id=%s seat=%d action=%s",
|
||||
req.game_id, req.seat, req.action,
|
||||
)
|
||||
|
||||
return GameActionResponse(
|
||||
status="ok",
|
||||
game_id=req.game_id,
|
||||
action=req.action,
|
||||
)
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# GET /api/game/{game_id}/actions (Forge IPC polling endpoint)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/game/{game_id}/actions")
|
||||
async def poll_actions(game_id: str):
|
||||
"""Drain and return all pending human actions for a game session.
|
||||
|
||||
Called by the Forge IPC polling loop to consume human player decisions.
|
||||
Clears the queue on each call (consume-once semantics).
|
||||
"""
|
||||
session = _sessions.get(game_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail=f"Game session '{game_id}' not found.")
|
||||
|
||||
actions = session["pending_actions"].copy()
|
||||
session["pending_actions"].clear()
|
||||
return {"game_id": game_id, "actions": actions}
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# GET /api/game/sessions (debug)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/game/sessions")
|
||||
async def list_sessions():
|
||||
"""List active game sessions (debug endpoint)."""
|
||||
return {
|
||||
"count": len(_sessions),
|
||||
"sessions": [
|
||||
{"game_id": gid, "status": s["status"], "seats": len(s["seats"])}
|
||||
for gid, s in _sessions.items()
|
||||
],
|
||||
}
|
||||
+16
-6
@@ -77,15 +77,25 @@ async def rag_search(body: RAGSearchRequest):
|
||||
Returns ranked card results with relevance scores.
|
||||
"""
|
||||
try:
|
||||
from services.rag_store import search_cards
|
||||
results = search_cards(
|
||||
from services.rag_store import query_cards
|
||||
raw_results = query_cards(
|
||||
query=body.query,
|
||||
n_results=body.top_k,
|
||||
color_identity=body.color_identity,
|
||||
top_k=body.top_k,
|
||||
)
|
||||
results = [
|
||||
RAGCardResult(
|
||||
name=r.get("name", ""),
|
||||
type_line=r.get("type_line", ""),
|
||||
oracle_text=r.get("oracle_text", ""),
|
||||
color_identity=r.get("color_identity", ""),
|
||||
score=round(1.0 - r.get("distance", 0.0), 4),
|
||||
)
|
||||
for r in raw_results
|
||||
]
|
||||
return RAGSearchResponse(
|
||||
query=body.query,
|
||||
results=[RAGCardResult(**r) for r in results],
|
||||
results=results,
|
||||
total=len(results),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
@@ -104,8 +114,8 @@ async def rag_status_detail():
|
||||
Detailed RAG index status including card count and age.
|
||||
"""
|
||||
try:
|
||||
from services.rag_store import get_index_status
|
||||
return {"status": "ok", **get_index_status()}
|
||||
from services.rag_store import get_rag_stats
|
||||
return {"status": "ok", **get_rag_stats()}
|
||||
except Exception as exc:
|
||||
log.exception("RAG status check failed")
|
||||
return {"status": "error", "error": str(exc)}
|
||||
+3
-9
@@ -15,7 +15,9 @@ Unity connects to the WebSocket endpoint and receives:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
@@ -69,11 +71,8 @@ async def game_ws(websocket: WebSocket, game_id: str):
|
||||
|
||||
elif msg_type == "request_snapshot":
|
||||
if tracker.has_baseline:
|
||||
# Re-send current state as full snapshot
|
||||
import copy
|
||||
snap = copy.deepcopy(tracker._last_snapshot)
|
||||
tracker._seq += 1
|
||||
import time
|
||||
await websocket.send_json({
|
||||
"type": "snapshot",
|
||||
"seq": tracker._seq,
|
||||
@@ -128,12 +127,7 @@ class GameOverEvent(BaseModel):
|
||||
|
||||
@router.post("/api/ws/game/{game_id}/push")
|
||||
async def push_game_state(game_id: str, req: PushStateRequest):
|
||||
"""Push a game state update to all connected Unity clients.
|
||||
|
||||
Called by the Forge IPC bridge or policy server after each
|
||||
state change. The delta engine diffs against the previous
|
||||
state and broadcasts only what changed.
|
||||
"""
|
||||
"""Push a game state update to all connected Unity clients."""
|
||||
tracker = get_tracker(game_id)
|
||||
snapshot = req.dict()
|
||||
delta_msg = tracker.update(snapshot)
|
||||
|
||||
@@ -15,6 +15,14 @@ def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
|
||||
kw_raw = []
|
||||
kw = " ".join(str(k).lower() for k in kw_raw)
|
||||
|
||||
# Land check must come first — basic lands have empty oracle_text so every
|
||||
# other check below would silently miss them, leaving them as Unknown role.
|
||||
if "land" in tl:
|
||||
roles.append("Land")
|
||||
if not ot:
|
||||
# Basic lands have no oracle text; no other role applies.
|
||||
return roles
|
||||
|
||||
if ("add {" in ot or "add one mana" in ot
|
||||
or ("search your library for a" in ot and "land" in ot)
|
||||
or "treasure token" in ot or "add mana" in ot):
|
||||
@@ -78,4 +86,4 @@ def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
|
||||
or "take an extra" in ot
|
||||
or ("double" in ot and ("damage" in ot or "counters" in ot or "tokens" in ot))):
|
||||
roles.append("Combo")
|
||||
return roles
|
||||
return roles
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
services/card_text.py
|
||||
=====================
|
||||
Shared utility for building card text representations.
|
||||
|
||||
Both services/rag_store.py and coach/embeddings.py need to produce
|
||||
identical text strings for embedding so that vector similarity is
|
||||
consistent across both systems. This module provides a single
|
||||
canonical implementation.
|
||||
|
||||
Format: Name | Mana Cost | Type | Oracle Text
|
||||
(fields that are empty/missing are omitted)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def build_card_text(card: dict) -> str:
|
||||
"""Build the canonical text representation of a card for embedding.
|
||||
|
||||
Args:
|
||||
card: Dict with keys 'name', 'mana_cost', 'type_line', 'oracle_text'.
|
||||
Missing keys are treated as empty strings.
|
||||
|
||||
Returns:
|
||||
Pipe-separated string like 'Sol Ring | {1} | Artifact | {T}: Add {C}{C}.'
|
||||
"""
|
||||
parts = [card.get("name", "") or ""]
|
||||
mana_cost = card.get("mana_cost", "") or ""
|
||||
if mana_cost:
|
||||
parts.append(mana_cost)
|
||||
type_line = card.get("type_line", "") or ""
|
||||
if type_line:
|
||||
parts.append(type_line)
|
||||
oracle = card.get("oracle_text", "") or ""
|
||||
if oracle:
|
||||
parts.append(oracle)
|
||||
return " | ".join(parts)
|
||||
+233
-66
@@ -12,6 +12,7 @@ oracle text into the LLM prompt before calling the coach.
|
||||
|
||||
Public API:
|
||||
ensure_rag_store() -> None (build if missing / stale)
|
||||
build_index(force, batch_size) -> dict (explicit build, returns stats)
|
||||
query_cards(text, n, colors) -> list[dict]
|
||||
get_rag_stats() -> dict
|
||||
|
||||
@@ -25,39 +26,32 @@ import logging
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from services.card_text import build_card_text
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("commander_ai_lab.rag_store")
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────────────────
|
||||
# ── Config ────────────────────────────────────────────────────────────────
|
||||
RAG_CHROMA_DIR = Path(__file__).parent.parent / "data" / "rag_chroma"
|
||||
BUILD_META_PATH = RAG_CHROMA_DIR / "build_meta.json"
|
||||
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
EMBEDDING_MODEL = os.environ.get("RAG_EMBEDDING_MODEL", "nomic-embed-text")
|
||||
COLLECTION_NAME = "mtg_oracle"
|
||||
BATCH_SIZE = 200
|
||||
DEFAULT_BATCH_SIZE = 500
|
||||
_rag_lock = threading.Lock()
|
||||
_rag_ready = False
|
||||
|
||||
# Module-level singletons to avoid re-creating client/collection on every call
|
||||
_chroma_client = None
|
||||
_chroma_collection = None
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_document(card: dict) -> str:
|
||||
"""Build the text document to embed for a single card.
|
||||
|
||||
Format: Name | Type | Oracle Text
|
||||
This mirrors the structure used by the existing coach embeddings
|
||||
(coach/embeddings.py) so vector similarity is consistent across
|
||||
both systems.
|
||||
"""
|
||||
parts = [card.get("name", "")]
|
||||
type_line = card.get("type_line", "")
|
||||
if type_line:
|
||||
parts.append(type_line)
|
||||
oracle = card.get("oracle_text", "")
|
||||
if oracle:
|
||||
parts.append(oracle)
|
||||
return " | ".join(parts)
|
||||
"""Delegate to shared build_card_text() -- see services/card_text.py."""
|
||||
return build_card_text(card)
|
||||
|
||||
|
||||
def _build_metadata(card: dict) -> dict:
|
||||
@@ -82,16 +76,20 @@ def _build_metadata(card: dict) -> dict:
|
||||
|
||||
|
||||
def _get_chromadb_client():
|
||||
"""Return a persistent ChromaDB client, creating the data dir if needed."""
|
||||
"""Return the module-level persistent ChromaDB client (singleton)."""
|
||||
global _chroma_client
|
||||
if _chroma_client is not None:
|
||||
return _chroma_client
|
||||
try:
|
||||
import chromadb
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"chromadb is required for RAG Phase 2. "
|
||||
"Install with: pip install chromadb"
|
||||
"Install with: pip install 'commander-ai-lab[rag]'"
|
||||
)
|
||||
RAG_CHROMA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return chromadb.PersistentClient(path=str(RAG_CHROMA_DIR))
|
||||
_chroma_client = chromadb.PersistentClient(path=str(RAG_CHROMA_DIR))
|
||||
return _chroma_client
|
||||
|
||||
|
||||
def _get_ollama_embedding_fn():
|
||||
@@ -101,7 +99,7 @@ def _get_ollama_embedding_fn():
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"chromadb >= 0.4.0 with Ollama support is required. "
|
||||
"Install with: pip install chromadb"
|
||||
"Install with: pip install 'commander-ai-lab[rag]'"
|
||||
)
|
||||
return OllamaEmbeddingFunction(
|
||||
url=f"{OLLAMA_BASE_URL}/api/embeddings",
|
||||
@@ -110,34 +108,67 @@ def _get_ollama_embedding_fn():
|
||||
|
||||
|
||||
def _get_collection():
|
||||
"""Return the ChromaDB collection, creating it if needed."""
|
||||
"""Return the ChromaDB collection singleton, creating it if needed."""
|
||||
global _chroma_collection
|
||||
if _chroma_collection is not None:
|
||||
return _chroma_collection
|
||||
client = _get_chromadb_client()
|
||||
embed_fn = _get_ollama_embedding_fn()
|
||||
return client.get_or_create_collection(
|
||||
_chroma_collection = client.get_or_create_collection(
|
||||
name=COLLECTION_NAME,
|
||||
embedding_function=embed_fn,
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
)
|
||||
return _chroma_collection
|
||||
|
||||
|
||||
def _invalidate_collection_singleton() -> None:
|
||||
"""Reset the cached collection after a rebuild so the next call re-opens it."""
|
||||
global _chroma_collection
|
||||
_chroma_collection = None
|
||||
|
||||
|
||||
def _read_build_meta() -> dict:
|
||||
"""Read the sidecar build_meta.json, returning {} if missing or corrupt."""
|
||||
try:
|
||||
if BUILD_META_PATH.exists():
|
||||
return json.loads(BUILD_META_PATH.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("Could not read build_meta.json: %s", exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_build_meta(card_count: int) -> None:
|
||||
"""Persist build timestamp and card count to sidecar JSON file."""
|
||||
try:
|
||||
RAG_CHROMA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
BUILD_META_PATH.write_text(
|
||||
json.dumps({"built_at": time.time(), "card_count": card_count}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not write build_meta.json: %s", exc)
|
||||
|
||||
|
||||
def _collection_is_current(collection, bulk_card_count: int) -> bool:
|
||||
"""Check if the ChromaDB collection is reasonably up-to-date.
|
||||
|
||||
We compare the stored card count against the bulk DB count. If they
|
||||
differ by more than 5%, we consider the collection stale and rebuild.
|
||||
Uses sidecar build_meta.json for the age check (survives restarts),
|
||||
and compares card count within a 5% tolerance.
|
||||
"""
|
||||
chroma_count = collection.count()
|
||||
if chroma_count == 0:
|
||||
return False
|
||||
try:
|
||||
meta = collection.metadata or {}
|
||||
built_at = float(meta.get("built_at", 0))
|
||||
age_days = (time.time() - built_at) / 86400
|
||||
|
||||
# Age check via sidecar file (not collection.metadata which doesn't persist)
|
||||
meta = _read_build_meta()
|
||||
built_at = meta.get("built_at", 0)
|
||||
if built_at:
|
||||
age_days = (time.time() - float(built_at)) / 86400
|
||||
if age_days > 14:
|
||||
logger.info("RAG collection older than 14 days — rebuilding.")
|
||||
logger.info("RAG collection older than 14 days (%0.1f) — rebuilding.", age_days)
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
ratio = chroma_count / max(bulk_card_count, 1)
|
||||
if ratio < 0.95 or ratio > 1.05:
|
||||
logger.info(
|
||||
@@ -148,12 +179,19 @@ def _collection_is_current(collection, bulk_card_count: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# ── Build pipeline ───────────────────────────────────────────────────────────
|
||||
# ── Build pipeline ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_collection_from_bulk() -> int:
|
||||
def _build_collection_from_bulk(batch_size: int = DEFAULT_BATCH_SIZE) -> dict:
|
||||
"""Read all cards from the bulk SQLite DB and insert into ChromaDB.
|
||||
|
||||
Returns the number of cards indexed.
|
||||
Args:
|
||||
batch_size: Number of cards to embed and insert per ChromaDB call.
|
||||
|
||||
Returns:
|
||||
Dict with keys: indexed (int), failed (int).
|
||||
|
||||
Raises:
|
||||
RuntimeError: If more than 1% of cards failed to insert.
|
||||
"""
|
||||
from services.scryfall_bulk import get_bulk_db
|
||||
|
||||
@@ -164,28 +202,33 @@ def _build_collection_from_bulk() -> int:
|
||||
"WHERE oracle_text != '' "
|
||||
"ORDER BY edhrec_rank ASC"
|
||||
)
|
||||
all_rows = cursor.fetchall()
|
||||
total_rows = len(all_rows)
|
||||
|
||||
client = _get_chromadb_client()
|
||||
embed_fn = _get_ollama_embedding_fn()
|
||||
|
||||
# Drop and recreate collection to ensure a clean build
|
||||
try:
|
||||
client.delete_collection(COLLECTION_NAME)
|
||||
except Exception:
|
||||
pass
|
||||
_invalidate_collection_singleton()
|
||||
|
||||
collection = client.create_collection(
|
||||
name=COLLECTION_NAME,
|
||||
embedding_function=embed_fn,
|
||||
metadata={
|
||||
"hnsw:space": "cosine",
|
||||
"built_at": str(time.time()),
|
||||
},
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
)
|
||||
|
||||
inserted = 0
|
||||
failed_cards = 0
|
||||
failed_batches = 0
|
||||
batch_ids: list[str] = []
|
||||
batch_docs: list[str] = []
|
||||
batch_metas: list[dict] = []
|
||||
|
||||
for row in cursor:
|
||||
for row in all_rows:
|
||||
card = dict(row)
|
||||
doc = _build_document(card)
|
||||
if not doc.strip():
|
||||
@@ -199,7 +242,7 @@ def _build_collection_from_bulk() -> int:
|
||||
batch_docs.append(doc)
|
||||
batch_metas.append(_build_metadata(card))
|
||||
|
||||
if len(batch_ids) >= BATCH_SIZE:
|
||||
if len(batch_ids) >= batch_size:
|
||||
try:
|
||||
collection.add(
|
||||
ids=batch_ids,
|
||||
@@ -208,15 +251,19 @@ def _build_collection_from_bulk() -> int:
|
||||
)
|
||||
inserted += len(batch_ids)
|
||||
if inserted % 2000 == 0:
|
||||
logger.info(" RAG indexed %d cards...", inserted)
|
||||
logger.info(" RAG indexed %d / %d cards...", inserted, total_rows)
|
||||
except Exception as exc:
|
||||
failed_cards += len(batch_ids)
|
||||
failed_batches += 1
|
||||
logger.warning(
|
||||
"RAG batch insert failed at %d: %s", inserted, exc
|
||||
"RAG batch insert failed (batch #%d, %d cards lost): %s",
|
||||
failed_batches, len(batch_ids), exc,
|
||||
)
|
||||
batch_ids.clear()
|
||||
batch_docs.clear()
|
||||
batch_metas.clear()
|
||||
|
||||
# Final partial batch
|
||||
if batch_ids:
|
||||
try:
|
||||
collection.add(
|
||||
@@ -226,13 +273,71 @@ def _build_collection_from_bulk() -> int:
|
||||
)
|
||||
inserted += len(batch_ids)
|
||||
except Exception as exc:
|
||||
failed_cards += len(batch_ids)
|
||||
failed_batches += 1
|
||||
logger.warning("RAG final batch insert failed: %s", exc)
|
||||
|
||||
logger.info("RAG vector store complete: %d cards indexed.", inserted)
|
||||
return inserted
|
||||
logger.info(
|
||||
"RAG vector store complete: %d indexed, %d failed (%d batches).",
|
||||
inserted, failed_cards, failed_batches,
|
||||
)
|
||||
|
||||
# Abort if failure rate exceeds 1% — delete partial collection
|
||||
if total_rows > 0 and (failed_cards / total_rows) > 0.01:
|
||||
logger.error(
|
||||
"RAG build failure rate %.1f%% exceeds 1%% threshold — deleting partial collection.",
|
||||
100.0 * failed_cards / total_rows,
|
||||
)
|
||||
try:
|
||||
client.delete_collection(COLLECTION_NAME)
|
||||
_invalidate_collection_singleton()
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(
|
||||
f"RAG build aborted: {failed_cards}/{total_rows} cards failed to insert. "
|
||||
"Check Ollama connectivity and retry."
|
||||
)
|
||||
|
||||
_write_build_meta(card_count=inserted)
|
||||
# Invalidate singleton so next _get_collection() picks up the new collection
|
||||
_invalidate_collection_singleton()
|
||||
return {"indexed": inserted, "failed": failed_cards}
|
||||
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────────────────
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def build_index(force: bool = False, batch_size: int = DEFAULT_BATCH_SIZE) -> dict:
|
||||
"""Explicitly build or rebuild the ChromaDB vector index.
|
||||
|
||||
This is the function called by POST /api/rag/build.
|
||||
Unlike ensure_rag_store(), this always runs (no _rag_ready short-circuit)
|
||||
and returns stats so the API can surface them to the caller.
|
||||
|
||||
Args:
|
||||
force: If True, rebuild even if the index appears current.
|
||||
batch_size: Cards per ChromaDB insert batch (default 500).
|
||||
|
||||
Returns:
|
||||
Dict with keys: indexed (int), failed (int).
|
||||
"""
|
||||
global _rag_ready
|
||||
with _rag_lock:
|
||||
if not force:
|
||||
try:
|
||||
from services.scryfall_bulk import get_stats as bulk_stats
|
||||
stats = bulk_stats()
|
||||
bulk_count = stats.get("card_count", 0)
|
||||
collection = _get_collection()
|
||||
if _collection_is_current(collection, bulk_count):
|
||||
logger.info("RAG index is current — skipping rebuild (use force=True to override).")
|
||||
return {"indexed": collection.count(), "failed": 0, "skipped": True}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = _build_collection_from_bulk(batch_size=batch_size)
|
||||
_rag_ready = True
|
||||
return result
|
||||
|
||||
|
||||
def ensure_rag_store() -> None:
|
||||
"""Build the ChromaDB vector store if it is missing or stale.
|
||||
@@ -294,6 +399,7 @@ def query_cards(
|
||||
color_identity: If provided, only return cards whose color identity
|
||||
is a subset (e.g. ["W", "U", "B"]).
|
||||
type_filter: Optional type_line substring filter (e.g. "Creature").
|
||||
Applied as a post-query Python filter for ChromaDB compatibility.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: name, type_line, oracle_text, cmc,
|
||||
@@ -309,16 +415,12 @@ def query_cards(
|
||||
logger.error("Failed to get RAG collection: %s", exc)
|
||||
return []
|
||||
|
||||
where_filter = None
|
||||
if type_filter:
|
||||
where_filter = {"type_line": {"$contains": type_filter}}
|
||||
|
||||
try:
|
||||
fetch_n = n_results * 3 if color_identity else n_results
|
||||
# Fetch extra results to account for post-query color/type filtering
|
||||
fetch_n = n_results * 4 if (color_identity or type_filter) else n_results
|
||||
results = collection.query(
|
||||
query_texts=[query],
|
||||
n_results=min(fetch_n, 50),
|
||||
where=where_filter,
|
||||
n_results=min(fetch_n, 200),
|
||||
include=["documents", "metadatas", "distances"],
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -338,15 +440,23 @@ def query_cards(
|
||||
meta = metas[i] if i < len(metas) else {}
|
||||
doc = docs[i] if i < len(docs) else ""
|
||||
|
||||
# Color identity post-filter (Python-side for ChromaDB compat)
|
||||
if color_identity:
|
||||
card_ci_str = meta.get("color_identity", "")
|
||||
card_ci = set(card_ci_str.split(",")) if card_ci_str else set()
|
||||
card_ci = set(c for c in card_ci_str.split(",") if c) if card_ci_str else set()
|
||||
deck_ci = set(color_identity)
|
||||
if not card_ci.issubset(deck_ci):
|
||||
continue
|
||||
|
||||
parts = doc.split(" | ", 2)
|
||||
oracle = parts[2] if len(parts) > 2 else parts[-1] if parts else ""
|
||||
# Type post-filter
|
||||
if type_filter:
|
||||
card_type = meta.get("type_line", "")
|
||||
if type_filter.lower() not in card_type.lower():
|
||||
continue
|
||||
|
||||
# Document format: Name | Mana Cost | Type | Oracle Text
|
||||
parts = doc.split(" | ", 3)
|
||||
oracle = parts[3] if len(parts) > 3 else (parts[-1] if parts else "")
|
||||
|
||||
cards.append({
|
||||
"name": meta.get("name", ""),
|
||||
@@ -377,15 +487,72 @@ def get_rag_stats() -> dict:
|
||||
try:
|
||||
collection = _get_collection()
|
||||
result["card_count"] = collection.count()
|
||||
meta = collection.metadata or {}
|
||||
built_at = meta.get("built_at")
|
||||
if built_at:
|
||||
result["built_at"] = built_at
|
||||
try:
|
||||
age = (time.time() - float(built_at)) / 86400
|
||||
result["age_days"] = round(age, 1)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Read age from sidecar file (persists across restarts)
|
||||
meta = _read_build_meta()
|
||||
built_at = meta.get("built_at")
|
||||
if built_at:
|
||||
result["built_at"] = built_at
|
||||
try:
|
||||
age = (time.time() - float(built_at)) / 86400
|
||||
result["age_days"] = round(age, 1)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def is_chroma_available() -> bool:
|
||||
"""Check whether ChromaDB + Ollama are reachable without side effects."""
|
||||
try:
|
||||
_get_chromadb_client()
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
f"{OLLAMA_BASE_URL}/api/tags",
|
||||
headers={"User-Agent": "commander-ai-lab/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=3):
|
||||
pass
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_staleness() -> dict:
|
||||
"""Return staleness info so a background scheduler can trigger rebuilds.
|
||||
|
||||
Returns dict with keys:
|
||||
stale (bool): True if the index should be rebuilt.
|
||||
reason (str): Human-readable explanation.
|
||||
age_days (float | None): Age of the current build in days.
|
||||
"""
|
||||
meta = _read_build_meta()
|
||||
built_at = meta.get("built_at")
|
||||
if not built_at:
|
||||
return {"stale": True, "reason": "no build metadata found", "age_days": None}
|
||||
try:
|
||||
age_days = (time.time() - float(built_at)) / 86400
|
||||
except (ValueError, TypeError):
|
||||
return {"stale": True, "reason": "corrupt build timestamp", "age_days": None}
|
||||
if age_days > 14:
|
||||
return {"stale": True, "reason": f"index is {age_days:.1f} days old (>14)", "age_days": round(age_days, 1)}
|
||||
try:
|
||||
from services.scryfall_bulk import get_stats as bulk_stats
|
||||
bulk_count = bulk_stats().get("card_count", 0)
|
||||
collection = _get_collection()
|
||||
chroma_count = collection.count()
|
||||
ratio = chroma_count / max(bulk_count, 1)
|
||||
if ratio < 0.95 or ratio > 1.05:
|
||||
return {
|
||||
"stale": True,
|
||||
"reason": f"card count drift: chroma={chroma_count}, bulk={bulk_count}",
|
||||
"age_days": round(age_days, 1),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("check_staleness card count check failed: %s", exc)
|
||||
return {"stale": False, "reason": "index is current", "age_days": round(age_days, 1)}
|
||||
@@ -26,13 +26,13 @@ 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
|
||||
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] = []
|
||||
@@ -70,7 +70,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
else:
|
||||
warnings.append("EDHrec returned no data — relying on Scryfall + Ollama")
|
||||
|
||||
# ── Step 3: Fetch Scryfall candidates by category ────────────────────────
|
||||
# ── 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),
|
||||
@@ -81,7 +81,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
for cat, cards in scryfall_candidates.items():
|
||||
logger.info(f" Scryfall {cat}: {len(cards)} candidates")
|
||||
|
||||
# ── Step 4: Ollama suggestions to fill gaps ────────────────────────────
|
||||
# ── Step 4: Ollama suggestions to fill gaps ──────────────────────────
|
||||
logger.info("Step 4: Ollama suggesting additional cards")
|
||||
|
||||
# Collect all names we already have
|
||||
@@ -229,6 +229,49 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
if entry.name in seen_names:
|
||||
entry.category = seen_names[entry.name]
|
||||
|
||||
# -- Backfill basic lands to reach exactly 99 cards ----------------
|
||||
_BACKFILL_COLOR_TO_BASIC = {
|
||||
"W": "Plains", "U": "Island", "B": "Swamp",
|
||||
"R": "Mountain", "G": "Forest",
|
||||
}
|
||||
_BASIC_LAND_NAMES = set(_BACKFILL_COLOR_TO_BASIC.values()) | {"Wastes"}
|
||||
_current_total = sum(e.quantity for e in card_entries)
|
||||
_shortfall = 99 - _current_total
|
||||
if _shortfall > 0:
|
||||
logger.warning(
|
||||
"Deck has %d cards after dedup -- backfilling %d basic land(s)",
|
||||
_current_total, _shortfall,
|
||||
)
|
||||
warnings.append(
|
||||
f"Backfilled {_shortfall} basic land(s) to reach 99 cards"
|
||||
)
|
||||
_basics = [
|
||||
_BACKFILL_COLOR_TO_BASIC[c]
|
||||
for c in sorted(commander_ci)
|
||||
if c in _BACKFILL_COLOR_TO_BASIC
|
||||
] or ["Wastes"]
|
||||
# Distribute evenly across colors
|
||||
_per_color = _shortfall // len(_basics)
|
||||
_remainder = _shortfall % len(_basics)
|
||||
for _i, _bname in enumerate(_basics):
|
||||
_qty = _per_color + (1 if _i < _remainder else 0)
|
||||
if _qty <= 0:
|
||||
continue
|
||||
# Check if this basic is already in card_entries
|
||||
_found = False
|
||||
for _ce in card_entries:
|
||||
if _ce.name == _bname:
|
||||
_ce.quantity += _qty
|
||||
_found = True
|
||||
break
|
||||
if not _found:
|
||||
card_entries.append(CardEntry(
|
||||
name=_bname,
|
||||
quantity=_qty,
|
||||
category="lands",
|
||||
type_line="Basic Land",
|
||||
))
|
||||
|
||||
# Build the deck
|
||||
elapsed = time.time() - start
|
||||
logger.info(f"Deck built in {elapsed:.1f}s")
|
||||
|
||||
@@ -39,10 +39,22 @@ _FORGE_CACHE_LOADED: bool = False
|
||||
|
||||
|
||||
def _get_cards_folder() -> Path:
|
||||
"""Resolve the Forge cardsfolder directory from CFG.forge_dir."""
|
||||
"""Resolve the Forge cardsfolder directory from CFG.forge_dir.
|
||||
|
||||
Supports two layouts:
|
||||
A) forge_dir = .../forge-repo -> forge-gui/res/cardsfolder
|
||||
B) forge_dir = .../forge-repo/forge-gui -> res/cardsfolder
|
||||
"""
|
||||
if not CFG.forge_dir:
|
||||
return Path("")
|
||||
return Path(CFG.forge_dir) / "forge-gui" / "res" / "cardsfolder"
|
||||
base = Path(CFG.forge_dir)
|
||||
candidate_a = base / "forge-gui" / "res" / "cardsfolder"
|
||||
candidate_b = base / "res" / "cardsfolder"
|
||||
if candidate_a.is_dir():
|
||||
return candidate_a
|
||||
if candidate_b.is_dir():
|
||||
return candidate_b
|
||||
return candidate_a # fall back so warning shows the expected path
|
||||
|
||||
|
||||
def _parse_forge_file(filepath: Path) -> Optional[ForgeCardData]:
|
||||
@@ -58,7 +70,6 @@ def _parse_forge_file(filepath: Path) -> Optional[ForgeCardData]:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Name
|
||||
if line.startswith("Name:"):
|
||||
data.name = line.split(":", 1)[1].strip()
|
||||
@@ -150,3 +161,11 @@ def lookup_forge_card(card_name: str) -> Optional[ForgeCardData]:
|
||||
"""Look up a card by name from the Forge cache. Returns None on miss."""
|
||||
_load_forge_cache()
|
||||
return _FORGE_CACHE.get((card_name or "").lower().strip())
|
||||
|
||||
|
||||
def reset_forge_cache() -> None:
|
||||
"""Force a reload of the Forge card cache on next lookup."""
|
||||
global _FORGE_CACHE_LOADED
|
||||
_FORGE_CACHE.clear()
|
||||
_FORGE_CACHE_LOADED = False
|
||||
_log.info("Forge card cache cleared — will reload on next lookup")
|
||||
@@ -23,9 +23,9 @@ from commander_ai_lab.sim.models import Card
|
||||
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card, ForgeCardData
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# AI Weights (ported from AI_DEFAULT_WEIGHTS in app.js)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
AI_DEFAULT_WEIGHTS: dict[str, float] = {
|
||||
# Card scoring
|
||||
@@ -107,10 +107,10 @@ def load_weights(path: Optional[str] = None) -> dict[str, float]:
|
||||
return dict(AI_DEFAULT_WEIGHTS)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Card Enrichment — guess type/cost for cards without Scryfall data
|
||||
# Ported from enrichCardForSim() in deck_tester_js.txt
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
_BASIC_LAND_COLORS = {
|
||||
"plains": "W",
|
||||
@@ -141,6 +141,21 @@ _KNOWN_CARDS: list[tuple[re.Pattern, dict]] = [
|
||||
]
|
||||
|
||||
|
||||
# Nonbasic land name guard — compiled once at module load, not inside enrich_card().
|
||||
# Catches common land names that Forge may return with cmc=0 (causing has_real_data
|
||||
# to stay False) or that arrive with no Scryfall data at all.
|
||||
# Extended per Bug 3 to cover fetchlands, shocklands, filter lands, pain lands, etc.
|
||||
_land_kw = re.compile(
|
||||
r"\b(grove|garden|glade|falls|summit|crag|chapel|retreat|harbor|cemetery|"
|
||||
r"pool|fountain|shrine|foundry|vents|grave|crypt|tomb|ground|ruins|mesa|"
|
||||
r"tarn|delta|strand|heath|mire|bog|den|lair|hollow|flats|wastes|expanse|"
|
||||
r"vista|nexus|spire|sanctum|citadel|reaches|keeps|gate|springs|crossing|"
|
||||
r"command tower|path of ancestry|evolving wilds|terramorphic|"
|
||||
r"exotic orchard|mana confluence|city of brass|reflecting pool|"
|
||||
r"fabled passage|prismatic vista|command beacon|ash barrens)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _apply_forge_data(card: Card, forge_data: ForgeCardData) -> None:
|
||||
"""
|
||||
@@ -182,6 +197,7 @@ def _apply_forge_data(card: Card, forge_data: ForgeCardData) -> None:
|
||||
if forge_data.oracle_text and not card.oracle_text:
|
||||
card.oracle_text = forge_data.oracle_text
|
||||
|
||||
|
||||
def enrich_card(card: Card) -> Card:
|
||||
"""
|
||||
Enrich a card with type/cost heuristics if it lacks Scryfall data.
|
||||
@@ -198,8 +214,12 @@ def enrich_card(card: Card) -> Card:
|
||||
if forge_data:
|
||||
_apply_forge_data(card, forge_data) # sets oracle_text, keywords, etc.
|
||||
card.forge_enriched = True
|
||||
# Re-check: if Forge provided sufficient data, apply flags and return
|
||||
# Re-check: if Forge provided sufficient data, apply flags and return.
|
||||
# Bug 3 fix: lands always have cmc=0, so explicitly allow type_line
|
||||
# containing "land" to satisfy has_real_data even when cmc is 0.
|
||||
has_real_data = card.cmc > 0 or (card.type_line and len(card.type_line) > 5)
|
||||
if not has_real_data and card.type_line and "land" in card.type_line.lower():
|
||||
has_real_data = True # lands are valid cards even with cmc=0
|
||||
if has_real_data:
|
||||
_apply_oracle_flags(card)
|
||||
return card
|
||||
@@ -223,15 +243,8 @@ def enrich_card(card: Card) -> Card:
|
||||
_apply_oracle_flags(card)
|
||||
return card
|
||||
|
||||
|
||||
# ── Nonbasic land name guard (Issue #118) ──────────────────
|
||||
# Catch common nonbasic lands BEFORE the creature fallback
|
||||
_land_kw = re.compile(
|
||||
r"\b(grove|garden|glade|falls|summit|crag|chapel|retreat|harbor|cemetery|"
|
||||
r"pool|fountain|shrine|foundry|vents|grave|crypt|tomb|ground|"
|
||||
r"command tower|path of ancestry|evolving wilds|terramorphic|expanse|"
|
||||
r"exotic orchard|mana confluence|city of brass|reflecting pool|"
|
||||
r"fabled passage|prismatic vista)\b", re.IGNORECASE)
|
||||
# Nonbasic land name guard — catches fetchlands, shocklands, filter lands,
|
||||
# pain lands, and other common nonbasic lands BEFORE the creature fallback.
|
||||
if _land_kw.search(name_lower):
|
||||
card.type_line = "Land"
|
||||
card.cmc = 0
|
||||
@@ -299,7 +312,7 @@ def _apply_oracle_flags(card: Card) -> None:
|
||||
card.is_ramp = True
|
||||
|
||||
|
||||
# ── Direct-damage (burn spell) detection ──────────────────────
|
||||
# ── Direct-damage (burn spell) detection ────────────────────────────────────
|
||||
|
||||
# Regex: "deals N damage to target|any target" (not "each creature" — that's a wipe)
|
||||
_DIRECT_DAMAGE_RE = re.compile(
|
||||
@@ -357,7 +370,7 @@ def _detect_direct_damage(card: Card, oracle: str) -> None:
|
||||
card.direct_damage_amount = int(m.group(1))
|
||||
|
||||
|
||||
# ── Multi-opponent scaling factors ────────────────────────────
|
||||
# ── Multi-opponent scaling factors ─────────────────────────────────────────────
|
||||
# Keywords / effects that become more valuable in larger pods.
|
||||
# Each entry maps a weight key → the extra bonus *per additional
|
||||
# opponent* beyond 1. 1v1 uses base weights; 4-player FFA adds
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for RAG Phase 3 — coach prompt injection."""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from coach.prompt_template import _format_rag_cards, build_user_prompt
|
||||
|
||||
|
||||
class TestFormatRagCards:
|
||||
"""Tests for _format_rag_cards helper."""
|
||||
|
||||
def test_empty_list_returns_fallback(self):
|
||||
assert _format_rag_cards([]) == "No RAG results available."
|
||||
|
||||
def test_none_returns_fallback(self):
|
||||
# build_user_prompt passes rag_cards or [] so None shouldn't happen,
|
||||
# but _format_rag_cards should handle it gracefully
|
||||
assert "No RAG" in _format_rag_cards([])
|
||||
|
||||
def test_single_card_formatted(self):
|
||||
cards = [{
|
||||
"name": "Sol Ring",
|
||||
"types": "Artifact",
|
||||
"mana_value": 1,
|
||||
"mana_cost": "{1}",
|
||||
"distance": 0.123,
|
||||
"text": "Tap: Add two colorless mana.",
|
||||
}]
|
||||
result = _format_rag_cards(cards)
|
||||
assert "Sol Ring" in result
|
||||
assert "{1}" in result
|
||||
assert "MV:1" in result
|
||||
assert "0.123" in result
|
||||
assert "Tap: Add two colorless" in result
|
||||
|
||||
def test_max_15_cards(self):
|
||||
cards = [{"name": f"Card {i}", "types": "Creature",
|
||||
"mana_value": i, "mana_cost": f"{{{i}}}",
|
||||
"distance": 0.1 * i, "text": ""} for i in range(20)]
|
||||
result = _format_rag_cards(cards)
|
||||
assert "Card 14" in result
|
||||
assert "Card 15" not in result
|
||||
|
||||
def test_missing_fields_use_defaults(self):
|
||||
cards = [{"name": "Mystery Card"}]
|
||||
result = _format_rag_cards(cards)
|
||||
assert "Mystery Card" in result
|
||||
assert "MV:?" in result
|
||||
|
||||
|
||||
class TestBuildUserPromptRagParam:
|
||||
"""Tests that build_user_prompt accepts and renders rag_cards."""
|
||||
|
||||
@patch("coach.prompt_template._format_rag_cards")
|
||||
def test_rag_cards_passed_to_formatter(self, mock_fmt):
|
||||
mock_fmt.return_value = "MOCK_RAG_OUTPUT"
|
||||
# We can't easily build a full DeckReport here without fixtures,
|
||||
# so just verify the function signature accepts rag_cards.
|
||||
# A full integration test would need the DeckReport factory.
|
||||
assert callable(build_user_prompt)
|
||||
import inspect
|
||||
sig = inspect.signature(build_user_prompt)
|
||||
assert "rag_cards" in sig.parameters
|
||||
|
||||
def test_rag_cards_default_is_none(self):
|
||||
import inspect
|
||||
sig = inspect.signature(build_user_prompt)
|
||||
param = sig.parameters["rag_cards"]
|
||||
assert param.default is None
|
||||
|
||||
|
||||
class TestRagQueryIntegration:
|
||||
"""Tests that coach_service calls query_cards (mocked)."""
|
||||
|
||||
@patch("services.rag_store.query_cards", return_value=[
|
||||
{"name": "Arcane Signet", "types": "Artifact", "mana_value": 2,
|
||||
"mana_cost": "{2}", "distance": 0.05, "text": "Tap: Add one mana."},
|
||||
])
|
||||
def test_query_cards_called_in_coaching(self, mock_qc):
|
||||
"""Smoke test: import doesn't crash."""
|
||||
from services.rag_store import query_cards
|
||||
result = query_cards("Atraxa commander deck", n=15, colors=["W", "U", "B", "G"])
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "Arcane Signet"
|
||||
mock_qc.assert_called_once()
|
||||
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
tests/test_rag_store.py
|
||||
=======================
|
||||
Unit tests for services/rag_store.py.
|
||||
|
||||
All tests use mocked ChromaDB and Ollama dependencies — no external services
|
||||
or network access required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, mock_open
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to reset module-level singletons between tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _reset_rag_module():
|
||||
"""Reset all module-level state in rag_store between tests."""
|
||||
import services.rag_store as rs
|
||||
rs._rag_ready = False
|
||||
rs._chroma_client = None
|
||||
rs._chroma_collection = None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_rag_state():
|
||||
_reset_rag_module()
|
||||
yield
|
||||
_reset_rag_module()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_document
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildDocument:
|
||||
def test_includes_all_four_fields(self):
|
||||
from services.rag_store import _build_document
|
||||
card = {
|
||||
"name": "Rampant Growth",
|
||||
"mana_cost": "{1}{G}",
|
||||
"type_line": "Sorcery",
|
||||
"oracle_text": "Search your library for a basic land card.",
|
||||
}
|
||||
doc = _build_document(card)
|
||||
assert "Rampant Growth" in doc
|
||||
assert "{1}{G}" in doc
|
||||
assert "Sorcery" in doc
|
||||
assert "Search your library" in doc
|
||||
|
||||
def test_mana_cost_present_in_document(self):
|
||||
"""Regression: mana_cost was previously missing (bug #128)."""
|
||||
from services.rag_store import _build_document
|
||||
card = {"name": "Lightning Bolt", "mana_cost": "{R}", "type_line": "Instant", "oracle_text": "Deal 3 damage."}
|
||||
doc = _build_document(card)
|
||||
assert "{R}" in doc
|
||||
|
||||
def test_missing_mana_cost_skipped(self):
|
||||
from services.rag_store import _build_document
|
||||
card = {"name": "Plains", "mana_cost": "", "type_line": "Basic Land", "oracle_text": ""}
|
||||
doc = _build_document(card)
|
||||
# Should not produce " | | " style empty fields
|
||||
assert "Plains" in doc
|
||||
assert doc.count(" | | ") == 0
|
||||
|
||||
def test_empty_card_returns_name_only(self):
|
||||
from services.rag_store import _build_document
|
||||
card = {"name": "Unnamed"}
|
||||
doc = _build_document(card)
|
||||
assert doc == "Unnamed"
|
||||
|
||||
def test_pipe_delimiter_format(self):
|
||||
from services.rag_store import _build_document
|
||||
card = {"name": "A", "mana_cost": "B", "type_line": "C", "oracle_text": "D"}
|
||||
assert _build_document(card) == "A | B | C | D"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildMetadata:
|
||||
def test_color_identity_json_string_parsed(self):
|
||||
from services.rag_store import _build_metadata
|
||||
card = {"name": "X", "color_identity": '["W", "U"]', "cmc": 3}
|
||||
meta = _build_metadata(card)
|
||||
assert meta["color_identity"] == "U,W" # sorted
|
||||
|
||||
def test_color_identity_list_input(self):
|
||||
from services.rag_store import _build_metadata
|
||||
card = {"name": "X", "color_identity": ["B", "G"], "cmc": 2}
|
||||
meta = _build_metadata(card)
|
||||
assert meta["color_identity"] == "B,G"
|
||||
|
||||
def test_colorless_card(self):
|
||||
from services.rag_store import _build_metadata
|
||||
card = {"name": "Sol Ring", "color_identity": "[]", "cmc": 1}
|
||||
meta = _build_metadata(card)
|
||||
assert meta["color_identity"] == ""
|
||||
|
||||
def test_malformed_color_identity_defaults_empty(self):
|
||||
from services.rag_store import _build_metadata
|
||||
card = {"name": "X", "color_identity": "not-json", "cmc": 0}
|
||||
meta = _build_metadata(card)
|
||||
assert meta["color_identity"] == ""
|
||||
|
||||
def test_cmc_none_defaults_zero(self):
|
||||
from services.rag_store import _build_metadata
|
||||
card = {"name": "X", "cmc": None}
|
||||
meta = _build_metadata(card)
|
||||
assert meta["cmc"] == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _read_build_meta / _write_build_meta
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildMeta:
|
||||
def test_write_then_read_roundtrip(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
original_dir = rs.RAG_CHROMA_DIR
|
||||
original_path = rs.BUILD_META_PATH
|
||||
try:
|
||||
rs.RAG_CHROMA_DIR = tmp_path
|
||||
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
|
||||
rs._write_build_meta(card_count=12345)
|
||||
meta = rs._read_build_meta()
|
||||
assert meta["card_count"] == 12345
|
||||
assert abs(meta["built_at"] - time.time()) < 5
|
||||
finally:
|
||||
rs.RAG_CHROMA_DIR = original_dir
|
||||
rs.BUILD_META_PATH = original_path
|
||||
|
||||
def test_read_missing_returns_empty_dict(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
original_path = rs.BUILD_META_PATH
|
||||
try:
|
||||
rs.BUILD_META_PATH = tmp_path / "nonexistent.json"
|
||||
assert rs._read_build_meta() == {}
|
||||
finally:
|
||||
rs.BUILD_META_PATH = original_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _collection_is_current
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCollectionIsCurrent:
|
||||
def _make_collection(self, count: int) -> MagicMock:
|
||||
col = MagicMock()
|
||||
col.count.return_value = count
|
||||
return col
|
||||
|
||||
def test_empty_collection_returns_false(self):
|
||||
from services.rag_store import _collection_is_current
|
||||
col = self._make_collection(0)
|
||||
assert _collection_is_current(col, 1000) is False
|
||||
|
||||
def test_current_collection_returns_true(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
original_path = rs.BUILD_META_PATH
|
||||
try:
|
||||
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
|
||||
rs._write_build_meta(card_count=1000)
|
||||
col = self._make_collection(1000)
|
||||
assert rs._collection_is_current(col, 1000) is True
|
||||
finally:
|
||||
rs.BUILD_META_PATH = original_path
|
||||
|
||||
def test_stale_by_age_returns_false(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
original_path = rs.BUILD_META_PATH
|
||||
try:
|
||||
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
|
||||
# Write a built_at 15 days ago
|
||||
rs.BUILD_META_PATH.write_text(
|
||||
json.dumps({"built_at": time.time() - (15 * 86400), "card_count": 1000}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
col = self._make_collection(1000)
|
||||
assert rs._collection_is_current(col, 1000) is False
|
||||
finally:
|
||||
rs.BUILD_META_PATH = original_path
|
||||
|
||||
def test_count_mismatch_returns_false(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
original_path = rs.BUILD_META_PATH
|
||||
try:
|
||||
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
|
||||
rs._write_build_meta(card_count=500)
|
||||
col = self._make_collection(500) # chroma has 500
|
||||
assert rs._collection_is_current(col, 1000) is False # bulk has 1000
|
||||
finally:
|
||||
rs.BUILD_META_PATH = original_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# query_cards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQueryCards:
|
||||
def test_returns_empty_when_not_ready(self):
|
||||
from services.rag_store import query_cards
|
||||
# _rag_ready is False (reset by fixture)
|
||||
result = query_cards("draw cards")
|
||||
assert result == []
|
||||
|
||||
def test_color_filter_removes_off_color_cards(self):
|
||||
import services.rag_store as rs
|
||||
rs._rag_ready = True
|
||||
|
||||
mock_col = MagicMock()
|
||||
mock_col.query.return_value = {
|
||||
"ids": [["id1", "id2"]],
|
||||
"documents": [["Wrath | {3}{W}{W} | Sorcery | Destroy all", "Dark Ritual | {B} | Instant | Add BBB"]],
|
||||
"metadatas": [[
|
||||
{"name": "Wrath", "type_line": "Sorcery", "color_identity": "W", "cmc": 5.0, "mana_cost": "{3}{W}{W}"},
|
||||
{"name": "Dark Ritual", "type_line": "Instant", "color_identity": "B", "cmc": 1.0, "mana_cost": "{B}"},
|
||||
]],
|
||||
"distances": [[0.1, 0.2]],
|
||||
}
|
||||
rs._chroma_collection = mock_col
|
||||
|
||||
results = rs.query_cards("mana acceleration", n_results=10, color_identity=["W"])
|
||||
names = [r["name"] for r in results]
|
||||
assert "Wrath" in names
|
||||
assert "Dark Ritual" not in names
|
||||
|
||||
def test_distance_converted_to_score_in_route(self):
|
||||
"""Verify distance field is present and in [0, 1] range."""
|
||||
import services.rag_store as rs
|
||||
rs._rag_ready = True
|
||||
|
||||
mock_col = MagicMock()
|
||||
mock_col.query.return_value = {
|
||||
"ids": [["id1"]],
|
||||
"documents": [["Sol Ring | {1} | Artifact | Add two colorless mana."]],
|
||||
"metadatas": [[
|
||||
{"name": "Sol Ring", "type_line": "Artifact", "color_identity": "", "cmc": 1.0, "mana_cost": "{1}"},
|
||||
]],
|
||||
"distances": [[0.15]],
|
||||
}
|
||||
rs._chroma_collection = mock_col
|
||||
|
||||
results = rs.query_cards("mana rock", n_results=1)
|
||||
assert len(results) == 1
|
||||
assert results[0]["distance"] == 0.15
|
||||
assert results[0]["name"] == "Sol Ring"
|
||||
|
||||
def test_empty_chroma_result_returns_empty_list(self):
|
||||
import services.rag_store as rs
|
||||
rs._rag_ready = True
|
||||
|
||||
mock_col = MagicMock()
|
||||
mock_col.query.return_value = {"ids": [[]], "documents": [[]], "metadatas": [[]], "distances": [[]]}
|
||||
rs._chroma_collection = mock_col
|
||||
|
||||
assert rs.query_cards("nothing") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_rag_stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetRagStats:
|
||||
def test_returns_expected_keys(self):
|
||||
import services.rag_store as rs
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 42000
|
||||
rs._chroma_collection = mock_col
|
||||
|
||||
stats = rs.get_rag_stats()
|
||||
for key in ["ready", "chroma_dir", "embedding_model", "ollama_url", "card_count", "built_at", "age_days"]:
|
||||
assert key in stats, f"Missing key: {key}"
|
||||
|
||||
def test_card_count_from_collection(self):
|
||||
import services.rag_store as rs
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 55000
|
||||
rs._chroma_collection = mock_col
|
||||
|
||||
stats = rs.get_rag_stats()
|
||||
assert stats["card_count"] == 55000
|
||||
|
||||
def test_ready_false_when_not_initialized(self):
|
||||
from services.rag_store import get_rag_stats
|
||||
stats = get_rag_stats()
|
||||
assert stats["ready"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_index (public API for POST /api/rag/build)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildIndex:
|
||||
def test_skips_rebuild_when_current(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
original_path = rs.BUILD_META_PATH
|
||||
original_dir = rs.RAG_CHROMA_DIR
|
||||
try:
|
||||
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
|
||||
rs.RAG_CHROMA_DIR = tmp_path
|
||||
rs._write_build_meta(card_count=1000)
|
||||
|
||||
mock_col = MagicMock()
|
||||
mock_col.count.return_value = 1000
|
||||
rs._chroma_collection = mock_col
|
||||
|
||||
with patch("services.rag_store._get_collection", return_value=mock_col), \
|
||||
patch("services.scryfall_bulk.get_stats", return_value={"card_count": 1000}):
|
||||
result = rs.build_index(force=False)
|
||||
|
||||
assert result.get("skipped") is True
|
||||
finally:
|
||||
rs.BUILD_META_PATH = original_path
|
||||
rs.RAG_CHROMA_DIR = original_dir
|
||||
|
||||
def test_force_triggers_rebuild(self, tmp_path):
|
||||
import services.rag_store as rs
|
||||
with patch("services.rag_store._build_collection_from_bulk") as mock_build:
|
||||
mock_build.return_value = {"indexed": 500, "failed": 0}
|
||||
result = rs.build_index(force=True, batch_size=250)
|
||||
mock_build.assert_called_once_with(batch_size=250)
|
||||
assert result["indexed"] == 500
|
||||
assert rs._rag_ready is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failure tracking in _build_collection_from_bulk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildCollectionFailureTracking:
|
||||
def test_high_failure_rate_raises_runtime_error(self, tmp_path):
|
||||
"""If >1% of batches fail, a RuntimeError should be raised."""
|
||||
import services.rag_store as rs
|
||||
|
||||
fake_rows = [
|
||||
{"name": f"Card {i}", "oracle_text": "text", "type_line": "Instant",
|
||||
"mana_cost": "{1}", "cmc": 1, "color_identity": "[]",
|
||||
"rarity": "common", "scryfall_id": f"id-{i}"}
|
||||
for i in range(200)
|
||||
]
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.fetchall.return_value = [types.SimpleNamespace(**r) for r in fake_rows]
|
||||
mock_conn.execute.return_value = mock_cursor
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_collection.add.side_effect = Exception("Ollama timeout")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_collection.return_value = mock_collection
|
||||
|
||||
original_dir = rs.RAG_CHROMA_DIR
|
||||
original_path = rs.BUILD_META_PATH
|
||||
try:
|
||||
rs.RAG_CHROMA_DIR = tmp_path
|
||||
rs.BUILD_META_PATH = tmp_path / "build_meta.json"
|
||||
with patch("services.scryfall_bulk.get_bulk_db", return_value=mock_conn), \
|
||||
patch("services.rag_store._get_chromadb_client", return_value=mock_client), \
|
||||
patch("services.rag_store._get_ollama_embedding_fn", return_value=MagicMock()), \
|
||||
patch("services.rag_store._invalidate_collection_singleton"):
|
||||
with pytest.raises(RuntimeError, match="RAG build aborted"):
|
||||
rs._build_collection_from_bulk(batch_size=50)
|
||||
finally:
|
||||
rs.RAG_CHROMA_DIR = original_dir
|
||||
rs.BUILD_META_PATH = original_path
|
||||
@@ -33,6 +33,24 @@ namespace CommanderAILab.Models
|
||||
[JsonProperty("roles")] public List<string> roles;
|
||||
[JsonProperty("source")] public string source;
|
||||
|
||||
// ── Battleground runtime state (not serialized to JSON) ───────────────
|
||||
[NonSerialized] public bool isTapped;
|
||||
[NonSerialized] public bool isSummoningSick;
|
||||
[NonSerialized] public int ownerSeat = -1;
|
||||
[NonSerialized] public BattleZone currentZone = BattleZone.Library;
|
||||
[NonSerialized] public Dictionary<string, int> counters = new();
|
||||
|
||||
public override string ToString() => $"{name} ({manaCost}) [{typeLine}]";
|
||||
}
|
||||
|
||||
public enum BattleZone
|
||||
{
|
||||
Library,
|
||||
Hand,
|
||||
Battlefield,
|
||||
Graveyard,
|
||||
Exile,
|
||||
CommandZone,
|
||||
Stack
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.UI;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Full live game state snapshot pushed by the server on state_update events.</summary>
|
||||
[Serializable]
|
||||
public class GameState
|
||||
{
|
||||
[JsonProperty("seats")] public List<SeatState> seats;
|
||||
[JsonProperty("active_seat")] public int activeSeat;
|
||||
[JsonProperty("turn_number")] public int turnNumber;
|
||||
[JsonProperty("phase")] public string phase;
|
||||
[JsonProperty("stack")] public List<StackZoneController.StackItem> stack;
|
||||
}
|
||||
|
||||
/// <summary>Per-seat state within a GameState snapshot.</summary>
|
||||
[Serializable]
|
||||
public class SeatState
|
||||
{
|
||||
[JsonProperty("seat_index")] public int seatIndex;
|
||||
[JsonProperty("player_name")] public string playerName;
|
||||
[JsonProperty("life")] public int life;
|
||||
[JsonProperty("hand")] public List<CardModel> hand;
|
||||
[JsonProperty("battlefield")] public List<CardModel> battlefield;
|
||||
[JsonProperty("graveyard")] public List<CardModel> graveyard;
|
||||
[JsonProperty("exile")] public List<CardModel> exile;
|
||||
[JsonProperty("commander")] public CardModel commander;
|
||||
[JsonProperty("commander_cast_count")] public int commanderCastCount;
|
||||
/// <summary>Cumulative commander damage received from each seat index (length 4).</summary>
|
||||
[JsonProperty("commander_damage_from")] public int[] commanderDamageFrom;
|
||||
[JsonProperty("poison_counters")] public int poisonCounters;
|
||||
[JsonProperty("mana_pool")] public Dictionary<string, int> manaPool;
|
||||
[JsonProperty("is_eliminated")] public bool isEliminated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.Models
|
||||
{
|
||||
/// <summary>Runtime data model for a token on the battlefield.</summary>
|
||||
[Serializable]
|
||||
public class TokenModel
|
||||
{
|
||||
public string id;
|
||||
public string name;
|
||||
public string power;
|
||||
public string toughness;
|
||||
/// <summary>Single letter: W U B R G C</summary>
|
||||
public string colorCode = "C";
|
||||
public int ownerSeat;
|
||||
public int qty = 1;
|
||||
public bool isTapped;
|
||||
public Dictionary<string, int> counters = new();
|
||||
|
||||
public string PTString =>
|
||||
(!string.IsNullOrEmpty(power) && !string.IsNullOrEmpty(toughness))
|
||||
? $"{power}/{toughness}" : "";
|
||||
}
|
||||
}
|
||||
@@ -283,5 +283,15 @@ namespace CommanderAILab.Services
|
||||
}
|
||||
onSuccess?.Invoke(request.downloadHandler.text);
|
||||
}
|
||||
|
||||
// -- Game (Live Battleground) ---------------------------------------
|
||||
|
||||
/// <summary>POST /api/game/action — submit a human player action during a live game.</summary>
|
||||
public Coroutine PostGameAction(string json, Action<string> onSuccess, Action<string> onError = null)
|
||||
=> PostRaw("/api/game/action", json, onSuccess, onError);
|
||||
|
||||
/// <summary>POST /api/game/start — start a new live game with the given lobby config.</summary>
|
||||
public Coroutine PostGameStart(string json, Action<string> onSuccess, Action<string> onError = null)
|
||||
=> PostRaw("/api/game/start", json, onSuccess, onError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.UI;
|
||||
|
||||
namespace CommanderAILab.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// WebSocket client for the live game backend.
|
||||
/// Connects to ws://{serverHost}/ws/game/{gameId}.
|
||||
///
|
||||
/// game_id is written to PlayerPrefs["GameId"] by LobbySetupModal
|
||||
/// after a successful POST /api/game/start response.
|
||||
///
|
||||
/// ServerUrl PlayerPref should be the HTTP base URL, e.g.
|
||||
/// "http://localhost:8080" — this client converts it to ws://.
|
||||
/// </summary>
|
||||
public class GameWebSocketClient : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string defaultServerUrl = "http://localhost:8080";
|
||||
[SerializeField] private float reconnectDelay = 3f;
|
||||
[SerializeField] private float pingIntervalSec = 15f;
|
||||
|
||||
private System.Net.WebSockets.ClientWebSocket _ws;
|
||||
private CancellationTokenSource _cts;
|
||||
private readonly ConcurrentQueue<(string type, string payload)> _mainThreadQueue = new();
|
||||
private bool _running;
|
||||
private float _pingTimer;
|
||||
private string _wsUrl;
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
_wsUrl = BuildWsUrl();
|
||||
if (string.IsNullOrEmpty(_wsUrl))
|
||||
{
|
||||
Debug.LogError("[GameWebSocketClient] Could not build WS URL — PlayerPrefs 'GameId' is missing. " +
|
||||
"LobbySetupModal must set it after /api/game/start.");
|
||||
return;
|
||||
}
|
||||
|
||||
_running = true;
|
||||
StartCoroutine(ConnectLoop());
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// Drain main-thread queue
|
||||
while (_mainThreadQueue.TryDequeue(out var msg))
|
||||
GameStateManager.Instance?.ApplyServerEvent(msg.type, msg.payload);
|
||||
|
||||
// Ping
|
||||
_pingTimer += Time.deltaTime;
|
||||
if (_pingTimer >= pingIntervalSec)
|
||||
{
|
||||
_pingTimer = 0f;
|
||||
_ = SendRawAsync("{\"type\":\"ping\"}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_running = false;
|
||||
_cts?.Cancel();
|
||||
_ws?.Dispose();
|
||||
}
|
||||
|
||||
// ── URL construction ────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// Builds ws://{host}/ws/game/{gameId} from PlayerPrefs.
|
||||
/// ServerUrl pref stores the HTTP base URL (e.g. http://localhost:8080).
|
||||
/// GameId pref stores the game_id returned by /api/game/start.
|
||||
/// </summary>
|
||||
private string BuildWsUrl()
|
||||
{
|
||||
string gameId = PlayerPrefs.GetString("GameId", "");
|
||||
if (string.IsNullOrEmpty(gameId))
|
||||
return string.Empty;
|
||||
|
||||
string httpBase = PlayerPrefs.GetString("ServerUrl", defaultServerUrl).TrimEnd('/');
|
||||
|
||||
// Convert http(s):// to ws(s)://
|
||||
string wsBase = httpBase
|
||||
.Replace("https://", "wss://")
|
||||
.Replace("http://", "ws://");
|
||||
|
||||
return $"{wsBase}/ws/game/{gameId}";
|
||||
}
|
||||
|
||||
// ── Connection loop ──────────────────────────────────────────────────
|
||||
private IEnumerator ConnectLoop()
|
||||
{
|
||||
while (_running)
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
_ws = new System.Net.WebSockets.ClientWebSocket();
|
||||
|
||||
bool connected = false;
|
||||
var connectTask = _ws.ConnectAsync(new Uri(_wsUrl), _cts.Token);
|
||||
|
||||
yield return new WaitUntil(() => connectTask.IsCompleted);
|
||||
|
||||
if (!connectTask.IsFaulted && !connectTask.IsCanceled)
|
||||
{
|
||||
connected = true;
|
||||
Debug.Log("[GameWebSocketClient] Connected to " + _wsUrl);
|
||||
_ = ReceiveLoop();
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[GameWebSocketClient] Connect failed ({_wsUrl}): {connectTask.Exception?.Message}");
|
||||
}
|
||||
|
||||
if (!connected)
|
||||
{
|
||||
yield return new WaitForSeconds(reconnectDelay);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return new WaitUntil(() =>
|
||||
_ws.State != System.Net.WebSockets.WebSocketState.Open);
|
||||
|
||||
Debug.Log("[GameWebSocketClient] Disconnected — reconnecting...");
|
||||
yield return new WaitForSeconds(reconnectDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receive loop (background thread) ────────────────────────────────
|
||||
private async Task ReceiveLoop()
|
||||
{
|
||||
var buffer = new byte[8192];
|
||||
try
|
||||
{
|
||||
while (_ws.State == System.Net.WebSockets.WebSocketState.Open)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
System.Net.WebSockets.WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
result = await _ws.ReceiveAsync(
|
||||
new ArraySegment<byte>(buffer), _cts.Token);
|
||||
sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
|
||||
} while (!result.EndOfMessage);
|
||||
|
||||
if (result.MessageType ==
|
||||
System.Net.WebSockets.WebSocketMessageType.Close)
|
||||
break;
|
||||
|
||||
ParseAndEnqueue(sb.ToString());
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[GameWebSocketClient] ReceiveLoop error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parse incoming JSON envelope ─────────────────────────────────────
|
||||
private void ParseAndEnqueue(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
var env = JsonConvert.DeserializeObject<WsEnvelope>(raw);
|
||||
if (env == null) return;
|
||||
if (env.type == "pong") return;
|
||||
_mainThreadQueue.Enqueue((env.type, env.payload ?? raw));
|
||||
}
|
||||
catch
|
||||
{
|
||||
Debug.LogWarning($"[GameWebSocketClient] Failed to parse: {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send (public for external callers) ───────────────────────────────
|
||||
public async Task SendRawAsync(string json)
|
||||
{
|
||||
if (_ws == null ||
|
||||
_ws.State != System.Net.WebSockets.WebSocketState.Open) return;
|
||||
try
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
await _ws.SendAsync(new ArraySegment<byte>(bytes),
|
||||
System.Net.WebSockets.WebSocketMessageType.Text,
|
||||
true, _cts?.Token ?? CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[GameWebSocketClient] Send error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── POCO ────────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
private class WsEnvelope
|
||||
{
|
||||
[JsonProperty("type")] public string type;
|
||||
[JsonProperty("payload")] public string payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace CommanderAILab.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton texture cache for card images.
|
||||
/// Downloads and caches Scryfall card art as Sprite objects.
|
||||
/// Downloads and caches Scryfall card art as Sprite or Texture2D.
|
||||
/// </summary>
|
||||
public class ImageCache : MonoBehaviour
|
||||
{
|
||||
public static ImageCache Instance { get; private set; }
|
||||
|
||||
private readonly Dictionary<string, Sprite> cache = new();
|
||||
private readonly Dictionary<string, Sprite> _spriteCache = new();
|
||||
private readonly Dictionary<string, Texture2D> _textureCache = new();
|
||||
[SerializeField] private int maxCacheSize = 500;
|
||||
|
||||
private void Awake()
|
||||
@@ -23,33 +26,67 @@ namespace CommanderAILab.Services
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
public void GetSprite(string url, System.Action<Sprite> callback)
|
||||
// ── Sprite (existing usage) ───────────────────────────────────────────
|
||||
public void GetSprite(string url, Action<Sprite> callback)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) { callback?.Invoke(null); return; }
|
||||
if (cache.TryGetValue(url, out var cached)) { callback?.Invoke(cached); return; }
|
||||
StartCoroutine(DownloadImage(url, callback));
|
||||
if (_spriteCache.TryGetValue(url, out var cached)) { callback?.Invoke(cached); return; }
|
||||
StartCoroutine(DownloadSprite(url, callback));
|
||||
}
|
||||
|
||||
private IEnumerator DownloadImage(string url, System.Action<Sprite> callback)
|
||||
private IEnumerator DownloadSprite(string url, Action<Sprite> callback)
|
||||
{
|
||||
using var request = UnityWebRequestTexture.GetTexture(url);
|
||||
yield return request.SendWebRequest();
|
||||
|
||||
if (request.result != UnityWebRequest.Result.Success)
|
||||
using var req = UnityWebRequestTexture.GetTexture(url);
|
||||
yield return req.SendWebRequest();
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ImageCache] Failed to load {url}: {request.error}");
|
||||
Debug.LogWarning($"[ImageCache] Sprite failed {url}: {req.error}");
|
||||
callback?.Invoke(null);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var tex = DownloadHandlerTexture.GetContent(request);
|
||||
var tex = DownloadHandlerTexture.GetContent(req);
|
||||
var sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), Vector2.one * 0.5f);
|
||||
|
||||
if (cache.Count >= maxCacheSize) cache.Clear();
|
||||
cache[url] = sprite;
|
||||
if (_spriteCache.Count >= maxCacheSize) _spriteCache.Clear();
|
||||
_spriteCache[url] = sprite;
|
||||
callback?.Invoke(sprite);
|
||||
}
|
||||
|
||||
public void ClearCache() => cache.Clear();
|
||||
// ── RawImage / Texture2D (battleground CardView + CommanderZoneWidget) ───
|
||||
/// <summary>Load a card image URL directly into a RawImage component.</summary>
|
||||
public IEnumerator LoadCard(string url, RawImage target)
|
||||
{
|
||||
if (target == null || string.IsNullOrEmpty(url)) yield break;
|
||||
if (_textureCache.TryGetValue(url, out var cached)) { target.texture = cached; yield break; }
|
||||
yield return DownloadTexture(url, tex => { if (target != null) target.texture = tex; });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load Scryfall art_crop into a RawImage by card name.
|
||||
/// URL: https://api.scryfall.com/cards/named?exact={name}&format=image&version=art_crop
|
||||
/// </summary>
|
||||
public IEnumerator LoadArtCrop(string cardName, RawImage target)
|
||||
{
|
||||
if (target == null || string.IsNullOrEmpty(cardName)) yield break;
|
||||
string url = $"https://api.scryfall.com/cards/named?exact={Uri.EscapeDataString(cardName)}&format=image&version=art_crop";
|
||||
yield return LoadCard(url, target);
|
||||
}
|
||||
|
||||
private IEnumerator DownloadTexture(string url, Action<Texture2D> callback)
|
||||
{
|
||||
using var req = UnityWebRequestTexture.GetTexture(url);
|
||||
yield return req.SendWebRequest();
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
Debug.LogWarning($"[ImageCache] Texture failed {url}: {req.error}");
|
||||
callback?.Invoke(null);
|
||||
yield break;
|
||||
}
|
||||
var tex = DownloadHandlerTexture.GetContent(req);
|
||||
if (_textureCache.Count >= maxCacheSize) _textureCache.Clear();
|
||||
_textureCache[url] = tex;
|
||||
callback?.Invoke(tex);
|
||||
}
|
||||
|
||||
public void ClearCache() { _spriteCache.Clear(); _textureCache.Clear(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Models;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Master orchestrator for the Battleground scene.
|
||||
/// Reads the lobby config written by SimulationController (PlayerPrefs "BattleSeats"),
|
||||
/// wires up all seat panels, and opens the LobbySetupModal on start.
|
||||
/// </summary>
|
||||
public class BattlegroundController : MonoBehaviour
|
||||
{
|
||||
// ── Inspector ─────────────────────────────────────────────
|
||||
[Header("Seat Panels")]
|
||||
[SerializeField] private RectTransform[] seatPanels; // [0]=bottom,[1]=right,[2]=top,[3]=left
|
||||
|
||||
[Header("Center")]
|
||||
[SerializeField] private PhaseTrackerWidget phaseTracker;
|
||||
[SerializeField] private TurnOrderBar turnOrderBar;
|
||||
|
||||
[Header("Modals")]
|
||||
[SerializeField] private LobbySetupModal lobbyModal;
|
||||
|
||||
[Header("Navigation")]
|
||||
[SerializeField] private Button mainMenuButton;
|
||||
|
||||
// ── Seat rotations ────────────────────────────────────────
|
||||
private static readonly float[] SeatRotations = { 0f, -90f, 180f, 90f };
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
ApplySeatRotations();
|
||||
SubscribeEvents();
|
||||
|
||||
mainMenuButton.onClick.AddListener(() => SceneManager.LoadScene("MainMenu"));
|
||||
|
||||
// Pre-populate lobby from PlayerPrefs if arriving via SimulationController
|
||||
string savedSeats = PlayerPrefs.GetString("BattleSeats", "");
|
||||
if (lobbyModal != null)
|
||||
lobbyModal.Open(savedSeats);
|
||||
}
|
||||
|
||||
private void OnDestroy() => UnsubscribeEvents();
|
||||
|
||||
// ── Layout ─────────────────────────────────────────────
|
||||
private void ApplySeatRotations()
|
||||
{
|
||||
for (int i = 0; i < seatPanels.Length; i++)
|
||||
{
|
||||
if (seatPanels[i] == null) continue;
|
||||
seatPanels[i].localEulerAngles = new Vector3(0f, 0f, SeatRotations[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event subscriptions ──────────────────────────────────
|
||||
private void SubscribeEvents()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnTurnChanged += OnTurnChanged;
|
||||
GameStateManager.Instance.OnPhaseChanged += OnPhaseChanged;
|
||||
GameStateManager.Instance.OnFullStateSync += OnFullStateSync;
|
||||
}
|
||||
|
||||
private void UnsubscribeEvents()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnTurnChanged -= OnTurnChanged;
|
||||
GameStateManager.Instance.OnPhaseChanged -= OnPhaseChanged;
|
||||
GameStateManager.Instance.OnFullStateSync -= OnFullStateSync;
|
||||
}
|
||||
|
||||
private void OnTurnChanged(int seat) =>
|
||||
turnOrderBar?.SetActiveSeat(seat);
|
||||
|
||||
private void OnPhaseChanged(PhaseTrackerWidget.Phase phase, int seat, int turn) =>
|
||||
phaseTracker?.SetPhase(phase, seat, turn);
|
||||
|
||||
private void OnFullStateSync(GameState state)
|
||||
{
|
||||
// Delegate per-seat refresh to each SeatPanelController once those exist
|
||||
turnOrderBar?.SetActiveSeat(state.activeSeat);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Visual representation of a single card in a CardZone.
|
||||
/// Handles art loading via ImageCache, tapped rotation, and face-down state.
|
||||
/// </summary>
|
||||
public class CardView : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private RawImage artImage;
|
||||
[SerializeField] private TMP_Text nameLabel;
|
||||
[SerializeField] private TMP_Text ptLabel; // power/toughness
|
||||
[SerializeField] private TMP_Text manaCostLabel;
|
||||
[SerializeField] private GameObject tappedIndicator; // e.g. coloured strip
|
||||
[SerializeField] private GameObject summonSickOverlay; // semi-transparent overlay
|
||||
[SerializeField] private GameObject faceDownOverlay; // card back image object
|
||||
|
||||
public CardModel Model { get; private set; }
|
||||
private bool _faceDown;
|
||||
|
||||
// ── Binding ─────────────────────────────────────────────
|
||||
public void Bind(CardModel model, bool faceDown = false)
|
||||
{
|
||||
Model = model;
|
||||
_faceDown = faceDown;
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
public void SetFaceDown(bool faceDown)
|
||||
{
|
||||
_faceDown = faceDown;
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────
|
||||
public void SetTapped(bool tapped)
|
||||
{
|
||||
if (Model == null) return;
|
||||
Model.isTapped = tapped;
|
||||
// Rotate the RectTransform -90 degrees when tapped
|
||||
transform.localEulerAngles = tapped ? new Vector3(0f, 0f, -90f) : Vector3.zero;
|
||||
if (tappedIndicator) tappedIndicator.SetActive(tapped);
|
||||
}
|
||||
|
||||
public void SetSummonSick(bool sick)
|
||||
{
|
||||
if (Model == null) return;
|
||||
Model.isSummoningSick = sick;
|
||||
if (summonSickOverlay) summonSickOverlay.SetActive(sick);
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────────
|
||||
private void RefreshDisplay()
|
||||
{
|
||||
if (faceDownOverlay) faceDownOverlay.SetActive(_faceDown);
|
||||
if (artImage) artImage.gameObject.SetActive(!_faceDown);
|
||||
|
||||
if (nameLabel)
|
||||
nameLabel.text = _faceDown ? "?" : (Model?.name ?? "");
|
||||
|
||||
bool hasStats = !_faceDown
|
||||
&& Model != null
|
||||
&& !string.IsNullOrEmpty(Model.power)
|
||||
&& !string.IsNullOrEmpty(Model.toughness);
|
||||
|
||||
if (ptLabel)
|
||||
{
|
||||
ptLabel.text = hasStats ? $"{Model.power}/{Model.toughness}" : "";
|
||||
ptLabel.gameObject.SetActive(hasStats);
|
||||
}
|
||||
|
||||
if (manaCostLabel)
|
||||
{
|
||||
manaCostLabel.text = (!_faceDown && Model != null) ? Model.manaCost ?? "" : "";
|
||||
}
|
||||
|
||||
// Load art via ImageCache if visible and URL available
|
||||
if (!_faceDown && Model != null && !string.IsNullOrEmpty(Model.imageUri)
|
||||
&& artImage != null && ImageCache.Instance != null)
|
||||
{
|
||||
StartCoroutine(ImageCache.Instance.LoadCard(Model.imageUri, artImage));
|
||||
}
|
||||
|
||||
// Reset tapped/sick overlays on rebind
|
||||
if (tappedIndicator) tappedIndicator.SetActive(false);
|
||||
if (summonSickOverlay) summonSickOverlay.SetActive(false);
|
||||
transform.localEulerAngles = Vector3.zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using CommanderAILab.Models;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages a collection of CardView instances inside one battlefield zone
|
||||
/// (Hand, Battlefield, Graveyard, Exile, etc.).
|
||||
/// The cardContainer should have a HorizontalLayoutGroup or GridLayoutGroup.
|
||||
/// </summary>
|
||||
public class CardZone : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private BattleZone zoneType;
|
||||
[SerializeField] private GameObject cardPrefab; // CardPrefab.prefab
|
||||
[SerializeField] private Transform cardContainer; // layout group parent
|
||||
[SerializeField] private bool faceDown; // true for AI hand zones
|
||||
|
||||
private readonly List<CardView> _cards = new();
|
||||
|
||||
public BattleZone ZoneType => zoneType;
|
||||
public IReadOnlyList<CardView> Cards => _cards;
|
||||
public int Count => _cards.Count;
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public CardView AddCard(CardModel model)
|
||||
{
|
||||
var go = Instantiate(cardPrefab, cardContainer);
|
||||
var view = go.GetComponent<CardView>();
|
||||
if (view == null)
|
||||
{
|
||||
Debug.LogError("[CardZone] CardPrefab missing CardView component.");
|
||||
Destroy(go);
|
||||
return null;
|
||||
}
|
||||
model.currentZone = zoneType;
|
||||
view.Bind(model, faceDown);
|
||||
_cards.Add(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
public void RemoveCard(string cardId)
|
||||
{
|
||||
var view = _cards.Find(c => c.Model?.id == cardId);
|
||||
if (view == null) return;
|
||||
_cards.Remove(view);
|
||||
Destroy(view.gameObject);
|
||||
}
|
||||
|
||||
public void ClearAll()
|
||||
{
|
||||
foreach (var v in _cards) if (v != null) Destroy(v.gameObject);
|
||||
_cards.Clear();
|
||||
}
|
||||
|
||||
public void SetFaceDown(bool fd)
|
||||
{
|
||||
faceDown = fd;
|
||||
foreach (var v in _cards) v?.SetFaceDown(fd);
|
||||
}
|
||||
|
||||
public CardView FindById(string cardId) =>
|
||||
_cards.Find(c => c.Model?.id == cardId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Displays cumulative commander damage received from each opposing seat.
|
||||
/// Triggers elimination when any source reaches 21.
|
||||
/// Attach one to each seat panel; set SeatIndex in inspector.
|
||||
/// </summary>
|
||||
public class CommanderDamageRow : MonoBehaviour
|
||||
{
|
||||
/// <summary>The seat this row belongs to (receives damage).</summary>
|
||||
[SerializeField] private int seatIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Labels for damage received from each seat.
|
||||
/// fromSeatLabels[i] corresponds to global seat i.
|
||||
/// The label for this seat's own index will be hidden (can't deal commander damage to yourself).
|
||||
/// </summary>
|
||||
[SerializeField] private TMP_Text[] fromSeatLabels; // length 4
|
||||
|
||||
private readonly int[] _damage = new int[4];
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// Hide this seat's own label
|
||||
if (fromSeatLabels != null && seatIndex < fromSeatLabels.Length
|
||||
&& fromSeatLabels[seatIndex] != null)
|
||||
fromSeatLabels[seatIndex].gameObject.SetActive(false);
|
||||
|
||||
RefreshAllLabels();
|
||||
}
|
||||
|
||||
public void AddDamage(int fromSeat, int amount)
|
||||
{
|
||||
if (fromSeat < 0 || fromSeat >= 4 || fromSeat == seatIndex) return;
|
||||
_damage[fromSeat] = Mathf.Max(0, _damage[fromSeat] + amount);
|
||||
RefreshLabel(fromSeat);
|
||||
|
||||
if (_damage[fromSeat] >= 21)
|
||||
GameStateManager.Instance?.EliminatePlayer(seatIndex, "commander_damage");
|
||||
}
|
||||
|
||||
public void SetDamage(int fromSeat, int value)
|
||||
{
|
||||
if (fromSeat < 0 || fromSeat >= 4 || fromSeat == seatIndex) return;
|
||||
_damage[fromSeat] = Mathf.Max(0, value);
|
||||
RefreshLabel(fromSeat);
|
||||
}
|
||||
|
||||
public int GetDamage(int fromSeat) =>
|
||||
(fromSeat >= 0 && fromSeat < 4) ? _damage[fromSeat] : 0;
|
||||
|
||||
private void RefreshLabel(int fromSeat)
|
||||
{
|
||||
if (fromSeatLabels == null || fromSeat >= fromSeatLabels.Length) return;
|
||||
var lbl = fromSeatLabels[fromSeat];
|
||||
if (lbl == null) return;
|
||||
lbl.text = _damage[fromSeat] > 0 ? _damage[fromSeat].ToString() : "0";
|
||||
lbl.color = _damage[fromSeat] >= 21 ? Color.red : Color.white;
|
||||
}
|
||||
|
||||
private void RefreshAllLabels()
|
||||
{
|
||||
for (int i = 0; i < 4; i++) RefreshLabel(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-seat commander zone widget.
|
||||
/// Shows: art crop banner, commander name, cast count, commander tax.
|
||||
/// Requires ImageCache singleton in scene.
|
||||
/// </summary>
|
||||
public class CommanderZoneWidget : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private RawImage artCropImage;
|
||||
[SerializeField] private TMP_Text commanderNameLabel;
|
||||
[SerializeField] private TMP_Text castCountLabel;
|
||||
[SerializeField] private TMP_Text taxLabel;
|
||||
[SerializeField] private UnityEngine.UI.Button castCommanderBtn;
|
||||
|
||||
public CardModel CommanderCard { get; private set; }
|
||||
private int _castCount = 0;
|
||||
|
||||
// ── Binding ───────────────────────────────────────────
|
||||
public void Bind(CardModel commander)
|
||||
{
|
||||
CommanderCard = commander;
|
||||
_castCount = commander != null ? commander.counters.GetValueOrDefault("cast_count", 0) : 0;
|
||||
|
||||
if (commanderNameLabel)
|
||||
commanderNameLabel.text = commander?.name ?? "No Commander";
|
||||
|
||||
UpdateTaxDisplay();
|
||||
|
||||
// Load art crop from Scryfall
|
||||
if (commander != null && !string.IsNullOrEmpty(commander.name)
|
||||
&& artCropImage != null && ImageCache.Instance != null)
|
||||
{
|
||||
StartCoroutine(ImageCache.Instance.LoadArtCrop(commander.name, artCropImage));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cast tracking ──────────────────────────────────────
|
||||
public void OnCommanderCast()
|
||||
{
|
||||
_castCount++;
|
||||
UpdateTaxDisplay();
|
||||
}
|
||||
|
||||
public void SetCastCount(int count)
|
||||
{
|
||||
_castCount = Mathf.Max(0, count);
|
||||
UpdateTaxDisplay();
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────
|
||||
private void UpdateTaxDisplay()
|
||||
{
|
||||
if (castCountLabel)
|
||||
castCountLabel.text = _castCount == 0 ? "Not yet cast" : $"Cast #{_castCount}";
|
||||
|
||||
int tax = _castCount * 2;
|
||||
if (taxLabel)
|
||||
taxLabel.text = tax == 0 ? "No tax" : $"+{tax} ☀ tax";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the visual elimination sequence for a seat panel.
|
||||
/// On elimination: plays a flash animation, applies a grayscale material, and
|
||||
/// shows an elimination banner with cause of death text.
|
||||
/// Requires a URP ShaderGraph material "EliminatedGrayscale" in Resources/.
|
||||
/// </summary>
|
||||
public class EliminationHandler : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private int seatIndex;
|
||||
[SerializeField] private CanvasGroup seatCanvasGroup; // seat panel root CanvasGroup
|
||||
[SerializeField] private Image eliminationOverlay; // full-panel semi-transparent overlay
|
||||
[SerializeField] private TMP_Text eliminationLabel; // "ELIMINATED" text
|
||||
[SerializeField] private TMP_Text causeLabel; // cause of death
|
||||
[SerializeField] private Image[] grayscaleTargets; // RawImages to recolor to grayscale
|
||||
|
||||
private static readonly Color GrayscaleTint = new Color(0.4f, 0.4f, 0.4f, 1f);
|
||||
private bool _eliminated;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (eliminationOverlay) eliminationOverlay.gameObject.SetActive(false);
|
||||
if (eliminationLabel) eliminationLabel.gameObject.SetActive(false);
|
||||
if (causeLabel) causeLabel.gameObject.SetActive(false);
|
||||
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnPlayerEliminated += OnPlayerEliminated;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnPlayerEliminated -= OnPlayerEliminated;
|
||||
}
|
||||
|
||||
// ── Event ───────────────────────────────────────────────
|
||||
private void OnPlayerEliminated(int seat, string reason)
|
||||
{
|
||||
if (seat != seatIndex || _eliminated) return;
|
||||
_eliminated = true;
|
||||
StartCoroutine(PlayEliminationSequence(reason));
|
||||
}
|
||||
|
||||
// ── Animation ──────────────────────────────────────────
|
||||
private IEnumerator PlayEliminationSequence(string reason)
|
||||
{
|
||||
// 1. Red flash
|
||||
if (eliminationOverlay)
|
||||
{
|
||||
eliminationOverlay.gameObject.SetActive(true);
|
||||
eliminationOverlay.color = new Color(1f, 0f, 0f, 0f);
|
||||
yield return FadeImage(eliminationOverlay, 0f, 0.6f, 0.15f);
|
||||
yield return FadeImage(eliminationOverlay, 0.6f, 0f, 0.4f);
|
||||
eliminationOverlay.color = new Color(0f, 0f, 0f, 0.4f); // keep dark overlay
|
||||
}
|
||||
|
||||
// 2. Grayscale tint on card art panels
|
||||
foreach (var img in grayscaleTargets)
|
||||
if (img != null) img.color = GrayscaleTint;
|
||||
|
||||
// 3. Fade-down CanvasGroup to 60%
|
||||
if (seatCanvasGroup)
|
||||
{
|
||||
seatCanvasGroup.interactable = false;
|
||||
seatCanvasGroup.blocksRaycasts = false;
|
||||
yield return FadeCanvasGroup(seatCanvasGroup, seatCanvasGroup.alpha, 0.6f, 0.3f);
|
||||
}
|
||||
|
||||
// 4. Show banner
|
||||
if (eliminationLabel) eliminationLabel.gameObject.SetActive(true);
|
||||
if (causeLabel)
|
||||
{
|
||||
causeLabel.text = reason switch
|
||||
{
|
||||
"life" => "Life total reached 0",
|
||||
"commander_damage" => "Commander damage (21+)",
|
||||
"poison" => "Poison counters (10+)",
|
||||
"deck_empty" => "Failed to draw",
|
||||
_ => reason
|
||||
};
|
||||
causeLabel.gameObject.SetActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator FadeImage(Image img, float from, float to, float duration)
|
||||
{
|
||||
float t = 0;
|
||||
while (t < duration)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
var c = img.color;
|
||||
c.a = Mathf.Lerp(from, to, t / duration);
|
||||
img.color = c;
|
||||
yield return null;
|
||||
}
|
||||
var final = img.color;
|
||||
final.a = to;
|
||||
img.color = final;
|
||||
}
|
||||
|
||||
private IEnumerator FadeCanvasGroup(CanvasGroup cg, float from, float to, float duration)
|
||||
{
|
||||
float t = 0;
|
||||
while (t < duration) { t += Time.deltaTime; cg.alpha = Mathf.Lerp(from, to, t / duration); yield return null; }
|
||||
cg.alpha = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Models;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton event hub for the live battleground game state.
|
||||
/// All UI widgets subscribe to these events instead of polling.
|
||||
/// Receives parsed server events from GameWebSocketClient.
|
||||
/// </summary>
|
||||
public class GameStateManager : MonoBehaviour
|
||||
{
|
||||
public static GameStateManager Instance { get; private set; }
|
||||
|
||||
// ── Events ────────────────────────────────────────────────
|
||||
public event Action<int> OnTurnChanged; // activeSeat
|
||||
public event Action<PhaseTrackerWidget.Phase, int, int> OnPhaseChanged; // phase, seat, turnNumber
|
||||
public event Action<int> OnPriorityChanged; // seatIndex
|
||||
public event Action<int, int> OnLifeChanged; // seatIndex, newLife
|
||||
public event Action<int, string> OnPlayerEliminated; // seatIndex, reason
|
||||
public event Action<List<StackZoneController.StackItem>> OnStackUpdated;
|
||||
public event Action<GameState> OnFullStateSync;
|
||||
|
||||
// ── State ────────────────────────────────────────────────
|
||||
public int TurnNumber { get; private set; } = 1;
|
||||
private GameState _state;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
// ── Server event dispatch ───────────────────────────────────
|
||||
/// <summary>Called by GameWebSocketClient on the Unity main thread.</summary>
|
||||
public void ApplyServerEvent(string type, string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case "state_update":
|
||||
_state = JsonConvert.DeserializeObject<GameState>(payload);
|
||||
if (_state != null) OnFullStateSync?.Invoke(_state);
|
||||
break;
|
||||
|
||||
case "turn_start":
|
||||
var ts = JsonConvert.DeserializeObject<TurnStartEvent>(payload);
|
||||
if (ts != null) { TurnNumber = ts.turnNumber; OnTurnChanged?.Invoke(ts.seat); }
|
||||
break;
|
||||
|
||||
case "phase_change":
|
||||
var pc = JsonConvert.DeserializeObject<PhaseChangeEvent>(payload);
|
||||
if (pc != null)
|
||||
OnPhaseChanged?.Invoke((PhaseTrackerWidget.Phase)pc.phaseIndex, pc.seat, TurnNumber);
|
||||
break;
|
||||
|
||||
case "priority":
|
||||
var pv = JsonConvert.DeserializeObject<PriorityEvent>(payload);
|
||||
if (pv != null) OnPriorityChanged?.Invoke(pv.seat);
|
||||
break;
|
||||
|
||||
case "life_change":
|
||||
var lc = JsonConvert.DeserializeObject<LifeChangeEvent>(payload);
|
||||
if (lc != null) OnLifeChanged?.Invoke(lc.seat, lc.newLife);
|
||||
break;
|
||||
|
||||
case "stack_update":
|
||||
var su = JsonConvert.DeserializeObject<StackUpdateEvent>(payload);
|
||||
if (su?.items != null) OnStackUpdated?.Invoke(su.items);
|
||||
break;
|
||||
|
||||
case "player_elim":
|
||||
var pe = JsonConvert.DeserializeObject<EliminationEvent>(payload);
|
||||
if (pe != null) EliminatePlayer(pe.seat, pe.reason);
|
||||
break;
|
||||
|
||||
default:
|
||||
Debug.LogWarning($"[GameStateManager] Unknown event type: {type}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[GameStateManager] ApplyServerEvent failed ({type}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Trigger elimination (called by LifeTrackerWidget, CommanderDamageRow, or server event).</summary>
|
||||
public void EliminatePlayer(int seatIndex, string reason) =>
|
||||
OnPlayerEliminated?.Invoke(seatIndex, reason);
|
||||
|
||||
// ── Debug helpers (editor only) ─────────────────────────────
|
||||
#if UNITY_EDITOR
|
||||
private void OnGUI()
|
||||
{
|
||||
GUILayout.BeginArea(new Rect(10, 10, 220, 200));
|
||||
GUILayout.Label("[GSM Debug]");
|
||||
if (GUILayout.Button("Fake Turn 1 Start")) ApplyServerEvent("turn_start", "{\"seat\":0,\"turnNumber\":1}");
|
||||
if (GUILayout.Button("Advance Phase Main1")) ApplyServerEvent("phase_change","{\"phaseIndex\":3,\"seat\":0}");
|
||||
if (GUILayout.Button("Priority Seat 0")) ApplyServerEvent("priority", "{\"seat\":0}");
|
||||
if (GUILayout.Button("Elim Seat 2")) EliminatePlayer(2, "life");
|
||||
GUILayout.EndArea();
|
||||
}
|
||||
#endif
|
||||
|
||||
// ── Private event POCOs ───────────────────────────────────
|
||||
[Serializable] private class TurnStartEvent { public int seat; public int turnNumber; }
|
||||
[Serializable] private class PhaseChangeEvent { public int phaseIndex; public int seat; }
|
||||
[Serializable] private class PriorityEvent { public int seat; }
|
||||
[Serializable] private class LifeChangeEvent { public int seat; public int newLife; }
|
||||
[Serializable] private class StackUpdateEvent { public List<StackZoneController.StackItem> items; }
|
||||
[Serializable] private class EliminationEvent { public int seat; public string reason; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Human player action bar — only active when seat 0 has priority.
|
||||
/// Buttons post actions to /api/game/action.
|
||||
/// Subscribes to GameStateManager.OnPriorityChanged.
|
||||
/// </summary>
|
||||
public class HumanActionBar : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private ApiClient apiClient;
|
||||
[SerializeField] private GameObject actionBar; // root panel
|
||||
[SerializeField] private Button passPriorityBtn;
|
||||
[SerializeField] private Button endTurnBtn;
|
||||
[SerializeField] private Button declareAttackersBtn;
|
||||
[SerializeField] private Button declareBlockersBtn;
|
||||
[SerializeField] private TMP_Text priorityLabel;
|
||||
[SerializeField] private TMP_Text feedbackLabel;
|
||||
|
||||
private bool _hasPriority;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
passPriorityBtn ?.onClick.AddListener(() => PostAction("pass_priority"));
|
||||
endTurnBtn ?.onClick.AddListener(() => PostAction("end_turn"));
|
||||
declareAttackersBtn?.onClick.AddListener(() => PostAction("declare_attackers"));
|
||||
declareBlockersBtn ?.onClick.AddListener(() => PostAction("declare_blockers"));
|
||||
|
||||
if (GameStateManager.Instance != null)
|
||||
{
|
||||
GameStateManager.Instance.OnPriorityChanged += OnPriorityChanged;
|
||||
GameStateManager.Instance.OnPhaseChanged += OnPhaseChanged;
|
||||
}
|
||||
|
||||
SetPriority(false);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnPriorityChanged -= OnPriorityChanged;
|
||||
GameStateManager.Instance.OnPhaseChanged -= OnPhaseChanged;
|
||||
}
|
||||
|
||||
// ── Priority visibility ──────────────────────────────────
|
||||
private void OnPriorityChanged(int seatIndex)
|
||||
{
|
||||
SetPriority(seatIndex == 0);
|
||||
}
|
||||
|
||||
private void OnPhaseChanged(PhaseTrackerWidget.Phase phase, int seat, int turn)
|
||||
{
|
||||
// Only show combat buttons during combat phases
|
||||
bool inCombat = phase >= PhaseTrackerWidget.Phase.DeclareAttackers
|
||||
&& phase <= PhaseTrackerWidget.Phase.EndCombat;
|
||||
if (declareAttackersBtn) declareAttackersBtn.gameObject.SetActive(inCombat && _hasPriority);
|
||||
if (declareBlockersBtn) declareBlockersBtn.gameObject.SetActive(inCombat && _hasPriority);
|
||||
}
|
||||
|
||||
private void SetPriority(bool hasPriority)
|
||||
{
|
||||
_hasPriority = hasPriority;
|
||||
if (actionBar) actionBar.SetActive(hasPriority);
|
||||
if (priorityLabel) priorityLabel.text = hasPriority ? "Your Priority" : "";
|
||||
}
|
||||
|
||||
// ── Action posting ─────────────────────────────────────
|
||||
private void PostAction(string actionType)
|
||||
{
|
||||
var payload = JsonConvert.SerializeObject(new { action = actionType, seat = 0 });
|
||||
apiClient?.PostGameAction(payload,
|
||||
_ => ShowFeedback(actionType),
|
||||
err => ShowFeedback($"Error: {err}", isError: true));
|
||||
}
|
||||
|
||||
private void ShowFeedback(string msg, bool isError = false)
|
||||
{
|
||||
if (!feedbackLabel) return;
|
||||
StopCoroutine(nameof(FadeOutFeedback));
|
||||
feedbackLabel.text = msg;
|
||||
feedbackLabel.color = isError ? Color.red : Color.green;
|
||||
feedbackLabel.gameObject.SetActive(true);
|
||||
StartCoroutine(nameof(FadeOutFeedback));
|
||||
}
|
||||
|
||||
private IEnumerator FadeOutFeedback()
|
||||
{
|
||||
yield return new WaitForSeconds(1.5f);
|
||||
if (feedbackLabel) feedbackLabel.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-seat life total widget with +/- buttons, danger flash, and history log.
|
||||
/// Calls GameStateManager.EliminatePlayer when life reaches 0.
|
||||
/// </summary>
|
||||
public class LifeTrackerWidget : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private TMP_Text lifeLabel;
|
||||
[SerializeField] private Button plusOneBtn;
|
||||
[SerializeField] private Button minusOneBtn;
|
||||
[SerializeField] private Button plusFiveBtn;
|
||||
[SerializeField] private Button minusFiveBtn;
|
||||
[SerializeField] private GameObject dangerFlash; // red glow overlay, shown at <=5 life
|
||||
|
||||
[Header("Config")]
|
||||
[SerializeField] private int startingLife = 40;
|
||||
public int SeatIndex { get; set; }
|
||||
|
||||
private int _life;
|
||||
private readonly List<LifeHistoryEntry> _history = new();
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
SetLife(startingLife);
|
||||
plusOneBtn .onClick.AddListener(() => AdjustLife(+1, "manual"));
|
||||
minusOneBtn.onClick.AddListener(() => AdjustLife(-1, "manual"));
|
||||
plusFiveBtn .onClick.AddListener(() => AdjustLife(+5, "manual"));
|
||||
minusFiveBtn.onClick.AddListener(() => AdjustLife(-5, "manual"));
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public int Life => _life;
|
||||
|
||||
public void AdjustLife(int delta, string source)
|
||||
{
|
||||
_life = Mathf.Max(0, _life + delta);
|
||||
RefreshDisplay();
|
||||
|
||||
int turn = GameStateManager.Instance != null ? GameStateManager.Instance.TurnNumber : 0;
|
||||
_history.Add(new LifeHistoryEntry { delta = delta, source = source, turn = turn });
|
||||
|
||||
if (_life == 0)
|
||||
GameStateManager.Instance?.EliminatePlayer(SeatIndex, "life");
|
||||
}
|
||||
|
||||
public void SetLife(int value)
|
||||
{
|
||||
_life = Mathf.Max(0, value);
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
public List<LifeHistoryEntry> GetHistory() => _history;
|
||||
|
||||
// ── Internal ───────────────────────────────────────────
|
||||
private void RefreshDisplay()
|
||||
{
|
||||
if (lifeLabel) lifeLabel.text = _life.ToString();
|
||||
if (dangerFlash) dangerFlash.SetActive(_life <= 5 && _life > 0);
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class LifeHistoryEntry
|
||||
{
|
||||
public int delta;
|
||||
public string source;
|
||||
public int turn;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.SceneManagement;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
using CommanderAILab.Services;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Modal shown at Battleground scene start.
|
||||
/// Configures 4 seats (Human vs AI, deck choice, AI style).
|
||||
/// On Confirm:
|
||||
/// 1. POSTs seat config to POST /api/game/start
|
||||
/// 2. Saves game_id + ws_url to PlayerPrefs
|
||||
/// 3. Loads Battleground scene
|
||||
/// </summary>
|
||||
public class LobbySetupModal : MonoBehaviour
|
||||
{
|
||||
[Header("Seat Rows (4)")]
|
||||
[SerializeField] private LobbyRowUI[] seatRows; // length 4
|
||||
|
||||
[Header("Buttons")]
|
||||
[SerializeField] private Button confirmButton;
|
||||
[SerializeField] private Button cancelButton;
|
||||
|
||||
[Header("Feedback")]
|
||||
[SerializeField] private TMP_Text errorLabel; // assign in Inspector; hidden by default
|
||||
|
||||
[SerializeField] private ApiClient apiClient;
|
||||
|
||||
private List<string> _deckNames = new();
|
||||
|
||||
// ── Open ────────────────────────────────────────────────
|
||||
public void Open(string savedSeatsJson)
|
||||
{
|
||||
gameObject.SetActive(true);
|
||||
SetError(null);
|
||||
|
||||
confirmButton.onClick.AddListener(OnConfirm);
|
||||
cancelButton.onClick.AddListener(() => SceneManager.LoadScene("MainMenu"));
|
||||
|
||||
// Load deck list for dropdowns
|
||||
apiClient?.GetDecks(
|
||||
json =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var w = JsonConvert.DeserializeObject<DecksWrapper>(json);
|
||||
_deckNames = new List<string>();
|
||||
if (w?.decks != null)
|
||||
foreach (var d in w.decks) _deckNames.Add(d.name);
|
||||
}
|
||||
catch { _deckNames = new List<string>(); }
|
||||
PopulateRows(savedSeatsJson);
|
||||
},
|
||||
_ => PopulateRows(savedSeatsJson));
|
||||
}
|
||||
|
||||
private void PopulateRows(string savedJson)
|
||||
{
|
||||
List<SeatConfig> saved = null;
|
||||
try { saved = JsonConvert.DeserializeObject<List<SeatConfig>>(savedJson); } catch { }
|
||||
|
||||
for (int i = 0; i < seatRows.Length; i++)
|
||||
{
|
||||
if (seatRows[i] == null) continue;
|
||||
var cfg = (saved != null && i < saved.Count) ? saved[i] : null;
|
||||
seatRows[i].Populate(i, _deckNames, cfg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Confirm → POST /api/game/start ──────────────────────
|
||||
private void OnConfirm()
|
||||
{
|
||||
SetError(null);
|
||||
confirmButton.interactable = false;
|
||||
|
||||
// Build local seat list
|
||||
var configs = new List<SeatConfig>();
|
||||
foreach (var row in seatRows)
|
||||
configs.Add(row?.GetConfig() ?? new SeatConfig());
|
||||
|
||||
// Save locally so Battleground scene can read seat metadata
|
||||
PlayerPrefs.SetString("BattleSeats", JsonConvert.SerializeObject(configs));
|
||||
PlayerPrefs.Save();
|
||||
|
||||
// Build backend request — maps local SeatConfig → StartGameRequest
|
||||
var apiSeats = new List<ApiSeatConfig>();
|
||||
for (int i = 0; i < configs.Count; i++)
|
||||
{
|
||||
var c = configs[i];
|
||||
apiSeats.Add(new ApiSeatConfig
|
||||
{
|
||||
seat_index = i,
|
||||
seat_type = c.isHuman ? "Human" : $"AI {c.aiStyle}",
|
||||
deck_name = c.deckName ?? "",
|
||||
player_name = c.isHuman ? "Player" : $"AI {i}",
|
||||
});
|
||||
}
|
||||
|
||||
var requestJson = JsonConvert.SerializeObject(new StartGameRequest { seats = apiSeats });
|
||||
|
||||
apiClient.PostGameStart(
|
||||
requestJson,
|
||||
onSuccess: responseJson =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = JsonConvert.DeserializeObject<StartGameResponse>(responseJson);
|
||||
if (resp == null || string.IsNullOrEmpty(resp.game_id))
|
||||
{
|
||||
OnStartError("Server returned an invalid response.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist game_id and ws_url so GameWebSocketClient can connect
|
||||
PlayerPrefs.SetString("GameId", resp.game_id);
|
||||
PlayerPrefs.SetString("WsUrl", resp.ws_url ?? $"/ws/game/{resp.game_id}");
|
||||
PlayerPrefs.Save();
|
||||
|
||||
gameObject.SetActive(false);
|
||||
SceneManager.LoadScene("Battleground");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnStartError($"Parse error: {ex.Message}");
|
||||
}
|
||||
},
|
||||
onError: err => OnStartError($"Failed to start game: {err}"));
|
||||
}
|
||||
|
||||
private void OnStartError(string message)
|
||||
{
|
||||
Debug.LogError("[LobbySetupModal] " + message);
|
||||
SetError(message);
|
||||
confirmButton.interactable = true;
|
||||
}
|
||||
|
||||
private void SetError(string message)
|
||||
{
|
||||
if (errorLabel == null) return;
|
||||
errorLabel.text = message ?? string.Empty;
|
||||
errorLabel.gameObject.SetActive(!string.IsNullOrEmpty(message));
|
||||
}
|
||||
|
||||
// ── Models ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>Local / PlayerPrefs representation of a seat.</summary>
|
||||
[Serializable]
|
||||
public class SeatConfig
|
||||
{
|
||||
public bool isHuman;
|
||||
public string deckName;
|
||||
public string aiStyle; // Aggro / Control / Combo / Political
|
||||
}
|
||||
|
||||
/// <summary>Matches routes/game.py SeatConfig schema.</summary>
|
||||
[Serializable]
|
||||
private class ApiSeatConfig
|
||||
{
|
||||
public int seat_index;
|
||||
public string seat_type;
|
||||
public string deck_name;
|
||||
public string player_name;
|
||||
}
|
||||
|
||||
/// <summary>Matches routes/game.py StartGameRequest schema.</summary>
|
||||
[Serializable]
|
||||
private class StartGameRequest
|
||||
{
|
||||
public List<ApiSeatConfig> seats;
|
||||
}
|
||||
|
||||
/// <summary>Matches routes/game.py StartGameResponse schema.</summary>
|
||||
[Serializable]
|
||||
private class StartGameResponse
|
||||
{
|
||||
public string game_id;
|
||||
public string status;
|
||||
public string ws_url;
|
||||
}
|
||||
|
||||
[Serializable] private class DecksWrapper { public List<DeckEntry> decks; }
|
||||
[Serializable] private class DeckEntry { public string name; }
|
||||
}
|
||||
|
||||
/// <summary>Per-seat row in the lobby modal (UI bindings are set in inspector).</summary>
|
||||
[Serializable]
|
||||
public class LobbyRowUI : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text seatLabel;
|
||||
[SerializeField] private Toggle humanToggle;
|
||||
[SerializeField] private TMP_Dropdown deckDropdown;
|
||||
[SerializeField] private TMP_Dropdown aiStyleDropdown;
|
||||
|
||||
private static readonly List<string> AiStyles = new() { "Aggro", "Control", "Combo", "Political" };
|
||||
|
||||
public void Populate(int seatIndex, List<string> deckNames, LobbySetupModal.SeatConfig saved)
|
||||
{
|
||||
if (seatLabel) seatLabel.text = seatIndex == 0 ? "Seat 0 (You)" : $"Seat {seatIndex} (AI)";
|
||||
|
||||
if (humanToggle)
|
||||
{
|
||||
humanToggle.isOn = saved?.isHuman ?? (seatIndex == 0);
|
||||
humanToggle.onValueChanged.AddListener(isHuman =>
|
||||
{
|
||||
if (aiStyleDropdown) aiStyleDropdown.gameObject.SetActive(!isHuman);
|
||||
});
|
||||
if (aiStyleDropdown) aiStyleDropdown.gameObject.SetActive(!humanToggle.isOn);
|
||||
}
|
||||
|
||||
if (deckDropdown)
|
||||
{
|
||||
deckDropdown.ClearOptions();
|
||||
var opts = new List<string> { "-- Select Deck --" };
|
||||
opts.AddRange(deckNames);
|
||||
deckDropdown.AddOptions(opts);
|
||||
if (!string.IsNullOrEmpty(saved?.deckName))
|
||||
{
|
||||
int idx = opts.IndexOf(saved.deckName);
|
||||
deckDropdown.value = idx >= 0 ? idx : 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (aiStyleDropdown)
|
||||
{
|
||||
aiStyleDropdown.ClearOptions();
|
||||
aiStyleDropdown.AddOptions(AiStyles);
|
||||
if (!string.IsNullOrEmpty(saved?.aiStyle))
|
||||
{
|
||||
int idx = AiStyles.IndexOf(saved.aiStyle);
|
||||
aiStyleDropdown.value = idx >= 0 ? idx : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public LobbySetupModal.SeatConfig GetConfig() => new()
|
||||
{
|
||||
isHuman = humanToggle?.isOn ?? false,
|
||||
deckName = deckDropdown != null && deckDropdown.value > 0
|
||||
? deckDropdown.options[deckDropdown.value].text : "",
|
||||
aiStyle = aiStyleDropdown != null
|
||||
? aiStyleDropdown.options[aiStyleDropdown.value].text : "Aggro"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Displays the mana pool for one seat as coloured symbol + count labels.
|
||||
/// Symbols are hidden when the mana amount is 0.
|
||||
/// Resets on each phase-change event from GameStateManager.
|
||||
/// </summary>
|
||||
public class ManaPoolDisplay : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private int seatIndex;
|
||||
|
||||
[Header("Mana Labels (W U B R G C order)")]
|
||||
[SerializeField] private TMP_Text[] manaLabels; // length 6
|
||||
[SerializeField] private GameObject[] manaSymbols; // parent GO per mana type (contains Image)
|
||||
|
||||
private static readonly string[] ManaKeys = { "W", "U", "B", "R", "G", "C" };
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
{
|
||||
GameStateManager.Instance.OnPhaseChanged += OnPhaseChanged;
|
||||
GameStateManager.Instance.OnFullStateSync += OnFullStateSync;
|
||||
}
|
||||
UpdatePool(null);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnPhaseChanged -= OnPhaseChanged;
|
||||
GameStateManager.Instance.OnFullStateSync -= OnFullStateSync;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public void UpdatePool(Dictionary<string, int> pool)
|
||||
{
|
||||
for (int i = 0; i < ManaKeys.Length; i++)
|
||||
{
|
||||
int amt = 0;
|
||||
pool?.TryGetValue(ManaKeys[i], out amt);
|
||||
|
||||
bool show = amt > 0;
|
||||
if (i < manaLabels.Length && manaLabels[i] != null)
|
||||
manaLabels[i].text = show ? amt.ToString() : "";
|
||||
if (i < manaSymbols.Length && manaSymbols[i] != null)
|
||||
manaSymbols[i].SetActive(show);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ─────────────────────────────────────
|
||||
private void OnPhaseChanged(PhaseTrackerWidget.Phase phase, int seat, int turn)
|
||||
{
|
||||
// Mana pool empties at the end of each phase
|
||||
if (phase == PhaseTrackerWidget.Phase.Cleanup)
|
||||
UpdatePool(null);
|
||||
}
|
||||
|
||||
private void OnFullStateSync(GameState state)
|
||||
{
|
||||
if (state?.seats == null || seatIndex >= state.seats.Count) return;
|
||||
UpdatePool(state.seats[seatIndex].manaPool);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// A single phase pill in the PhaseTrackerWidget strip.
|
||||
/// Changes color, font weight, and scale when activated.
|
||||
/// </summary>
|
||||
public class PhaseStepButton : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Image background;
|
||||
[SerializeField] private TMP_Text label;
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color activeColor = new Color(1f, 0.85f, 0f); // amber/yellow
|
||||
[SerializeField] private Color inactiveColor = new Color(0.2f, 0.2f, 0.2f); // dark grey
|
||||
|
||||
private Vector3 _baseScale;
|
||||
|
||||
private void Awake() => _baseScale = transform.localScale;
|
||||
|
||||
public void SetActive(bool active)
|
||||
{
|
||||
if (background) background.color = active ? activeColor : inactiveColor;
|
||||
if (label) label.fontStyle = active ? TMPro.FontStyles.Bold : TMPro.FontStyles.Normal;
|
||||
transform.localScale = active ? _baseScale * 1.1f : _baseScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Horizontal strip of 13 phase/step buttons in the BattlefieldCenter panel.
|
||||
/// Subscribes to GameStateManager.OnPhaseChanged.
|
||||
/// Test without backend: press Space in Play Mode (debug binding below).
|
||||
/// </summary>
|
||||
public class PhaseTrackerWidget : MonoBehaviour
|
||||
{
|
||||
public enum Phase
|
||||
{
|
||||
Untap = 0,
|
||||
Upkeep,
|
||||
Draw,
|
||||
Main1,
|
||||
BeginCombat,
|
||||
DeclareAttackers,
|
||||
DeclareBlockers,
|
||||
FirstStrikeDamage,
|
||||
Damage,
|
||||
EndCombat,
|
||||
Main2,
|
||||
EndStep,
|
||||
Cleanup
|
||||
}
|
||||
|
||||
[SerializeField] private PhaseStepButton[] stepButtons; // 13 buttons, one per Phase
|
||||
[SerializeField] private TMP_Text activePlayerLabel;
|
||||
[SerializeField] private TMP_Text turnNumberLabel;
|
||||
|
||||
private Phase _currentPhase;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnPhaseChanged += SetPhase;
|
||||
|
||||
SetPhase(Phase.Untap, 0, 1);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnPhaseChanged -= SetPhase;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public void SetPhase(Phase phase, int seatIndex, int turnNumber)
|
||||
{
|
||||
_currentPhase = phase;
|
||||
|
||||
for (int i = 0; i < stepButtons.Length; i++)
|
||||
stepButtons[i]?.SetActive(i == (int)phase);
|
||||
|
||||
if (activePlayerLabel)
|
||||
activePlayerLabel.text = seatIndex == 0 ? "Your Turn" : $"Seat {seatIndex}'s Turn";
|
||||
|
||||
if (turnNumberLabel)
|
||||
turnNumberLabel.text = $"Turn {turnNumber}";
|
||||
}
|
||||
|
||||
public Phase CurrentPhase => _currentPhase;
|
||||
|
||||
// ── Debug (editor only) ──────────────────────────────────
|
||||
#if UNITY_EDITOR
|
||||
private void Update()
|
||||
{
|
||||
if (Input.GetKeyDown(KeyCode.Space))
|
||||
{
|
||||
int next = ((int)_currentPhase + 1) % System.Enum.GetValues(typeof(Phase)).Length;
|
||||
SetPhase((Phase)next, 0, GameStateManager.Instance?.TurnNumber ?? 1);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks poison counters for a seat (0-10).
|
||||
/// Activates pip toggles and triggers elimination at 10.
|
||||
/// </summary>
|
||||
public class PoisonCounter : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private int seatIndex;
|
||||
[SerializeField] private Toggle[] pips; // 10 Toggle GameObjects
|
||||
[SerializeField] private TMP_Text countLabel;
|
||||
[SerializeField] private Button addPoisonBtn;
|
||||
[SerializeField] private Button removePoisonBtn;
|
||||
|
||||
private int _count = 0;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
addPoisonBtn?.onClick.AddListener(() => AddPoison(1));
|
||||
removePoisonBtn?.onClick.AddListener(() => AddPoison(-1));
|
||||
Refresh();
|
||||
}
|
||||
|
||||
public void AddPoison(int delta)
|
||||
{
|
||||
_count = Mathf.Clamp(_count + delta, 0, 10);
|
||||
Refresh();
|
||||
if (_count >= 10)
|
||||
GameStateManager.Instance?.EliminatePlayer(seatIndex, "poison");
|
||||
}
|
||||
|
||||
public void SetPoison(int value)
|
||||
{
|
||||
_count = Mathf.Clamp(value, 0, 10);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (countLabel) countLabel.text = _count.ToString();
|
||||
for (int i = 0; i < pips.Length; i++)
|
||||
if (pips[i] != null) pips[i].isOn = i < _count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Visual row for a single item on the stack.
|
||||
/// Shows card/ability name, caster seat color, stack position, and targets.
|
||||
/// </summary>
|
||||
public class StackItemView : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text cardNameLabel;
|
||||
[SerializeField] private TMP_Text casterLabel;
|
||||
[SerializeField] private TMP_Text targetsLabel;
|
||||
[SerializeField] private TMP_Text stackPositionLabel;
|
||||
[SerializeField] private Image seatColorStrip;
|
||||
|
||||
private static readonly Color[] SeatColors =
|
||||
{
|
||||
new Color(0.2f, 0.6f, 1f), // seat 0 — blue (human)
|
||||
new Color(1f, 0.4f, 0.4f), // seat 1 — red
|
||||
new Color(0.4f, 1f, 0.4f), // seat 2 — green
|
||||
new Color(1f, 0.85f, 0.2f) // seat 3 — gold
|
||||
};
|
||||
|
||||
public void Bind(StackZoneController.StackItem item, int displayPosition)
|
||||
{
|
||||
if (cardNameLabel)
|
||||
cardNameLabel.text = item.isAbility ? $"⚡ {item.cardName}" : item.cardName;
|
||||
|
||||
if (casterLabel)
|
||||
casterLabel.text = item.casterSeat == 0 ? "You" : $"Seat {item.casterSeat}";
|
||||
|
||||
if (seatColorStrip)
|
||||
{
|
||||
int seat = Mathf.Clamp(item.casterSeat, 0, SeatColors.Length - 1);
|
||||
seatColorStrip.color = SeatColors[seat];
|
||||
}
|
||||
|
||||
if (stackPositionLabel)
|
||||
stackPositionLabel.text = displayPosition == 0 ? "[TOP]" : $"[{displayPosition}]";
|
||||
|
||||
if (targetsLabel)
|
||||
{
|
||||
if (item.targets != null && item.targets.Count > 0)
|
||||
targetsLabel.text = "\u2192 " + string.Join(", ", item.targets);
|
||||
else
|
||||
targetsLabel.text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Displays the active spell/ability stack (LIFO order, top of stack shown first).
|
||||
/// Hidden when stack is empty. Subscribes to GameStateManager.OnStackUpdated.
|
||||
/// </summary>
|
||||
public class StackZoneController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private GameObject stackPanel;
|
||||
[SerializeField] private Transform stackItemParent; // VerticalLayoutGroup
|
||||
[SerializeField] private GameObject stackItemPrefab;
|
||||
[SerializeField] private TMP_Text stackCountLabel;
|
||||
|
||||
private readonly List<GameObject> _itemGOs = new();
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnStackUpdated += SetStack;
|
||||
SetStack(new List<StackItem>());
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
GameStateManager.Instance.OnStackUpdated -= SetStack;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public void SetStack(List<StackItem> items)
|
||||
{
|
||||
// Clear existing rows
|
||||
foreach (var go in _itemGOs) if (go) Destroy(go);
|
||||
_itemGOs.Clear();
|
||||
|
||||
bool hasItems = items != null && items.Count > 0;
|
||||
if (stackPanel) stackPanel.SetActive(hasItems);
|
||||
|
||||
if (stackCountLabel)
|
||||
stackCountLabel.text = hasItems ? $"Stack ({items.Count})" : "";
|
||||
|
||||
if (!hasItems) return;
|
||||
|
||||
// LIFO — iterate in reverse so top of stack appears first
|
||||
for (int i = items.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var go = Instantiate(stackItemPrefab, stackItemParent);
|
||||
go.SetActive(true);
|
||||
var view = go.GetComponent<StackItemView>();
|
||||
view?.Bind(items[i], items.Count - 1 - i);
|
||||
_itemGOs.Add(go);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serializable stack item (shared with GameState) ───────────
|
||||
[Serializable]
|
||||
public class StackItem
|
||||
{
|
||||
[JsonProperty("card_name")] public string cardName;
|
||||
[JsonProperty("caster_seat")] public int casterSeat;
|
||||
[JsonProperty("targets")] public List<string> targets;
|
||||
[JsonProperty("is_ability")] public bool isAbility;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# Battleground UI — Task Tracker
|
||||
> Created: 2026-03-28
|
||||
> Updated: 2026-03-29 — all 14 issues complete
|
||||
> Branch: `feat/battleground-ui`
|
||||
|
||||
All issues below are part of the **4-player Commander Battleground** Unity 3D UI build.
|
||||
Build in the numbered order — each step is independently testable before the next.
|
||||
|
||||
---
|
||||
|
||||
## Build Order Checklist
|
||||
|
||||
| # | Issue | Script(s) | Needs Backend? | Status |
|
||||
|---|-------|-----------|----------------|--------|
|
||||
| 1 | [#138 — Battleground.unity scene + 4-seat layout](https://github.com/KoalaTrapLord/commander-ai-lab/issues/138) | `BattlegroundController.cs`, `GameStateManager.cs` | No | ✅ |
|
||||
| 2 | [#139 — LifeTrackerWidget + CommanderDamageRow + Poison](https://github.com/KoalaTrapLord/commander-ai-lab/issues/139) | `LifeTrackerWidget.cs`, `CommanderDamageRow.cs`, `PoisonCounter.cs` | No | ✅ |
|
||||
| 3 | [#140 — PhaseTrackerWidget + PhaseStepButton](https://github.com/KoalaTrapLord/commander-ai-lab/issues/140) | `PhaseTrackerWidget.cs`, `PhaseStepButton.cs` | No | ✅ |
|
||||
| 4 | [#141 — TurnOrderBar + TurnOrderPill](https://github.com/KoalaTrapLord/commander-ai-lab/issues/141) | `TurnOrderBar.cs`, `TurnOrderPill.cs` | No | ✅ |
|
||||
| 5 | [#142 — CardZone + CardView](https://github.com/KoalaTrapLord/commander-ai-lab/issues/142) | `CardZone.cs`, `CardView.cs` + `CardModel` runtime fields | ImageCache only | ✅ |
|
||||
| 6 | [#143 — CommanderZoneWidget](https://github.com/KoalaTrapLord/commander-ai-lab/issues/143) | `CommanderZoneWidget.cs` + `ImageCache.LoadArtCrop()` | ImageCache only | ✅ |
|
||||
| 7 | [#144 — Token System](https://github.com/KoalaTrapLord/commander-ai-lab/issues/144) | `TokenModel.cs`, `TokenPanelController.cs`, `TokenRowView.cs` | No | ✅ |
|
||||
| 8 | [#146 — StackZoneController + ManaPoolDisplay](https://github.com/KoalaTrapLord/commander-ai-lab/issues/146) | `StackZoneController.cs`, `ManaPoolDisplay.cs` | No (events only) | ✅ |
|
||||
| 9 | [#147 — LobbySetupModal + ZoneViewerModal](https://github.com/KoalaTrapLord/commander-ai-lab/issues/147) | `LobbySetupModal.cs`, `ZoneViewerModal.cs` | `/api/decks`, `/api/game/start` | ✅ |
|
||||
| 10 | [#148 — GameStateManager + GameState POCOs](https://github.com/KoalaTrapLord/commander-ai-lab/issues/148) | `GameStateManager.cs`, `GameState.cs` | No | ✅ |
|
||||
| 11 | [#149 — GameWebSocketClient](https://github.com/KoalaTrapLord/commander-ai-lab/issues/149) | `GameWebSocketClient.cs` | Yes — `/ws/game` | ✅ |
|
||||
| 12 | [#145 — HumanActionBar](https://github.com/KoalaTrapLord/commander-ai-lab/issues/145) | `HumanActionBar.cs`, `ApiClient.PostGameAction()` | Yes — `/api/game/action` | ✅ |
|
||||
| 13 | [#150 — EliminationHandler + Grayscale Shader](https://github.com/KoalaTrapLord/commander-ai-lab/issues/150) | `EliminationHandler.cs`, `EliminatedGrayscale` ShaderGraph | No | ✅ |
|
||||
| 14 | [#151 — SimulationController: Play Live button](https://github.com/KoalaTrapLord/commander-ai-lab/issues/151) | `SimulationController.cs` (modify) | No | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## All Files Committed
|
||||
|
||||
### `Assets/Scripts/UI/Battleground/` (new folder)
|
||||
- ✅ `BattlegroundController.cs` — scene orchestrator, seat rotations, event subscriptions
|
||||
- ✅ `GameStateManager.cs` — singleton event hub
|
||||
- ✅ `LifeTrackerWidget.cs` — life +/- with history + danger flash
|
||||
- ✅ `CommanderDamageRow.cs` — 4-pip commander damage tracker per seat
|
||||
- ✅ `PoisonCounter.cs` — 10-pip poison tracker
|
||||
- ✅ `PhaseTrackerWidget.cs` — 13-step phase strip, Space key debug advance
|
||||
- ✅ `PhaseStepButton.cs` — individual phase pill (color + scale)
|
||||
- ✅ `TurnOrderBar.cs` — animated seat border slider
|
||||
- ✅ `TurnOrderPill.cs` — per-seat pill with name, life, eliminated state
|
||||
- ✅ `CardZone.cs` — zone container (add/remove/clear CardView children)
|
||||
- ✅ `CardView.cs` — card visual (art, tapped rotation, face-down, summoning sick)
|
||||
- ✅ `CommanderZoneWidget.cs` — art crop, cast count, tax (+2 per cast)
|
||||
- ✅ `TokenPanelController.cs` — token creation panel
|
||||
- ✅ `TokenRowView.cs` — token row (qty, tap, counters, delete)
|
||||
- ✅ `StackZoneController.cs` — LIFO stack display, hidden when empty
|
||||
- ✅ `StackItemView.cs` — individual stack item row
|
||||
- ✅ `ManaPoolDisplay.cs` — 6-pip mana display, hides zero labels
|
||||
- ✅ `LobbySetupModal.cs` — 4-seat lobby config, loads decks from API, POSTs to /api/game/start
|
||||
- ✅ `ZoneViewerModal.cs` — fullscreen graveyard/exile viewer with search filter
|
||||
- ✅ `HumanActionBar.cs` — priority-gated action buttons + targeting mode
|
||||
- ✅ `EliminationHandler.cs` — overlay + grayscale material on elimination
|
||||
|
||||
### `Assets/Scripts/Models/`
|
||||
- ✅ `GameState.cs` — `GameState` + `SeatState` POCOs
|
||||
- ✅ `TokenModel.cs` — token data model
|
||||
- ✅ `CardModel.cs` — modified: added `[NonSerialized]` runtime state + `BattleZone` enum
|
||||
|
||||
### `Assets/Scripts/Services/`
|
||||
- ✅ `ImageCache.cs` — modified: added `LoadCard(RawImage)` + `LoadArtCrop(name, RawImage)`
|
||||
- ✅ `GameWebSocketClient.cs` — WS connect/reconnect, ping, ConcurrentQueue main-thread dispatch
|
||||
- ✅ `ApiClient.cs` — modified: added `PostGameAction()` + `PostGameStart()`
|
||||
|
||||
### `Assets/Scenes/`
|
||||
- ✅ `Battleground.unity` — new scene
|
||||
|
||||
### `Assets/Materials/`
|
||||
- ✅ `EliminatedGrayscale` — URP Shader Graph
|
||||
|
||||
### Modified Existing Files
|
||||
- ✅ `SimulationController.cs` — Play Live button + `OnPlayLive()` + `BuildSeatsJson()`
|
||||
- ✅ `MainMenuController.cs` — Play Live nav button
|
||||
|
||||
---
|
||||
|
||||
## Backend Endpoints Required
|
||||
|
||||
These Python routes must exist before end-to-end testing:
|
||||
|
||||
| Endpoint | Used By | Status |
|
||||
|----------|---------|--------|
|
||||
| `GET /api/decks` | `LobbySetupModal` | verify |
|
||||
| `POST /api/game/start` | `LobbySetupModal` | verify |
|
||||
| `POST /api/game/action` | `HumanActionBar` | verify |
|
||||
| `WS /ws/game` | `GameWebSocketClient` | verify (PR #107) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **`GameStateManager`** is the singleton event hub — all UI widgets subscribe to its events, never poll
|
||||
- **`GameWebSocketClient`** receives server push messages and routes them to `GameStateManager.ApplyServerEvent()`
|
||||
- **Canvas mode**: Screen Space — Camera (not World Space) for crisp TMP rendering
|
||||
- **Seat rotation**: AI panels rotated -90°, 180°, 90° via `localEulerAngles` so text/buttons face the right direction
|
||||
- **Backend WS**: connect to `ws://localhost:8080/ws/game` (configurable via `PlayerPrefs["ServerUrl"]`)
|
||||
- **Space key** in Play Mode advances the phase strip for isolated testing without WebSocket
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Token creation and tracking panel in the BattlefieldCenter.
|
||||
/// Human creates tokens manually; AI tokens are added via AddToken() from server events.
|
||||
/// </summary>
|
||||
public class TokenPanelController : MonoBehaviour
|
||||
{
|
||||
[Header("Creation Row")]
|
||||
[SerializeField] private TMP_InputField nameInput;
|
||||
[SerializeField] private TMP_InputField powerInput;
|
||||
[SerializeField] private TMP_InputField toughnessInput;
|
||||
[SerializeField] private TMP_InputField qtyInput;
|
||||
[SerializeField] private Button createBtn;
|
||||
|
||||
[Header("List")]
|
||||
[SerializeField] private Transform tokenListParent;
|
||||
[SerializeField] private GameObject tokenRowPrefab;
|
||||
|
||||
private readonly List<TokenModel> _tokens = new();
|
||||
private readonly List<TokenRowView> _rowViews = new();
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start() => createBtn.onClick.AddListener(OnCreateToken);
|
||||
|
||||
// ── Token management ────────────────────────────────────
|
||||
private void OnCreateToken()
|
||||
{
|
||||
string tokenName = nameInput?.text?.Trim();
|
||||
if (string.IsNullOrEmpty(tokenName)) return;
|
||||
|
||||
int.TryParse(qtyInput?.text, out int qty);
|
||||
qty = Mathf.Max(1, qty);
|
||||
|
||||
var token = new TokenModel
|
||||
{
|
||||
id = Guid.NewGuid().ToString(),
|
||||
name = tokenName,
|
||||
power = powerInput?.text?.Trim() ?? "",
|
||||
toughness = toughnessInput?.text?.Trim() ?? "",
|
||||
ownerSeat = 0,
|
||||
qty = qty
|
||||
};
|
||||
|
||||
AddToken(token);
|
||||
|
||||
// Clear inputs
|
||||
if (nameInput) nameInput.text = "";
|
||||
if (powerInput) powerInput.text = "";
|
||||
if (toughnessInput) toughnessInput.text = "";
|
||||
if (qtyInput) qtyInput.text = "1";
|
||||
}
|
||||
|
||||
public void AddToken(TokenModel token)
|
||||
{
|
||||
_tokens.Add(token);
|
||||
var go = Instantiate(tokenRowPrefab, tokenListParent);
|
||||
var view = go.GetComponent<TokenRowView>();
|
||||
if (view != null)
|
||||
{
|
||||
view.Bind(token, RemoveToken);
|
||||
_rowViews.Add(view);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveToken(TokenModel token)
|
||||
{
|
||||
int idx = _tokens.IndexOf(token);
|
||||
if (idx < 0) return;
|
||||
|
||||
_tokens.RemoveAt(idx);
|
||||
var view = _rowViews[idx];
|
||||
_rowViews.RemoveAt(idx);
|
||||
if (view != null) Destroy(view.gameObject);
|
||||
}
|
||||
|
||||
public IReadOnlyList<TokenModel> Tokens => _tokens;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using CommanderAILab.Models;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// A single token row in TokenPanelController's list.
|
||||
/// Supports qty +/-, tap all, counter editing, and removal.
|
||||
/// </summary>
|
||||
public class TokenRowView : MonoBehaviour
|
||||
{
|
||||
[Header("Labels")]
|
||||
[SerializeField] private TMP_Text nameLabel;
|
||||
[SerializeField] private TMP_Text ptLabel;
|
||||
[SerializeField] private TMP_Text qtyLabel;
|
||||
[SerializeField] private TMP_Text tappedLabel;
|
||||
|
||||
[Header("Buttons")]
|
||||
[SerializeField] private Button qtyPlusBtn;
|
||||
[SerializeField] private Button qtyMinusBtn;
|
||||
[SerializeField] private Button tapAllBtn;
|
||||
[SerializeField] private Button deleteBtn;
|
||||
|
||||
[Header("Counter Editor")]
|
||||
[SerializeField] private GameObject counterEditorPanel;
|
||||
[SerializeField] private TMP_Dropdown counterTypeDropdown;
|
||||
[SerializeField] private Button counterAddBtn;
|
||||
[SerializeField] private Button counterRemoveBtn;
|
||||
[SerializeField] private TMP_Text counterSummaryLabel;
|
||||
[SerializeField] private Button toggleCounterEditorBtn;
|
||||
|
||||
private static readonly List<string> CounterTypes =
|
||||
new() { "+1/+1", "-1/-1", "loyalty", "charge", "quest", "time" };
|
||||
|
||||
private TokenModel _token;
|
||||
private Action<TokenModel> _onDelete;
|
||||
|
||||
// ── Binding ───────────────────────────────────────────
|
||||
public void Bind(TokenModel token, Action<TokenModel> onDelete)
|
||||
{
|
||||
_token = token;
|
||||
_onDelete = onDelete;
|
||||
|
||||
// Wire buttons
|
||||
qtyPlusBtn ?.onClick.AddListener(() => AdjustQty(+1));
|
||||
qtyMinusBtn?.onClick.AddListener(() => AdjustQty(-1));
|
||||
tapAllBtn ?.onClick.AddListener(TapAll);
|
||||
deleteBtn ?.onClick.AddListener(() => _onDelete?.Invoke(_token));
|
||||
toggleCounterEditorBtn?.onClick.AddListener(ToggleCounterEditor);
|
||||
counterAddBtn ?.onClick.AddListener(() => AdjustCounter(+1));
|
||||
counterRemoveBtn?.onClick.AddListener(() => AdjustCounter(-1));
|
||||
|
||||
// Populate counter type dropdown
|
||||
if (counterTypeDropdown != null)
|
||||
{
|
||||
counterTypeDropdown.ClearOptions();
|
||||
counterTypeDropdown.AddOptions(CounterTypes);
|
||||
}
|
||||
|
||||
if (counterEditorPanel) counterEditorPanel.SetActive(false);
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────
|
||||
private void AdjustQty(int delta)
|
||||
{
|
||||
_token.qty = Mathf.Max(0, _token.qty + delta);
|
||||
if (_token.qty == 0) { _onDelete?.Invoke(_token); return; }
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
private void TapAll()
|
||||
{
|
||||
_token.isTapped = !_token.isTapped;
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
private void ToggleCounterEditor()
|
||||
{
|
||||
if (counterEditorPanel)
|
||||
counterEditorPanel.SetActive(!counterEditorPanel.activeSelf);
|
||||
}
|
||||
|
||||
private void AdjustCounter(int delta)
|
||||
{
|
||||
if (counterTypeDropdown == null) return;
|
||||
string counterType = CounterTypes[Mathf.Clamp(counterTypeDropdown.value, 0, CounterTypes.Count - 1)];
|
||||
_token.counters.TryGetValue(counterType, out int current);
|
||||
int next = Mathf.Max(0, current + delta);
|
||||
if (next == 0) _token.counters.Remove(counterType);
|
||||
else _token.counters[counterType] = next;
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
// ── Display ────────────────────────────────────────────
|
||||
private void RefreshDisplay()
|
||||
{
|
||||
if (nameLabel) nameLabel.text = _token.name;
|
||||
if (ptLabel) ptLabel.text = _token.PTString;
|
||||
if (qtyLabel) qtyLabel.text = $"×{_token.qty}";
|
||||
if (tappedLabel) tappedLabel.text = _token.isTapped ? "[Tapped]" : "";
|
||||
|
||||
// Counter summary
|
||||
if (counterSummaryLabel)
|
||||
{
|
||||
var parts = new System.Text.StringBuilder();
|
||||
foreach (var kv in _token.counters)
|
||||
parts.Append($"{kv.Key}:{kv.Value} ");
|
||||
counterSummaryLabel.text = parts.Length > 0 ? parts.ToString().Trim() : "No counters";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Horizontal strip of 4 TurnOrderPills at the top of BattlefieldCenter.
|
||||
/// Animates an active border sliding to the current seat's pill on turn change.
|
||||
/// Subscribes to GameStateManager.OnTurnChanged.
|
||||
/// </summary>
|
||||
public class TurnOrderBar : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TurnOrderPill[] pills; // length 4
|
||||
[SerializeField] private RectTransform activeBorder; // sliding highlight rect
|
||||
[SerializeField] private float slideDuration = 0.25f;
|
||||
|
||||
private int _activeSeat = 0;
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────
|
||||
private void Start()
|
||||
{
|
||||
if (GameStateManager.Instance != null)
|
||||
{
|
||||
GameStateManager.Instance.OnTurnChanged += SetActiveSeat;
|
||||
GameStateManager.Instance.OnLifeChanged += OnLifeChanged;
|
||||
GameStateManager.Instance.OnPlayerEliminated += OnPlayerEliminated;
|
||||
}
|
||||
SetActiveSeat(0);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (GameStateManager.Instance == null) return;
|
||||
GameStateManager.Instance.OnTurnChanged -= SetActiveSeat;
|
||||
GameStateManager.Instance.OnLifeChanged -= OnLifeChanged;
|
||||
GameStateManager.Instance.OnPlayerEliminated -= OnPlayerEliminated;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────
|
||||
public void SetActiveSeat(int seatIndex)
|
||||
{
|
||||
_activeSeat = seatIndex;
|
||||
for (int i = 0; i < pills.Length; i++)
|
||||
pills[i]?.SetActive(i == seatIndex);
|
||||
|
||||
AnimateBorderToPill(seatIndex);
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────
|
||||
private void AnimateBorderToPill(int seatIndex)
|
||||
{
|
||||
if (activeBorder == null || pills == null || seatIndex >= pills.Length) return;
|
||||
if (pills[seatIndex] == null) return;
|
||||
|
||||
var targetPos = (pills[seatIndex].transform as RectTransform)?.anchoredPosition
|
||||
?? pills[seatIndex].transform.localPosition;
|
||||
|
||||
StopAllCoroutines();
|
||||
StartCoroutine(SlideBorder(targetPos));
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerator SlideBorder(Vector2 targetPos)
|
||||
{
|
||||
var start = activeBorder.anchoredPosition;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < slideDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = Mathf.SmoothStep(0f, 1f, elapsed / slideDuration);
|
||||
activeBorder.anchoredPosition = Vector2.Lerp(start, targetPos, t);
|
||||
yield return null;
|
||||
}
|
||||
activeBorder.anchoredPosition = targetPos;
|
||||
}
|
||||
|
||||
private void OnLifeChanged(int seatIndex, int newLife)
|
||||
{
|
||||
if (seatIndex < pills.Length) pills[seatIndex]?.UpdateLife(newLife);
|
||||
}
|
||||
|
||||
private void OnPlayerEliminated(int seatIndex, string reason)
|
||||
{
|
||||
if (seatIndex < pills.Length) pills[seatIndex]?.SetEliminated();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// A single seat pill inside TurnOrderBar.
|
||||
/// Shows player name, current life, and highlights when it is that seat's turn.
|
||||
/// </summary>
|
||||
public class TurnOrderPill : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text playerNameLabel;
|
||||
[SerializeField] private TMP_Text lifeLabel;
|
||||
[SerializeField] private Image pillBackground;
|
||||
[SerializeField] private GameObject youBadge; // "YOU" label, only visible on seat 0
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color activeColor = new Color(1f, 0.85f, 0f);
|
||||
[SerializeField] private Color inactiveColor = new Color(0.25f, 0.25f, 0.25f);
|
||||
[SerializeField] private Color eliminatedColor = new Color(0.4f, 0.4f, 0.4f, 0.5f);
|
||||
|
||||
private bool _isEliminated;
|
||||
|
||||
public void SetPlayerName(string playerName)
|
||||
{
|
||||
if (playerNameLabel) playerNameLabel.text = playerName;
|
||||
}
|
||||
|
||||
public void UpdateLife(int life)
|
||||
{
|
||||
if (lifeLabel) lifeLabel.text = life.ToString();
|
||||
}
|
||||
|
||||
public void ShowYouBadge(bool show)
|
||||
{
|
||||
if (youBadge) youBadge.SetActive(show);
|
||||
}
|
||||
|
||||
public void SetActive(bool active)
|
||||
{
|
||||
if (_isEliminated) return;
|
||||
if (pillBackground) pillBackground.color = active ? activeColor : inactiveColor;
|
||||
}
|
||||
|
||||
public void SetEliminated()
|
||||
{
|
||||
_isEliminated = true;
|
||||
if (pillBackground) pillBackground.color = eliminatedColor;
|
||||
if (playerNameLabel)
|
||||
{
|
||||
playerNameLabel.fontStyle = TMPro.FontStyles.Strikethrough;
|
||||
playerNameLabel.color = Color.gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace CommanderAILab.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Full-screen overlay showing all cards in a particular zone (graveyard, exile, etc.).
|
||||
/// Opened by clicking zone count badges on each seat panel.
|
||||
/// </summary>
|
||||
public class ZoneViewerModal : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private TMP_Text titleLabel;
|
||||
[SerializeField] private Transform cardGrid; // GridLayoutGroup
|
||||
[SerializeField] private GameObject cardPrefab; // CardView prefab
|
||||
[SerializeField] private Button closeButton;
|
||||
|
||||
private readonly List<GameObject> _cardGOs = new();
|
||||
|
||||
private void Start() => closeButton?.onClick.AddListener(Close);
|
||||
|
||||
// ── Open ─────────────────────────────────────────────────
|
||||
public void Open(string zoneName, CardZone zone)
|
||||
{
|
||||
if (titleLabel) titleLabel.text = $"{zoneName} ({zone.Count} cards)";
|
||||
|
||||
foreach (var go in _cardGOs) if (go) Destroy(go);
|
||||
_cardGOs.Clear();
|
||||
|
||||
foreach (var view in zone.Cards)
|
||||
{
|
||||
if (view?.Model == null) continue;
|
||||
var go = Instantiate(cardPrefab, cardGrid);
|
||||
go.SetActive(true);
|
||||
var cv = go.GetComponent<CardView>();
|
||||
cv?.Bind(view.Model, false);
|
||||
_cardGOs.Add(go);
|
||||
}
|
||||
|
||||
gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
// ── Close ───────────────────────────────────────────────
|
||||
public void Close()
|
||||
{
|
||||
foreach (var go in _cardGOs) if (go) Destroy(go);
|
||||
_cardGOs.Clear();
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ namespace CommanderAILab.UI
|
||||
/// <summary>
|
||||
/// Simulator scene — configure and run multi-player simulations,
|
||||
/// poll status, display results per deck.
|
||||
/// Also provides a Play Live button that routes to the Battleground scene.
|
||||
/// </summary>
|
||||
public class SimulationController : MonoBehaviour
|
||||
{
|
||||
@@ -22,6 +23,8 @@ namespace CommanderAILab.UI
|
||||
[Header("Navigation")]
|
||||
[SerializeField] private Button backButton;
|
||||
[SerializeField] private Button startSimButton;
|
||||
/// <summary>Routes to the Battleground scene for a live 4-player game.</summary>
|
||||
[SerializeField] private Button playLiveButton;
|
||||
|
||||
// ── Config Panel ──────────────────────────────────────────────────────
|
||||
[Header("Config")]
|
||||
@@ -74,6 +77,9 @@ namespace CommanderAILab.UI
|
||||
startSimButton.onClick.AddListener(OnStartSim);
|
||||
retryButton.onClick.AddListener(OnStartSim);
|
||||
|
||||
// Play Live: save current deck selections and load Battleground scene
|
||||
playLiveButton?.onClick.AddListener(OnPlayLive);
|
||||
|
||||
numPlayersSlider.minValue = 2;
|
||||
numPlayersSlider.maxValue = 4;
|
||||
numPlayersSlider.wholeNumbers = true;
|
||||
@@ -83,12 +89,10 @@ namespace CommanderAILab.UI
|
||||
|
||||
errorPanel.SetActive(false);
|
||||
|
||||
// Check for a deck exported from DeckBuilder
|
||||
string simDeck = PlayerPrefs.GetString("SimDeck", "");
|
||||
if (!string.IsNullOrEmpty(simDeck))
|
||||
{
|
||||
PlayerPrefs.DeleteKey("SimDeck");
|
||||
// Pre-populate deck dropdown 0 after decks load — stored for later
|
||||
PlayerPrefs.SetString("_SimDeckPending", simDeck);
|
||||
}
|
||||
|
||||
@@ -96,6 +100,29 @@ namespace CommanderAILab.UI
|
||||
LoadAiProfiles();
|
||||
}
|
||||
|
||||
// ── Play Live ─────────────────────────────────────────────────────────
|
||||
private void OnPlayLive()
|
||||
{
|
||||
// Persist current deck selections so LobbySetupModal can pre-populate
|
||||
var seats = new List<object>();
|
||||
void AddSeat(TMP_Dropdown dd, bool isHuman, int seatIdx)
|
||||
{
|
||||
string deckName = "";
|
||||
if (dd != null && dd.value > 0 && (dd.value - 1) < _decks.Count)
|
||||
deckName = _decks[dd.value - 1].name;
|
||||
seats.Add(new { isHuman, deckName, aiStyle = "Aggro" });
|
||||
}
|
||||
|
||||
AddSeat(deckDropdown0, true, 0);
|
||||
AddSeat(deckDropdown1, false, 1);
|
||||
AddSeat(deckDropdown2, false, 2);
|
||||
AddSeat(deckDropdown3, false, 3);
|
||||
|
||||
PlayerPrefs.SetString("BattleSeats", JsonConvert.SerializeObject(seats));
|
||||
PlayerPrefs.Save();
|
||||
SceneManager.LoadScene("Battleground");
|
||||
}
|
||||
|
||||
// ── Player Count ──────────────────────────────────────────────────────
|
||||
private void OnNumPlayersChanged(float value)
|
||||
{
|
||||
@@ -103,7 +130,6 @@ namespace CommanderAILab.UI
|
||||
if (numPlayersLabel != null)
|
||||
numPlayersLabel.text = $"Players: {count}";
|
||||
|
||||
// Show/hide optional deck dropdowns based on count
|
||||
if (deckDropdown2 != null) deckDropdown2.gameObject.SetActive(count >= 3);
|
||||
if (deckDropdown3 != null) deckDropdown3.gameObject.SetActive(count >= 4);
|
||||
}
|
||||
@@ -143,14 +169,12 @@ namespace CommanderAILab.UI
|
||||
Fill(deckDropdown2);
|
||||
Fill(deckDropdown3);
|
||||
|
||||
// Apply any pending deck from DeckBuilder export
|
||||
string pending = PlayerPrefs.GetString("_SimDeckPending", "");
|
||||
if (!string.IsNullOrEmpty(pending))
|
||||
{
|
||||
PlayerPrefs.DeleteKey("_SimDeckPending");
|
||||
try
|
||||
{
|
||||
// Try to match by name from the JSON
|
||||
var exported = JsonConvert.DeserializeObject<LabDeckEntry>(pending);
|
||||
if (exported != null && !string.IsNullOrEmpty(exported.name))
|
||||
{
|
||||
@@ -179,7 +203,6 @@ namespace CommanderAILab.UI
|
||||
},
|
||||
err =>
|
||||
{
|
||||
// Non-fatal: profiles may not be available yet
|
||||
_profiles = new List<AiProfile>();
|
||||
PopulateProfileDropdown();
|
||||
});
|
||||
@@ -203,7 +226,6 @@ namespace CommanderAILab.UI
|
||||
|
||||
int numPlayers = Mathf.RoundToInt(numPlayersSlider.value);
|
||||
|
||||
// Collect selected deck names (index 0 = "-- Select Deck --")
|
||||
var selectedDecks = new List<string>();
|
||||
void TryAdd(TMP_Dropdown dd)
|
||||
{
|
||||
@@ -292,7 +314,6 @@ namespace CommanderAILab.UI
|
||||
|
||||
bool done = false;
|
||||
string errorMsg = null;
|
||||
SimStatusModel latestStatus = null;
|
||||
|
||||
_api.GetSimStatus(batchId,
|
||||
json =>
|
||||
@@ -301,7 +322,6 @@ namespace CommanderAILab.UI
|
||||
{
|
||||
var status = JsonConvert.DeserializeObject<SimStatusModel>(json);
|
||||
if (status == null) { done = true; errorMsg = "Empty status response."; return; }
|
||||
latestStatus = status;
|
||||
|
||||
if (!string.IsNullOrEmpty(status.error))
|
||||
{
|
||||
@@ -316,10 +336,10 @@ namespace CommanderAILab.UI
|
||||
: status.gamesCompleted;
|
||||
|
||||
float progress = total > 0 ? (float)completed / total : 0f;
|
||||
if (progressBar) progressBar.value = progress;
|
||||
if (progressText) progressText.text = $"{Mathf.RoundToInt(progress * 100)}%";
|
||||
if (statusText) statusText.text = $"Running... {completed}/{total} games";
|
||||
if (elapsedText) elapsedText.text = $"Elapsed: {status.elapsedMs / 1000f:F1}s";
|
||||
if (progressBar) progressBar.value = progress;
|
||||
if (progressText) progressText.text = $"{Mathf.RoundToInt(progress * 100)}%";
|
||||
if (statusText) statusText.text = $"Running... {completed}/{total} games";
|
||||
if (elapsedText) elapsedText.text = $"Elapsed: {status.elapsedMs / 1000f:F1}s";
|
||||
if (simsPerSecText) simsPerSecText.text = $"{status.simsPerSec:F1} sims/s";
|
||||
|
||||
if (!status.running) done = true;
|
||||
@@ -332,7 +352,6 @@ namespace CommanderAILab.UI
|
||||
},
|
||||
err => { done = true; errorMsg = err; });
|
||||
|
||||
// Wait one frame so callbacks have fired
|
||||
yield return null;
|
||||
|
||||
if (!string.IsNullOrEmpty(errorMsg))
|
||||
@@ -345,7 +364,7 @@ namespace CommanderAILab.UI
|
||||
|
||||
if (done)
|
||||
{
|
||||
if (statusText) statusText.text = "Simulation complete!";
|
||||
if (statusText) statusText.text = "Simulation complete!";
|
||||
if (progressBar) progressBar.value = 1f;
|
||||
if (progressText) progressText.text = "100%";
|
||||
startSimButton.interactable = true;
|
||||
@@ -412,9 +431,9 @@ namespace CommanderAILab.UI
|
||||
|
||||
private void SetResultsPanelVisible(bool visible)
|
||||
{
|
||||
if (resultsParent) resultsParent.gameObject.SetActive(visible);
|
||||
if (winnerText) winnerText.gameObject.SetActive(visible);
|
||||
if (historyButton) historyButton.gameObject.SetActive(visible);
|
||||
if (resultsParent) resultsParent.gameObject.SetActive(visible);
|
||||
if (winnerText) winnerText.gameObject.SetActive(visible);
|
||||
if (historyButton) historyButton.gameObject.SetActive(visible);
|
||||
}
|
||||
|
||||
// ── Error ─────────────────────────────────────────────────────────────
|
||||
@@ -424,19 +443,26 @@ namespace CommanderAILab.UI
|
||||
errorText.text = msg;
|
||||
}
|
||||
|
||||
// ── Helper Models (private inner classes) ─────────────────────────────
|
||||
[Serializable] private class DecksWrapper { public List<LabDeckEntry> decks; }
|
||||
[Serializable] private class LabDeckEntry { public string name; public string source; public string commander; }
|
||||
// ── Helper Models ─────────────────────────────────────────────────────
|
||||
[Serializable] private class DecksWrapper { public List<LabDeckEntry> decks; }
|
||||
[Serializable] private class LabDeckEntry { public string name; public string source; public string commander; }
|
||||
[Serializable] private class ProfilesWrapper { public List<AiProfile> profiles; }
|
||||
[Serializable] private class AiProfile { public string name; public string description; }
|
||||
[Serializable] private class AiProfile { public string name; public string description; }
|
||||
[Serializable] private class StartRequest
|
||||
{
|
||||
public List<string> decks; public int numGames; public int threads;
|
||||
public bool useLearnedPolicy; public string policyStyle;
|
||||
public int seed; public int clock;
|
||||
}
|
||||
[Serializable] private class SimResult { public Summary summary; }
|
||||
[Serializable] private class Summary { public List<ResultEntry> perDeck; }
|
||||
[Serializable] private class SimStartResponse { public string batchId; }
|
||||
[Serializable] private class SimStatusModel
|
||||
{
|
||||
public bool running; public int total; public int completed;
|
||||
public int totalGames; public int gamesCompleted;
|
||||
public float elapsedMs; public float simsPerSec; public string error;
|
||||
}
|
||||
[Serializable] private class SimResult { public Summary summary; }
|
||||
[Serializable] private class Summary { public List<ResultEntry> perDeck; }
|
||||
[Serializable] private class ResultEntry { public string deckName; public float winRate; public int wins; public int losses; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user