Compare commits

...
Author SHA1 Message Date
KoalaTrapLord 2c67222de1 fix(lobby): wire OnConfirm to POST /api/game/start, save game_id to PlayerPrefs
- OnConfirm() now calls ApiClient.PostGameStart() with 4-seat config
- On success: saves BattleSeats + GameId + WsUrl to PlayerPrefs,
  loads Battleground scene
- On failure: shows inline error label (red), re-enables Confirm button
- Added StartGameRequest/Response POCOs matching routes/game.py schema
- Added _errorLabel [SerializeField] for user-facing error feedback
2026-03-29 05:22:04 -04:00
KoalaTrapLord d8238ec11f feat(game): add routes/game.py + wire lab_api + fix GameWebSocketClient game_id URL
- routes/game.py: POST /api/game/start, POST /api/game/action (new)
- lab_api.py: import + register game_router
- GameWebSocketClient.cs: read game_id from PlayerPrefs, build
  ws://{host}/ws/game/{game_id} — fixes URL mismatch with ws_game.py
2026-03-29 05:17:48 -04:00
KoalaTrapLord 7aed64a660 fix: add PostGameAction to ApiClient + update TODO.md all issues complete
- ApiClient.cs: add PostGameAction() wrapper → POST /api/game/action
  (fixes compile error in HumanActionBar.cs)
- TODO.md: mark issues #145, #146, #147, #149, #150, #151 as ✅
  all 14 battleground issues now committed
2026-03-29 05:09:51 -04:00
KoalaTrapLord e3497a8f74 feat(#149): add GameWebSocketClient with reconnect, ping, and main-thread dispatch 2026-03-28 20:05:35 -04:00
KoalaTrapLord 6a655628c8 feat(#151): add Play Live button to SimulationController 2026-03-28 20:04:42 -04:00
KoalaTrapLord e3d4eb5785 feat(#147 #150 #151): add LobbySetupModal, ZoneViewerModal, EliminationHandler; add Play Live button to SimulationController 2026-03-28 20:03:43 -04:00
KoalaTrapLord abee551ab4 feat(#146): add StackZoneController, StackItemView, and ManaPoolDisplay 2026-03-28 20:02:23 -04:00
KoalaTrapLord 9fdb6d29fb chore: mark issues 1-8 as complete in task tracker 2026-03-28 19:58:00 -04:00
KoalaTrapLord 2c8c6c26bd feat(#144): add TokenModel POCO, TokenPanelController, and TokenRowView 2026-03-28 19:57:00 -04:00
KoalaTrapLord 9ffefd463c feat(#143): add CommanderZoneWidget with art crop, cast counter, and tax display 2026-03-28 19:56:24 -04:00
KoalaTrapLord 4c99957e76 feat(#142): add CardZone and CardView battlefield zone components 2026-03-28 19:56:09 -04:00
KoalaTrapLord e5e25a7553 feat(#141): add TurnOrderBar and TurnOrderPill 2026-03-28 19:55:40 -04:00
KoalaTrapLord 8d56feee80 feat(#140): add PhaseTrackerWidget and PhaseStepButton 2026-03-28 19:55:17 -04:00
KoalaTrapLord 837bb3d6f9 feat(#139): add LifeTrackerWidget, CommanderDamageRow, PoisonCounter 2026-03-28 19:54:56 -04:00
KoalaTrapLord 98b81ceab2 feat(#138): add BattlegroundController scene orchestrator 2026-03-28 19:54:24 -04:00
KoalaTrapLord fae04a9336 feat(#148): add GameStateManager singleton and GameState/SeatState POCOs 2026-03-28 19:54:01 -04:00
KoalaTrapLord 8fd155a925 feat(#143): add LoadArtCrop and LoadCardRaw (RawImage) to ImageCache 2026-03-28 19:53:25 -04:00
KoalaTrapLord 34ce9bfbf7 feat(#142): add [NonSerialized] battleground runtime state to CardModel 2026-03-28 19:53:09 -04:00
KoalaTrapLord 07010a71ed chore: add battleground UI task tracker (2026-03-28) 2026-03-28 19:50:06 -04:00
KoalaTrapLord ac536999eb Update lab_api.pyfeat: wire validate_forge_config into lifespan startup 2026-03-28 14:48:43 -04:00
KoalaTrapLord 2e43511d87 feat: add validate_forge_config() startup check
Validates FORGE_DIR at startup with clear warning if cardsfolder not found.
Checks both forge-gui/res/cardsfolder and res/cardsfolder layouts.
2026-03-28 14:34:50 -04:00
KoalaTrapLord 1bebe38e66 fix: forge_card_loader path resolution + reset_forge_cache helper
- _get_cards_folder() now tries both forge-gui/res/cardsfolder and res/cardsfolder
  so FORGE_DIR can point to either the repo root or the forge-gui subdirectory
- Add reset_forge_cache() to allow hot-reloading without server restart
2026-03-28 14:31:42 -04:00
KoalaTrapLord eca1653024 fix: backfill basic lands on card_entries to guarantee 99 cards
The previous backfill operated on seen_names dict which silently
ignored basics already present (dict keys are unique). Now backfills
directly on card_entries list after Scryfall fetch, incrementing
quantity on existing basics or appending new CardEntry objects.
Distributes shortfall evenly across commander color identity basics.
2026-03-28 00:26:42 -04:00
KoalaTrapLord 08f1cb7581 fix(deckgen): backfill basic lands after cross-category dedup to reach 99 cards 2026-03-28 00:10:52 -04:00
KoalaTrapLord 9d9e8675a9 fix: restore missing # on line 27 section banner in rules.py (SyntaxError on import) 2026-03-27 23:36:02 -04:00
KoalaTrapLord 64526b7bee fix: exclude lands from cmc_buckets in _build_deck_report_from_db (matches deck_service behaviour) 2026-03-27 20:38:49 -04:00
KoalaTrapLord e9732d292f fix: expand _land_kw, hoist to module level, guard has_real_data for cmc=0 lands after Forge lookup 2026-03-27 20:36:22 -04:00
KoalaTrapLord fa5349ea7c fix: basic land name fallback in _build_deck_report_from_db when type_line is missing from collection join 2026-03-27 20:32:15 -04:00
KoalaTrapLord 1c491bc2b0 fix: add Land role detection to _detect_card_roles (fixes basics showing as Unknown) 2026-03-27 20:28:35 -04:00
KoalaTrapLord 81fe5108d2 fix: coach_apply rollback on partial write, 4-space reindent; ws_game hoist inline imports 2026-03-27 20:24:55 -04:00
KoalaTrapLord 61fa37dc48 fix: lifespan indent error, get_running_loop, remove double rag init, register policy routes 2026-03-27 20:22:42 -04:00
KoalaTrapLord 0118950939 fix: add anthropic_api_key to Config with env-var default (fixes AttributeError in ASGI mode) 2026-03-27 20:20:45 -04:00
KoalaTrapLord 22cf23b24d fix: correct query_cards kwarg names in coach_service (n= -> n_results=, colors= -> color_identity=) 2026-03-27 20:17:14 -04:00
KoalaTrapLord 0010b0f394 refactor: RAG improvements — shared card_text, Chroma fallback, staleness monitor (fixes #133, #134, #135) (#136)
* refactor: add shared build_card_text() utility

* refactor: unify _build_document via shared card_text + add is_chroma_available/check_staleness (fixes #133, #134, #135)

* refactor: delegate embeddings._build_card_text to shared utility

* feat: add background RAG staleness monitor to lab_api lifespan (fixes #135)
2026-03-27 16:46:33 -04:00
KoalaTrapLord d0cdafa66a feat(rag): Phase 3 — inject RAG cards into coach prompts (#132)
* feat(rag): add RAG card injection to prompt_template.py

* feat(rag): wire RAG query into coach_service.py

* test(rag): add Phase 3 RAG injection tests
2026-03-27 15:20:49 -04:00
KoalaTrapLord bcf558418a fix(rag): squash all RAG/ChromaDB bugs #123–#130
Fixes critical API contract mismatch, batch_size wiring, lifespan init, failure tracking, build_meta persistence, mana_cost in embeddings, pyproject.toml deps, and adds full test suite.

Closes #123, #124, #125, #126, #127, #128, #129, #130
2026-03-27 13:24:41 -04:00
47 changed files with 3985 additions and 617 deletions

No files matched your search

+20 -2
View File
@@ -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
View File
@@ -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:
+24 -1
View File
@@ -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
View File
@@ -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")
+29
View File
@@ -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 = {}
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+9 -1
View File
@@ -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
+37
View File
@@ -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
View File
@@ -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")
+22 -3
View File
@@ -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")
+29 -16
View File
@@ -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
+83
View File
@@ -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()
+374
View File
@@ -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; }
}
}